MEGAINFRA-WEBAPP/src/app/api/upload/route.ts
DEls 3506640e4a Add Uploads page with 4 colored drop zones
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
2026-08-09 19:30:00 +02:00

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