Add COI/manual reassignment emails, confirmation dialog, and smart juror selection
All checks were successful
Build and Push Docker Image / build (push) Successful in 9m14s
All checks were successful
Build and Push Docker Image / build (push) Successful in 9m14s
- Add COI_REASSIGNED and MANUAL_REASSIGNED notification types with distinct email templates, icons, and priorities - COI declaration dialog now shows a confirmation step warning that the project will be reassigned before submitting - reassignAfterCOI now checks historical assignments (all rounds, audit logs) to never assign the same project to a juror twice, and prefers jurors with incomplete evaluations over those who have finished all their work - Admin transfer (transferAssignments) sends per-juror MANUAL_REASSIGNED notifications with actual project names instead of generic batch emails - docker-entrypoint syncs notification settings on every deploy via upsert Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -20,7 +20,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Loader2, ShieldAlert } from 'lucide-react'
|
||||
import { AlertTriangle, Loader2, ShieldAlert } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
interface COIDeclarationDialogProps {
|
||||
@@ -39,22 +39,31 @@ export function COIDeclarationDialog({
|
||||
const [hasConflict, setHasConflict] = useState<boolean | null>(null)
|
||||
const [conflictType, setConflictType] = useState<string>('')
|
||||
const [description, setDescription] = useState('')
|
||||
const [showConfirmation, setShowConfirmation] = useState(false)
|
||||
|
||||
const declareCOI = trpc.evaluation.declareCOI.useMutation({
|
||||
onSuccess: (data) => {
|
||||
if (data.hasConflict) {
|
||||
toast.info('Conflict of interest recorded. An admin will review your declaration.')
|
||||
toast.info('Conflict of interest recorded. This project will be reassigned to another juror.')
|
||||
}
|
||||
setShowConfirmation(false)
|
||||
onComplete(data.hasConflict)
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message || 'Failed to submit COI declaration')
|
||||
setShowConfirmation(false)
|
||||
},
|
||||
})
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (hasConflict === null) return
|
||||
|
||||
// If declaring a conflict, show confirmation first
|
||||
if (hasConflict && !showConfirmation) {
|
||||
setShowConfirmation(true)
|
||||
return
|
||||
}
|
||||
|
||||
declareCOI.mutate({
|
||||
assignmentId,
|
||||
hasConflict,
|
||||
@@ -71,91 +80,132 @@ export function COIDeclarationDialog({
|
||||
return (
|
||||
<AlertDialog open={open}>
|
||||
<AlertDialogContent className="max-w-md">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle className="flex items-center gap-2">
|
||||
<ShieldAlert className="h-5 w-5 text-amber-500" />
|
||||
Conflict of Interest Declaration
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Before evaluating “{projectTitle}”, please declare whether
|
||||
you have any conflict of interest with this project.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
|
||||
<div className="space-y-4 py-2">
|
||||
<div className="space-y-3">
|
||||
<Label className="text-sm font-medium">
|
||||
Do you have a conflict of interest with this project?
|
||||
</Label>
|
||||
<div className="flex gap-3">
|
||||
{showConfirmation ? (
|
||||
<>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle className="flex items-center gap-2">
|
||||
<AlertTriangle className="h-5 w-5 text-red-500" />
|
||||
Confirm Conflict of Interest
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription asChild>
|
||||
<div className="space-y-3">
|
||||
<p>
|
||||
Are you sure you want to declare a conflict of interest with
|
||||
“{projectTitle}”?
|
||||
</p>
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 p-3 text-sm text-red-800">
|
||||
<strong>This action cannot be undone.</strong> The project will be
|
||||
removed from your assignments and reassigned to another juror.
|
||||
</div>
|
||||
</div>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter className="gap-2 sm:gap-0">
|
||||
<Button
|
||||
type="button"
|
||||
variant={hasConflict === false ? 'default' : 'outline'}
|
||||
className="flex-1"
|
||||
onClick={() => setHasConflict(false)}
|
||||
variant="outline"
|
||||
onClick={() => setShowConfirmation(false)}
|
||||
disabled={declareCOI.isPending}
|
||||
>
|
||||
No Conflict
|
||||
Go Back
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant={hasConflict === true ? 'destructive' : 'outline'}
|
||||
className="flex-1"
|
||||
onClick={() => setHasConflict(true)}
|
||||
variant="destructive"
|
||||
onClick={handleSubmit}
|
||||
disabled={declareCOI.isPending}
|
||||
>
|
||||
Yes, I Have a Conflict
|
||||
{declareCOI.isPending && (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
)}
|
||||
Yes, Confirm COI
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</AlertDialogFooter>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle className="flex items-center gap-2">
|
||||
<ShieldAlert className="h-5 w-5 text-amber-500" />
|
||||
Conflict of Interest Declaration
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Before evaluating “{projectTitle}”, please declare whether
|
||||
you have any conflict of interest with this project.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
|
||||
{hasConflict && (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="conflict-type">Type of Conflict</Label>
|
||||
<Select value={conflictType} onValueChange={setConflictType}>
|
||||
<SelectTrigger id="conflict-type">
|
||||
<SelectValue placeholder="Select conflict type..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="financial">Financial Interest</SelectItem>
|
||||
<SelectItem value="personal">Personal Relationship</SelectItem>
|
||||
<SelectItem value="organizational">Organizational Affiliation</SelectItem>
|
||||
<SelectItem value="other">Other</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="conflict-description">
|
||||
Description <span className="text-muted-foreground">(optional)</span>
|
||||
<div className="space-y-4 py-2">
|
||||
<div className="space-y-3">
|
||||
<Label className="text-sm font-medium">
|
||||
Do you have a conflict of interest with this project?
|
||||
</Label>
|
||||
<Textarea
|
||||
id="conflict-description"
|
||||
placeholder="Briefly describe the nature of your conflict..."
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
rows={3}
|
||||
maxLength={1000}
|
||||
/>
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant={hasConflict === false ? 'default' : 'outline'}
|
||||
className="flex-1"
|
||||
onClick={() => setHasConflict(false)}
|
||||
>
|
||||
No Conflict
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant={hasConflict === true ? 'destructive' : 'outline'}
|
||||
className="flex-1"
|
||||
onClick={() => setHasConflict(true)}
|
||||
>
|
||||
Yes, I Have a Conflict
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<AlertDialogFooter>
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={!canSubmit}
|
||||
>
|
||||
{declareCOI.isPending && (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
)}
|
||||
{hasConflict === null
|
||||
? 'Select an option'
|
||||
: hasConflict
|
||||
? 'Submit Declaration'
|
||||
: 'Confirm No Conflict'}
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
{hasConflict && (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="conflict-type">Type of Conflict</Label>
|
||||
<Select value={conflictType} onValueChange={setConflictType}>
|
||||
<SelectTrigger id="conflict-type">
|
||||
<SelectValue placeholder="Select conflict type..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="financial">Financial Interest</SelectItem>
|
||||
<SelectItem value="personal">Personal Relationship</SelectItem>
|
||||
<SelectItem value="organizational">Organizational Affiliation</SelectItem>
|
||||
<SelectItem value="other">Other</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="conflict-description">
|
||||
Description <span className="text-muted-foreground">(optional)</span>
|
||||
</Label>
|
||||
<Textarea
|
||||
id="conflict-description"
|
||||
placeholder="Briefly describe the nature of your conflict..."
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
rows={3}
|
||||
maxLength={1000}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<AlertDialogFooter>
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={!canSubmit}
|
||||
>
|
||||
{hasConflict === null
|
||||
? 'Select an option'
|
||||
: hasConflict
|
||||
? 'Submit Declaration'
|
||||
: 'Confirm No Conflict'}
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
</>
|
||||
)}
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user