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() {

) : ( - + + )} @@ -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;