Add request single page
This commit is contained in:
394
src/components/commissions/requests/CommissionRequestEditor.tsx
Normal file
394
src/components/commissions/requests/CommissionRequestEditor.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user