208 lines
6.8 KiB
TypeScript
208 lines
6.8 KiB
TypeScript
"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 item’s 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>
|
||
);
|
||
}
|