Add extras and options CRUD, add sidebar, add kanban board, udpate packages

This commit is contained in:
2026-01-29 16:18:57 +01:00
parent f9c14ed9fb
commit 507e1b9ee4
28 changed files with 2455 additions and 42 deletions

View File

@ -0,0 +1,207 @@
"use client";
import { updateCommissionRequestStatus } from "@/actions/commissions/requests/updateCommissionRequestStatus";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Kanban, KanbanBoard, KanbanColumn, KanbanItem, KanbanOverlay } from "@/components/ui/kanban";
import {
BOARD_COLUMNS,
type BoardColumnId,
canonicalStatusForColumn,
} from "@/lib/commissions/kanban";
import Link from "next/link";
import * as React from "react";
type BoardItem = {
id: string;
createdAt: string;
status: string;
customerName: string;
customerEmail: string;
message: string;
typeName: string | null;
optionName: string | null;
extrasCount: number;
filesCount: number;
};
type ColumnsState = Record<BoardColumnId, BoardItem[]>;
import type { UniqueIdentifier } from "@dnd-kit/core";
type KanbanValue = Record<UniqueIdentifier, BoardItem[]>;
function isColumnsState(v: KanbanValue): v is ColumnsState {
return (
Array.isArray(v.intake) &&
Array.isArray(v.inProgress) &&
Array.isArray(v.completed)
);
}
function asColumnsState(v: KanbanValue): ColumnsState {
if (!isColumnsState(v)) {
// Defensive: if something ever changes upstream, keep UI stable
return { intake: [], inProgress: [], completed: [] };
}
return v;
}
function findItemColumn(columns: ColumnsState, itemId: string): BoardColumnId | null {
for (const col of Object.keys(columns) as BoardColumnId[]) {
if (columns[col].some((x) => x.id === itemId)) return col;
}
return null;
}
function diffMovedItem(prev: ColumnsState, next: ColumnsState) {
const prevLoc = new Map<string, BoardColumnId>();
const nextLoc = new Map<string, BoardColumnId>();
for (const c of Object.keys(prev) as BoardColumnId[]) {
for (const i of prev[c]) {
prevLoc.set(i.id, c);
}
}
for (const c of Object.keys(next) as BoardColumnId[]) {
for (const i of next[c]) {
nextLoc.set(i.id, c);
}
}
for (const [id, from] of prevLoc.entries()) {
const to = nextLoc.get(id);
if (to && to !== from) return { id, from, to };
}
return null;
}
export default function CommissionsKanbanClient({
initialColumns,
}: {
initialColumns: ColumnsState;
}) {
const [columns, setColumns] = React.useState<ColumnsState>(initialColumns);
const prevRef = React.useRef(columns);
React.useEffect(() => {
prevRef.current = columns;
}, [columns]);
async function persistMove(moved: { id: string; to: BoardColumnId }, snapshotBefore: ColumnsState) {
const status = canonicalStatusForColumn(moved.to);
try {
await updateCommissionRequestStatus({ id: moved.id, status });
// optional: you could also update the items status in local state here
// but revalidatePath + eventual refresh will keep it consistent anyway.
setColumns((cur) => {
const col = findItemColumn(cur, moved.id);
if (!col) return cur;
return {
...cur,
[col]: cur[col].map((x) => (x.id === moved.id ? { ...x, status } : x)),
};
});
} catch {
// Revert optimistic state if update fails
setColumns(snapshotBefore);
}
}
function onValueChange(next: KanbanValue) {
const nextColumns = asColumnsState(next);
const prev = prevRef.current;
setColumns(nextColumns);
const moved = diffMovedItem(prev, nextColumns);
if (!moved) return;
void persistMove({ id: moved.id, to: moved.to }, prev);
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between gap-3">
<div>
<h1 className="text-lg font-semibold">Commissions Board</h1>
<p className="text-sm text-muted-foreground">
Drag requests between columns to update their status.
</p>
</div>
<Button asChild variant="outline">
<Link href="/commissions/requests">Open list</Link>
</Button>
</div>
<Kanban
value={columns as unknown as KanbanValue}
onValueChange={onValueChange}
getItemValue={(item) => item.id}
>
<KanbanBoard className="grid auto-rows-fr gap-3 lg:grid-cols-3">
{(Object.keys(columns) as BoardColumnId[]).map((colId) => {
const col = BOARD_COLUMNS[colId];
const items = columns[colId];
return (
<KanbanColumn key={colId} value={colId} className="rounded-lg border bg-card/50 p-3">
<div className="mb-3 flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold">{col.title}</span>
<Badge variant="secondary" className="rounded-sm">
{items.length}
</Badge>
</div>
</div>
<div className="flex flex-col gap-2">
{items.map((item) => (
<KanbanItem key={item.id} value={item.id} asHandle asChild>
<div className="rounded-md border bg-background p-3 shadow-xs">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<div className="truncate text-sm font-medium">
{item.customerName}
<span className="text-muted-foreground"> #{item.id.slice(0, 6)}</span>
</div>
<div className="truncate text-xs text-muted-foreground">
{item.typeName ?? "No type"}{item.optionName ? ` · ${item.optionName}` : ""}
</div>
</div>
<Badge variant="outline" className="rounded-sm text-[11px]">
{item.status}
</Badge>
</div>
<p className="mt-2 line-clamp-2 text-sm text-muted-foreground">
{item.message}
</p>
<div className="mt-3 flex items-center justify-between text-xs text-muted-foreground">
<span>
Files: {item.filesCount} · Extras: {item.extrasCount}
</span>
<Button asChild size="sm" variant="ghost" className="h-7 px-2">
<Link href={`/commissions/requests/${item.id}`}>Details</Link>
</Button>
</div>
</div>
</KanbanItem>
))}
</div>
</KanbanColumn>
);
})}
</KanbanBoard>
<KanbanOverlay>
<div className="size-full rounded-md bg-primary/10" />
</KanbanOverlay>
</Kanban>
</div>
);
}