Add extras and options CRUD, add sidebar, add kanban board, udpate packages

This commit is contained in:
2026-01-29 16:18:57 +01:00
parent f9c14ed9fb
commit 507e1b9ee4
28 changed files with 2455 additions and 42 deletions

View File

@ -0,0 +1,27 @@
"use server";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { COMMISSION_STATUSES } from "@/lib/commissions/kanban";
import { prisma } from "@/lib/prisma"; // adjust to your prisma import
// import { requireAdmin } from "@/lib/auth/requireAdmin"; // recommended if you have it
const schema = z.object({
id: z.string().min(1),
status: z.enum(COMMISSION_STATUSES),
});
export async function updateCommissionRequestStatus(input: z.infer<typeof schema>) {
// await requireAdmin(); // enforce auth/role check here
const { id, status } = schema.parse(input);
await prisma.commissionRequest.update({
where: { id },
data: { status },
});
// revalidate the board page so a refresh always reflects server truth
revalidatePath("/commissions/board");
}

View File

@ -0,0 +1,30 @@
"use server";
import { prisma } from "@/lib/prisma";
import { commissionExtraSchema } from "@/schemas/commissionType";
import { revalidatePath } from "next/cache";
const LIST_PATH = "/commissions/extras";
export async function createCommissionExtra(input: unknown) {
const data = commissionExtraSchema.parse(input);
const created = await prisma.commissionExtra.create({ data });
revalidatePath(LIST_PATH);
return created;
}
export async function updateCommissionExtra(id: string, input: unknown) {
const data = commissionExtraSchema.parse(input);
const updated = await prisma.commissionExtra.update({ where: { id }, data });
revalidatePath(LIST_PATH);
return updated;
}
export async function deleteCommissionExtra(id: string) {
// Optional safety:
// const used = await prisma.commissionTypeExtra.count({ where: { extraId: id } });
// if (used > 0) throw new Error("Extra is linked to types.");
console.log("TBD");
// await prisma.commissionExtra.delete({ where: { id } });
// revalidatePath(LIST_PATH);
}

View File

@ -0,0 +1,45 @@
"use server";
import { prisma } from "@/lib/prisma";
import { commissionOptionSchema } from "@/schemas/commissionType";
import { revalidatePath } from "next/cache";
const LIST_PATH = "/commissions/options";
function toInt(v: string) {
const n = Number.parseInt(v, 10);
return Number.isFinite(n) ? n : 0;
}
export async function createCommissionOption(input: unknown) {
const data = commissionOptionSchema.parse(input);
const created = await prisma.commissionOption.create({
data: {
name: data.name,
description: data.description?.trim() ? data.description : null,
sortIndex: toInt(data.sortIndex),
},
});
revalidatePath(LIST_PATH);
return created;
}
export async function updateCommissionOption(id: string, input: unknown) {
const data = commissionOptionSchema.parse(input);
const updated = await prisma.commissionOption.update({
where: { id },
data: {
name: data.name,
description: data.description?.trim() ? data.description : null,
sortIndex: toInt(data.sortIndex),
},
});
revalidatePath(LIST_PATH);
return updated;
}
export async function deleteCommissionOption(id: string) {
console.log("TBD");
// await prisma.commissionOption.delete({ where: { id } });
// revalidatePath(LIST_PATH);
}

View File

@ -0,0 +1,47 @@
import CommissionsKanbanClient from "@/components/commissions/kanban/CommissionsKanbanClient";
import { columnIdForStatus } from "@/lib/commissions/kanban";
import { prisma } from "@/lib/prisma";
import type { BoardItem, ColumnsState } from "@/types/Board";
export default async function CommissionsBoardPage() {
const requests = await prisma.commissionRequest.findMany({
where: {
status: { in: ["NEW", "REVIEWING", "ACCEPTED", "INPROGRESS", "COMPLETED"] },
},
orderBy: [{ createdAt: "desc" }],
include: {
type: true,
option: true,
extras: true,
files: true,
},
});
const initial: ColumnsState = {
intake: [],
inProgress: [],
completed: [],
};
for (const r of requests) {
const col = columnIdForStatus(r.status) ?? "intake";
const item: BoardItem = {
id: r.id,
createdAt: r.createdAt.toISOString(),
status: r.status,
customerName: r.customerName,
customerEmail: r.customerEmail,
message: r.message,
typeName: r.type?.name ?? null,
optionName: r.option?.name ?? null,
extrasCount: r.extras.length,
filesCount: r.files.length,
};
initial[col].push(item);
}
return <CommissionsKanbanClient initialColumns={initial} />;
}

View File

@ -19,9 +19,9 @@ export default async function CommissionTypesEditPage({ params }: { params: { id
const extras = await prisma.commissionExtra.findMany({
orderBy: [{ sortIndex: "asc" }, { name: "asc" }],
})
const customInputs = await prisma.commissionCustomInput.findMany({
orderBy: [{ sortIndex: "asc" }, { name: "asc" }],
})
// const customInputs = await prisma.commissionCustomInput.findMany({
// orderBy: [{ sortIndex: "asc" }, { name: "asc" }],
// })
if (!commissionType) {
return <div>Type not found</div>
@ -32,7 +32,7 @@ export default async function CommissionTypesEditPage({ params }: { params: { id
<div className="flex gap-4 justify-between pb-8">
<h1 className="text-2xl font-bold mb-4">Edit Commission Type</h1>
</div>
<EditTypeForm type={commissionType} allOptions={options} allExtras={extras} allCustomInputs={customInputs} />
<EditTypeForm type={commissionType} allOptions={options} allExtras={extras} />
</div>
);
}

View File

@ -0,0 +1,10 @@
import { ExtraListClient } from "@/components/commissions/extras/ExtraListClient";
import { prisma } from "@/lib/prisma";
export default async function CommissionTypesExtrasPage() {
const extras = await prisma.commissionExtra.findMany({
orderBy: [{ createdAt: "asc" }, { name: "asc" }],
});
return <ExtraListClient extras={extras} />;
}

View File

@ -0,0 +1,10 @@
import { OptionsListClient } from "@/components/commissions/options/OptionsListClient";
import { prisma } from "@/lib/prisma";
export default async function CommissionTypesOptionsPage() {
const options = await prisma.commissionOption.findMany({
orderBy: [{ createdAt: "asc" }, { name: "asc" }],
});
return <OptionsListClient options={options} />;
}

View File

@ -1,6 +1,8 @@
import LogoutButton from "@/components/auth/LogoutButton";
import Footer from "@/components/global/Footer";
import Header from "@/components/global/Header";
import { Toaster } from "@/components/ui/sonner";
import MobileSidebar from "@/components/global/MobileSidebar";
import ModeToggle from "@/components/global/ModeToggle";
import Sidebar from "@/components/global/Sidebar";
export default function AdminLayout({
children,
@ -8,17 +10,48 @@ export default function AdminLayout({
children: React.ReactNode;
}>) {
return (
<div className="flex flex-col min-h-screen min-w-screen">
<header className="sticky top-0 z-50 h-14 w-full border-b bg-background/95 backdrop-blur supports-backdrop-filter:bg-background/60 px-4 py-2">
<Header />
</header>
<main className="container mx-auto px-4 py-8">
{children}
</main>
<footer className="mt-auto px-4 py-2 h-14 border-t bg-background/95 backdrop-blur supports-backdrop-filter:bg-background/60">
<Footer />
</footer>
<Toaster />
// <div className="flex flex-col min-h-screen min-w-screen">
// <header className="sticky top-0 z-50 h-14 w-full border-b bg-background/95 backdrop-blur supports-backdrop-filter:bg-background/60 px-4 py-2">
// <Header />
// </header>
// <main className="container mx-auto px-4 py-8">
// {children}
// </main>
// <footer className="mt-auto px-4 py-2 h-14 border-t bg-background/95 backdrop-blur supports-backdrop-filter:bg-background/60">
// <Footer />
// </footer>
// <Toaster />
// </div>
<div className="min-h-screen w-full">
<div className="flex min-h-screen w-full">
<aside className="hidden md:flex md:w-64 md:flex-col md:border-r md:bg-background">
<Sidebar />
</aside>
<div className="flex min-h-screen flex-1 flex-col">
<header className="sticky top-0 z-50 h-14 w-full border-b bg-background/95 backdrop-blur supports-backdrop-filter:bg-background/60">
<div className="flex h-14 items-center gap-3 px-4">
<div className="md:hidden">
<MobileSidebar />
</div>
<div className="flex-1">
{/* Optional: put breadcrumbs or page title here later */}
</div>
<div className="flex items-center gap-3">
<LogoutButton />
<ModeToggle />
</div>
</div>
</header>
<main className="flex-1">
<div className="container mx-auto px-4 py-8">{children}</div>
</main>
<footer className="mt-auto h-14 border-t bg-background/95 px-4 py-2 backdrop-blur supports-backdrop-filter:bg-background/60">
<Footer />
</footer>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,122 @@
"use client";
import { createCommissionExtra, updateCommissionExtra } from "@/actions/commissions/types/extras";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { type CommissionExtraValues, commissionExtraSchema } from "@/schemas/commissionType";
import { zodResolver } from "@hookform/resolvers/zod";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
type Initial = {
id: string;
name: string;
description: string | null;
};
type Props = {
trigger: React.ReactNode;
mode: "create" | "edit";
initial?: Initial;
};
export function ExtraDialog({ trigger, mode, initial }: Props) {
const [open, setOpen] = useState(false);
// ✅ key remounts the form per item / create mode, no useEffect needed
const formKey = mode === "create" ? "new" : initial?.id ?? "missing";
const form = useForm<CommissionExtraValues>({
resolver: zodResolver(commissionExtraSchema),
defaultValues: {
name: initial?.name ?? "",
description: initial?.description ?? ""
},
});
async function onSubmit(values: CommissionExtraValues) {
try {
if (mode === "create") {
await createCommissionExtra(values);
toast.success("Extra created.");
} else {
if (!initial?.id) throw new Error("Missing extra id");
await updateCommissionExtra(initial.id, values);
toast.success("Extra updated.");
}
setOpen(false);
} catch (e) {
console.error(e);
toast.error("Failed to save extra.");
}
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>{trigger}</DialogTrigger>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>{mode === "create" ? "New extra" : "Edit extra"}</DialogTitle>
</DialogHeader>
<div key={formKey}>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-5">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl><Input {...field} /></FormControl>
<FormDescription>Shown to customers.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl><Input {...field} /></FormControl>
<FormDescription>Optional helper text.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter className="gap-2 sm:gap-0">
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button type="submit">Save</Button>
</DialogFooter>
</form>
</Form>
</div>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,101 @@
"use client";
import { deleteCommissionExtra } from "@/actions/commissions/types/extras";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Pencil, Plus, Trash2 } from "lucide-react";
import * as React from "react";
import { toast } from "sonner";
import { ExtraDialog } from "./ExtraDialog";
type Item = {
id: string;
name: string;
description: string | null;
};
export function ExtraListClient({ extras }: { extras: Item[] }) {
const [busyId, setBusyId] = React.useState<string | null>(null);
async function onDelete(id: string) {
try {
setBusyId(id);
await deleteCommissionExtra(id);
toast.success("Extra deleted.");
} catch (e) {
console.error(e);
toast.error("Failed to delete extra.");
} finally {
setBusyId(null);
}
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between gap-4">
<h1 className="text-2xl font-bold">Extras</h1>
<ExtraDialog
mode="create"
trigger={
<Button className="gap-2">
<Plus className="h-4 w-4" />
New extra
</Button>
}
/>
</div>
<Card>
<CardHeader>
<CardTitle className="text-base">All extras</CardTitle>
</CardHeader>
<CardContent className="p-0">
{extras.length === 0 ? (
<div className="p-6 text-sm text-muted-foreground italic">No extras yet.</div>
) : (
<ul className="divide-y">
{extras.map((o) => (
<li key={o.id} className="flex items-center justify-between gap-4 px-6 py-4">
<div className="min-w-0">
<div className="flex items-center gap-2">
<div className="font-medium truncate">{o.name}</div>
</div>
{o.description ? (
<div className="text-sm text-muted-foreground truncate">{o.description}</div>
) : null}
</div>
<div className="flex items-center gap-2">
<ExtraDialog
mode="edit"
initial={o}
trigger={
<Button variant="secondary" size="sm" className="gap-2">
<Pencil className="h-4 w-4" />
Edit
</Button>
}
/>
<Button
variant="destructive"
size="sm"
className="gap-2"
disabled={busyId === o.id}
onClick={() => onDelete(o.id)}
>
<Trash2 className="h-4 w-4" />
Delete
</Button>
</div>
</li>
))}
</ul>
)}
</CardContent>
</Card>
</div>
);
}

View File

@ -0,0 +1,207 @@
"use client";
import { updateCommissionRequestStatus } from "@/actions/commissions/requests/updateCommissionRequestStatus";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Kanban, KanbanBoard, KanbanColumn, KanbanItem, KanbanOverlay } from "@/components/ui/kanban";
import {
BOARD_COLUMNS,
type BoardColumnId,
canonicalStatusForColumn,
} from "@/lib/commissions/kanban";
import Link from "next/link";
import * as React from "react";
type BoardItem = {
id: string;
createdAt: string;
status: string;
customerName: string;
customerEmail: string;
message: string;
typeName: string | null;
optionName: string | null;
extrasCount: number;
filesCount: number;
};
type ColumnsState = Record<BoardColumnId, BoardItem[]>;
import type { UniqueIdentifier } from "@dnd-kit/core";
type KanbanValue = Record<UniqueIdentifier, BoardItem[]>;
function isColumnsState(v: KanbanValue): v is ColumnsState {
return (
Array.isArray(v.intake) &&
Array.isArray(v.inProgress) &&
Array.isArray(v.completed)
);
}
function asColumnsState(v: KanbanValue): ColumnsState {
if (!isColumnsState(v)) {
// Defensive: if something ever changes upstream, keep UI stable
return { intake: [], inProgress: [], completed: [] };
}
return v;
}
function findItemColumn(columns: ColumnsState, itemId: string): BoardColumnId | null {
for (const col of Object.keys(columns) as BoardColumnId[]) {
if (columns[col].some((x) => x.id === itemId)) return col;
}
return null;
}
function diffMovedItem(prev: ColumnsState, next: ColumnsState) {
const prevLoc = new Map<string, BoardColumnId>();
const nextLoc = new Map<string, BoardColumnId>();
for (const c of Object.keys(prev) as BoardColumnId[]) {
for (const i of prev[c]) {
prevLoc.set(i.id, c);
}
}
for (const c of Object.keys(next) as BoardColumnId[]) {
for (const i of next[c]) {
nextLoc.set(i.id, c);
}
}
for (const [id, from] of prevLoc.entries()) {
const to = nextLoc.get(id);
if (to && to !== from) return { id, from, to };
}
return null;
}
export default function CommissionsKanbanClient({
initialColumns,
}: {
initialColumns: ColumnsState;
}) {
const [columns, setColumns] = React.useState<ColumnsState>(initialColumns);
const prevRef = React.useRef(columns);
React.useEffect(() => {
prevRef.current = columns;
}, [columns]);
async function persistMove(moved: { id: string; to: BoardColumnId }, snapshotBefore: ColumnsState) {
const status = canonicalStatusForColumn(moved.to);
try {
await updateCommissionRequestStatus({ id: moved.id, status });
// optional: you could also update the items status in local state here
// but revalidatePath + eventual refresh will keep it consistent anyway.
setColumns((cur) => {
const col = findItemColumn(cur, moved.id);
if (!col) return cur;
return {
...cur,
[col]: cur[col].map((x) => (x.id === moved.id ? { ...x, status } : x)),
};
});
} catch {
// Revert optimistic state if update fails
setColumns(snapshotBefore);
}
}
function onValueChange(next: KanbanValue) {
const nextColumns = asColumnsState(next);
const prev = prevRef.current;
setColumns(nextColumns);
const moved = diffMovedItem(prev, nextColumns);
if (!moved) return;
void persistMove({ id: moved.id, to: moved.to }, prev);
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between gap-3">
<div>
<h1 className="text-lg font-semibold">Commissions Board</h1>
<p className="text-sm text-muted-foreground">
Drag requests between columns to update their status.
</p>
</div>
<Button asChild variant="outline">
<Link href="/commissions/requests">Open list</Link>
</Button>
</div>
<Kanban
value={columns as unknown as KanbanValue}
onValueChange={onValueChange}
getItemValue={(item) => item.id}
>
<KanbanBoard className="grid auto-rows-fr gap-3 lg:grid-cols-3">
{(Object.keys(columns) as BoardColumnId[]).map((colId) => {
const col = BOARD_COLUMNS[colId];
const items = columns[colId];
return (
<KanbanColumn key={colId} value={colId} className="rounded-lg border bg-card/50 p-3">
<div className="mb-3 flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold">{col.title}</span>
<Badge variant="secondary" className="rounded-sm">
{items.length}
</Badge>
</div>
</div>
<div className="flex flex-col gap-2">
{items.map((item) => (
<KanbanItem key={item.id} value={item.id} asHandle asChild>
<div className="rounded-md border bg-background p-3 shadow-xs">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<div className="truncate text-sm font-medium">
{item.customerName}
<span className="text-muted-foreground"> #{item.id.slice(0, 6)}</span>
</div>
<div className="truncate text-xs text-muted-foreground">
{item.typeName ?? "No type"}{item.optionName ? ` · ${item.optionName}` : ""}
</div>
</div>
<Badge variant="outline" className="rounded-sm text-[11px]">
{item.status}
</Badge>
</div>
<p className="mt-2 line-clamp-2 text-sm text-muted-foreground">
{item.message}
</p>
<div className="mt-3 flex items-center justify-between text-xs text-muted-foreground">
<span>
Files: {item.filesCount} · Extras: {item.extrasCount}
</span>
<Button asChild size="sm" variant="ghost" className="h-7 px-2">
<Link href={`/commissions/requests/${item.id}`}>Details</Link>
</Button>
</div>
</div>
</KanbanItem>
))}
</div>
</KanbanColumn>
);
})}
</KanbanBoard>
<KanbanOverlay>
<div className="size-full rounded-md bg-primary/10" />
</KanbanOverlay>
</Kanban>
</div>
);
}

View File

@ -0,0 +1,121 @@
"use client";
import { createCommissionOption, updateCommissionOption } from "@/actions/commissions/types/options";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { type CommissionOptionValues, commissionOptionSchema } from "@/schemas/commissionType";
import { zodResolver } from "@hookform/resolvers/zod";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
type Initial = {
id: string;
name: string;
description: string | null;
};
type Props = {
trigger: React.ReactNode;
mode: "create" | "edit";
initial?: Initial;
};
export function OptionDialog({ trigger, mode, initial }: Props) {
const [open, setOpen] = useState(false);
const formKey = mode === "create" ? "new" : initial?.id ?? "missing";
const form = useForm<CommissionOptionValues>({
resolver: zodResolver(commissionOptionSchema),
defaultValues: {
name: initial?.name ?? "",
description: initial?.description ?? ""
},
});
async function onSubmit(values: CommissionOptionValues) {
try {
if (mode === "create") {
await createCommissionOption(values);
toast.success("Option created.");
} else {
if (!initial?.id) throw new Error("Missing option id");
await updateCommissionOption(initial.id, values);
toast.success("Option updated.");
}
setOpen(false);
} catch (e) {
console.error(e);
toast.error("Failed to save option.");
}
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>{trigger}</DialogTrigger>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>{mode === "create" ? "New option" : "Edit option"}</DialogTitle>
</DialogHeader>
<div key={formKey}>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-5">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl><Input {...field} /></FormControl>
<FormDescription>Shown to customers.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl><Input {...field} /></FormControl>
<FormDescription>Optional helper text.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter className="gap-2 sm:gap-0">
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button type="submit">Save</Button>
</DialogFooter>
</form>
</Form>
</div>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,101 @@
"use client";
import { deleteCommissionOption } from "@/actions/commissions/types/options";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Pencil, Plus, Trash2 } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { OptionDialog } from "./OptionDialog";
type Item = {
id: string;
name: string;
description: string | null;
};
export function OptionsListClient({ options }: { options: Item[] }) {
const [busyId, setBusyId] = useState<string | null>(null);
async function onDelete(id: string) {
try {
setBusyId(id);
await deleteCommissionOption(id);
toast.success("Option deleted.");
} catch (e) {
console.error(e);
toast.error("Failed to delete option.");
} finally {
setBusyId(null);
}
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between gap-4">
<h1 className="text-2xl font-bold">Options</h1>
<OptionDialog
mode="create"
trigger={
<Button className="gap-2">
<Plus className="h-4 w-4" />
New option
</Button>
}
/>
</div>
<Card>
<CardHeader>
<CardTitle className="text-base">All options</CardTitle>
</CardHeader>
<CardContent className="p-0">
{options.length === 0 ? (
<div className="p-6 text-sm text-muted-foreground italic">No options yet.</div>
) : (
<ul className="divide-y">
{options.map((o) => (
<li key={o.id} className="flex items-center justify-between gap-4 px-6 py-4">
<div className="min-w-0">
<div className="flex items-center gap-2">
<div className="font-medium truncate">{o.name}</div>
</div>
{o.description ? (
<div className="text-sm text-muted-foreground truncate">{o.description}</div>
) : null}
</div>
<div className="flex items-center gap-2">
<OptionDialog
mode="edit"
initial={o}
trigger={
<Button variant="secondary" size="sm" className="gap-2">
<Pencil className="h-4 w-4" />
Edit
</Button>
}
/>
<Button
variant="destructive"
size="sm"
className="gap-2"
disabled={busyId === o.id}
onClick={() => onDelete(o.id)}
>
<Trash2 className="h-4 w-4" />
Delete
</Button>
</div>
</li>
))}
</ul>
)}
</CardContent>
</Card>
</div>
);
}

View File

@ -4,14 +4,13 @@ import { updateCommissionType } from "@/actions/commissions/types/updateType";
import { Button } from "@/components/ui/button";
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { CommissionCustomInput, CommissionExtra, CommissionOption, CommissionType, CommissionTypeCustomInput, CommissionTypeExtra, CommissionTypeOption } from "@/generated/prisma/client";
import type { CommissionCustomInput, CommissionExtra, CommissionOption, CommissionType, CommissionTypeCustomInput, CommissionTypeExtra, CommissionTypeOption } from "@/generated/prisma/client";
import { commissionTypeSchema } from "@/schemas/commissionType";
import { zodResolver } from "@hookform/resolvers/zod";
import { useRouter } from "next/navigation";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import * as z from "zod/v4";
import { CommissionCustomInputField } from "./form/CommissionCustomInputField";
import type * as z from "zod/v4";
import { CommissionExtraField } from "./form/CommissionExtraField";
import { CommissionOptionField } from "./form/CommissionOptionField";
@ -25,10 +24,10 @@ type Props = {
type: CommissionTypeWithConnections
allOptions: CommissionOption[],
allExtras: CommissionExtra[],
allCustomInputs: CommissionCustomInput[]
// allCustomInputs: CommissionCustomInput[]
}
export default function EditTypeForm({ type, allOptions, allExtras, allCustomInputs }: Props) {
export default function EditTypeForm({ type, allOptions, allExtras }: Props) {
const router = useRouter();
const form = useForm<z.infer<typeof commissionTypeSchema>>({
resolver: zodResolver(commissionTypeSchema),
@ -103,7 +102,7 @@ export default function EditTypeForm({ type, allOptions, allExtras, allCustomInp
<CommissionOptionField options={allOptions} />
<CommissionExtraField extras={allExtras} />
<CommissionCustomInputField customInputs={allCustomInputs} />
{/* <CommissionCustomInputField customInputs={allCustomInputs} /> */}
<div className="flex flex-col gap-4">
<Button type="submit">Submit</Button>

View File

@ -0,0 +1,33 @@
"use client";
import { Menu } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@/components/ui/sheet";
import Sidebar from "./Sidebar";
export default function MobileSidebar() {
return (
<Sheet>
<SheetTrigger asChild>
<Button variant="ghost" size="icon" aria-label="Open navigation">
<Menu className="h-5 w-5" />
</Button>
</SheetTrigger>
<SheetContent side="left" className="p-0 w-80">
<SheetHeader className="px-4 py-3">
<SheetTitle>Navigation</SheetTitle>
</SheetHeader>
<Sidebar />
</SheetContent>
</Sheet>
);
}

View File

@ -0,0 +1,108 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { Button } from "@/components/ui/button";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Separator } from "@/components/ui/separator";
import { cn } from "@/lib/utils";
import { adminNav } from "./nav";
function isActive(pathname: string, href: string) {
if (href === "/") return pathname === "/";
return pathname === href || pathname.startsWith(`${href}/`);
}
export default function AdminSidebar() {
const pathname = usePathname();
return (
<div className="flex h-full flex-col">
{/* Brand / header */}
<div className="flex h-14 items-center px-4">
<Link href="/" className="text-sm font-semibold tracking-tight">
Admin
</Link>
</div>
<Separator />
<ScrollArea className="flex-1">
<nav className="flex flex-col gap-1 p-2">
{adminNav.map((entry) => {
if (entry.type === "link") {
const active = isActive(pathname, entry.href);
return (
<Button
key={entry.href}
asChild
variant={active ? "secondary" : "ghost"}
className={cn("justify-start")}
>
<Link href={entry.href}>{entry.title}</Link>
</Button>
);
}
// group
const anyChildActive = entry.items.some((i) =>
isActive(pathname, i.href)
);
return (
<Collapsible
key={entry.title}
defaultOpen={anyChildActive}
className="flex flex-col gap-1"
>
<CollapsibleTrigger asChild>
<Button
variant="ghost"
className={cn(
"justify-start",
anyChildActive && "font-medium"
)}
>
{entry.title}
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="pl-2">
<div className="flex flex-col gap-1">
{entry.items.map((item) => {
const active = isActive(pathname, item.href);
return (
<Button
key={item.href}
asChild
variant={active ? "secondary" : "ghost"}
size="sm"
className="justify-start"
>
<Link href={item.href}>{item.title}</Link>
</Button>
);
})}
</div>
</CollapsibleContent>
</Collapsible>
);
})}
</nav>
</ScrollArea>
<Separator />
{/* Optional: bottom area for version/build info */}
<div className="p-3 text-xs text-muted-foreground">
{/* e.g. v0.1.0 */}
</div>
</div>
);
}

View File

@ -0,0 +1,64 @@
export type AdminNavItem = {
title: string;
href: string;
};
export type AdminNavGroup =
| {
type: "link";
title: string;
href: string;
}
| {
type: "group";
title: string;
items: AdminNavItem[];
};
export const adminNav: AdminNavGroup[] = [
{ type: "link", title: "Home", href: "/" },
{
type: "group",
title: "Upload",
items: [
{ title: "Single Image", href: "/uploads/single" },
{ title: "Multiple Images", href: "/uploads/bulk" },
],
},
{ type: "link", title: "Artworks", href: "/artworks" },
{
type: "group",
title: "Artwork Management",
items: [
{ title: "Categories", href: "/categories" },
{ title: "Tags", href: "/tags" },
],
},
{
type: "group",
title: "Commissions",
items: [
{ title: "Requests", href: "/commissions/requests" },
{ title: "Board", href: "/commissions/kanban" },
{ title: "Types", href: "/commissions/types" },
{ title: "TypeOptions", href: "/commissions/types/options" },
{ title: "TypeExtras", href: "/commissions/types/extras" },
{ title: "Guidelines", href: "/commissions/guidelines" },
],
},
{ type: "link", title: "Terms of Service", href: "/tos" },
{
type: "group",
title: "Users",
items: [
{ title: "Users", href: "/users" },
{ title: "New User", href: "/users/new" },
],
},
];

View File

@ -0,0 +1,33 @@
"use client"
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
function Collapsible({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
}
function CollapsibleTrigger({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
return (
<CollapsiblePrimitive.CollapsibleTrigger
data-slot="collapsible-trigger"
{...props}
/>
)
}
function CollapsibleContent({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
return (
<CollapsiblePrimitive.CollapsibleContent
data-slot="collapsible-content"
{...props}
/>
)
}
export { Collapsible, CollapsibleTrigger, CollapsibleContent }

1106
src/components/ui/kanban.tsx Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,58 @@
"use client"
import * as React from "react"
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
import { cn } from "@/lib/utils"
function ScrollArea({
className,
children,
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
return (
<ScrollAreaPrimitive.Root
data-slot="scroll-area"
className={cn("relative", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport
data-slot="scroll-area-viewport"
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
)
}
function ScrollBar({
className,
orientation = "vertical",
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
return (
<ScrollAreaPrimitive.ScrollAreaScrollbar
data-slot="scroll-area-scrollbar"
orientation={orientation}
className={cn(
"flex touch-none p-px transition-colors select-none",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent",
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb
data-slot="scroll-area-thumb"
className="bg-border relative flex-1 rounded-full"
/>
</ScrollAreaPrimitive.ScrollAreaScrollbar>
)
}
export { ScrollArea, ScrollBar }

View File

@ -0,0 +1,44 @@
export const COMMISSION_STATUSES = [
"NEW",
"REVIEWING",
"ACCEPTED",
"REJECTED",
"INPROGRESS",
"COMPLETED",
"SPAM",
] as const;
export type CommissionStatus = (typeof COMMISSION_STATUSES)[number];
export const BOARD_COLUMNS = {
intake: {
title: "Intake",
statuses: ["NEW", "REVIEWING", "ACCEPTED"] as const,
// when you drop into this column, we normalize to one canonical status:
// NEW should usually be system-created; for manual moves, REVIEWING is safer.
canonicalStatus: "REVIEWING" as const,
},
inProgress: {
title: "In Progress",
statuses: ["INPROGRESS"] as const,
canonicalStatus: "INPROGRESS" as const,
},
completed: {
title: "Completed",
statuses: ["COMPLETED"] as const,
canonicalStatus: "COMPLETED" as const,
},
} as const;
export type BoardColumnId = keyof typeof BOARD_COLUMNS;
export function columnIdForStatus(status: string): BoardColumnId | null {
if (BOARD_COLUMNS.intake.statuses.includes(status as any)) return "intake";
if (BOARD_COLUMNS.inProgress.statuses.includes(status as any)) return "inProgress";
if (BOARD_COLUMNS.completed.statuses.includes(status as any)) return "completed";
return null;
}
export function canonicalStatusForColumn(col: BoardColumnId): CommissionStatus {
return BOARD_COLUMNS[col].canonicalStatus as CommissionStatus;
}

62
src/lib/compose-refs.ts Normal file
View File

@ -0,0 +1,62 @@
import * as React from "react";
type PossibleRef<T> = React.Ref<T> | undefined;
/**
* Set a given ref to a given value
* This utility takes care of different types of refs: callback refs and RefObject(s)
*/
function setRef<T>(ref: PossibleRef<T>, value: T) {
if (typeof ref === "function") {
return ref(value);
}
if (ref !== null && ref !== undefined) {
ref.current = value;
}
}
/**
* A utility to compose multiple refs together
* Accepts callback refs and RefObject(s)
*/
function composeRefs<T>(...refs: PossibleRef<T>[]): React.RefCallback<T> {
return (node) => {
let hasCleanup = false;
const cleanups = refs.map((ref) => {
const cleanup = setRef(ref, node);
if (!hasCleanup && typeof cleanup === "function") {
hasCleanup = true;
}
return cleanup;
});
// React <19 will log an error to the console if a callback ref returns a
// value. We don't use ref cleanups internally so this will only happen if a
// user's ref callback returns a value, which we only expect if they are
// using the cleanup functionality added in React 19.
if (hasCleanup) {
return () => {
for (let i = 0; i < cleanups.length; i++) {
const cleanup = cleanups[i];
if (typeof cleanup === "function") {
cleanup();
} else {
setRef(refs[i], null);
}
}
};
}
};
}
/**
* A custom hook that composes multiple refs
* Accepts callback refs and RefObject(s)
*/
function useComposedRefs<T>(...refs: PossibleRef<T>[]): React.RefCallback<T> {
// biome-ignore lint/correctness/useExhaustiveDependencies: we want to memoize by all values
return React.useCallback(composeRefs(...refs), refs);
}
export { composeRefs, useComposedRefs };

View File

@ -6,14 +6,20 @@ const optionField = z.object({
optionId: z.string(),
price: z.number().optional(),
pricePercent: z.number().optional(),
priceRange: z.string().regex(rangePattern, "Format must be like '1080'").optional(),
priceRange: z
.string()
.regex(rangePattern, "Format must be like '1080'")
.optional(),
});
const extraField = z.object({
extraId: z.string(),
price: z.number().optional(),
pricePercent: z.number().optional(),
priceRange: z.string().regex(rangePattern, "Format must be like '1080'").optional(),
priceRange: z
.string()
.regex(rangePattern, "Format must be like '1080'")
.optional(),
});
const customInputsField = z.object({
@ -29,6 +35,20 @@ export const commissionTypeSchema = z.object({
options: z.array(optionField).optional(),
extras: z.array(extraField).optional(),
customInputs: z.array(customInputsField).optional(),
})
});
export type commissionTypeSchema = z.infer<typeof commissionTypeSchema>
export type commissionTypeSchema = z.infer<typeof commissionTypeSchema>;
export const commissionOptionSchema = z.object({
name: z.string().min(1, "Name is required"),
description: z.string().optional(),
});
export type CommissionOptionValues = z.infer<typeof commissionOptionSchema>;
export const commissionExtraSchema = z.object({
name: z.string().min(1, "Name is required"),
description: z.string().optional(),
});
export type CommissionExtraValues = z.infer<typeof commissionExtraSchema>;

16
src/types/Board.ts Normal file
View File

@ -0,0 +1,16 @@
import type { BoardColumnId } from "@/lib/commissions/kanban";
export type BoardItem = {
id: string;
createdAt: string;
status: string;
customerName: string;
customerEmail: string;
message: string;
typeName: string | null;
optionName: string | null;
extrasCount: number;
filesCount: number;
};
export type ColumnsState = Record<BoardColumnId, BoardItem[]>;