'use client'; import { useState } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { DndContext, closestCenter, type DragEndEvent, PointerSensor, KeyboardSensor, useSensor, useSensors, } from '@dnd-kit/core'; import { SortableContext, arrayMove, sortableKeyboardCoordinates, useSortable, verticalListSortingStrategy, } from '@dnd-kit/sortable'; import { CSS } from '@dnd-kit/utilities'; import { GripVertical, Plus, Save, Trash2 } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Textarea } from '@/components/ui/textarea'; import { Switch } from '@/components/ui/switch'; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from '@/components/ui/dialog'; import { apiFetch } from '@/lib/api/client'; import { toastError } from '@/lib/api/toast-error'; interface CriterionRow { id: string; key: string; label: string; description: string | null; enabled: boolean; displayOrder: number; } interface ListResponse { data: CriterionRow[]; } /** * Per-port qualification-criteria admin. Lists current criteria, add via * the dialog, toggle enabled inline, drag-to-reorder via dnd-kit (the * whole list ships in one PATCH so partial failure can't scramble the * order — see qualification.service.reorderCriteria). */ export function QualificationCriteriaAdmin() { const queryClient = useQueryClient(); const [createOpen, setCreateOpen] = useState(false); const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 4 } }), useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }), ); const { data, isLoading } = useQuery({ queryKey: ['qualification-criteria'], queryFn: () => apiFetch('/api/v1/admin/qualification-criteria'), }); const criteria = data?.data ?? []; const toggleEnabled = useMutation({ mutationFn: async (vars: { id: string; enabled: boolean }) => apiFetch(`/api/v1/admin/qualification-criteria/${vars.id}`, { method: 'PATCH', body: { enabled: vars.enabled }, }), onSuccess: () => queryClient.invalidateQueries({ queryKey: ['qualification-criteria'] }), onError: (err) => toastError(err), }); const reorder = useMutation< ListResponse, Error, string[], { previous: ListResponse | undefined } >({ mutationFn: async (ids: string[]) => apiFetch('/api/v1/admin/qualification-criteria/reorder', { method: 'POST', body: { ids }, }), onMutate: async (ids: string[]) => { // Optimistic: rewrite the cache order before the round-trip so the // dropped row doesn't snap back to its old slot during the request. await queryClient.cancelQueries({ queryKey: ['qualification-criteria'] }); const previous = queryClient.getQueryData(['qualification-criteria']); if (previous) { const byId = new Map(previous.data.map((c) => [c.id, c] as const)); const next = ids .map((id, idx) => { const c = byId.get(id); return c ? { ...c, displayOrder: idx } : null; }) .filter((c): c is CriterionRow => c !== null); queryClient.setQueryData(['qualification-criteria'], { data: next }); } return { previous }; }, onError: (err, _ids, ctx) => { // Roll back to the snapshot we took in onMutate. if (ctx?.previous) { queryClient.setQueryData(['qualification-criteria'], ctx.previous); } toastError(err); }, onSettled: () => queryClient.invalidateQueries({ queryKey: ['qualification-criteria'] }), }); const deleteCriterion = useMutation({ mutationFn: async (id: string) => apiFetch(`/api/v1/admin/qualification-criteria/${id}`, { method: 'DELETE' }), onSuccess: () => queryClient.invalidateQueries({ queryKey: ['qualification-criteria'] }), onError: (err) => toastError(err), }); function handleDragEnd(event: DragEndEvent) { const { active, over } = event; if (!over || active.id === over.id) return; const oldIndex = criteria.findIndex((c) => c.id === active.id); const newIndex = criteria.findIndex((c) => c.id === over.id); if (oldIndex < 0 || newIndex < 0) return; const nextIds = arrayMove(criteria, oldIndex, newIndex).map((c) => c.id); reorder.mutate(nextIds); } if (isLoading) { return
Loading criteria…
; } return (

{criteria.length} criteria configured · {criteria.filter((c) => c.enabled).length} enabled

{criteria.length === 0 ? (

No criteria configured yet.

Add the first criterion the rep needs to confirm before a deal can be qualified.

) : ( c.id)} strategy={verticalListSortingStrategy}>
    {criteria.map((c) => ( toggleEnabled.mutate({ id: c.id, enabled })} onDelete={() => { if ( confirm( `Delete criterion "${c.label}"? Per-interest state rows for this key will become orphaned (hidden from the UI but kept in audit history).`, ) ) { deleteCriterion.mutate(c.id); } }} deleteDisabled={deleteCriterion.isPending} /> ))}
)}
); } function SortableCriterionRow({ criterion, onToggleEnabled, onDelete, deleteDisabled, }: { criterion: CriterionRow; onToggleEnabled: (enabled: boolean) => void; onDelete: () => void; deleteDisabled: boolean; }) { const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: criterion.id, }); const style = { transform: CSS.Transform.toString(transform), transition, opacity: isDragging ? 0.5 : 1, }; return (
  • ); } function CriterionEditableRow({ criterion, onToggleEnabled, }: { criterion: CriterionRow; onToggleEnabled: (enabled: boolean) => void; }) { const queryClient = useQueryClient(); const [label, setLabel] = useState(criterion.label); const [description, setDescription] = useState(criterion.description ?? ''); const isDirty = label.trim() !== criterion.label || (description.trim() || null) !== criterion.description; const save = useMutation({ mutationFn: async () => apiFetch(`/api/v1/admin/qualification-criteria/${criterion.id}`, { method: 'PATCH', body: { label: label.trim(), description: description.trim() || null }, }), onSuccess: () => queryClient.invalidateQueries({ queryKey: ['qualification-criteria'] }), onError: (err) => toastError(err), }); return (
    setLabel(e.target.value)} className="h-7 max-w-md text-sm font-medium" /> {criterion.key}
    {isDirty ? ( ) : null}