Files
v2.admin.gaertan.art/src/components/commissions/requests/CommissionRequestEditor.tsx
2026-01-01 11:52:24 +01:00

395 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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>
);
}