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

@@ -0,0 +1,24 @@
import { NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { errorResponse } from '@/lib/errors';
import { reorderQualificationCriteriaSchema } from '@/lib/validators/qualification';
import { reorderCriteria } from '@/lib/services/qualification.service';
export const POST = withAuth(
withPermission('admin', 'manage_settings', async (req, ctx) => {
try {
const body = await parseBody(req, reorderQualificationCriteriaSchema);
const rows = await reorderCriteria(ctx.portId, body, {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return NextResponse.json({ data: rows });
} catch (error) {
return errorResponse(error);
}
}),
);

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,

View File

@@ -14,10 +14,11 @@ import { and, asc, eq } from 'drizzle-orm';
import { db } from '@/lib/db';
import { interestQualifications, interests, qualificationCriteria } from '@/lib/db/schema';
import { createAuditLog, type AuditMeta } from '@/lib/audit';
import { ConflictError, NotFoundError } from '@/lib/errors';
import { ConflictError, NotFoundError, ValidationError } from '@/lib/errors';
import { emitToRoom } from '@/lib/socket/server';
import type {
CreateQualificationCriterionInput,
ReorderQualificationCriteriaInput,
SetInterestQualificationInput,
UpdateQualificationCriterionInput,
} from '@/lib/validators/qualification';
@@ -137,6 +138,70 @@ export async function deleteCriterion(id: string, portId: string, meta: AuditMet
return { ok: true };
}
/**
* Whole-list reorder. Rewrites display_order to match the array index of
* each id, inside a single transaction so partial failure can't leave the
* port with a scrambled order. The ids array must cover exactly the port's
* current criteria — any mismatch (extra or missing id) is rejected so the
* UI can't silently drop a criterion by sending a stale list.
*/
export async function reorderCriteria(
portId: string,
data: ReorderQualificationCriteriaInput,
meta: AuditMeta,
) {
return db
.transaction(async (tx) => {
const current = await tx
.select({ id: qualificationCriteria.id })
.from(qualificationCriteria)
.where(eq(qualificationCriteria.portId, portId));
const currentIds = new Set(current.map((r) => r.id));
const incomingIds = new Set(data.ids);
if (
currentIds.size !== incomingIds.size ||
[...currentIds].some((id) => !incomingIds.has(id))
) {
throw new ValidationError(
'Reorder payload must reference exactly the ports current criteria',
);
}
for (let i = 0; i < data.ids.length; i++) {
await tx
.update(qualificationCriteria)
.set({ displayOrder: i, updatedAt: new Date() })
.where(
and(
eq(qualificationCriteria.id, data.ids[i]!),
eq(qualificationCriteria.portId, portId),
),
);
}
return tx
.select()
.from(qualificationCriteria)
.where(eq(qualificationCriteria.portId, portId))
.orderBy(asc(qualificationCriteria.displayOrder), asc(qualificationCriteria.createdAt));
})
.then((rows) => {
void createAuditLog({
userId: meta.userId,
portId,
action: 'update',
entityType: 'qualification_criterion',
entityId: 'reorder',
newValue: { orderedIds: data.ids },
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
});
return rows;
});
}
// ─── Per-interest state ─────────────────────────────────────────────────────
export interface QualificationRow {

View File

@@ -22,6 +22,15 @@ export const updateQualificationCriterionSchema = createQualificationCriterionSc
.omit({ key: true })
.partial();
/**
* Whole-list reorder. The IDs array must cover exactly the port's current
* criteria — the service rejects partial / extraneous IDs to keep the
* resulting display_order contiguous.
*/
export const reorderQualificationCriteriaSchema = z.object({
ids: z.array(z.string().min(1)).min(1),
});
/**
* Per-interest qualification state. Only `confirmed` + optional `notes` are
* writable — `confirmedAt` / `confirmedBy` are stamped server-side from
@@ -36,3 +45,4 @@ export const setInterestQualificationSchema = z.object({
export type CreateQualificationCriterionInput = z.infer<typeof createQualificationCriterionSchema>;
export type UpdateQualificationCriterionInput = z.infer<typeof updateQualificationCriterionSchema>;
export type SetInterestQualificationInput = z.infer<typeof setInterestQualificationSchema>;
export type ReorderQualificationCriteriaInput = z.infer<typeof reorderQualificationCriteriaSchema>;