New /dashboard/uploads page with a 2x2 grid of drop zones, one per ingest source: - Orders (orange) — CSV / XLS / XLSX, max 10 MB - Delivery Notes (teal) — PDF / images, max 20 MB - Fuel Logs (yellow-green) — CSV / XLS / XLSX, max 5 MB - Time Sheets (green) — CSV / XLS / XLSX, max 5 MB Each block has: - A coloured left stripe + icon disc in the accent colour - Title, format-count badge, and a long description of what to upload - File-format chips and a max-size hint - A drag-and-drop drop zone that also accepts files via click - A primary 'Upload X' button in the accent colour - A 'Recent' list showing the last 4 uploads with status badges When files are dropped or picked, the client POSTs to /api/upload with a 'type' field. The endpoint validates MIME and size against the kind-specific rules in src/lib/uploads.ts and returns a JSON descriptor with accepted/rejected entries. New entries are prepended to the page's recent-uploads list in real time, and a bottom-right toast shows accepted/rejected counts. The sidebar gets a new 'Uploads' nav item with the upload icon. Files: - src/lib/types.ts type definitions - src/lib/uploads.ts acceptance rules + mock history - src/app/api/upload/route.ts type-aware upload handler - src/app/dashboard/uploads/page.tsx the new page - src/components/layout/sidebar.tsx Uploads nav entry
96 lines
2.8 KiB
TypeScript
96 lines
2.8 KiB
TypeScript
import { NextResponse } from "next/server";
|
|
import type { UploadKind } from "@/lib/types";
|
|
import { UPLOAD_ACCEPTANCE } from "@/lib/uploads";
|
|
|
|
const VALID_KINDS: UploadKind[] = [
|
|
"orders",
|
|
"delivery-notes",
|
|
"fuel-logs",
|
|
"time-sheets",
|
|
];
|
|
|
|
function isUploadKind(value: string): value is UploadKind {
|
|
return (VALID_KINDS as string[]).includes(value);
|
|
}
|
|
|
|
/**
|
|
* File upload endpoint. Accepts multipart/form-data with:
|
|
* - `type` — one of `orders`, `delivery-notes`, `fuel-logs`, `time-sheets`
|
|
* - `file` — one or more files matching the type's allowed MIME list
|
|
*
|
|
* Returns a JSON descriptor with the accepted and rejected files. Each
|
|
* type has its own MIME allowlist and max size (see `lib/uploads.ts`).
|
|
*
|
|
* NOTE: this is a stub — when PocketBase is wired the body will be
|
|
* forwarded to the matching collection.
|
|
*/
|
|
export async function POST(request: Request) {
|
|
const form = await request.formData();
|
|
const typeRaw = (form.get("type") as string | null) ?? "";
|
|
if (!isUploadKind(typeRaw)) {
|
|
return NextResponse.json(
|
|
{
|
|
error: `Missing or invalid 'type' field. Expected one of: ${VALID_KINDS.join(", ")}.`,
|
|
},
|
|
{ status: 400 }
|
|
);
|
|
}
|
|
|
|
const rules = UPLOAD_ACCEPTANCE[typeRaw];
|
|
const files = form.getAll("file");
|
|
if (files.length === 0) {
|
|
return NextResponse.json({ error: "No file provided" }, { status: 400 });
|
|
}
|
|
|
|
const accepted: Array<{ name: string; size: number; mime: string }> = [];
|
|
const rejected: Array<{ name: string; reason: string }> = [];
|
|
|
|
for (const entry of files) {
|
|
if (!(entry instanceof File)) {
|
|
rejected.push({ name: "?", reason: "Not a file" });
|
|
continue;
|
|
}
|
|
if (!rules.mimes.includes(entry.type)) {
|
|
rejected.push({
|
|
name: entry.name,
|
|
reason: `Unsupported type ${entry.type || "unknown"} for ${typeRaw}. Expected ${rules.extensions.join(", ")}.`,
|
|
});
|
|
continue;
|
|
}
|
|
if (entry.size > rules.maxBytes) {
|
|
const maxMb = (rules.maxBytes / 1024 / 1024).toFixed(0);
|
|
rejected.push({
|
|
name: entry.name,
|
|
reason: `Exceeds ${maxMb}MB limit`,
|
|
});
|
|
continue;
|
|
}
|
|
accepted.push({ name: entry.name, size: entry.size, mime: entry.type });
|
|
}
|
|
|
|
return NextResponse.json({
|
|
ok: true,
|
|
type: typeRaw,
|
|
accepted,
|
|
rejected,
|
|
uploadedAt: new Date().toISOString(),
|
|
});
|
|
}
|
|
|
|
export async function GET() {
|
|
return NextResponse.json({
|
|
endpoint: "/api/upload",
|
|
method: "POST",
|
|
body: "multipart/form-data with fields 'type' (one of: orders, delivery-notes, fuel-logs, time-sheets) and 'file'.",
|
|
acceptedTypes: VALID_KINDS.reduce<Record<string, { extensions: string[]; maxBytes: number }>>(
|
|
(acc, k) => {
|
|
acc[k] = {
|
|
extensions: UPLOAD_ACCEPTANCE[k].extensions,
|
|
maxBytes: UPLOAD_ACCEPTANCE[k].maxBytes,
|
|
};
|
|
return acc;
|
|
},
|
|
{}
|
|
),
|
|
});
|
|
}
|