Refactor requests, refactor users, add home dashboard
This commit is contained in:
@ -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)
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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 };
|
||||
}
|
||||
123
src/actions/home/getDashboard.ts
Normal file
123
src/actions/home/getDashboard.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
}
|
||||
@ -1,9 +0,0 @@
|
||||
import { CommissionRequestsTable } from "@/components/commissions/CommissionRequestsTable";
|
||||
|
||||
export default function CommissionPage() {
|
||||
return (
|
||||
<div>
|
||||
<CommissionRequestsTable />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
25
src/app/(admin)/commissions/requests/page.tsx
Normal file
25
src/app/(admin)/commissions/requests/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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 zero—those 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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
78
src/components/commissions/requests/RequestsTable.tsx
Normal file
78
src/components/commissions/requests/RequestsTable.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -28,7 +28,7 @@ const artworkItems = [
|
||||
const commissionItems = [
|
||||
{
|
||||
title: "Requests",
|
||||
href: "/commissions",
|
||||
href: "/commissions/requests",
|
||||
},
|
||||
{
|
||||
title: "Types",
|
||||
|
||||
35
src/components/home/StatCard.tsx
Normal file
35
src/components/home/StatCard.tsx
Normal 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
|
||||
);
|
||||
}
|
||||
8
src/components/home/StatusPill.tsx
Normal file
8
src/components/home/StatusPill.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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(),
|
||||
|
||||
@ -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>;
|
||||
Reference in New Issue
Block a user