Files
v2.admin.gaertan.art/src/components/commissions/kanban/CommissionsKanbanClient.tsx

208 lines
6.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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>
);
}