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,24 @@
-- AlterTable
ALTER TABLE "CommissionRequest" ADD COLUMN "customFields" JSONB,
ADD COLUMN "customerSocials" TEXT,
ADD COLUMN "ipAddress" TEXT,
ADD COLUMN "status" TEXT NOT NULL DEFAULT 'NEW',
ADD COLUMN "userAgent" TEXT;
-- CreateTable
CREATE TABLE "CommissionRequestFile" (
"id" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"sortIndex" INTEGER NOT NULL DEFAULT 0,
"objectKey" TEXT NOT NULL,
"originalName" TEXT NOT NULL,
"mimeType" TEXT NOT NULL,
"sizeBytes" INTEGER NOT NULL,
"requestId" TEXT NOT NULL,
CONSTRAINT "CommissionRequestFile_pkey" PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "CommissionRequestFile" ADD CONSTRAINT "CommissionRequestFile_requestId_fkey" FOREIGN KEY ("requestId") REFERENCES "CommissionRequest"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -0,0 +1,30 @@
/*
Warnings:
- You are about to drop the column `mimeType` on the `CommissionRequestFile` table. All the data in the column will be lost.
- You are about to drop the column `objectKey` on the `CommissionRequestFile` table. All the data in the column will be lost.
- You are about to drop the column `originalName` on the `CommissionRequestFile` table. All the data in the column will be lost.
- You are about to drop the column `sizeBytes` on the `CommissionRequestFile` table. All the data in the column will be lost.
- You are about to drop the column `sortIndex` on the `CommissionRequestFile` table. All the data in the column will be lost.
- A unique constraint covering the columns `[fileKey]` on the table `CommissionRequestFile` will be added. If there are existing duplicate values, this will fail.
- Added the required column `fileKey` to the `CommissionRequestFile` table without a default value. This is not possible if the table is not empty.
- Added the required column `fileSize` to the `CommissionRequestFile` table without a default value. This is not possible if the table is not empty.
- Added the required column `fileType` to the `CommissionRequestFile` table without a default value. This is not possible if the table is not empty.
- Added the required column `originalFile` to the `CommissionRequestFile` table without a default value. This is not possible if the table is not empty.
- Added the required column `uploadDate` to the `CommissionRequestFile` table without a default value. This is not possible if the table is not empty.
*/
-- AlterTable
ALTER TABLE "CommissionRequestFile" DROP COLUMN "mimeType",
DROP COLUMN "objectKey",
DROP COLUMN "originalName",
DROP COLUMN "sizeBytes",
DROP COLUMN "sortIndex",
ADD COLUMN "fileKey" TEXT NOT NULL,
ADD COLUMN "fileSize" INTEGER NOT NULL,
ADD COLUMN "fileType" TEXT NOT NULL,
ADD COLUMN "originalFile" TEXT NOT NULL,
ADD COLUMN "uploadDate" TIMESTAMP(3) NOT NULL;
-- CreateIndex
CREATE UNIQUE INDEX "CommissionRequestFile_fileKey_key" ON "CommissionRequestFile"("fileKey");

View File

@ -360,6 +360,12 @@ model CommissionRequest {
customerName String customerName String
customerEmail String customerEmail String
message String message String
status String @default("NEW") // NEW | REVIEWING | ACCEPTED | REJECTED | SPAM
customerSocials String?
ipAddress String?
userAgent String?
customFields Json?
optionId String? optionId String?
typeId String? typeId String?
@ -367,6 +373,7 @@ model CommissionRequest {
type CommissionType? @relation(fields: [typeId], references: [id]) type CommissionType? @relation(fields: [typeId], references: [id])
extras CommissionExtra[] extras CommissionExtra[]
files CommissionRequestFile[]
} }
model CommissionGuidelines { model CommissionGuidelines {
@ -380,6 +387,21 @@ model CommissionGuidelines {
@@index([isActive]) @@index([isActive])
} }
model CommissionRequestFile {
id String @id @default(cuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
fileKey String @unique
originalFile String
fileType String
fileSize Int
uploadDate DateTime
requestId String
request CommissionRequest @relation(fields: [requestId], references: [id], onDelete: Cascade)
}
model TermsOfService { model TermsOfService {
id String @id @default(cuid()) id String @id @default(cuid())
createdAt DateTime @default(now()) createdAt DateTime @default(now())

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() { export default function CommissionPage() {
return ( 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 = [ const commissionItems = [
{ {
title: "Commissions", title: "Requests",
href: "/commissions", href: "/commissions",
}, },
{ {

View File

@ -26,5 +26,5 @@ export async function proxy(request: NextRequest) {
} }
export const config = { 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>;