Add functions to commission form

This commit is contained in:
2026-01-01 09:47:04 +01:00
parent 61421aa487
commit 42f23dddcf
12 changed files with 982 additions and 3 deletions

View File

@ -0,0 +1,14 @@
"use server";
import { prisma } from "@/lib/prisma";
import { z } from "zod";
export async function deleteCommissionRequest(id: string) {
const parsed = z.string().min(1).parse(id);
await prisma.commissionRequest.delete({
where: { id: parsed },
});
return { ok: true };
}

View File

@ -0,0 +1,110 @@
"use server";
import { prisma } from "@/lib/prisma";
import {
commissionRequestTableRowSchema,
commissionStatusSchema,
} from "@/schemas/commissions/tableSchema";
import { z } from "zod";
export type CursorPagination = { pageIndex: number; pageSize: number };
export type SortDir = "asc" | "desc";
const triStateSchema = z.enum(["any", "true", "false"]);
type TriState = z.infer<typeof triStateSchema>;
const sortingSchema = z.array(
z.object({
id: z.string(),
desc: z.boolean(),
})
);
const filtersSchema = z.object({
q: z.string().optional(),
email: z.string().optional(),
status: z.union([z.literal("any"), commissionStatusSchema]).default("any"),
hasFiles: triStateSchema.default("any"),
});
export async function getCommissionRequestsTablePage(input: {
pagination: CursorPagination;
sorting: z.infer<typeof sortingSchema>;
filters: z.infer<typeof filtersSchema>;
}) {
const { pagination, sorting, filters } = input;
const where: any = {};
if (filters.q) {
const q = filters.q.trim();
if (q) {
where.OR = [
{ customerName: { contains: q, mode: "insensitive" } },
{ customerEmail: { contains: q, mode: "insensitive" } },
{ message: { contains: q, mode: "insensitive" } },
];
}
}
if (filters.email) {
const e = filters.email.trim();
if (e) where.customerEmail = { contains: e, mode: "insensitive" };
}
if (filters.status !== "any") {
where.status = filters.status;
}
if (filters.hasFiles !== "any") {
where.files = filters.hasFiles === "true" ? { some: {} } : { none: {} };
}
// sorting
const sort = sorting?.[0] ?? { id: "createdAt", desc: true };
const orderBy: any =
sort.id === "createdAt"
? { createdAt: sort.desc ? "desc" : "asc" }
: sort.id === "status"
? { status: sort.desc ? "desc" : "asc" }
: { createdAt: "desc" };
const [total, rows] = await prisma.$transaction([
prisma.commissionRequest.count({ where }),
prisma.commissionRequest.findMany({
where,
orderBy,
skip: pagination.pageIndex * pagination.pageSize,
take: pagination.pageSize,
select: {
id: true,
createdAt: true,
status: true,
customerName: true,
customerEmail: true,
customerSocials: true,
message: true,
_count: { select: { files: true } },
},
}),
]);
const mapped = rows.map((r) => ({
id: r.id,
createdAt: r.createdAt.toISOString(),
status: r.status as any,
customerName: r.customerName,
customerEmail: r.customerEmail,
customerSocials: r.customerSocials ?? null,
messagePreview: r.message.slice(0, 140),
filesCount: r._count.files,
}));
// Validate output once (helps catch schema drift)
const parsed = z.array(commissionRequestTableRowSchema).safeParse(mapped);
if (!parsed.success) {
throw new Error("Commission table row shape mismatch");
}
return { total, rows: parsed.data };
}

View File

@ -0,0 +1,20 @@
"use server";
import { prisma } from "@/lib/prisma";
import { commissionStatusSchema } from "@/schemas/commissions/tableSchema";
import { z } from "zod";
export async function setCommissionRequestStatus(input: {
id: string;
status: z.infer<typeof commissionStatusSchema>;
}) {
const id = z.string().min(1).parse(input.id);
const status = commissionStatusSchema.parse(input.status);
await prisma.commissionRequest.update({
where: { id },
data: { status },
});
return { ok: true };
}

View File

@ -1,5 +1,9 @@
import { CommissionRequestsTable } from "@/components/commissions/CommissionRequestsTable";
export default function CommissionPage() {
return (
<div>CommissionPage</div>
<div>
<CommissionRequestsTable />
</div>
);
}

View File

@ -0,0 +1,139 @@
import { prisma } from "@/lib/prisma";
import { s3 } from "@/lib/s3";
import { PutObjectCommand } from "@aws-sdk/client-s3";
import { NextResponse } from "next/server";
import { v4 as uuidv4 } from "uuid";
import { z } from "zod/v4";
export const runtime = "nodejs"; // required for AWS SDK on many setups
export const dynamic = "force-dynamic"; // API endpoint should be dynamic
const payloadSchema = z.object({
typeId: z.string().min(1).optional().nullable(),
optionId: z.string().min(1).optional().nullable(),
extraIds: z.array(z.string().min(1)).default([]),
customerName: z.string().min(1).max(200),
customerEmail: z.string().email().max(320),
customerSocials: z.string().max(2000).optional().nullable(),
message: z.string().min(1).max(20_000),
// customFields: z.record(z.string(), z.unknown()).optional(),
});
function safeJsonParse(input: string) {
try {
return JSON.parse(input) as unknown;
} catch {
return null;
}
}
export async function POST(request: Request) {
// Optional: basic origin allowlist check (recommended)
// const origin = request.headers.get("origin");
// if (origin && !["https://domain.com", "https://www.domain.com"].includes(origin)) {
// return NextResponse.json({ error: "Invalid origin" }, { status: 403 });
// }
const form = await request.formData();
const payloadRaw = form.get("payload");
if (typeof payloadRaw !== "string") {
return NextResponse.json({ error: "Missing payload" }, { status: 400 });
}
const parsedJson = safeJsonParse(payloadRaw);
if (!parsedJson) {
return NextResponse.json({ error: "Invalid payload JSON" }, { status: 400 });
}
const payload = payloadSchema.safeParse(parsedJson);
if (!payload.success) {
return NextResponse.json(
{ error: "Validation error", issues: payload.error.issues },
{ status: 422 }
);
}
const files = form.getAll("files").filter((v): v is File => v instanceof File);
// Optional: enforce limits
const MAX_FILES = 10;
const MAX_BYTES_EACH = 10 * 1024 * 1024; // 10MB
if (files.length > MAX_FILES) {
return NextResponse.json({ error: "Too many files" }, { status: 413 });
}
for (const f of files) {
if (f.size > MAX_BYTES_EACH) {
return NextResponse.json({ error: `File too large: ${f.name}` }, { status: 413 });
}
}
// Capture basic metadata
const ipAddress =
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? null;
const userAgent = request.headers.get("user-agent") ?? null;
// Create request first to get requestId; then upload files and store file rows
// Use a transaction to keep DB consistent.
const result = await prisma.$transaction(async (tx) => {
const created = await tx.commissionRequest.create({
data: {
status: "NEW",
customerName: payload.data.customerName,
customerEmail: payload.data.customerEmail,
customerSocials: payload.data.customerSocials ?? null,
message: payload.data.message,
typeId: payload.data.typeId ?? null,
optionId: payload.data.optionId ?? null,
ipAddress,
userAgent,
// customFields: payload.data.customFields ?? undefined,
// Extras are M:N; connect by ids
extras: payload.data.extraIds?.length
? { connect: payload.data.extraIds.map((id) => ({ id })) }
: undefined,
},
select: { id: true, createdAt: true },
});
for (const f of files) {
const fileKey = uuidv4();
const fileType = f.type;
const realFileType = fileType.split("/")[1];
const originalKey = `commissions/${fileKey}.${realFileType}`;
const arrayBuffer = await f.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
await s3.send(
new PutObjectCommand({
Bucket: `${process.env.BUCKET_NAME}`,
Key: originalKey,
Body: buffer,
ContentType: f.type || "application/octet-stream",
})
);
await tx.commissionRequestFile.create({
data: {
requestId: created.id,
fileKey: originalKey,
originalFile: f.name,
fileType: fileType || "application/octet-stream",
fileSize: f.size,
uploadDate: created.createdAt,
},
});
}
return created;
});
return NextResponse.json(result, { status: 201 });
}

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

View File

@ -27,7 +27,7 @@ const artworkItems = [
const commissionItems = [
{
title: "Commissions",
title: "Requests",
href: "/commissions",
},
{

View File

@ -26,5 +26,5 @@ export async function proxy(request: NextRequest) {
}
export const config = {
matcher: ["/((?!api/auth|api/image|login|_next/static|_next/image|favicon.ico).*)"],
matcher: ["/((?!api/auth|api/image|api/v1|login|_next/static|_next/image|favicon.ico).*)"],
};

View File

@ -0,0 +1,26 @@
import { z } from "zod";
export const commissionStatusSchema = z.enum([
"NEW",
"REVIEWING",
"ACCEPTED",
"REJECTED",
"COMPLETED",
"SPAM",
]);
export const commissionRequestTableRowSchema = z.object({
id: z.string(),
createdAt: z.string(), // ISO
status: commissionStatusSchema,
customerName: z.string(),
customerEmail: z.string(),
customerSocials: z.string().nullable().optional(),
messagePreview: z.string().optional(), // optional, useful for hover later
filesCount: z.number().int().nonnegative(),
});
export type CommissionRequestTableRow = z.infer<typeof commissionRequestTableRowSchema>;
export type CommissionStatus = z.infer<typeof commissionStatusSchema>;