Add extras and options CRUD, add sidebar, add kanban board, udpate packages
This commit is contained in:
122
src/components/commissions/extras/ExtraDialog.tsx
Normal file
122
src/components/commissions/extras/ExtraDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
101
src/components/commissions/extras/ExtraListClient.tsx
Normal file
101
src/components/commissions/extras/ExtraListClient.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
207
src/components/commissions/kanban/CommissionsKanbanClient.tsx
Normal file
207
src/components/commissions/kanban/CommissionsKanbanClient.tsx
Normal 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 item’s 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>
|
||||
);
|
||||
}
|
||||
121
src/components/commissions/options/OptionDialog.tsx
Normal file
121
src/components/commissions/options/OptionDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
101
src/components/commissions/options/OptionsListClient.tsx
Normal file
101
src/components/commissions/options/OptionsListClient.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
|
||||
33
src/components/global/MobileSidebar.tsx
Normal file
33
src/components/global/MobileSidebar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
108
src/components/global/Sidebar.tsx
Normal file
108
src/components/global/Sidebar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
64
src/components/global/nav.ts
Normal file
64
src/components/global/nav.ts
Normal 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" },
|
||||
],
|
||||
},
|
||||
];
|
||||
33
src/components/ui/collapsible.tsx
Normal file
33
src/components/ui/collapsible.tsx
Normal 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
1106
src/components/ui/kanban.tsx
Normal file
File diff suppressed because it is too large
Load Diff
58
src/components/ui/scroll-area.tsx
Normal file
58
src/components/ui/scroll-area.tsx
Normal 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 }
|
||||
Reference in New Issue
Block a user