Refactor requests, refactor users, add home dashboard
This commit is contained in:
9
prisma/migrations/20260101220549_com_5/migration.sql
Normal file
9
prisma/migrations/20260101220549_com_5/migration.sql
Normal 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;
|
||||||
@ -353,14 +353,14 @@ model CommissionTypeCustomInput {
|
|||||||
|
|
||||||
model CommissionRequest {
|
model CommissionRequest {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
|
index Int @default(autoincrement())
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
sortIndex Int @default(0)
|
|
||||||
|
|
||||||
customerName String
|
customerName String
|
||||||
customerEmail String
|
customerEmail String
|
||||||
message String
|
message String
|
||||||
status String @default("NEW") // NEW | REVIEWING | ACCEPTED | REJECTED | SPAM
|
status String @default("NEW")
|
||||||
|
|
||||||
customerSocials String?
|
customerSocials String?
|
||||||
ipAddress String?
|
ipAddress String?
|
||||||
|
|||||||
@ -4,14 +4,13 @@ import { prisma } from "@/lib/prisma";
|
|||||||
import {
|
import {
|
||||||
commissionRequestTableRowSchema,
|
commissionRequestTableRowSchema,
|
||||||
commissionStatusSchema,
|
commissionStatusSchema,
|
||||||
} from "@/schemas/commissions/tableSchema";
|
} from "@/schemas/commissions/requests";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
export type CursorPagination = { pageIndex: number; pageSize: number };
|
export type CursorPagination = { pageIndex: number; pageSize: number };
|
||||||
export type SortDir = "asc" | "desc";
|
export type SortDir = "asc" | "desc";
|
||||||
|
|
||||||
const triStateSchema = z.enum(["any", "true", "false"]);
|
const triStateSchema = z.enum(["any", "true", "false"]);
|
||||||
type TriState = z.infer<typeof triStateSchema>;
|
|
||||||
|
|
||||||
const sortingSchema = z.array(
|
const sortingSchema = z.array(
|
||||||
z.object({
|
z.object({
|
||||||
@ -42,7 +41,6 @@ export async function getCommissionRequestsTablePage(input: {
|
|||||||
where.OR = [
|
where.OR = [
|
||||||
{ customerName: { contains: q, mode: "insensitive" } },
|
{ customerName: { contains: q, mode: "insensitive" } },
|
||||||
{ customerEmail: { 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,
|
take: pagination.pageSize,
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
|
index: true,
|
||||||
createdAt: true,
|
createdAt: true,
|
||||||
status: true,
|
status: true,
|
||||||
customerName: true,
|
customerName: true,
|
||||||
customerEmail: true,
|
customerEmail: true,
|
||||||
customerSocials: true,
|
customerSocials: true,
|
||||||
message: true,
|
|
||||||
_count: { select: { files: true } },
|
_count: { select: { files: true } },
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
@ -91,13 +89,13 @@ export async function getCommissionRequestsTablePage(input: {
|
|||||||
|
|
||||||
const mapped = rows.map((r) => ({
|
const mapped = rows.map((r) => ({
|
||||||
id: r.id,
|
id: r.id,
|
||||||
|
index: r.index,
|
||||||
createdAt: r.createdAt.toISOString(),
|
createdAt: r.createdAt.toISOString(),
|
||||||
status: r.status as any,
|
|
||||||
customerName: r.customerName,
|
customerName: r.customerName,
|
||||||
customerEmail: r.customerEmail,
|
customerEmail: r.customerEmail,
|
||||||
customerSocials: r.customerSocials ?? null,
|
customerSocials: r.customerSocials ?? null,
|
||||||
messagePreview: r.message.slice(0, 140),
|
status: r.status as any,
|
||||||
filesCount: r._count.files,
|
fileCount: r._count.files,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Validate output once (helps catch schema drift)
|
// Validate output once (helps catch schema drift)
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { commissionStatusSchema } from "@/schemas/commissions/tableSchema";
|
import { commissionStatusSchema } from "@/schemas/commissions/requests";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
export async function setCommissionRequestStatus(input: {
|
export async function setCommissionRequestStatus(input: {
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { commissionStatusSchema } from "@/schemas/commissions/tableSchema";
|
import { commissionStatusSchema } from "@/schemas/commissions/requests";
|
||||||
import { z } from "zod/v4";
|
import { z } from "zod/v4";
|
||||||
|
|
||||||
const updateSchema = z.object({
|
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();
|
if (!request) notFound();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto w-full max-w-5xl space-y-6 p-4 md:p-8">
|
<div className="space-y-8">
|
||||||
<div className="space-y-1">
|
<div className="flex flex-col gap-4">
|
||||||
<h1 className="text-2xl font-semibold tracking-tight">Commission Request</h1>
|
<div>
|
||||||
<p className="text-sm text-muted-foreground">
|
<h1 className="text-2xl font-semibold">Commission Request</h1>
|
||||||
Submitted: {new Date(request.createdAt).toLocaleString()} · ID: {request.id}
|
<p className="text-sm text-muted-foreground">
|
||||||
</p>
|
Submitted: {new Date(request.createdAt).toLocaleString()} · ID: {request.id}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<CommissionRequestEditor request={request as any} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<CommissionRequestEditor request={request as any} />
|
|
||||||
</div>
|
</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 (
|
return (
|
||||||
<div>
|
<div className="space-y-8">
|
||||||
ADMIN HOME
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,10 +1,21 @@
|
|||||||
import { ResetPasswordForm } from "@/components/auth/ResetPasswordForm";
|
import { ResetPasswordForm } from "@/components/auth/ResetPasswordForm";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
export default function ResetPasswordPage({
|
export default async function ResetPasswordPage({
|
||||||
searchParams,
|
searchParams,
|
||||||
}: {
|
}: {
|
||||||
searchParams: { token?: string };
|
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 (
|
return (
|
||||||
<div className="mx-auto max-w-md p-6">
|
<div className="mx-auto max-w-md p-6">
|
||||||
<h1 className="text-xl font-semibold">Reset password</h1>
|
<h1 className="text-xl font-semibold">Reset password</h1>
|
||||||
@ -12,7 +23,7 @@ export default function ResetPasswordPage({
|
|||||||
Choose a new password.
|
Choose a new password.
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-6">
|
<div className="mt-6">
|
||||||
<ResetPasswordForm token={searchParams.token ?? ""} />
|
<ResetPasswordForm token={token ?? ""} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,13 +1,5 @@
|
|||||||
"use client";
|
"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 { deleteCommissionRequest } from "@/actions/commissions/requests/deleteCommissionRequest";
|
||||||
import { updateCommissionRequest } from "@/actions/commissions/requests/updateCommissionRequest";
|
import { updateCommissionRequest } from "@/actions/commissions/requests/updateCommissionRequest";
|
||||||
import {
|
import {
|
||||||
@ -31,6 +23,12 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
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 = {
|
type RequestFile = {
|
||||||
id: string;
|
id: string;
|
||||||
@ -65,6 +63,7 @@ const STATUS_OPTIONS: CommissionStatus[] = [
|
|||||||
"REVIEWING",
|
"REVIEWING",
|
||||||
"ACCEPTED",
|
"ACCEPTED",
|
||||||
"REJECTED",
|
"REJECTED",
|
||||||
|
"INPROGRESS",
|
||||||
"COMPLETED",
|
"COMPLETED",
|
||||||
"SPAM",
|
"SPAM",
|
||||||
];
|
];
|
||||||
@ -114,30 +113,30 @@ export function CommissionRequestEditor({ request }: { request: RequestShape })
|
|||||||
message !== request.message;
|
message !== request.message;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-8">
|
||||||
{/* Top bar */}
|
{/* 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-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">
|
<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"}
|
{request.files.length} file{request.files.length === 1 ? "" : "s"}
|
||||||
</Badge>
|
</Badge> */}
|
||||||
<Badge variant="secondary" className="px-2 py-0.5">
|
<Badge variant="secondary" className="px-2 py-0.5">
|
||||||
Status: {request.status}
|
Status: {request.status}
|
||||||
</Badge>
|
</Badge>
|
||||||
<span className="text-sm text-muted-foreground">
|
{/* <span className="text-sm text-muted-foreground">
|
||||||
Updated: {new Date(request.updatedAt).toLocaleString()}
|
Updated: {new Date(request.updatedAt).toLocaleString()}
|
||||||
</span>
|
</span> */}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{request.files.length > 1 ? (
|
{/* {request.files.length > 1 ? (
|
||||||
<Button asChild variant="outline" className="h-9">
|
<Button asChild variant="outline" className="h-9">
|
||||||
<a href={bulkUrl(request.id)}>
|
<a href={bulkUrl(request.id)}>
|
||||||
<Download className="mr-2 h-4 w-4" />
|
<Download className="mr-2 h-4 w-4" />
|
||||||
Download all (ZIP)
|
Download all (ZIP)
|
||||||
</a>
|
</a>
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null} */}
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
@ -174,21 +173,21 @@ export function CommissionRequestEditor({ request }: { request: RequestShape })
|
|||||||
{isSaving ? "Saving…" : "Save changes"}
|
{isSaving ? "Saving…" : "Save changes"}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button
|
{/* <Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
disabled={isSaving}
|
disabled={isSaving}
|
||||||
onClick={() => setDeleteOpen(true)}
|
onClick={() => setDeleteOpen(true)}
|
||||||
>
|
>
|
||||||
Delete
|
Delete
|
||||||
</Button>
|
</Button> */}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Request details (artist-facing) */}
|
{/* Request details (artist-facing) */}
|
||||||
<div className="grid gap-6 lg:grid-cols-3">
|
<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-4 border bg-card p-4 shadow-sm lg:col-span-1">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2 border-b pb-4">
|
||||||
<div className="text-sm font-semibold">Status</div>
|
<div className="text-sm font-semibold">Status</div>
|
||||||
<Select value={status} onValueChange={(v) => setStatus(v as CommissionStatus)}>
|
<Select value={status} onValueChange={(v) => setStatus(v as CommissionStatus)}>
|
||||||
<SelectTrigger className="h-9">
|
<SelectTrigger className="h-9">
|
||||||
@ -208,6 +207,17 @@ export function CommissionRequestEditor({ request }: { request: RequestShape })
|
|||||||
</div>
|
</div>
|
||||||
</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="space-y-2">
|
||||||
<div className="text-sm font-semibold">Selection</div>
|
<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-sm">
|
||||||
<div className="text-muted-foreground text-xs">Extras</div>
|
<div className="text-muted-foreground text-xs">Extras</div>
|
||||||
{request.extras?.length ? (
|
{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) => (
|
{request.extras.map((e) => (
|
||||||
<span
|
<div
|
||||||
key={e.id}
|
key={e.id}
|
||||||
className="inline-flex items-center rounded-md border bg-muted/40 px-2 py-0.5 text-xs text-foreground/80"
|
className="inline-flex items-center rounded-md border bg-muted/40 px-2 py-0.5 text-xs text-foreground/80"
|
||||||
>
|
>
|
||||||
{e.name}
|
{e.name}
|
||||||
</span>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@ -244,28 +254,28 @@ export function CommissionRequestEditor({ request }: { request: RequestShape })
|
|||||||
<div className="font-medium tabular-nums">
|
<div className="font-medium tabular-nums">
|
||||||
{request.priceEstimate
|
{request.priceEstimate
|
||||||
? request.priceEstimate.min === request.priceEstimate.max
|
? request.priceEstimate.min === request.priceEstimate.max
|
||||||
? `€${request.priceEstimate.min.toFixed(2)}`
|
? `€ ${request.priceEstimate.min.toFixed(2)}`
|
||||||
: `€${request.priceEstimate.min.toFixed(2)} – €${request.priceEstimate.max.toFixed(2)}`
|
: `€ ${request.priceEstimate.min.toFixed(2)} – € ${request.priceEstimate.max.toFixed(2)}`
|
||||||
: "—"}
|
: "—"}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div className="space-y-6 lg:col-span-2">
|
<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 */}
|
{/* 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="flex items-center justify-between gap-3">
|
||||||
<div className="text-sm font-semibold">Reference Images</div>
|
<div className="text-sm font-semibold">Reference Images</div>
|
||||||
{request.files.length > 1 ? (
|
{request.files.length > 1 ? (
|
||||||
@ -339,17 +349,6 @@ export function CommissionRequestEditor({ request }: { request: RequestShape })
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -1,32 +1,8 @@
|
|||||||
"use client";
|
"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 { deleteCommissionRequest } from "@/actions/commissions/requests/deleteCommissionRequest";
|
||||||
import { getCommissionRequestsTablePage } from "@/actions/commissions/requests/getCommissionRequestsTablePage";
|
import { getCommissionRequestsTablePage } from "@/actions/commissions/requests/getCommissionRequestsTablePage";
|
||||||
import { setCommissionRequestStatus } from "@/actions/commissions/requests/setCommissionRequestStatus";
|
import { setCommissionRequestStatus } from "@/actions/commissions/requests/setCommissionRequestStatus";
|
||||||
import type {
|
|
||||||
CommissionRequestTableRow,
|
|
||||||
CommissionStatus,
|
|
||||||
} from "@/schemas/commissions/tableSchema";
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
AlertDialogAction,
|
AlertDialogAction,
|
||||||
@ -62,21 +38,30 @@ import {
|
|||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from "@/components/ui/table";
|
} 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";
|
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 }) {
|
function SortHeader(props: { title: string; column: any }) {
|
||||||
const sorted = props.column.getIsSorted() as false | "asc" | "desc";
|
const sorted = props.column.getIsSorted() as false | "asc" | "desc";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="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 }) {
|
function TriSelectInline(props: { value: TriState; onChange: (v: TriState) => void }) {
|
||||||
return (
|
return (
|
||||||
<Select value={props.value} onValueChange={(v) => props.onChange(v as TriState)}>
|
<Select value={props.value} onValueChange={(v) => props.onChange(v as TriState)}>
|
||||||
@ -130,6 +124,7 @@ const STATUS_OPTIONS: CommissionStatus[] = [
|
|||||||
"REVIEWING",
|
"REVIEWING",
|
||||||
"ACCEPTED",
|
"ACCEPTED",
|
||||||
"REJECTED",
|
"REJECTED",
|
||||||
|
"INPROGRESS",
|
||||||
"COMPLETED",
|
"COMPLETED",
|
||||||
"SPAM",
|
"SPAM",
|
||||||
];
|
];
|
||||||
@ -202,190 +197,153 @@ export function CommissionRequestsTable() {
|
|||||||
refresh();
|
refresh();
|
||||||
}, [refresh]);
|
}, [refresh]);
|
||||||
|
|
||||||
// React.useEffect(() => {
|
const columns = React.useMemo<ColumnDef<CommissionRequestTableRow>[]>(() => [
|
||||||
// // Only poll when tab is visible (avoid wasting requests)
|
{
|
||||||
// let timer: ReturnType<typeof setInterval> | null = null;
|
accessorKey: "index",
|
||||||
|
header: "Index",
|
||||||
// const start = () => {
|
cell: ({ row }) => (
|
||||||
// if (timer) return;
|
<span className="text-sm font-medium tabular-nums">#{row.index + 1}</span>
|
||||||
// timer = setInterval(() => {
|
),
|
||||||
// // Avoid stacking requests
|
},
|
||||||
// if (!isPending) refresh();
|
{
|
||||||
// }, 10_000); // every 10s (adjust as you like)
|
id: "requestor",
|
||||||
// };
|
header: "Requestor",
|
||||||
|
enableSorting: false,
|
||||||
// const stop = () => {
|
cell: ({ row }) => {
|
||||||
// if (!timer) return;
|
const r = row.original;
|
||||||
// clearInterval(timer);
|
return (
|
||||||
// timer = null;
|
<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">
|
||||||
// const onVisibilityChange = () => {
|
{r.customerEmail}
|
||||||
// if (document.visibilityState === "visible") {
|
{r.customerSocials ? ` · ${r.customerSocials}` : ""}
|
||||||
// // 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>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
</div>
|
||||||
},
|
);
|
||||||
},
|
},
|
||||||
{
|
},
|
||||||
accessorKey: "status",
|
{
|
||||||
header: ({ column }) => <SortHeader title="Status" column={column} />,
|
accessorKey: "createdAt",
|
||||||
cell: ({ row }) => <StatusBadge status={row.original.status} />,
|
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: "",
|
id: "actions",
|
||||||
enableSorting: false,
|
header: "",
|
||||||
cell: ({ row }) => {
|
enableSorting: false,
|
||||||
const r = row.original;
|
cell: ({ row }) => {
|
||||||
const isCompleted = r.status === "COMPLETED";
|
const r = row.original;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-center">
|
<div className="flex justify-end">
|
||||||
<Button
|
<DropdownMenu>
|
||||||
variant={isCompleted ? "secondary" : "outline"}
|
<DropdownMenuTrigger asChild>
|
||||||
size="icon"
|
<Button variant="ghost" size="icon" className="h-9 w-9">
|
||||||
className="h-9 w-9"
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
disabled={isPending || isCompleted}
|
<span className="sr-only">Open row actions</span>
|
||||||
title={isCompleted ? "Completed" : "Mark as completed"}
|
</Button>
|
||||||
onClick={() => {
|
</DropdownMenuTrigger>
|
||||||
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 />
|
|
||||||
|
|
||||||
|
<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
|
<DropdownMenuItem
|
||||||
className="cursor-pointer text-destructive focus:text-destructive"
|
key={s}
|
||||||
|
className="cursor-pointer"
|
||||||
onSelect={(e) => {
|
onSelect={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setDeleteTarget({
|
startTransition(async () => {
|
||||||
id: r.id,
|
await setCommissionRequestStatus({ id: r.id, status: s });
|
||||||
label: `${r.customerName} (${r.customerEmail})`,
|
refresh();
|
||||||
});
|
});
|
||||||
setDeleteOpen(true);
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Trash2 className="mr-2 h-4 w-4" />
|
{s}
|
||||||
Delete
|
|
||||||
</DropdownMenuItem>
|
</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],
|
[isPending, refresh],
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -409,7 +367,7 @@ export function CommissionRequestsTable() {
|
|||||||
<div className="space-y-4">
|
<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="overflow-hidden rounded-2xl border border-border/60 bg-card shadow-sm ring-1 ring-border/40">
|
||||||
<div className="relative">
|
<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>
|
<Table>
|
||||||
<TableHeader className="sticky top-0 z-20 bg-card">
|
<TableHeader className="sticky top-0 z-20 bg-card">
|
||||||
<TableRow className="hover:bg-transparent">
|
<TableRow className="hover:bg-transparent">
|
||||||
@ -541,7 +499,7 @@ export function CommissionRequestsTable() {
|
|||||||
setPageIndex(0);
|
setPageIndex(0);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="h-9 w-[120px]">
|
<SelectTrigger className="h-9 w-30">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@ -570,7 +528,7 @@ export function CommissionRequestsTable() {
|
|||||||
Prev
|
Prev
|
||||||
</Button>
|
</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}
|
Page {pageIndex + 1} / {pageCount}
|
||||||
</div>
|
</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 = [
|
const commissionItems = [
|
||||||
{
|
{
|
||||||
title: "Requests",
|
title: "Requests",
|
||||||
href: "/commissions",
|
href: "/commissions/requests",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Types",
|
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") {
|
if (pathname === "/login") {
|
||||||
return NextResponse.next();
|
return NextResponse.next();
|
||||||
}
|
}
|
||||||
|
if (pathname === "/reset-password") {
|
||||||
|
return NextResponse.next();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pathname === "/forgot-password") {
|
||||||
|
return NextResponse.next();
|
||||||
|
}
|
||||||
|
|
||||||
const session = await auth.api.getSession({
|
const session = await auth.api.getSession({
|
||||||
headers: await headers(),
|
headers: await headers(),
|
||||||
|
|||||||
@ -1,25 +1,26 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod/v4";
|
||||||
|
|
||||||
export const commissionStatusSchema = z.enum([
|
export const commissionStatusSchema = z.enum([
|
||||||
"NEW",
|
"NEW",
|
||||||
"REVIEWING",
|
"REVIEWING",
|
||||||
"ACCEPTED",
|
"ACCEPTED",
|
||||||
"REJECTED",
|
"REJECTED",
|
||||||
|
"INPROGRESS",
|
||||||
"COMPLETED",
|
"COMPLETED",
|
||||||
"SPAM",
|
"SPAM",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export const commissionRequestTableRowSchema = z.object({
|
export const commissionRequestTableRowSchema = z.object({
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
createdAt: z.string(), // ISO
|
index: z.number().int().nonnegative(),
|
||||||
status: commissionStatusSchema,
|
createdAt: z.string(),
|
||||||
|
|
||||||
customerName: z.string(),
|
customerName: z.string(),
|
||||||
customerEmail: z.string(),
|
customerEmail: z.string(),
|
||||||
customerSocials: z.string().nullable().optional(),
|
customerSocials: z.string().nullable().optional(),
|
||||||
|
status: commissionStatusSchema,
|
||||||
|
|
||||||
messagePreview: z.string().optional(), // optional, useful for hover later
|
fileCount: z.number().int().nonnegative(),
|
||||||
filesCount: z.number().int().nonnegative(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export type CommissionRequestTableRow = z.infer<typeof commissionRequestTableRowSchema>;
|
export type CommissionRequestTableRow = z.infer<typeof commissionRequestTableRowSchema>;
|
||||||
Reference in New Issue
Block a user