Add request single page

This commit is contained in:
2026-01-01 11:52:24 +01:00
parent 42f23dddcf
commit 2fcf19c0df
13 changed files with 1007 additions and 83 deletions

View File

@ -0,0 +1,394 @@
"use client";
import { Download, ExternalLink } from "lucide-react";
import Image from "next/image";
import { useRouter } from "next/navigation";
import * as React from "react";
import { toast } from "sonner";
import type { CommissionStatus } from "@/schemas/commissions/tableSchema";
import { deleteCommissionRequest } from "@/actions/commissions/requests/deleteCommissionRequest";
import { updateCommissionRequest } from "@/actions/commissions/requests/updateCommissionRequest";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
type RequestFile = {
id: string;
createdAt: Date;
originalFile: string;
fileType: string;
fileSize: number;
};
type RequestShape = {
id: string;
createdAt: Date;
updatedAt: Date;
status: CommissionStatus;
customerName: string;
customerEmail: string;
customerSocials: string | null;
message: string;
type: { id: string; name: string } | null;
option: { id: string; name: string } | null;
extras: { id: string; name: string }[];
priceEstimate?: { min: number; max: number };
files: RequestFile[];
};
const STATUS_OPTIONS: CommissionStatus[] = [
"NEW",
"REVIEWING",
"ACCEPTED",
"REJECTED",
"COMPLETED",
"SPAM",
];
function isImage(mime: string) {
return !!mime && mime.startsWith("image/");
}
function formatBytes(bytes: number) {
if (!Number.isFinite(bytes)) return "—";
if (bytes < 1024) return `${bytes} B`;
const kb = bytes / 1024;
if (kb < 1024) return `${kb.toFixed(1)} KB`;
const mb = kb / 1024;
return `${mb.toFixed(1)} MB`;
}
function displayUrl(fileId: string) {
return `/api/requests/image?mode=display&fileId=${encodeURIComponent(fileId)}`;
}
function downloadUrl(fileId: string) {
return `/api/requests/image?mode=download&fileId=${encodeURIComponent(fileId)}`;
}
function bulkUrl(requestId: string) {
return `/api/requests/image?mode=bulk&requestId=${encodeURIComponent(requestId)}`;
}
export function CommissionRequestEditor({ request }: { request: RequestShape }) {
const router = useRouter();
const [status, setStatus] = React.useState<CommissionStatus>(request.status);
const [customerName, setCustomerName] = React.useState(request.customerName);
const [customerEmail, setCustomerEmail] = React.useState(request.customerEmail);
const [customerSocials, setCustomerSocials] = React.useState(request.customerSocials ?? "");
const [message, setMessage] = React.useState(request.message);
const [isSaving, startSaving] = React.useTransition();
const [deleteOpen, setDeleteOpen] = React.useState(false);
const dirty =
status !== request.status ||
customerName !== request.customerName ||
customerEmail !== request.customerEmail ||
(customerSocials || "") !== (request.customerSocials || "") ||
message !== request.message;
return (
<div className="space-y-6">
{/* Top bar */}
<div className="flex flex-col gap-3 rounded-2xl border bg-card p-4 shadow-sm md:flex-row md:items-center md:justify-between">
<div className="flex flex-wrap items-center gap-2">
<Badge variant="secondary" className="px-2 py-0.5">
{request.files.length} file{request.files.length === 1 ? "" : "s"}
</Badge>
<Badge variant="secondary" className="px-2 py-0.5">
Status: {request.status}
</Badge>
<span className="text-sm text-muted-foreground">
Updated: {new Date(request.updatedAt).toLocaleString()}
</span>
</div>
<div className="flex flex-wrap gap-2">
{request.files.length > 1 ? (
<Button asChild variant="outline" className="h-9">
<a href={bulkUrl(request.id)}>
<Download className="mr-2 h-4 w-4" />
Download all (ZIP)
</a>
</Button>
) : null}
<Button
type="button"
variant="outline"
disabled={!dirty || isSaving}
onClick={() => router.refresh()}
>
Discard
</Button>
<Button
type="button"
disabled={isSaving}
onClick={() => {
startSaving(async () => {
try {
await updateCommissionRequest({
id: request.id,
status,
customerName,
customerEmail,
customerSocials: customerSocials?.trim() ? customerSocials.trim() : null,
message,
});
toast.success("Saved");
router.refresh();
} catch (err) {
const msg = err instanceof Error ? err.message : "Save failed";
toast.error("Save failed", { description: msg });
}
});
}}
>
{isSaving ? "Saving…" : "Save changes"}
</Button>
<Button
type="button"
variant="destructive"
disabled={isSaving}
onClick={() => setDeleteOpen(true)}
>
Delete
</Button>
</div>
</div>
{/* Request details (artist-facing) */}
<div className="grid gap-6 lg:grid-cols-3">
<div className="space-y-4 rounded-2xl border bg-card p-4 shadow-sm lg:col-span-1">
<div className="space-y-2">
<div className="text-sm font-semibold">Status</div>
<Select value={status} onValueChange={(v) => setStatus(v as CommissionStatus)}>
<SelectTrigger className="h-9">
<SelectValue />
</SelectTrigger>
<SelectContent>
{STATUS_OPTIONS.map((s) => (
<SelectItem key={s} value={s}>
{s}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="pt-2 text-xs text-muted-foreground">
Submitted: {new Date(request.createdAt).toLocaleString()}
</div>
</div>
<div className="space-y-2">
<div className="text-sm font-semibold">Selection</div>
<div className="text-sm">
<div className="text-muted-foreground text-xs">Type</div>
<div className="font-medium">{request.type?.name ?? "—"}</div>
</div>
<div className="text-sm">
<div className="text-muted-foreground text-xs">Base option</div>
<div className="font-medium">{request.option?.name ?? "—"}</div>
</div>
<div className="text-sm">
<div className="text-muted-foreground text-xs">Extras</div>
{request.extras?.length ? (
<div className="mt-1 flex flex-wrap gap-1">
{request.extras.map((e) => (
<span
key={e.id}
className="inline-flex items-center rounded-md border bg-muted/40 px-2 py-0.5 text-xs text-foreground/80"
>
{e.name}
</span>
))}
</div>
) : (
<div className="text-sm font-medium"></div>
)}
</div>
<div className="text-sm">
<div className="text-muted-foreground text-xs">Estimated price</div>
<div className="font-medium tabular-nums">
{request.priceEstimate
? request.priceEstimate.min === request.priceEstimate.max
? `${request.priceEstimate.min.toFixed(2)}`
: `${request.priceEstimate.min.toFixed(2)} ${request.priceEstimate.max.toFixed(2)}`
: "—"}
</div>
</div>
</div>
<div className="space-y-2">
<div className="text-sm font-semibold">Customer</div>
<Input value={customerName} onChange={(e) => setCustomerName(e.target.value)} />
<Input value={customerEmail} onChange={(e) => setCustomerEmail(e.target.value)} />
<Input
placeholder="Socials (optional)"
value={customerSocials}
onChange={(e) => setCustomerSocials(e.target.value)}
/>
</div>
</div>
<div className="space-y-6 lg:col-span-2">
{/* Images / files */}
<div className="space-y-3 rounded-2xl border bg-card p-4 shadow-sm">
<div className="flex items-center justify-between gap-3">
<div className="text-sm font-semibold">Reference Images</div>
{request.files.length > 1 ? (
<Button asChild variant="outline" className="h-9">
<a href={bulkUrl(request.id)}>
<Download className="mr-2 h-4 w-4" />
Download all (ZIP)
</a>
</Button>
) : null}
</div>
{request.files.length === 0 ? (
<div className="text-sm text-muted-foreground">No files uploaded.</div>
) : (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-3">
{request.files.map((f) => {
const preview = displayUrl(f.id);
const dl = downloadUrl(f.id);
return (
<div key={f.id} className="overflow-hidden rounded-xl border bg-muted/10">
<div className="relative aspect-square bg-muted">
{isImage(f.fileType) ? (
<Image
src={preview}
alt={f.originalFile}
fill
sizes="(max-width: 1280px) 50vw, 33vw"
className="object-cover"
unoptimized
/>
) : (
<div className="flex h-full items-center justify-center text-xs text-muted-foreground">
No preview
</div>
)}
</div>
<div className="space-y-2 p-3">
<div className="min-w-0">
<div className="truncate text-sm font-medium" title={f.originalFile}>
{f.originalFile}
</div>
<div className="text-xs text-muted-foreground">
{f.fileType} · {formatBytes(f.fileSize)}
</div>
</div>
<div className="flex flex-wrap gap-2">
{isImage(f.fileType) ? (
<Button asChild variant="outline" size="sm" className="h-8">
<a href={preview} target="_blank" rel="noreferrer">
<ExternalLink className="mr-2 h-4 w-4" />
Open
</a>
</Button>
) : null}
<Button asChild variant="outline" size="sm" className="h-8">
<a href={dl}>
<Download className="mr-2 h-4 w-4" />
Download
</a>
</Button>
</div>
</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>
{/* Delete confirmation */}
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete request?</AlertDialogTitle>
<AlertDialogDescription>
This will permanently delete this commission request and its file records.
This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isSaving}>Cancel</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
disabled={isSaving}
onClick={() => {
startSaving(async () => {
try {
await deleteCommissionRequest(request.id);
toast.success("Deleted");
router.push("/commissions"); // adjust to your table route
router.refresh();
} catch (err) {
const msg = err instanceof Error ? err.message : "Delete failed";
toast.error("Delete failed", { description: msg });
} finally {
setDeleteOpen(false);
}
});
}}
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}