Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture. New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy, ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow. New services: round-engine, round-assignment, deliberation, result-lock, submission-manager, competition-context, ai-prompt-guard. Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with structured prompts, retry logic, and injection detection. All legacy pipeline/stage code removed. 4 new migrations + seed aligned. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
239
src/components/admin/result/result-lock-controls.tsx
Normal file
239
src/components/admin/result/result-lock-controls.tsx
Normal file
@@ -0,0 +1,239 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { trpc } from '@/lib/trpc/client';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle
|
||||
} from '@/components/ui/dialog';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Lock, Unlock, History } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import type { CompetitionCategory } from '@prisma/client';
|
||||
|
||||
interface ResultLockControlsProps {
|
||||
competitionId: string;
|
||||
roundId: string;
|
||||
category: CompetitionCategory;
|
||||
}
|
||||
|
||||
export function ResultLockControls({ competitionId, roundId, category }: ResultLockControlsProps) {
|
||||
const utils = trpc.useUtils();
|
||||
const [lockDialogOpen, setLockDialogOpen] = useState(false);
|
||||
const [unlockDialogOpen, setUnlockDialogOpen] = useState(false);
|
||||
const [unlockReason, setUnlockReason] = useState('');
|
||||
|
||||
const { data: lockStatus } = trpc.resultLock.isLocked.useQuery({
|
||||
competitionId,
|
||||
roundId,
|
||||
category
|
||||
});
|
||||
|
||||
const { data: history } = trpc.resultLock.history.useQuery({
|
||||
competitionId
|
||||
});
|
||||
|
||||
const lockMutation = trpc.resultLock.lock.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.resultLock.isLocked.invalidate();
|
||||
utils.resultLock.history.invalidate();
|
||||
toast.success('Results locked successfully');
|
||||
setLockDialogOpen(false);
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err.message);
|
||||
}
|
||||
});
|
||||
|
||||
const unlockMutation = trpc.resultLock.unlock.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.resultLock.isLocked.invalidate();
|
||||
utils.resultLock.history.invalidate();
|
||||
toast.success('Results unlocked');
|
||||
setUnlockDialogOpen(false);
|
||||
setUnlockReason('');
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err.message);
|
||||
}
|
||||
});
|
||||
|
||||
const handleLock = () => {
|
||||
lockMutation.mutate({
|
||||
competitionId,
|
||||
roundId,
|
||||
category,
|
||||
resultSnapshot: {} // This would contain the actual results snapshot
|
||||
});
|
||||
};
|
||||
|
||||
const handleUnlock = () => {
|
||||
if (!unlockReason.trim()) {
|
||||
toast.error('Reason is required to unlock results');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!lockStatus?.lock?.id) {
|
||||
toast.error('No active lock found');
|
||||
return;
|
||||
}
|
||||
|
||||
unlockMutation.mutate({
|
||||
resultLockId: lockStatus.lock.id,
|
||||
reason: unlockReason.trim()
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>Result Lock Status</CardTitle>
|
||||
<CardDescription>Prevent changes to finalized results</CardDescription>
|
||||
</div>
|
||||
<Badge variant={lockStatus?.locked ? 'destructive' : 'outline'}>
|
||||
{lockStatus?.locked ? 'Locked' : 'Unlocked'}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{lockStatus?.locked ? (
|
||||
<div className="space-y-3">
|
||||
<div className="rounded-lg bg-destructive/10 p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<Lock className="h-5 w-5 text-destructive" />
|
||||
<div className="flex-1">
|
||||
<p className="font-medium text-destructive">Results are locked</p>
|
||||
{lockStatus.lock?.lockedAt && (
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Locked on {new Date(lockStatus.lock.lockedAt).toLocaleString()}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setUnlockDialogOpen(true)}
|
||||
className="w-full"
|
||||
>
|
||||
<Unlock className="mr-2 h-4 w-4" />
|
||||
Unlock Results (Super Admin Only)
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button onClick={() => setLockDialogOpen(true)} className="w-full">
|
||||
<Lock className="mr-2 h-4 w-4" />
|
||||
Lock Results
|
||||
</Button>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Lock History */}
|
||||
{history && history.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<History className="h-5 w-5" />
|
||||
Lock History
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{history.map((entry: any) => (
|
||||
<div key={entry.id} className="rounded-lg border p-3">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
{entry.action === 'LOCK' ? (
|
||||
<Lock className="h-4 w-4 text-destructive" />
|
||||
) : (
|
||||
<Unlock className="h-4 w-4 text-green-600" />
|
||||
)}
|
||||
<span className="font-medium">{entry.action}</span>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{entry.adminUser?.name} - {new Date(entry.timestamp).toLocaleString()}
|
||||
</p>
|
||||
{entry.reason && (
|
||||
<p className="mt-2 text-sm italic">“{entry.reason}”</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Lock Confirmation Dialog */}
|
||||
<Dialog open={lockDialogOpen} onOpenChange={setLockDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Lock Results</DialogTitle>
|
||||
<DialogDescription>
|
||||
This will prevent any further changes to the results. This action can only be
|
||||
reversed by a super admin.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setLockDialogOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleLock} disabled={lockMutation.isPending}>
|
||||
{lockMutation.isPending ? 'Locking...' : 'Confirm Lock'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Unlock Dialog */}
|
||||
<Dialog open={unlockDialogOpen} onOpenChange={setUnlockDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Unlock Results</DialogTitle>
|
||||
<DialogDescription>
|
||||
Unlocking results will allow modifications. This action will be audited.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="unlockReason">Reason (Required) *</Label>
|
||||
<Textarea
|
||||
id="unlockReason"
|
||||
value={unlockReason}
|
||||
onChange={(e) => setUnlockReason(e.target.value)}
|
||||
placeholder="Explain why results need to be unlocked..."
|
||||
rows={3}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setUnlockDialogOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleUnlock}
|
||||
disabled={unlockMutation.isPending || !unlockReason.trim()}
|
||||
>
|
||||
{unlockMutation.isPending ? 'Unlocking...' : 'Confirm Unlock'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user