feat(qualification-criteria): dnd reordering with whole-list PATCH

The chevron up/down buttons rewrote a single row's display_order,
which didn't actually swap positions since the neighbouring rows kept
their original orders. Replaced with a proper drag-handle (dnd-kit
sortable, matching the waiting-list-manager pattern) backed by a new
POST /admin/qualification-criteria/reorder endpoint that rewrites
display_order = index for every row in a transaction. The service
rejects partial / extraneous id lists so a stale UI can't silently
drop a criterion. Optimistic local-cache update keeps the row in
position during the round-trip; rollback on error.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-14 03:49:17 +02:00
parent 905852b8a5
commit 233129f91a
4 changed files with 228 additions and 59 deletions

View File

@@ -2,7 +2,24 @@
import { useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Plus, Trash2, ChevronUp, ChevronDown, Save } from 'lucide-react';
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';
@@ -19,7 +36,6 @@ import {
} from '@/components/ui/dialog';
import { apiFetch } from '@/lib/api/client';
import { toastError } from '@/lib/api/toast-error';
import { cn } from '@/lib/utils';
interface CriterionRow {
id: string;
@@ -36,12 +52,17 @@ interface ListResponse {
/**
* Per-port qualification-criteria admin. Lists current criteria, add via
* the dialog, toggle enabled inline, drag-style reorder via up/down buttons
* (keeps the UI simple for v1; can swap to a real DnD later if reps want it).
* 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<ListResponse>({
queryKey: ['qualification-criteria'],
@@ -59,14 +80,42 @@ export function QualificationCriteriaAdmin() {
onError: (err) => toastError(err),
});
const reorder = useMutation({
mutationFn: async (vars: { id: string; displayOrder: number }) =>
apiFetch(`/api/v1/admin/qualification-criteria/${vars.id}`, {
method: 'PATCH',
body: { displayOrder: vars.displayOrder },
const reorder = useMutation<
ListResponse,
Error,
string[],
{ previous: ListResponse | undefined }
>({
mutationFn: async (ids: string[]) =>
apiFetch('/api/v1/admin/qualification-criteria/reorder', {
method: 'POST',
body: { ids },
}),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['qualification-criteria'] }),
onError: (err) => toastError(err),
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<ListResponse>(['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({
@@ -76,6 +125,16 @@ export function QualificationCriteriaAdmin() {
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 <div className="text-sm text-muted-foreground">Loading criteria</div>;
}
@@ -100,47 +159,15 @@ export function QualificationCriteriaAdmin() {
</p>
</div>
) : (
<ul className="divide-y divide-border rounded-lg border">
{criteria.map((c, idx) => {
const isFirst = idx === 0;
const isLast = idx === criteria.length - 1;
return (
<li key={c.id} className="flex items-start gap-3 p-3">
<div className="flex flex-col gap-0.5">
<button
type="button"
aria-label="Move up"
disabled={isFirst || reorder.isPending}
onClick={() =>
reorder.mutate({ id: c.id, displayOrder: Math.max(0, c.displayOrder - 1) })
}
className={cn(
'rounded p-0.5 text-muted-foreground hover:text-foreground disabled:opacity-30',
)}
>
<ChevronUp className="size-3.5" aria-hidden />
</button>
<button
type="button"
aria-label="Move down"
disabled={isLast || reorder.isPending}
onClick={() => reorder.mutate({ id: c.id, displayOrder: c.displayOrder + 1 })}
className={cn(
'rounded p-0.5 text-muted-foreground hover:text-foreground disabled:opacity-30',
)}
>
<ChevronDown className="size-3.5" aria-hidden />
</button>
</div>
<CriterionEditableRow
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={criteria.map((c) => c.id)} strategy={verticalListSortingStrategy}>
<ul className="divide-y divide-border rounded-lg border">
{criteria.map((c) => (
<SortableCriterionRow
key={c.id}
criterion={c}
onToggleEnabled={(enabled) => toggleEnabled.mutate({ id: c.id, enabled })}
/>
<button
type="button"
aria-label="Delete criterion"
disabled={deleteCriterion.isPending}
onClick={() => {
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).`,
@@ -149,14 +176,12 @@ export function QualificationCriteriaAdmin() {
deleteCriterion.mutate(c.id);
}
}}
className="ml-auto rounded p-1.5 text-muted-foreground hover:bg-destructive/10 hover:text-destructive"
>
<Trash2 className="size-4" aria-hidden />
</button>
</li>
);
})}
</ul>
deleteDisabled={deleteCriterion.isPending}
/>
))}
</ul>
</SortableContext>
</DndContext>
)}
<CreateCriterionDialog open={createOpen} onOpenChange={setCreateOpen} />
@@ -164,6 +189,51 @@ export function QualificationCriteriaAdmin() {
);
}
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 (
<li ref={setNodeRef} style={style} className="flex items-start gap-3 p-3">
<button
type="button"
aria-label="Drag to reorder"
{...attributes}
{...listeners}
className="cursor-grab touch-none rounded p-1 text-muted-foreground hover:text-foreground active:cursor-grabbing"
>
<GripVertical className="size-4" aria-hidden />
</button>
<CriterionEditableRow criterion={criterion} onToggleEnabled={onToggleEnabled} />
<button
type="button"
aria-label="Delete criterion"
disabled={deleteDisabled}
onClick={onDelete}
className="ml-auto rounded p-1.5 text-muted-foreground hover:bg-destructive/10 hover:text-destructive"
>
<Trash2 className="size-4" aria-hidden />
</button>
</li>
);
}
function CriterionEditableRow({
criterion,
onToggleEnabled,