Refactor requests, refactor users, add home dashboard

This commit is contained in:
2026-01-02 00:02:24 +01:00
parent 36fb2358dd
commit 4b308a5c21
20 changed files with 761 additions and 319 deletions

View File

@ -1,13 +1,5 @@
"use client";
import { Download, ExternalLink } from "lucide-react";
import Image from "next/image";
import { useRouter } from "next/navigation";
import * as React from "react";
import { toast } from "sonner";
import type { CommissionStatus } from "@/schemas/commissions/tableSchema";
import { deleteCommissionRequest } from "@/actions/commissions/requests/deleteCommissionRequest";
import { updateCommissionRequest } from "@/actions/commissions/requests/updateCommissionRequest";
import {
@ -31,6 +23,12 @@ import {
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import type { CommissionStatus } from "@/schemas/commissions/requests";
import { Download, ExternalLink } from "lucide-react";
import Image from "next/image";
import { useRouter } from "next/navigation";
import * as React from "react";
import { toast } from "sonner";
type RequestFile = {
id: string;
@ -65,6 +63,7 @@ const STATUS_OPTIONS: CommissionStatus[] = [
"REVIEWING",
"ACCEPTED",
"REJECTED",
"INPROGRESS",
"COMPLETED",
"SPAM",
];
@ -114,30 +113,30 @@ export function CommissionRequestEditor({ request }: { request: RequestShape })
message !== request.message;
return (
<div className="space-y-6">
<div className="space-y-8">
{/* Top bar */}
<div className="flex flex-col gap-3 rounded-2xl border bg-card p-4 shadow-sm md:flex-row md:items-center md:justify-between">
<div className="flex flex-wrap items-center gap-2">
<Badge variant="secondary" className="px-2 py-0.5">
{/* <Badge variant="secondary" className="px-2 py-0.5">
{request.files.length} file{request.files.length === 1 ? "" : "s"}
</Badge>
</Badge> */}
<Badge variant="secondary" className="px-2 py-0.5">
Status: {request.status}
</Badge>
<span className="text-sm text-muted-foreground">
{/* <span className="text-sm text-muted-foreground">
Updated: {new Date(request.updatedAt).toLocaleString()}
</span>
</span> */}
</div>
<div className="flex flex-wrap gap-2">
{request.files.length > 1 ? (
{/* {request.files.length > 1 ? (
<Button asChild variant="outline" className="h-9">
<a href={bulkUrl(request.id)}>
<Download className="mr-2 h-4 w-4" />
Download all (ZIP)
</a>
</Button>
) : null}
) : null} */}
<Button
type="button"
@ -174,21 +173,21 @@ export function CommissionRequestEditor({ request }: { request: RequestShape })
{isSaving ? "Saving…" : "Save changes"}
</Button>
<Button
{/* <Button
type="button"
variant="destructive"
disabled={isSaving}
onClick={() => setDeleteOpen(true)}
>
Delete
</Button>
</Button> */}
</div>
</div>
{/* Request details (artist-facing) */}
<div className="grid gap-6 lg:grid-cols-3">
<div className="space-y-4 rounded-2xl border bg-card p-4 shadow-sm lg:col-span-1">
<div className="space-y-2">
<div className="space-y-4 border bg-card p-4 shadow-sm lg:col-span-1">
<div className="space-y-2 border-b pb-4">
<div className="text-sm font-semibold">Status</div>
<Select value={status} onValueChange={(v) => setStatus(v as CommissionStatus)}>
<SelectTrigger className="h-9">
@ -208,6 +207,17 @@ export function CommissionRequestEditor({ request }: { request: RequestShape })
</div>
</div>
<div className="space-y-2 border-b pb-4">
<div className="text-sm font-semibold">Customer</div>
<Input value={customerName} onChange={(e) => setCustomerName(e.target.value)} />
<Input value={customerEmail} onChange={(e) => setCustomerEmail(e.target.value)} />
<Input
placeholder="Socials (optional)"
value={customerSocials}
onChange={(e) => setCustomerSocials(e.target.value)}
/>
</div>
<div className="space-y-2">
<div className="text-sm font-semibold">Selection</div>
@ -224,14 +234,14 @@ export function CommissionRequestEditor({ request }: { request: RequestShape })
<div className="text-sm">
<div className="text-muted-foreground text-xs">Extras</div>
{request.extras?.length ? (
<div className="mt-1 flex flex-wrap gap-1">
<div className="mt-1 flex flex-wrap flex-col gap-2">
{request.extras.map((e) => (
<span
<div
key={e.id}
className="inline-flex items-center rounded-md border bg-muted/40 px-2 py-0.5 text-xs text-foreground/80"
>
{e.name}
</span>
</div>
))}
</div>
) : (
@ -244,28 +254,28 @@ export function CommissionRequestEditor({ request }: { request: RequestShape })
<div className="font-medium tabular-nums">
{request.priceEstimate
? request.priceEstimate.min === request.priceEstimate.max
? `${request.priceEstimate.min.toFixed(2)}`
: `${request.priceEstimate.min.toFixed(2)} ${request.priceEstimate.max.toFixed(2)}`
? ` ${request.priceEstimate.min.toFixed(2)}`
: ` ${request.priceEstimate.min.toFixed(2)} ${request.priceEstimate.max.toFixed(2)}`
: "—"}
</div>
</div>
</div>
<div className="space-y-2">
<div className="text-sm font-semibold">Customer</div>
<Input value={customerName} onChange={(e) => setCustomerName(e.target.value)} />
<Input value={customerEmail} onChange={(e) => setCustomerEmail(e.target.value)} />
<Input
placeholder="Socials (optional)"
value={customerSocials}
onChange={(e) => setCustomerSocials(e.target.value)}
/>
</div>
</div>
<div className="space-y-6 lg:col-span-2">
{/* Message */}
<div className="space-y-2 border bg-card p-4 shadow-sm">
<div className="text-sm font-semibold">Message</div>
<Textarea
rows={10}
value={message}
onChange={(e) => setMessage(e.target.value)}
className="leading-relaxed"
/>
</div>
{/* Images / files */}
<div className="space-y-3 rounded-2xl border bg-card p-4 shadow-sm">
<div className="space-y-2 border bg-card p-4 shadow-sm">
<div className="flex items-center justify-between gap-3">
<div className="text-sm font-semibold">Reference Images</div>
{request.files.length > 1 ? (
@ -339,17 +349,6 @@ export function CommissionRequestEditor({ request }: { request: RequestShape })
</div>
)}
</div>
{/* Message */}
<div className="space-y-2 rounded-2xl border bg-card p-4 shadow-sm">
<div className="text-sm font-semibold">Message</div>
<Textarea
rows={10}
value={message}
onChange={(e) => setMessage(e.target.value)}
className="leading-relaxed"
/>
</div>
</div>
</div>

View File

@ -1,32 +1,8 @@
"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/requests/deleteCommissionRequest";
import { getCommissionRequestsTablePage } from "@/actions/commissions/requests/getCommissionRequestsTablePage";
import { setCommissionRequestStatus } from "@/actions/commissions/requests/setCommissionRequestStatus";
import type {
CommissionRequestTableRow,
CommissionStatus,
} from "@/schemas/commissions/tableSchema";
import {
AlertDialog,
AlertDialogAction,
@ -62,21 +38,30 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table";
import { CommissionRequestTableRow, CommissionStatus } from "@/schemas/commissions/requests";
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";
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"
@ -110,6 +95,15 @@ function StatusBadge({ status }: { status: CommissionStatus }) {
);
}
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 TriSelectInline(props: { value: TriState; onChange: (v: TriState) => void }) {
return (
<Select value={props.value} onValueChange={(v) => props.onChange(v as TriState)}>
@ -130,6 +124,7 @@ const STATUS_OPTIONS: CommissionStatus[] = [
"REVIEWING",
"ACCEPTED",
"REJECTED",
"INPROGRESS",
"COMPLETED",
"SPAM",
];
@ -202,190 +197,153 @@ export function CommissionRequestsTable() {
refresh();
}, [refresh]);
// React.useEffect(() => {
// // Only poll when tab is visible (avoid wasting requests)
// let timer: ReturnType<typeof setInterval> | null = null;
// const start = () => {
// if (timer) return;
// timer = setInterval(() => {
// // Avoid stacking requests
// if (!isPending) refresh();
// }, 10_000); // every 10s (adjust as you like)
// };
// const stop = () => {
// if (!timer) return;
// clearInterval(timer);
// timer = null;
// };
// const onVisibilityChange = () => {
// if (document.visibilityState === "visible") {
// // Do an immediate refresh when the user returns
// refresh();
// start();
// } else {
// stop();
// }
// };
// onVisibilityChange(); // initialize
// document.addEventListener("visibilitychange", onVisibilityChange);
// return () => {
// stop();
// document.removeEventListener("visibilitychange", onVisibilityChange);
// };
// }, [refresh, isPending]);
// React.useEffect(() => {
// const onFocus = () => refresh();
// window.addEventListener("focus", onFocus);
// return () => window.removeEventListener("focus", onFocus);
// }, [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>
const columns = React.useMemo<ColumnDef<CommissionRequestTableRow>[]>(() => [
{
accessorKey: "index",
header: "Index",
cell: ({ row }) => (
<span className="text-sm font-medium tabular-nums">#{row.index + 1}</span>
),
},
{
id: "requestor",
header: "Requestor",
enableSorting: false,
cell: ({ row }) => {
const r = row.original;
return (
<div className="min-w-0 max-w-105">
<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} />,
},
{
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.fileCount}</span>
),
},
{
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: "complete",
header: "",
enableSorting: false,
cell: ({ row }) => {
const r = row.original;
const isCompleted = r.status === "COMPLETED";
},
{
id: "actions",
header: "",
enableSorting: false,
cell: ({ row }) => {
const r = row.original;
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 />
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
className="cursor-pointer text-destructive focus:text-destructive"
key={s}
className="cursor-pointer"
onSelect={(e) => {
e.preventDefault();
setDeleteTarget({
id: r.id,
label: `${r.customerName} (${r.customerEmail})`,
startTransition(async () => {
await setCommissionRequestStatus({ id: r.id, status: s });
refresh();
});
setDeleteOpen(true);
}}
>
<Trash2 className="mr-2 h-4 w-4" />
Delete
{s}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
},
))}
<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],
);
@ -409,7 +367,7 @@ export function CommissionRequestsTable() {
<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" />
<div className="pointer-events-none absolute inset-x-0 top-0 z-10 h-6 bg-linear-to-b from-background/60 to-transparent" />
<Table>
<TableHeader className="sticky top-0 z-20 bg-card">
<TableRow className="hover:bg-transparent">
@ -541,7 +499,7 @@ export function CommissionRequestsTable() {
setPageIndex(0);
}}
>
<SelectTrigger className="h-9 w-[120px]">
<SelectTrigger className="h-9 w-30">
<SelectValue />
</SelectTrigger>
<SelectContent>
@ -570,7 +528,7 @@ export function CommissionRequestsTable() {
Prev
</Button>
<div className="min-w-[120px] text-center text-sm tabular-nums">
<div className="min-w-30 text-center text-sm tabular-nums">
Page {pageIndex + 1} / {pageCount}
</div>

View File

@ -0,0 +1,78 @@
"use client"
import { deleteCommissionRequest } from "@/actions/commissions/requests/deleteCommissionRequest";
import { Button } from "@/components/ui/button";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { CommissionRequest } from "@/generated/prisma/client";
import { PencilIcon } from "lucide-react";
import Link from "next/link";
type CommissionRequestWithCount = CommissionRequest & {
_count: {
files: number;
};
};
export default function RequestsTable({ requests }: { requests: CommissionRequestWithCount[] }) {
const handleDelete = (id: string) => {
deleteCommissionRequest(id);
};
return (
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[8%]">Index</TableHead>
<TableHead className="w-[68%]">Requester</TableHead>
<TableHead className="w-[8%]">Status</TableHead>
<TableHead className="w-[8%]">Files</TableHead>
<TableHead className="w-[8%] text-right" />
</TableRow>
</TableHeader>
<TableBody>
{requests.map((r) => (
<TableRow key={r.id}>
<TableCell className="font-medium">
<Link href={`/commissions/requests/${r.id}`}># {r.index}</Link>
</TableCell>
<TableCell>
<div>
{r.customerName}
</div>
<div className="font-mono text-sm text-muted-foreground">{r.customerEmail} / {r.customerSocials ? r.customerSocials : "-"} </div>
</TableCell>
<TableCell>
<span className="rounded bg-muted px-2 py-1 text-xs">
{r.status.toLowerCase()}
</span>
</TableCell>
<TableCell className="tabular-nums">
{r._count.files}
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-2">
<Link href={`/commissions/requests/${r.id}`} aria-label={`Edit ${r.index}`}>
<Button size="icon" variant="secondary">
<PencilIcon className="h-4 w-4" />
</Button>
</Link>
{/* <Button
size="icon"
variant="destructive"
aria-label={`Delete ${r.index}`}
onClick={() => handleDelete(r.id)}
>
<Trash2Icon className="h-4 w-4" />
</Button> */}
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
);
}

View File

@ -28,7 +28,7 @@ const artworkItems = [
const commissionItems = [
{
title: "Requests",
href: "/commissions",
href: "/commissions/requests",
},
{
title: "Types",

View File

@ -0,0 +1,35 @@
import Link from "next/link";
import * as React from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
export function StatCard(props: {
title: string;
value: React.ReactNode;
hint?: React.ReactNode;
href?: string;
}) {
const inner = (
<Card className="h-full">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
{props.title}
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-semibold">{props.value}</div>
{props.hint ? (
<div className="mt-1 text-sm text-muted-foreground">{props.hint}</div>
) : null}
</CardContent>
</Card>
);
return props.href ? (
<Link href={props.href} className="block h-full">
{inner}
</Link>
) : (
inner
);
}

View File

@ -0,0 +1,8 @@
export function StatusPill(props: { label: string; value: number }) {
return (
<div className="flex items-center justify-between rounded-md border px-3 py-2 text-sm">
<span className="text-muted-foreground">{props.label}</span>
<span className="font-medium tabular-nums">{props.value}</span>
</div>
);
}