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

@ -0,0 +1,9 @@
/*
Warnings:
- You are about to drop the column `sortIndex` on the `CommissionRequest` table. All the data in the column will be lost.
*/
-- AlterTable
ALTER TABLE "CommissionRequest" DROP COLUMN "sortIndex",
ADD COLUMN "index" SERIAL NOT NULL;

View File

@ -353,14 +353,14 @@ model CommissionTypeCustomInput {
model CommissionRequest {
id String @id @default(cuid())
index Int @default(autoincrement())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
sortIndex Int @default(0)
customerName String
customerEmail String
message String
status String @default("NEW") // NEW | REVIEWING | ACCEPTED | REJECTED | SPAM
status String @default("NEW")
customerSocials String?
ipAddress String?

View File

@ -4,14 +4,13 @@ import { prisma } from "@/lib/prisma";
import {
commissionRequestTableRowSchema,
commissionStatusSchema,
} from "@/schemas/commissions/tableSchema";
} from "@/schemas/commissions/requests";
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({
@ -42,7 +41,6 @@ export async function getCommissionRequestsTablePage(input: {
where.OR = [
{ customerName: { contains: q, mode: "insensitive" } },
{ customerEmail: { contains: q, mode: "insensitive" } },
{ message: { contains: q, mode: "insensitive" } },
];
}
}
@ -78,12 +76,12 @@ export async function getCommissionRequestsTablePage(input: {
take: pagination.pageSize,
select: {
id: true,
index: true,
createdAt: true,
status: true,
customerName: true,
customerEmail: true,
customerSocials: true,
message: true,
_count: { select: { files: true } },
},
}),
@ -91,13 +89,13 @@ export async function getCommissionRequestsTablePage(input: {
const mapped = rows.map((r) => ({
id: r.id,
index: r.index,
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,
status: r.status as any,
fileCount: r._count.files,
}));
// Validate output once (helps catch schema drift)

View File

@ -1,7 +1,7 @@
"use server";
import { prisma } from "@/lib/prisma";
import { commissionStatusSchema } from "@/schemas/commissions/tableSchema";
import { commissionStatusSchema } from "@/schemas/commissions/requests";
import { z } from "zod";
export async function setCommissionRequestStatus(input: {

View File

@ -1,7 +1,7 @@
"use server";
import { prisma } from "@/lib/prisma";
import { commissionStatusSchema } from "@/schemas/commissions/tableSchema";
import { commissionStatusSchema } from "@/schemas/commissions/requests";
import { z } from "zod/v4";
const updateSchema = z.object({

View File

@ -1,23 +0,0 @@
"use server";
import { prisma } from "@/lib/prisma";
export async function deleteItems(itemId: string, type: string) {
switch (type) {
case "categories":
await prisma.artCategory.delete({ where: { id: itemId } });
break;
// case "tags":
// await prisma.artTag.delete({ where: { id: itemId } });
// break;
// case "types":
// await prisma.portfolioType.delete({ where: { id: itemId } });
// break;
// case "albums":
// await prisma.portfolioAlbum.delete({ where: { id: itemId } });
// break;
}
return { success: true };
}

View File

@ -0,0 +1,123 @@
"use server";
import { prisma } from "@/lib/prisma";
type CountRow<K extends string> = {
[P in K]: string;
} & { _count: { _all: number } };
function toCountMapSafe(rows: any[], key: string) {
const out: Record<string, number> = {};
for (const r of rows) out[String(r[key])] = Number(r?._count?._all ?? 0);
return out;
}
export async function getAdminDashboard() {
const now = new Date();
const days = (n: number) => new Date(now.getTime() - n * 24 * 60 * 60 * 1000);
const [
artworkTotal,
artworkPublished,
artworkNeedsWork,
artworkNsfw,
artworkHeader,
colorStatusRows,
recentArtworks,
commissionTotal,
commissionStatusRows,
commissionNew7d,
commissionNew30d,
recentRequests,
userTotal,
userUnverified,
userBanned,
] = await Promise.all([
prisma.artwork.count(),
prisma.artwork.count({ where: { published: true } }),
prisma.artwork.count({ where: { needsWork: true } }),
prisma.artwork.count({ where: { nsfw: true } }),
prisma.artwork.count({ where: { setAsHeader: true } }),
prisma.artwork.groupBy({
by: ["colorStatus"],
_count: { _all: true },
}),
prisma.artwork.findMany({
orderBy: { createdAt: "desc" },
take: 10,
select: {
id: true,
name: true,
slug: true,
createdAt: true,
published: true,
needsWork: true,
colorStatus: true,
},
}),
prisma.commissionRequest.count(),
prisma.commissionRequest.groupBy({
by: ["status"],
_count: { _all: true },
}),
prisma.commissionRequest.count({ where: { createdAt: { gte: days(7) } } }),
prisma.commissionRequest.count({ where: { createdAt: { gte: days(30) } } }),
prisma.commissionRequest.findMany({
orderBy: { createdAt: "desc" },
take: 10,
select: {
id: true,
createdAt: true,
status: true,
customerName: true,
customerEmail: true,
},
}),
prisma.user.count(),
prisma.user.count({ where: { emailVerified: false } }),
prisma.user.count({ where: { banned: true } }),
]);
const colorStatus = toCountMapSafe(colorStatusRows, "colorStatus");
const commissionStatus = toCountMapSafe(commissionStatusRows, "status");
return {
artworks: {
total: artworkTotal,
published: artworkPublished,
unpublished: artworkTotal - artworkPublished,
needsWork: artworkNeedsWork,
nsfw: artworkNsfw,
header: artworkHeader,
colorStatus: {
PENDING: colorStatus.PENDING ?? 0,
PROCESSING: colorStatus.PROCESSING ?? 0,
READY: colorStatus.READY ?? 0,
FAILED: colorStatus.FAILED ?? 0,
},
recent: recentArtworks,
},
commissions: {
total: commissionTotal,
status: {
NEW: commissionStatus.NEW ?? 0,
REVIEWING: commissionStatus.REVIEWING ?? 0,
ACCEPTED: commissionStatus.ACCEPTED ?? 0,
REJECTED: commissionStatus.REJECTED ?? 0,
SPAM: commissionStatus.SPAM ?? 0,
},
new7d: commissionNew7d,
new30d: commissionNew30d,
recent: recentRequests,
},
users: {
total: userTotal,
unverified: userUnverified,
banned: userBanned,
},
};
}

View File

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

View File

@ -12,15 +12,16 @@ export default async function CommissionRequestPage({
if (!request) notFound();
return (
<div className="mx-auto w-full max-w-5xl space-y-6 p-4 md:p-8">
<div className="space-y-1">
<h1 className="text-2xl font-semibold tracking-tight">Commission Request</h1>
<p className="text-sm text-muted-foreground">
Submitted: {new Date(request.createdAt).toLocaleString()} · ID: {request.id}
</p>
<div className="space-y-8">
<div className="flex flex-col gap-4">
<div>
<h1 className="text-2xl font-semibold">Commission Request</h1>
<p className="text-sm text-muted-foreground">
Submitted: {new Date(request.createdAt).toLocaleString()} · ID: {request.id}
</p>
</div>
<CommissionRequestEditor request={request as any} />
</div>
<CommissionRequestEditor request={request as any} />
</div>
);
}

View File

@ -0,0 +1,25 @@
import RequestsTable from "@/components/commissions/requests/RequestsTable";
import { prisma } from "@/lib/prisma";
export default async function CommissionPage() {
const items = await prisma.commissionRequest.findMany({
include: {
_count: { select: { files: true } },
},
orderBy: { index: "desc" },
});
return (
<div className="space-y-8">
<div className="flex flex-col gap-4">
<div>
<h1 className="text-2xl font-semibold">Commission Requests</h1>
<p className="text-sm text-muted-foreground">
List of all incomming requests via website.
</p>
</div>
<RequestsTable requests={items} />
</div>
</div>
);
}

View File

@ -1,7 +1,228 @@
export default function HomePage() {
import Link from "next/link";
import { getAdminDashboard } from "@/actions/home/getDashboard";
import { StatCard } from "@/components/home/StatCard";
import { StatusPill } from "@/components/home/StatusPill";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
function fmtDate(d: Date) {
return new Intl.DateTimeFormat("de-DE", {
year: "numeric",
month: "2-digit",
day: "2-digit",
}).format(d);
}
export default async function HomePage() {
const data = await getAdminDashboard();
return (
<div>
ADMIN HOME
<div className="space-y-8">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h1 className="text-2xl font-semibold">Dashboard</h1>
<p className="text-sm text-muted-foreground">
Quick status of content, commissions, and user hygiene.
</p>
</div>
<div className="flex flex-wrap gap-2">
{/* <Button asChild variant="secondary">
<Link href="/artworks/new">Add artwork</Link>
</Button> */}
<Button asChild>
<Link href="/commissions">Review requests</Link>
</Button>
</div>
</div>
{/* Top stats */}
<section className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<StatCard
title="Artworks"
value={data.artworks.total}
hint={
<>
{data.artworks.published} published · {data.artworks.unpublished}{" "}
unpublished
</>
}
href="/artworks"
/>
<StatCard
title="Needs work"
value={data.artworks.needsWork}
hint="Artwork items flagged for review"
href="/artworks?needsWork=true"
/>
<StatCard
title="Commission requests"
value={data.commissions.total}
hint={
<>
{data.commissions.new7d} new (7d) · {data.commissions.new30d} new
(30d)
</>
}
href="/commissions"
/>
<StatCard
title="Users"
value={data.users.total}
hint={
<>
{data.users.unverified} unverified · {data.users.banned} banned
</>
}
href="/users"
/>
</section>
<section className="grid gap-4 lg:grid-cols-3">
{/* Artwork status */}
<Card>
<CardHeader>
<CardTitle className="text-base">Artwork status</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
<StatusPill label="Published" value={data.artworks.published} />
<StatusPill label="Unpublished" value={data.artworks.unpublished} />
<StatusPill label="NSFW" value={data.artworks.nsfw} />
<StatusPill label="Set as header" value={data.artworks.header} />
</CardContent>
</Card>
{/* Color pipeline */}
<Card>
<CardHeader>
<CardTitle className="text-base">Color pipeline</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
<StatusPill
label="Pending"
value={data.artworks.colorStatus.PENDING}
/>
<StatusPill
label="Processing"
value={data.artworks.colorStatus.PROCESSING}
/>
<StatusPill
label="Ready"
value={data.artworks.colorStatus.READY}
/>
<StatusPill
label="Failed"
value={data.artworks.colorStatus.FAILED}
/>
<div className="pt-2 text-sm text-muted-foreground">
Tip: keep Failed near zerothose typically need a re-run or file
fix.
</div>
</CardContent>
</Card>
{/* Commissions status */}
<Card>
<CardHeader>
<CardTitle className="text-base">Commission pipeline</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
<StatusPill label="New" value={data.commissions.status.NEW} />
<StatusPill
label="Reviewing"
value={data.commissions.status.REVIEWING}
/>
<StatusPill
label="Accepted"
value={data.commissions.status.ACCEPTED}
/>
<StatusPill
label="Rejected"
value={data.commissions.status.REJECTED}
/>
<StatusPill label="Spam" value={data.commissions.status.SPAM} />
</CardContent>
</Card>
</section>
{/* Recent activity */}
<section className="grid gap-4 lg:grid-cols-2">
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="text-base">Recent artworks</CardTitle>
<Button asChild variant="ghost" size="sm">
<Link href="/artworks">Open</Link>
</Button>
</CardHeader>
<CardContent className="space-y-3">
{data.artworks.recent.length === 0 ? (
<div className="text-sm text-muted-foreground">No artworks yet.</div>
) : (
<ul className="space-y-2">
{data.artworks.recent.map((a) => (
<li
key={a.id}
className="flex items-center justify-between gap-3 rounded-md border px-3 py-2"
>
<div className="min-w-0">
<div className="truncate font-medium">{a.name}</div>
<div className="text-xs text-muted-foreground">
{fmtDate(a.createdAt)} · {a.colorStatus}
{a.published ? " · published" : " · draft"}
{a.needsWork ? " · needs work" : ""}
</div>
</div>
<Button asChild variant="secondary" size="sm">
<Link href={`/artworks/${a.slug}`}>Open</Link>
</Button>
</li>
))}
</ul>
)}
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="text-base">Recent commission requests</CardTitle>
<Button asChild variant="ghost" size="sm">
<Link href="/commissions/requests">Open</Link>
</Button>
</CardHeader>
<CardContent className="space-y-3">
{data.commissions.recent.length === 0 ? (
<div className="text-sm text-muted-foreground">
No commission requests yet.
</div>
) : (
<ul className="space-y-2">
{data.commissions.recent.map((r) => (
<li
key={r.id}
className="flex items-center justify-between gap-3 rounded-md border px-3 py-2"
>
<div className="min-w-0">
<div className="truncate font-medium">
{r.customerName}{" "}
<span className="text-muted-foreground">
({r.customerEmail})
</span>
</div>
<div className="text-xs text-muted-foreground">
{fmtDate(r.createdAt)} · {r.status}
</div>
</div>
<Button asChild variant="secondary" size="sm">
<Link href={`/commissions/requests/${r.id}`}>Open</Link>
</Button>
</li>
))}
</ul>
)}
</CardContent>
</Card>
</section>
</div>
);
}

View File

@ -1,10 +1,21 @@
import { ResetPasswordForm } from "@/components/auth/ResetPasswordForm";
import Link from "next/link";
export default function ResetPasswordPage({
export default async function ResetPasswordPage({
searchParams,
}: {
searchParams: { token?: string };
}) {
const { token } = await searchParams;
if (!token) {
return (
<div className="mx-auto max-w-md p-6">
<p>No valid token, please try again or get back to <Link href="/">Home</Link></p>
</div>
)
}
return (
<div className="mx-auto max-w-md p-6">
<h1 className="text-xl font-semibold">Reset password</h1>
@ -12,7 +23,7 @@ export default function ResetPasswordPage({
Choose a new password.
</p>
<div className="mt-6">
<ResetPasswordForm token={searchParams.token ?? ""} />
<ResetPasswordForm token={token ?? ""} />
</div>
</div>
);

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

View File

@ -65,6 +65,13 @@ export async function proxy(request: NextRequest) {
if (pathname === "/login") {
return NextResponse.next();
}
if (pathname === "/reset-password") {
return NextResponse.next();
}
if (pathname === "/forgot-password") {
return NextResponse.next();
}
const session = await auth.api.getSession({
headers: await headers(),

View File

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