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,53 @@
"use server";
import { prisma } from "@/lib/prisma";
import { calculatePriceRange, PriceSource } from "@/utils/commissionPricing";
export async function getCommissionRequestById(id: string) {
const req = await prisma.commissionRequest.findUnique({
where: { id },
include: {
type: { select: { id: true, name: true } },
option: { select: { id: true, name: true } },
extras: { select: { id: true, name: true } },
files: {
orderBy: { createdAt: "asc" },
select: {
id: true,
createdAt: true,
originalFile: true,
fileType: true,
fileSize: true,
},
},
},
});
if (!req) return null;
const baseSource: PriceSource | undefined =
req.typeId && req.optionId
? await prisma.commissionTypeOption.findUnique({
where: { typeId_optionId: { typeId: req.typeId, optionId: req.optionId } },
select: { price: true, pricePercent: true, priceRange: true },
}) ?? undefined
: undefined;
const extrasSources: PriceSource[] =
req.typeId && req.extras.length
? await prisma.commissionTypeExtra.findMany({
where: {
typeId: req.typeId,
extraId: { in: req.extras.map((e) => e.id) },
},
select: { price: true, pricePercent: true, priceRange: true },
})
: [];
const [min, max] = calculatePriceRange(baseSource, extrasSources);
return {
...req,
priceEstimate: { min, max },
};
}

View File

@ -0,0 +1,31 @@
"use server";
import { prisma } from "@/lib/prisma";
import { commissionStatusSchema } from "@/schemas/commissions/tableSchema";
import { z } from "zod/v4";
const updateSchema = z.object({
id: z.string().min(1),
status: commissionStatusSchema,
customerName: z.string().min(1).max(200),
customerEmail: z.string().email().max(320),
customerSocials: z.string().max(2000).optional().nullable(),
message: z.string().min(1).max(20_000),
});
export async function updateCommissionRequest(input: z.infer<typeof updateSchema>) {
const data = updateSchema.parse(input);
await prisma.commissionRequest.update({
where: { id: data.id },
data: {
status: data.status,
customerName: data.customerName,
customerEmail: data.customerEmail,
customerSocials: data.customerSocials ?? null,
message: data.message,
},
});
return { ok: true };
}

View File

@ -0,0 +1,26 @@
import { getCommissionRequestById } from "@/actions/commissions/requests/getCommissionRequestById";
import { CommissionRequestEditor } from "@/components/commissions/requests/CommissionRequestEditor";
import { notFound } from "next/navigation";
export default async function CommissionRequestPage({
params,
}: {
params: { id: string };
}) {
const { id } = await params;
const request = await getCommissionRequestById(id);
if (!request) notFound();
return (
<div className="mx-auto w-full max-w-5xl space-y-6 p-4 md:p-8">
<div className="space-y-1">
<h1 className="text-2xl font-semibold tracking-tight">Commission Request</h1>
<p className="text-sm text-muted-foreground">
Submitted: {new Date(request.createdAt).toLocaleString()} · ID: {request.id}
</p>
</div>
<CommissionRequestEditor request={request as any} />
</div>
);
}

View File

@ -0,0 +1,144 @@
import { prisma } from "@/lib/prisma";
import { s3 } from "@/lib/s3";
import { GetObjectCommand } from "@aws-sdk/client-s3";
import archiver from "archiver";
import { NextRequest } from "next/server";
type Mode = "display" | "download" | "bulk";
function contentDisposition(filename: string, mode: Mode) {
const type = mode === "display" ? "inline" : "attachment";
const safe = filename.replace(/["\\]/g, "_");
const encoded = encodeURIComponent(filename);
return `${type}; filename="${safe}"; filename*=UTF-8''${encoded}`;
}
function sanitizeZipEntryName(name: string) {
return name.replace(/[^\w.\- ()\[\]]+/g, "_").slice(0, 180);
}
export async function GET(req: NextRequest) {
try {
const bucket = process.env.BUCKET_NAME;
if (!bucket) return new Response("BUCKET_NAME is not set", { status: 500 });
const mode = (req.nextUrl.searchParams.get("mode") ?? "display") as Mode;
// ---- Single file: display/download ----
if (mode === "display" || mode === "download") {
const fileId = req.nextUrl.searchParams.get("fileId");
if (!fileId) return new Response("Missing fileId", { status: 400 });
const file = await prisma.commissionRequestFile.findUnique({
where: { id: fileId },
select: {
fileKey: true,
originalFile: true,
fileType: true,
fileSize: true,
},
});
if (!file) return new Response("Not found", { status: 404 });
const s3Res = await s3.send(
new GetObjectCommand({
Bucket: bucket,
Key: file.fileKey,
})
);
if (!s3Res.Body) return new Response("No body", { status: 500 });
const contentType = file.fileType || s3Res.ContentType || "application/octet-stream";
return new Response(s3Res.Body as ReadableStream, {
headers: {
"Content-Type": contentType,
// You can tune caching; admin-only content usually should be private.
"Cache-Control": mode === "display" ? "private, max-age=300" : "private, max-age=0, no-store",
"Content-Disposition": contentDisposition(file.originalFile || "file", mode),
},
});
}
// ---- Bulk zip for a request ----
if (mode === "bulk") {
const requestId = req.nextUrl.searchParams.get("requestId");
if (!requestId) return new Response("Missing requestId", { status: 400 });
const request = await prisma.commissionRequest.findUnique({
where: { id: requestId },
select: {
id: true,
createdAt: true,
files: {
orderBy: { createdAt: "asc" },
select: {
fileKey: true,
originalFile: true,
fileType: true,
},
},
},
});
if (!request) return new Response("Not found", { status: 404 });
if (!request.files.length) return new Response("No files", { status: 404 });
const zipName = `commission-${request.id}.zip`;
const archive = archiver("zip", { zlib: { level: 9 } });
const stream = new ReadableStream({
start(controller) {
archive.on("data", (chunk) => controller.enqueue(chunk));
archive.on("end", () => controller.close());
archive.on("error", (err) => controller.error(err));
},
cancel() {
archive.destroy();
},
});
(async () => {
try {
for (const f of request.files) {
const obj = await s3.send(
new GetObjectCommand({
Bucket: bucket,
Key: f.fileKey,
})
);
if (!obj.Body) continue;
const entryName = sanitizeZipEntryName(
f.originalFile || f.fileKey.split("/").pop() || "file"
);
// obj.Body is a Node stream in Node runtime; works with archiver
archive.append(obj.Body as any, { name: entryName });
}
await archive.finalize();
} catch (err) {
archive.destroy(err as Error);
}
})();
return new Response(stream, {
headers: {
"Content-Type": "application/zip",
"Content-Disposition": `attachment; filename="${zipName}"`,
"Cache-Control": "private, max-age=0, no-store",
},
});
}
return new Response("Invalid mode", { status: 400 });
} catch (err) {
console.error("[GET /api/requests/image] failed", err);
return new Response("Internal error", { status: 500 });
}
}

View File

@ -1,13 +1,10 @@
import { prisma } from "@/lib/prisma";
import { s3 } from "@/lib/s3";
import { PutObjectCommand } from "@aws-sdk/client-s3";
import { DeleteObjectsCommand, PutObjectCommand } from "@aws-sdk/client-s3";
import { NextResponse } from "next/server";
import { v4 as uuidv4 } from "uuid";
import { z } from "zod/v4";
export const runtime = "nodejs"; // required for AWS SDK on many setups
export const dynamic = "force-dynamic"; // API endpoint should be dynamic
const payloadSchema = z.object({
typeId: z.string().min(1).optional().nullable(),
optionId: z.string().min(1).optional().nullable(),
@ -17,8 +14,6 @@ const payloadSchema = z.object({
customerEmail: z.string().email().max(320),
customerSocials: z.string().max(2000).optional().nullable(),
message: z.string().min(1).max(20_000),
// customFields: z.record(z.string(), z.unknown()).optional(),
});
function safeJsonParse(input: string) {
@ -30,55 +25,56 @@ function safeJsonParse(input: string) {
}
export async function POST(request: Request) {
// Optional: basic origin allowlist check (recommended)
// const origin = request.headers.get("origin");
// if (origin && !["https://domain.com", "https://www.domain.com"].includes(origin)) {
// return NextResponse.json({ error: "Invalid origin" }, { status: 403 });
// }
const form = await request.formData();
const payloadRaw = form.get("payload");
if (typeof payloadRaw !== "string") {
return NextResponse.json({ error: "Missing payload" }, { status: 400 });
}
const parsedJson = safeJsonParse(payloadRaw);
if (!parsedJson) {
return NextResponse.json({ error: "Invalid payload JSON" }, { status: 400 });
}
const payload = payloadSchema.safeParse(parsedJson);
if (!payload.success) {
return NextResponse.json(
{ error: "Validation error", issues: payload.error.issues },
{ status: 422 }
);
}
const files = form.getAll("files").filter((v): v is File => v instanceof File);
// Optional: enforce limits
const MAX_FILES = 10;
const MAX_BYTES_EACH = 10 * 1024 * 1024; // 10MB
if (files.length > MAX_FILES) {
return NextResponse.json({ error: "Too many files" }, { status: 413 });
}
for (const f of files) {
if (f.size > MAX_BYTES_EACH) {
return NextResponse.json({ error: `File too large: ${f.name}` }, { status: 413 });
try {
const bucket = process.env.BUCKET_NAME;
if (!bucket) {
return NextResponse.json(
{ error: "Commission submission failed", message: "BUCKET_NAME is not set" },
{ status: 500 }
);
}
}
// Capture basic metadata
const ipAddress =
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? null;
const userAgent = request.headers.get("user-agent") ?? null;
const form = await request.formData();
// Create request first to get requestId; then upload files and store file rows
// Use a transaction to keep DB consistent.
const result = await prisma.$transaction(async (tx) => {
const created = await tx.commissionRequest.create({
const payloadRaw = form.get("payload");
if (typeof payloadRaw !== "string") {
return NextResponse.json({ error: "Missing payload" }, { status: 400 });
}
const parsedJson = safeJsonParse(payloadRaw);
if (!parsedJson) {
return NextResponse.json({ error: "Invalid payload JSON" }, { status: 400 });
}
const payload = payloadSchema.safeParse(parsedJson);
if (!payload.success) {
return NextResponse.json(
{ error: "Validation error", issues: payload.error.issues },
{ status: 422 }
);
}
const files = form.getAll("files").filter((v): v is File => v instanceof File);
// Optional: enforce limits
const MAX_FILES = 10;
const MAX_BYTES_EACH = 10 * 1024 * 1024; // 10MB
if (files.length > MAX_FILES) {
return NextResponse.json({ error: "Too many files" }, { status: 413 });
}
for (const f of files) {
if (f.size > MAX_BYTES_EACH) {
return NextResponse.json({ error: `File too large: ${f.name}` }, { status: 413 });
}
}
// Capture basic metadata
const ipAddress =
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? null;
const userAgent = request.headers.get("user-agent") ?? null;
// 1) Create request first (DB only — keep it fast)
const created = await prisma.commissionRequest.create({
data: {
status: "NEW",
customerName: payload.data.customerName,
@ -92,9 +88,6 @@ export async function POST(request: Request) {
ipAddress,
userAgent,
// customFields: payload.data.customFields ?? undefined,
// Extras are M:N; connect by ids
extras: payload.data.extraIds?.length
? { connect: payload.data.extraIds.map((id) => ({ id })) }
: undefined,
@ -102,38 +95,99 @@ export async function POST(request: Request) {
select: { id: true, createdAt: true },
});
for (const f of files) {
const fileKey = uuidv4();
const fileType = f.type;
const realFileType = fileType.split("/")[1];
const originalKey = `commissions/${fileKey}.${realFileType}`;
const arrayBuffer = await f.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
// 2) Upload to S3 outside any Prisma transaction
const uploadedKeys: string[] = [];
const fileRows: {
requestId: string;
fileKey: string;
originalFile: string;
fileType: string;
fileSize: number;
uploadDate: Date;
}[] = [];
await s3.send(
new PutObjectCommand({
Bucket: `${process.env.BUCKET_NAME}`,
Key: originalKey,
Body: buffer,
ContentType: f.type || "application/octet-stream",
})
);
try {
for (const f of files) {
const fileKey = uuidv4();
await tx.commissionRequestFile.create({
data: {
const mime = f.type || "application/octet-stream";
// Do NOT trust client mime too much; but ok for extension here
const ext = mime.includes("/") ? mime.split("/")[1] : "bin";
// Note: keep your existing shape: commissions/<uuid>.<ext>
const originalKey = `commissions/${fileKey}.${ext}`;
const arrayBuffer = await f.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
await s3.send(
new PutObjectCommand({
Bucket: bucket,
Key: originalKey,
Body: buffer,
ContentType: mime,
})
);
uploadedKeys.push(originalKey);
fileRows.push({
requestId: created.id,
fileKey: originalKey,
originalFile: f.name,
fileType: fileType || "application/octet-stream",
fileType: mime,
fileSize: f.size,
// Use the request creation time (matches your prior logic)
uploadDate: created.createdAt,
},
});
}
} catch (uploadErr) {
// Best-effort cleanup to avoid orphaned objects
if (uploadedKeys.length) {
try {
await s3.send(
new DeleteObjectsCommand({
Bucket: bucket,
Delete: {
Objects: uploadedKeys.map((Key) => ({ Key })),
Quiet: true,
},
})
);
} catch (cleanupErr) {
console.error("[POST /api/v1/commissions] S3 cleanup failed", cleanupErr);
}
}
// Keep request for audit OR delete it. Pick one:
// Option A: delete it (strict consistency)
await prisma.commissionRequest.delete({ where: { id: created.id } });
// Option B (alternative): keep but mark a status like FAILED_UPLOAD if you add it.
// await prisma.commissionRequest.update({ where: { id: created.id }, data: { status: "FAILED_UPLOAD" } });
throw uploadErr;
}
// 3) Insert file rows (DB only — keep it fast)
if (fileRows.length) {
await prisma.commissionRequestFile.createMany({
data: fileRows,
});
}
return created;
});
return NextResponse.json({ id: created.id, createdAt: created.createdAt }, { status: 201 });
} catch (err) {
console.error("[POST /api/v1/commissions] failed", err);
return NextResponse.json(result, { status: 201 });
const message = err instanceof Error ? err.message : "Unknown server error";
return NextResponse.json(
{
error: "Commission submission failed",
message,
},
{ status: 500 }
);
}
}

View File

@ -19,9 +19,9 @@ import {
import Link from "next/link";
import * as React from "react";
import { deleteCommissionRequest } from "@/actions/commissions/deleteCommissionRequest";
import { getCommissionRequestsTablePage } from "@/actions/commissions/getCommissionRequestsTablePage";
import { setCommissionRequestStatus } from "@/actions/commissions/setCommissionRequestStatus";
import { deleteCommissionRequest } from "@/actions/commissions/requests/deleteCommissionRequest";
import { getCommissionRequestsTablePage } from "@/actions/commissions/requests/getCommissionRequestsTablePage";
import { setCommissionRequestStatus } from "@/actions/commissions/requests/setCommissionRequestStatus";
import type {
CommissionRequestTableRow,
CommissionStatus,
@ -202,6 +202,49 @@ export function CommissionRequestsTable() {
refresh();
}, [refresh]);
// React.useEffect(() => {
// // Only poll when tab is visible (avoid wasting requests)
// let timer: ReturnType<typeof setInterval> | null = null;
// const start = () => {
// if (timer) return;
// timer = setInterval(() => {
// // Avoid stacking requests
// if (!isPending) refresh();
// }, 10_000); // every 10s (adjust as you like)
// };
// const stop = () => {
// if (!timer) return;
// clearInterval(timer);
// timer = null;
// };
// const onVisibilityChange = () => {
// if (document.visibilityState === "visible") {
// // Do an immediate refresh when the user returns
// refresh();
// start();
// } else {
// stop();
// }
// };
// onVisibilityChange(); // initialize
// document.addEventListener("visibilitychange", onVisibilityChange);
// return () => {
// stop();
// document.removeEventListener("visibilitychange", onVisibilityChange);
// };
// }, [refresh, isPending]);
// React.useEffect(() => {
// const onFocus = () => refresh();
// window.addEventListener("focus", onFocus);
// return () => window.removeEventListener("focus", onFocus);
// }, [refresh]);
const columns = React.useMemo<ColumnDef<CommissionRequestTableRow>[]>(
() => [
{

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

View File

@ -0,0 +1,48 @@
export type PriceSource = {
price?: number | null;
pricePercent?: number | null;
priceRange?: string | null;
};
export function calculatePrice(source: PriceSource, base: number): number {
if (source.price != null) return source.price;
if (source.pricePercent != null) return base * (source.pricePercent / 100);
if (source.priceRange) {
const parts = source.priceRange.split("").map(Number);
const max = Math.max(...parts);
return Number.isNaN(max) ? 0 : max;
}
return 0;
}
export function calculatePriceRange(
baseSource: PriceSource | undefined,
extras: PriceSource[]
): [number, number] {
if (!baseSource) return [0, 0];
const base = calculatePrice(baseSource, 0);
let minExtra = 0;
let maxExtra = 0;
for (const extra of extras) {
if (extra.price != null) {
minExtra += extra.price;
maxExtra += extra.price;
} else if (extra.pricePercent != null) {
const val = base * (extra.pricePercent / 100);
minExtra += val;
maxExtra += val;
} else if (extra.priceRange) {
const [minStr, maxStr] = extra.priceRange.split("");
const min = Number(minStr);
const max = Number(maxStr);
if (!Number.isNaN(min)) minExtra += min;
if (!Number.isNaN(max)) maxExtra += max;
}
}
return [base + minExtra, base + maxExtra];
}