From 233129f91a0d1ae1425acca987c93f34c58b3b49 Mon Sep 17 00:00:00 2001
From: Matt
Date: Thu, 14 May 2026 03:49:17 +0200
Subject: [PATCH] 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)
---
.../qualification-criteria/reorder/route.ts | 24 +++
.../admin/qualification-criteria-admin.tsx | 186 ++++++++++++------
src/lib/services/qualification.service.ts | 67 ++++++-
src/lib/validators/qualification.ts | 10 +
4 files changed, 228 insertions(+), 59 deletions(-)
create mode 100644 src/app/api/v1/admin/qualification-criteria/reorder/route.ts
diff --git a/src/app/api/v1/admin/qualification-criteria/reorder/route.ts b/src/app/api/v1/admin/qualification-criteria/reorder/route.ts
new file mode 100644
index 00000000..d3add368
--- /dev/null
+++ b/src/app/api/v1/admin/qualification-criteria/reorder/route.ts
@@ -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);
+ }
+ }),
+);
diff --git a/src/components/admin/qualification-criteria-admin.tsx b/src/components/admin/qualification-criteria-admin.tsx
index f05af49c..1f613546 100644
--- a/src/components/admin/qualification-criteria-admin.tsx
+++ b/src/components/admin/qualification-criteria-admin.tsx
@@ -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({
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(['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 Loading criteria…
;
}
@@ -100,47 +159,15 @@ export function QualificationCriteriaAdmin() {
) : (
-
- {criteria.map((c, idx) => {
- const isFirst = idx === 0;
- const isLast = idx === criteria.length - 1;
- return (
- -
-
-
-
-
-
+ c.id)} strategy={verticalListSortingStrategy}>
+
+ {criteria.map((c) => (
+ toggleEnabled.mutate({ id: c.id, enabled })}
- />
-
-
- );
- })}
-
+ deleteDisabled={deleteCriterion.isPending}
+ />
+ ))}
+
+
+
)}
@@ -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 (
+
+
+
+
+
+ );
+}
+
function CriterionEditableRow({
criterion,
onToggleEnabled,
diff --git a/src/lib/services/qualification.service.ts b/src/lib/services/qualification.service.ts
index ae159e67..b9adebb2 100644
--- a/src/lib/services/qualification.service.ts
+++ b/src/lib/services/qualification.service.ts
@@ -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 port’s 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 {
diff --git a/src/lib/validators/qualification.ts b/src/lib/validators/qualification.ts
index a897da48..284563f9 100644
--- a/src/lib/validators/qualification.ts
+++ b/src/lib/validators/qualification.ts
@@ -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;
export type UpdateQualificationCriterionInput = z.infer;
export type SetInterestQualificationInput = z.infer;
+export type ReorderQualificationCriteriaInput = z.infer;