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:
24
src/app/api/v1/admin/qualification-criteria/reorder/route.ts
Normal file
24
src/app/api/v1/admin/qualification-criteria/reorder/route.ts
Normal 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);
|
||||
}
|
||||
}),
|
||||
);
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>;
|
||||
|
||||
Reference in New Issue
Block a user