Add functions to commission form
This commit is contained in:
590
src/components/commissions/CommissionRequestsTable.tsx
Normal file
590
src/components/commissions/CommissionRequestsTable.tsx
Normal file
@ -0,0 +1,590 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
ColumnDef,
|
||||
SortingState,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table";
|
||||
import {
|
||||
ArrowUpDown,
|
||||
Check,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
MoreHorizontal,
|
||||
Pencil,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import * as React from "react";
|
||||
|
||||
import { deleteCommissionRequest } from "@/actions/commissions/deleteCommissionRequest";
|
||||
import { getCommissionRequestsTablePage } from "@/actions/commissions/getCommissionRequestsTablePage";
|
||||
import { setCommissionRequestStatus } from "@/actions/commissions/setCommissionRequestStatus";
|
||||
import type {
|
||||
CommissionRequestTableRow,
|
||||
CommissionStatus,
|
||||
} from "@/schemas/commissions/tableSchema";
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
|
||||
type TriState = "any" | "true" | "false";
|
||||
|
||||
function useDebouncedValue<T>(value: T, delayMs: number) {
|
||||
const [debounced, setDebounced] = React.useState(value);
|
||||
React.useEffect(() => {
|
||||
const t = setTimeout(() => setDebounced(value), delayMs);
|
||||
return () => clearTimeout(t);
|
||||
}, [value, delayMs]);
|
||||
return debounced;
|
||||
}
|
||||
|
||||
function SortHeader(props: { title: string; column: any }) {
|
||||
const sorted = props.column.getIsSorted() as false | "asc" | "desc";
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => props.column.toggleSorting(sorted === "asc")}
|
||||
className="group inline-flex items-center gap-2 font-semibold text-foreground/90 hover:text-foreground"
|
||||
>
|
||||
<span>{props.title}</span>
|
||||
{sorted === "asc" ? (
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
) : sorted === "desc" ? (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
) : (
|
||||
<ArrowUpDown className="h-4 w-4 opacity-60 group-hover:opacity-100" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: CommissionStatus }) {
|
||||
const variant =
|
||||
status === "COMPLETED"
|
||||
? "default"
|
||||
: status === "REJECTED" || status === "SPAM"
|
||||
? "destructive"
|
||||
: "secondary";
|
||||
|
||||
return (
|
||||
<Badge variant={variant as any} className="px-2 py-0.5">
|
||||
{status}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
function TriSelectInline(props: { value: TriState; onChange: (v: TriState) => void }) {
|
||||
return (
|
||||
<Select value={props.value} onValueChange={(v) => props.onChange(v as TriState)}>
|
||||
<SelectTrigger className="h-9">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="any">Any</SelectItem>
|
||||
<SelectItem value="true">Yes</SelectItem>
|
||||
<SelectItem value="false">No</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
const STATUS_OPTIONS: CommissionStatus[] = [
|
||||
"NEW",
|
||||
"REVIEWING",
|
||||
"ACCEPTED",
|
||||
"REJECTED",
|
||||
"COMPLETED",
|
||||
"SPAM",
|
||||
];
|
||||
|
||||
type Filters = {
|
||||
q: string;
|
||||
email: string;
|
||||
status: "any" | CommissionStatus;
|
||||
hasFiles: TriState;
|
||||
};
|
||||
|
||||
export function CommissionRequestsTable() {
|
||||
const [sorting, setSorting] = React.useState<SortingState>([
|
||||
{ id: "createdAt", desc: true },
|
||||
]);
|
||||
const [pageIndex, setPageIndex] = React.useState(0);
|
||||
const [pageSize, setPageSize] = React.useState(25);
|
||||
|
||||
const [filters, setFilters] = React.useState<Filters>({
|
||||
q: "",
|
||||
email: "",
|
||||
status: "any",
|
||||
hasFiles: "any",
|
||||
});
|
||||
|
||||
const debouncedQ = useDebouncedValue(filters.q, 300);
|
||||
const debouncedEmail = useDebouncedValue(filters.email, 300);
|
||||
|
||||
const [rows, setRows] = React.useState<CommissionRequestTableRow[]>([]);
|
||||
const [total, setTotal] = React.useState(0);
|
||||
|
||||
const [isPending, startTransition] = React.useTransition();
|
||||
|
||||
// Delete dialog
|
||||
const [deleteOpen, setDeleteOpen] = React.useState(false);
|
||||
const [deleteTarget, setDeleteTarget] = React.useState<{
|
||||
id: string;
|
||||
label: string;
|
||||
} | null>(null);
|
||||
|
||||
const pageCount = Math.max(1, Math.ceil(total / pageSize));
|
||||
|
||||
const refresh = React.useCallback(() => {
|
||||
startTransition(async () => {
|
||||
const res = await getCommissionRequestsTablePage({
|
||||
pagination: { pageIndex, pageSize },
|
||||
sorting,
|
||||
filters: {
|
||||
q: debouncedQ || undefined,
|
||||
email: debouncedEmail || undefined,
|
||||
status: filters.status,
|
||||
hasFiles: filters.hasFiles,
|
||||
},
|
||||
});
|
||||
|
||||
setRows(res.rows);
|
||||
setTotal(res.total);
|
||||
});
|
||||
}, [
|
||||
pageIndex,
|
||||
pageSize,
|
||||
sorting,
|
||||
debouncedQ,
|
||||
debouncedEmail,
|
||||
filters.status,
|
||||
filters.hasFiles,
|
||||
]);
|
||||
|
||||
React.useEffect(() => {
|
||||
refresh();
|
||||
}, [refresh]);
|
||||
|
||||
const columns = React.useMemo<ColumnDef<CommissionRequestTableRow>[]>(
|
||||
() => [
|
||||
{
|
||||
accessorKey: "createdAt",
|
||||
header: ({ column }) => <SortHeader title="Submitted" column={column} />,
|
||||
cell: ({ row }) => (
|
||||
<span className="text-sm text-foreground/80">
|
||||
{new Date(row.original.createdAt).toLocaleString()}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "filesCount",
|
||||
header: ({ column }) => <SortHeader title="Files" column={column} />,
|
||||
cell: ({ row }) => (
|
||||
<span className="text-sm font-medium tabular-nums">{row.original.filesCount}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "requestor",
|
||||
header: "Requestor",
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
const r = row.original;
|
||||
return (
|
||||
<div className="min-w-0 max-w-[420px]">
|
||||
<div className="truncate text-sm font-medium">{r.customerName}</div>
|
||||
<div className="truncate text-[11px] leading-4 text-muted-foreground">
|
||||
{r.customerEmail}
|
||||
{r.customerSocials ? ` · ${r.customerSocials}` : ""}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "status",
|
||||
header: ({ column }) => <SortHeader title="Status" column={column} />,
|
||||
cell: ({ row }) => <StatusBadge status={row.original.status} />,
|
||||
},
|
||||
{
|
||||
id: "complete",
|
||||
header: "",
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
const r = row.original;
|
||||
const isCompleted = r.status === "COMPLETED";
|
||||
|
||||
return (
|
||||
<div className="flex justify-center">
|
||||
<Button
|
||||
variant={isCompleted ? "secondary" : "outline"}
|
||||
size="icon"
|
||||
className="h-9 w-9"
|
||||
disabled={isPending || isCompleted}
|
||||
title={isCompleted ? "Completed" : "Mark as completed"}
|
||||
onClick={() => {
|
||||
if (isCompleted) return;
|
||||
startTransition(async () => {
|
||||
await setCommissionRequestStatus({ id: r.id, status: "COMPLETED" });
|
||||
refresh();
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Check className="h-4 w-4" />
|
||||
<span className="sr-only">Mark complete</span>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: "",
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
const r = row.original;
|
||||
|
||||
return (
|
||||
<div className="flex justify-end">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-9 w-9">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
<span className="sr-only">Open row actions</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
{/* status changes */}
|
||||
<div className="px-2 py-1.5 text-xs font-semibold text-muted-foreground">
|
||||
Set status
|
||||
</div>
|
||||
{STATUS_OPTIONS.filter((s) => s !== "COMPLETED").map((s) => (
|
||||
<DropdownMenuItem
|
||||
key={s}
|
||||
className="cursor-pointer"
|
||||
onSelect={(e) => {
|
||||
e.preventDefault();
|
||||
startTransition(async () => {
|
||||
await setCommissionRequestStatus({ id: r.id, status: s });
|
||||
refresh();
|
||||
});
|
||||
}}
|
||||
>
|
||||
{s}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={`/commissions/requests/${r.id}`} className="cursor-pointer">
|
||||
<Pencil className="mr-2 h-4 w-4" />
|
||||
Edit
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer text-destructive focus:text-destructive"
|
||||
onSelect={(e) => {
|
||||
e.preventDefault();
|
||||
setDeleteTarget({
|
||||
id: r.id,
|
||||
label: `${r.customerName} (${r.customerEmail})`,
|
||||
});
|
||||
setDeleteOpen(true);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
[isPending, refresh],
|
||||
);
|
||||
|
||||
const table = useReactTable({
|
||||
data: rows,
|
||||
columns,
|
||||
state: { sorting },
|
||||
manualPagination: true,
|
||||
manualSorting: true,
|
||||
pageCount,
|
||||
onSortingChange: (updater) => {
|
||||
setSorting((prev) => (typeof updater === "function" ? updater(prev) : updater));
|
||||
setPageIndex(0);
|
||||
},
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
});
|
||||
|
||||
const headerGroup = table.getHeaderGroups()[0];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="overflow-hidden rounded-2xl border border-border/60 bg-card shadow-sm ring-1 ring-border/40">
|
||||
<div className="relative">
|
||||
<div className="pointer-events-none absolute inset-x-0 top-0 z-10 h-6 bg-gradient-to-b from-background/60 to-transparent" />
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 z-20 bg-card">
|
||||
<TableRow className="hover:bg-transparent">
|
||||
{headerGroup.headers.map((header) => (
|
||||
<TableHead
|
||||
key={header.id}
|
||||
className="whitespace-nowrap border-b border-border/70 bg-muted/40 py-3 text-xs font-semibold uppercase tracking-wide text-foreground/80"
|
||||
>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(header.column.columnDef.header, header.getContext())}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
|
||||
{/* filter row */}
|
||||
<TableRow className="hover:bg-transparent">
|
||||
{headerGroup.headers.map((header) => {
|
||||
const colId = header.column.id;
|
||||
|
||||
return (
|
||||
<TableHead key={header.id} className="border-b border-border/70 bg-muted/30 py-2">
|
||||
{colId === "createdAt" ? (
|
||||
<div className="h-9" />
|
||||
) : colId === "filesCount" ? (
|
||||
<TriSelectInline
|
||||
value={filters.hasFiles}
|
||||
onChange={(v) => {
|
||||
setFilters((f) => ({ ...f, hasFiles: v }));
|
||||
setPageIndex(0);
|
||||
}}
|
||||
/>
|
||||
) : colId === "requestor" ? (
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
className="h-9"
|
||||
placeholder="Search name/message…"
|
||||
value={filters.q}
|
||||
onChange={(e) => {
|
||||
setFilters((f) => ({ ...f, q: e.target.value }));
|
||||
setPageIndex(0);
|
||||
}}
|
||||
/>
|
||||
<Input
|
||||
className="h-9"
|
||||
placeholder="Email…"
|
||||
value={filters.email}
|
||||
onChange={(e) => {
|
||||
setFilters((f) => ({ ...f, email: e.target.value }));
|
||||
setPageIndex(0);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : colId === "status" ? (
|
||||
<Select
|
||||
value={filters.status}
|
||||
onValueChange={(v) => {
|
||||
setFilters((f) => ({ ...f, status: v as any }));
|
||||
setPageIndex(0);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-9">
|
||||
<SelectValue placeholder="Any" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="any">Any</SelectItem>
|
||||
{STATUS_OPTIONS.map((s) => (
|
||||
<SelectItem key={s} value={s}>
|
||||
{s}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<div className="h-9" />
|
||||
)}
|
||||
</TableHead>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
|
||||
<TableBody>
|
||||
{rows.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={columns.length} className="py-14 text-center">
|
||||
<div className="text-sm font-medium">
|
||||
{isPending ? "Loading…" : "No results."}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
Adjust filters or change page size.
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
table.getRowModel().rows.map((r, idx) => (
|
||||
<TableRow
|
||||
key={r.id}
|
||||
className={[
|
||||
"transition-colors",
|
||||
"hover:bg-muted/50",
|
||||
idx % 2 === 0 ? "bg-background" : "bg-muted/10",
|
||||
].join(" ")}
|
||||
>
|
||||
{r.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id} className="py-3 align-top border-b border-border/40">
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* pagination */}
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{isPending ? "Updating…" : null} Total: {total}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Select
|
||||
value={String(pageSize)}
|
||||
onValueChange={(v) => {
|
||||
setPageSize(Number(v));
|
||||
setPageIndex(0);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-9 w-[120px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{[10, 25, 50, 100].map((n) => (
|
||||
<SelectItem key={n} value={String(n)}>
|
||||
{n} / page
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-9"
|
||||
onClick={() => setPageIndex(0)}
|
||||
disabled={pageIndex === 0 || isPending}
|
||||
>
|
||||
First
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-9"
|
||||
onClick={() => setPageIndex((p) => Math.max(0, p - 1))}
|
||||
disabled={pageIndex === 0 || isPending}
|
||||
>
|
||||
Prev
|
||||
</Button>
|
||||
|
||||
<div className="min-w-[120px] text-center text-sm tabular-nums">
|
||||
Page {pageIndex + 1} / {pageCount}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-9"
|
||||
onClick={() => setPageIndex((p) => Math.min(pageCount - 1, p + 1))}
|
||||
disabled={pageIndex >= pageCount - 1 || isPending}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-9"
|
||||
onClick={() => setPageIndex(Math.max(0, pageCount - 1))}
|
||||
disabled={pageIndex >= pageCount - 1 || isPending}
|
||||
>
|
||||
Last
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* delete confirmation */}
|
||||
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete request?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will delete{" "}
|
||||
<span className="font-medium">{deleteTarget?.label}</span>. This action cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isPending}>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
disabled={isPending || !deleteTarget}
|
||||
onClick={() => {
|
||||
const target = deleteTarget;
|
||||
if (!target) return;
|
||||
|
||||
startTransition(async () => {
|
||||
await deleteCommissionRequest(target.id);
|
||||
setDeleteOpen(false);
|
||||
setDeleteTarget(null);
|
||||
|
||||
// If current page becomes empty after delete, clamp page index.
|
||||
setPageIndex((p) => (p > 0 && rows.length === 1 ? p - 1 : p));
|
||||
refresh();
|
||||
});
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user