Add extras and options CRUD, add sidebar, add kanban board, udpate packages
This commit is contained in:
207
src/components/commissions/kanban/CommissionsKanbanClient.tsx
Normal file
207
src/components/commissions/kanban/CommissionsKanbanClient.tsx
Normal 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 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user