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 { useState } from 'react';
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
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 { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
@@ -19,7 +36,6 @@ import {
|
|||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
import { apiFetch } from '@/lib/api/client';
|
import { apiFetch } from '@/lib/api/client';
|
||||||
import { toastError } from '@/lib/api/toast-error';
|
import { toastError } from '@/lib/api/toast-error';
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
|
|
||||||
interface CriterionRow {
|
interface CriterionRow {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -36,12 +52,17 @@ interface ListResponse {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Per-port qualification-criteria admin. Lists current criteria, add via
|
* Per-port qualification-criteria admin. Lists current criteria, add via
|
||||||
* the dialog, toggle enabled inline, drag-style reorder via up/down buttons
|
* the dialog, toggle enabled inline, drag-to-reorder via dnd-kit (the
|
||||||
* (keeps the UI simple for v1; can swap to a real DnD later if reps want it).
|
* whole list ships in one PATCH so partial failure can't scramble the
|
||||||
|
* order — see qualification.service.reorderCriteria).
|
||||||
*/
|
*/
|
||||||
export function QualificationCriteriaAdmin() {
|
export function QualificationCriteriaAdmin() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [createOpen, setCreateOpen] = useState(false);
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
|
const sensors = useSensors(
|
||||||
|
useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
|
||||||
|
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
|
||||||
|
);
|
||||||
|
|
||||||
const { data, isLoading } = useQuery<ListResponse>({
|
const { data, isLoading } = useQuery<ListResponse>({
|
||||||
queryKey: ['qualification-criteria'],
|
queryKey: ['qualification-criteria'],
|
||||||
@@ -59,14 +80,42 @@ export function QualificationCriteriaAdmin() {
|
|||||||
onError: (err) => toastError(err),
|
onError: (err) => toastError(err),
|
||||||
});
|
});
|
||||||
|
|
||||||
const reorder = useMutation({
|
const reorder = useMutation<
|
||||||
mutationFn: async (vars: { id: string; displayOrder: number }) =>
|
ListResponse,
|
||||||
apiFetch(`/api/v1/admin/qualification-criteria/${vars.id}`, {
|
Error,
|
||||||
method: 'PATCH',
|
string[],
|
||||||
body: { displayOrder: vars.displayOrder },
|
{ previous: ListResponse | undefined }
|
||||||
|
>({
|
||||||
|
mutationFn: async (ids: string[]) =>
|
||||||
|
apiFetch('/api/v1/admin/qualification-criteria/reorder', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { ids },
|
||||||
}),
|
}),
|
||||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['qualification-criteria'] }),
|
onMutate: async (ids: string[]) => {
|
||||||
onError: (err) => toastError(err),
|
// 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({
|
const deleteCriterion = useMutation({
|
||||||
@@ -76,6 +125,16 @@ export function QualificationCriteriaAdmin() {
|
|||||||
onError: (err) => toastError(err),
|
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) {
|
if (isLoading) {
|
||||||
return <div className="text-sm text-muted-foreground">Loading criteria…</div>;
|
return <div className="text-sm text-muted-foreground">Loading criteria…</div>;
|
||||||
}
|
}
|
||||||
@@ -100,47 +159,15 @@ export function QualificationCriteriaAdmin() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<ul className="divide-y divide-border rounded-lg border">
|
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
||||||
{criteria.map((c, idx) => {
|
<SortableContext items={criteria.map((c) => c.id)} strategy={verticalListSortingStrategy}>
|
||||||
const isFirst = idx === 0;
|
<ul className="divide-y divide-border rounded-lg border">
|
||||||
const isLast = idx === criteria.length - 1;
|
{criteria.map((c) => (
|
||||||
return (
|
<SortableCriterionRow
|
||||||
<li key={c.id} className="flex items-start gap-3 p-3">
|
key={c.id}
|
||||||
<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
|
|
||||||
criterion={c}
|
criterion={c}
|
||||||
onToggleEnabled={(enabled) => toggleEnabled.mutate({ id: c.id, enabled })}
|
onToggleEnabled={(enabled) => toggleEnabled.mutate({ id: c.id, enabled })}
|
||||||
/>
|
onDelete={() => {
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label="Delete criterion"
|
|
||||||
disabled={deleteCriterion.isPending}
|
|
||||||
onClick={() => {
|
|
||||||
if (
|
if (
|
||||||
confirm(
|
confirm(
|
||||||
`Delete criterion "${c.label}"? Per-interest state rows for this key will become orphaned (hidden from the UI but kept in audit history).`,
|
`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);
|
deleteCriterion.mutate(c.id);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className="ml-auto rounded p-1.5 text-muted-foreground hover:bg-destructive/10 hover:text-destructive"
|
deleteDisabled={deleteCriterion.isPending}
|
||||||
>
|
/>
|
||||||
<Trash2 className="size-4" aria-hidden />
|
))}
|
||||||
</button>
|
</ul>
|
||||||
</li>
|
</SortableContext>
|
||||||
);
|
</DndContext>
|
||||||
})}
|
|
||||||
</ul>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<CreateCriterionDialog open={createOpen} onOpenChange={setCreateOpen} />
|
<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({
|
function CriterionEditableRow({
|
||||||
criterion,
|
criterion,
|
||||||
onToggleEnabled,
|
onToggleEnabled,
|
||||||
|
|||||||
@@ -14,10 +14,11 @@ import { and, asc, eq } from 'drizzle-orm';
|
|||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
import { interestQualifications, interests, qualificationCriteria } from '@/lib/db/schema';
|
import { interestQualifications, interests, qualificationCriteria } from '@/lib/db/schema';
|
||||||
import { createAuditLog, type AuditMeta } from '@/lib/audit';
|
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 { emitToRoom } from '@/lib/socket/server';
|
||||||
import type {
|
import type {
|
||||||
CreateQualificationCriterionInput,
|
CreateQualificationCriterionInput,
|
||||||
|
ReorderQualificationCriteriaInput,
|
||||||
SetInterestQualificationInput,
|
SetInterestQualificationInput,
|
||||||
UpdateQualificationCriterionInput,
|
UpdateQualificationCriterionInput,
|
||||||
} from '@/lib/validators/qualification';
|
} from '@/lib/validators/qualification';
|
||||||
@@ -137,6 +138,70 @@ export async function deleteCriterion(id: string, portId: string, meta: AuditMet
|
|||||||
return { ok: true };
|
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 ─────────────────────────────────────────────────────
|
// ─── Per-interest state ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
export interface QualificationRow {
|
export interface QualificationRow {
|
||||||
|
|||||||
@@ -22,6 +22,15 @@ export const updateQualificationCriterionSchema = createQualificationCriterionSc
|
|||||||
.omit({ key: true })
|
.omit({ key: true })
|
||||||
.partial();
|
.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
|
* Per-interest qualification state. Only `confirmed` + optional `notes` are
|
||||||
* writable — `confirmedAt` / `confirmedBy` are stamped server-side from
|
* 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 CreateQualificationCriterionInput = z.infer<typeof createQualificationCriterionSchema>;
|
||||||
export type UpdateQualificationCriterionInput = z.infer<typeof updateQualificationCriterionSchema>;
|
export type UpdateQualificationCriterionInput = z.infer<typeof updateQualificationCriterionSchema>;
|
||||||
export type SetInterestQualificationInput = z.infer<typeof setInterestQualificationSchema>;
|
export type SetInterestQualificationInput = z.infer<typeof setInterestQualificationSchema>;
|
||||||
|
export type ReorderQualificationCriteriaInput = z.infer<typeof reorderQualificationCriteriaSchema>;
|
||||||
|
|||||||
Reference in New Issue
Block a user