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
This commit is contained in:
DEls 2026-08-09 19:30:00 +02:00
parent df459de0f5
commit 3506640e4a
5 changed files with 565 additions and 14 deletions

View file

@ -1,19 +1,47 @@
import { NextResponse } from "next/server"; 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 one or more files * File upload endpoint. Accepts multipart/form-data with:
* (PDF or image) and returns a JSON descriptor that the dashboard can attach * - `type` — one of `orders`, `delivery-notes`, `fuel-logs`, `time-sheets`
* to a delivery note or fuel reading. * - `file` — one or more files matching the type's allowed MIME list
* *
* NOTE: this is a stub — when we wire PocketBase the body will be forwarded * Returns a JSON descriptor with the accepted and rejected files. Each
* to the `delivery_notes` / `fuel_attachments` collections. * 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) { export async function POST(request: Request) {
const form = await request.formData(); 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"); const files = form.getAll("file");
if (files.length === 0) { if (files.length === 0) {
return NextResponse.json({ error: "No file provided" }, { status: 400 }); return NextResponse.json({ error: "No file provided" }, { status: 400 });
} }
const accepted: Array<{ name: string; size: number; mime: string }> = []; const accepted: Array<{ name: string; size: number; mime: string }> = [];
const rejected: Array<{ name: string; reason: string }> = []; const rejected: Array<{ name: string; reason: string }> = [];
@ -22,15 +50,19 @@ export async function POST(request: Request) {
rejected.push({ name: "?", reason: "Not a file" }); rejected.push({ name: "?", reason: "Not a file" });
continue; continue;
} }
const isPdf = entry.type === "application/pdf"; if (!rules.mimes.includes(entry.type)) {
const isImage = entry.type.startsWith("image/"); rejected.push({
const isText = entry.type === "text/csv" || entry.type === "text/plain"; name: entry.name,
if (!isPdf && !isImage && !isText) { reason: `Unsupported type ${entry.type || "unknown"} for ${typeRaw}. Expected ${rules.extensions.join(", ")}.`,
rejected.push({ name: entry.name, reason: `Unsupported type ${entry.type}` }); });
continue; continue;
} }
if (entry.size > 20 * 1024 * 1024) { if (entry.size > rules.maxBytes) {
rejected.push({ name: entry.name, reason: "Exceeds 20MB limit" }); const maxMb = (rules.maxBytes / 1024 / 1024).toFixed(0);
rejected.push({
name: entry.name,
reason: `Exceeds ${maxMb}MB limit`,
});
continue; continue;
} }
accepted.push({ name: entry.name, size: entry.size, mime: entry.type }); accepted.push({ name: entry.name, size: entry.size, mime: entry.type });
@ -38,6 +70,7 @@ export async function POST(request: Request) {
return NextResponse.json({ return NextResponse.json({
ok: true, ok: true,
type: typeRaw,
accepted, accepted,
rejected, rejected,
uploadedAt: new Date().toISOString(), uploadedAt: new Date().toISOString(),
@ -48,7 +81,16 @@ export async function GET() {
return NextResponse.json({ return NextResponse.json({
endpoint: "/api/upload", endpoint: "/api/upload",
method: "POST", method: "POST",
body: "multipart/form-data with field 'file' (PDF or image, max 20MB)", body: "multipart/form-data with fields 'type' (one of: orders, delivery-notes, fuel-logs, time-sheets) and 'file'.",
acceptedMime: ["application/pdf", "image/*"], 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;
},
{}
),
}); });
} }

View file

@ -0,0 +1,369 @@
"use client";
import * as React from "react";
import { format, parseISO } from "date-fns";
import {
AlertCircle,
CheckCircle2,
Clock,
CloudUpload,
FileSpreadsheet,
FileText,
Fuel,
Image as ImageIcon,
Truck,
Upload,
X,
type LucideIcon,
} from "lucide-react";
import { Topbar } from "@/components/layout/sidebar";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { UPLOAD_ACCEPTANCE, UPLOAD_HISTORY } from "@/lib/uploads";
import type { UploadEntry, UploadKind, UploadStatus } from "@/lib/types";
interface BlockConfig {
kind: UploadKind;
label: string;
description: string;
longDescription: string;
icon: LucideIcon;
/** Tint colour — used for the icon background, accent stripe, and badge. */
hex: string;
/** Tailwind classes for the icon disc, kept in sync with the hex. */
iconBg: string;
stripe: string;
badge: string;
badgeText: string;
/** Pill background for the accepted-format chips. */
chip: string;
}
const BLOCKS: BlockConfig[] = [
{
kind: "orders",
label: "Orders",
description: "Bulk purchase order lines & supplier invoices",
longDescription: "Import PO lines, supplier invoices, or stock-sheet exports. CSV with vehicle registration, date, hours worked, odometer, litres, and unit price is the fastest format.",
icon: Truck,
hex: "#fdb13c",
iconBg: "bg-[#fdb13c]/15 text-[#9c6f1e] dark:text-[#fdb13c]",
stripe: "before:bg-[#fdb13c]",
badge: "bg-[#fdb13c]/15",
badgeText: "text-[#9c6f1e] dark:text-[#fdb13c]",
chip: "bg-[#fdb13c]/10 text-[#9c6f1e] dark:text-[#fdb13c]",
},
{
kind: "delivery-notes",
label: "Delivery Notes",
description: "Scanned GRNs, supplier dockets & photos",
longDescription: "Capture scanned delivery notes, supplier GRNs, or photos of the docket. PDFs and high-resolution JPEGs are matched to the order automatically.",
icon: FileText,
hex: "#008372",
iconBg: "bg-[#008372]/15 text-[#008372] dark:text-[#3fb8a8]",
stripe: "before:bg-[#008372]",
badge: "bg-[#008372]/15",
badgeText: "text-[#008372] dark:text-[#3fb8a8]",
chip: "bg-[#008372]/10 text-[#008372] dark:text-[#3fb8a8]",
},
{
kind: "fuel-logs",
label: "Fuel Logs",
description: "Per-vehicle daily fuel-issue exports",
longDescription: "Drop per-vehicle daily fuel-issue exports from the depot system. The CSV needs vehicle registration, date, hours worked, odometer, litres, unit price, and expected consumption.",
icon: Fuel,
hex: "#a1ae3b",
iconBg: "bg-[#a1ae3b]/15 text-[#5e6920] dark:text-[#c0cd5a]",
stripe: "before:bg-[#a1ae3b]",
badge: "bg-[#a1ae3b]/15",
badgeText: "text-[#5e6920] dark:text-[#c0cd5a]",
chip: "bg-[#a1ae3b]/10 text-[#5e6920] dark:text-[#c0cd5a]",
},
{
kind: "time-sheets",
label: "Time Sheets",
description: "Weekly or daily payroll timesheet exports",
longDescription: "Weekly or daily timesheet exports from the payroll system. CSV/XLSX with employee id, date, and hours worked will update the workforce dashboard automatically.",
icon: FileSpreadsheet,
hex: "#4f9c5b",
iconBg: "bg-[#4f9c5b]/15 text-[#2f6940] dark:text-[#6ec082]",
stripe: "before:bg-[#4f9c5b]",
badge: "bg-[#4f9c5b]/15",
badgeText: "text-[#2f6940] dark:text-[#6ec082]",
chip: "bg-[#4f9c5b]/10 text-[#2f6940] dark:text-[#6ec082]",
},
];
const STATUS_META: Record<UploadStatus, { label: string; variant: "success" | "warning" | "danger" | "secondary"; icon: LucideIcon }> = {
Processed: { label: "Processed", variant: "success", icon: CheckCircle2 },
Pending: { label: "Pending", variant: "warning", icon: Clock },
Failed: { label: "Failed", variant: "danger", icon: AlertCircle },
};
function formatBytes(n: number) {
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
return `${(n / 1024 / 1024).toFixed(2)} MB`;
}
export default function UploadsPage() {
const [recent, setRecent] = React.useState(UPLOAD_HISTORY);
const [busy, setBusy] = React.useState<UploadKind | null>(null);
const [toast, setToast] = React.useState<{ kind: UploadKind; ok: number; fail: number } | null>(null);
const fileInputRefs = React.useRef<Record<UploadKind, HTMLInputElement | null>>({
orders: null,
"delivery-notes": null,
"fuel-logs": null,
"time-sheets": null,
});
const handlePick = (kind: UploadKind) => {
fileInputRefs.current[kind]?.click();
};
const handleUpload = async (kind: UploadKind, fileList: FileList | null) => {
if (!fileList || fileList.length === 0) return;
setBusy(kind);
try {
const form = new FormData();
form.set("type", kind);
for (const f of Array.from(fileList)) form.append("file", f);
const res = await fetch("/api/upload", { method: "POST", body: form });
const json = await res.json();
const ok = (json.accepted as Array<unknown>).length;
const fail = (json.rejected as Array<unknown>).length;
setToast({ kind, ok, fail });
// Append a synthetic entry to the local history so the UI updates immediately.
const newEntries: UploadEntry[] = Array.from(fileList).map((f, i) => ({
id: `up-new-${Date.now()}-${i}`,
kind,
fileName: f.name,
size: f.size,
uploadedAt: new Date().toISOString(),
uploadedBy: "You",
status: i < ok ? "Processed" : "Failed",
note: i < ok ? "Just uploaded" : (json.rejected?.[0]?.reason ?? "Rejected"),
}));
setRecent((prev) => [...newEntries, ...prev]);
} catch (err) {
setToast({ kind, ok: 0, fail: fileList.length });
} finally {
setBusy(null);
if (fileInputRefs.current[kind]) fileInputRefs.current[kind]!.value = "";
setTimeout(() => setToast(null), 4000);
}
};
const handleDrop = (kind: UploadKind) => (e: React.DragEvent<HTMLLabelElement>) => {
e.preventDefault();
handleUpload(kind, e.dataTransfer.files);
};
return (
<>
<Topbar
title="Uploads"
subtitle="Drop or pick files for each module — the dashboard updates automatically"
/>
<div className="space-y-6 p-4 sm:p-6">
<p className="text-sm text-muted-foreground">
Each card is wired to a different upload endpoint. Files are validated
against the format and size limits shown, then forwarded to the right
place in the dashboard.
</p>
<section className="grid grid-cols-1 gap-4 md:grid-cols-2">
{BLOCKS.map((block) => {
const acc = UPLOAD_ACCEPTANCE[block.kind];
const history = recent.filter((u) => u.kind === block.kind).slice(0, 4);
return (
<Card
key={block.kind}
className={cn(
"relative overflow-hidden before:absolute before:inset-y-0 before:left-0 before:w-1.5",
block.stripe
)}
>
<CardHeader className="pb-3">
<div className="flex items-start gap-3">
<span
className={cn(
"inline-flex h-11 w-11 shrink-0 items-center justify-center rounded-lg",
block.iconBg
)}
style={{ color: block.hex }}
>
<block.icon className="h-5 w-5" style={{ color: block.hex }} />
</span>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<CardTitle>{block.label}</CardTitle>
<Badge className={cn(block.badge, block.badgeText)} variant="secondary">
{acc.extensions.length} formats
</Badge>
</div>
<CardDescription className="mt-0.5">{block.description}</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-sm text-muted-foreground">{block.longDescription}</p>
<div className="flex flex-wrap items-center gap-1.5">
{acc.extensions.map((ext) => (
<span
key={ext}
className={cn(
"rounded-md px-2 py-0.5 text-[11px] font-medium uppercase tracking-wider",
block.chip
)}
>
{ext}
</span>
))}
<span className="ml-auto text-xs text-muted-foreground">
Max {Math.round(acc.maxBytes / 1024 / 1024)} MB
</span>
</div>
{/* Drop zone */}
<label
htmlFor={`upload-${block.kind}`}
onDragOver={(e) => e.preventDefault()}
onDrop={handleDrop(block.kind)}
className={cn(
"flex cursor-pointer flex-col items-center justify-center gap-2 rounded-lg border-2 border-dashed border-border bg-muted/30 px-4 py-6 text-center transition-colors",
"hover:border-primary/50 hover:bg-muted/50",
busy === block.kind && "pointer-events-none opacity-60"
)}
>
{busy === block.kind ? (
<>
<CloudUpload className="h-6 w-6 animate-pulse text-muted-foreground" />
<p className="text-sm font-medium">Uploading…</p>
</>
) : (
<>
<Upload className="h-6 w-6 text-muted-foreground" />
<p className="text-sm font-medium">
Drop {block.label.toLowerCase()} files here
</p>
<p className="text-xs text-muted-foreground">
or click to browse
</p>
</>
)}
</label>
<input
ref={(el) => {
fileInputRefs.current[block.kind] = el;
}}
id={`upload-${block.kind}`}
type="file"
className="sr-only"
multiple
accept={acc.extensions.join(",")}
onChange={(e) => handleUpload(block.kind, e.target.files)}
/>
<Button
className="w-full"
disabled={busy === block.kind}
onClick={() => handlePick(block.kind)}
style={{ backgroundColor: block.hex, color: "white" }}
>
<Upload className="h-4 w-4" />
Upload {block.label.toLowerCase()}
</Button>
{/* Recent uploads for this kind */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
Recent
</p>
<span className="text-xs text-muted-foreground">
{history.length} of {recent.filter((u) => u.kind === block.kind).length}
</span>
</div>
{history.length === 0 ? (
<p className="rounded-md border border-dashed border-border p-4 text-center text-xs text-muted-foreground">
No uploads yet.
</p>
) : (
<ul className="space-y-1.5">
{history.map((u) => {
const meta = STATUS_META[u.status];
const Icon = meta.icon;
return (
<li
key={u.id}
className="flex items-center gap-3 rounded-md border border-border p-2.5 text-sm"
>
{u.kind === "delivery-notes" ? (
<ImageIcon className="h-4 w-4 shrink-0 text-muted-foreground" />
) : (
<FileSpreadsheet className="h-4 w-4 shrink-0 text-muted-foreground" />
)}
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{u.fileName}</p>
<p className="text-xs text-muted-foreground">
{formatBytes(u.size)} · {u.uploadedBy} ·{" "}
{format(parseISO(u.uploadedAt), "d MMM, HH:mm")}
</p>
</div>
<Badge
variant={meta.variant}
className="gap-1"
title={u.note}
>
<Icon className="h-3 w-3" />
{meta.label}
</Badge>
</li>
);
})}
</ul>
)}
</div>
</CardContent>
</Card>
);
})}
</section>
{toast && (
<div
className={cn(
"fixed bottom-4 right-4 z-50 flex items-center gap-3 rounded-lg border px-4 py-3 shadow-lg",
"bg-card text-card-foreground",
toast.fail > 0 ? "border-destructive" : "border-emerald-500"
)}
role="status"
aria-live="polite"
>
{toast.fail > 0 ? (
<AlertCircle className="h-4 w-4 text-destructive" />
) : (
<CheckCircle2 className="h-4 w-4 text-emerald-600" />
)}
<p className="text-sm">
{toast.ok} accepted · {toast.fail} rejected
</p>
<button
onClick={() => setToast(null)}
className="text-muted-foreground hover:text-foreground"
aria-label="Dismiss"
>
<X className="h-4 w-4" />
</button>
</div>
)}
</div>
</>
);
}

View file

@ -10,6 +10,7 @@ import {
Package, Package,
LogOut, LogOut,
Menu, Menu,
Upload,
X, X,
Users, Users,
} from "lucide-react"; } from "lucide-react";
@ -33,6 +34,7 @@ const NAV: NavItem[] = [
{ href: "/dashboard/orders", label: "Orders", icon: Package, description: "Purchase orders & delivery notes" }, { href: "/dashboard/orders", label: "Orders", icon: Package, description: "Purchase orders & delivery notes" },
{ href: "/dashboard/employees", label: "Workforce", icon: Users, description: "Weekly hours & attendance" }, { href: "/dashboard/employees", label: "Workforce", icon: Users, description: "Weekly hours & attendance" },
{ href: "/dashboard/sites", label: "Sites", icon: Building2, description: "Per-site financials & progress" }, { href: "/dashboard/sites", label: "Sites", icon: Building2, description: "Per-site financials & progress" },
{ href: "/dashboard/uploads", label: "Uploads", icon: Upload, description: "Drop files into the right module" },
]; ];
export function Sidebar() { export function Sidebar() {

View file

@ -150,3 +150,24 @@ export interface SiteFinancialSummary {
fuelCostThisMonth: number; fuelCostThisMonth: number;
fuelLitresThisMonth: number; fuelLitresThisMonth: number;
} }
export type UploadKind = "orders" | "delivery-notes" | "fuel-logs" | "time-sheets";
export type UploadStatus = "Processed" | "Pending" | "Failed";
export interface UploadEntry {
id: string;
kind: UploadKind;
fileName: string;
size: number; // bytes
uploadedAt: string; // ISO timestamp
uploadedBy: string;
status: UploadStatus;
note?: string;
}
export interface UploadAcceptance {
mimes: string[]; // e.g. ["text/csv", "application/vnd.ms-excel"]
extensions: string[]; // e.g. [".csv", ".xlsx"]
maxBytes: number;
description: string;
}

117
src/lib/uploads.ts Normal file
View file

@ -0,0 +1,117 @@
import type { UploadEntry, UploadKind, UploadAcceptance } from "./types";
const now = new Date();
const iso = (offsetMin: number) =>
new Date(now.getTime() - offsetMin * 60 * 1000).toISOString();
export const UPLOAD_ACCEPTANCE: Record<UploadKind, UploadAcceptance> = {
orders: {
mimes: ["text/csv", "application/vnd.ms-excel", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"],
extensions: [".csv", ".xls", ".xlsx"],
maxBytes: 10 * 1024 * 1024, // 10 MB
description: "Bulk purchase order lines, supplier invoices, or stock-sheet exports.",
},
"delivery-notes": {
mimes: ["application/pdf", "image/png", "image/jpeg", "image/webp"],
extensions: [".pdf", ".png", ".jpg", ".jpeg", ".webp"],
maxBytes: 20 * 1024 * 1024, // 20 MB
description: "Scanned delivery notes, supplier GRNs, or photos of the docket.",
},
"fuel-logs": {
mimes: ["text/csv", "application/vnd.ms-excel", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"],
extensions: [".csv", ".xls", ".xlsx"],
maxBytes: 5 * 1024 * 1024, // 5 MB
description: "Per-vehicle daily fuel-issue exports from the depot system.",
},
"time-sheets": {
mimes: ["text/csv", "application/vnd.ms-excel", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"],
extensions: [".csv", ".xls", ".xlsx"],
maxBytes: 5 * 1024 * 1024,
description: "Weekly or daily timesheet exports from the payroll system.",
},
};
export const UPLOAD_HISTORY: UploadEntry[] = [
{
id: "up-1",
kind: "orders",
fileName: "PO-2026-week31.csv",
size: 142_300,
uploadedAt: iso(42),
uploadedBy: "T. Mokoena",
status: "Processed",
note: "Imported 18 lines, 4 suppliers",
},
{
id: "up-2",
kind: "delivery-notes",
fileName: "GRN-Atlas-0408.pdf",
size: 458_911,
uploadedAt: iso(115),
uploadedBy: "P. Naidoo",
status: "Processed",
},
{
id: "up-3",
kind: "fuel-logs",
fileName: "fuel-depot-2026-08-08.csv",
size: 38_402,
uploadedAt: iso(180),
uploadedBy: "T. Mokoena",
status: "Processed",
note: "315 readings, 9 vehicles",
},
{
id: "up-4",
kind: "time-sheets",
fileName: "timesheet-week31.xlsx",
size: 89_110,
uploadedAt: iso(260),
uploadedBy: "L. Mokoena",
status: "Pending",
note: "Awaiting review",
},
{
id: "up-5",
kind: "delivery-notes",
fileName: "scan-0407.jpg",
size: 1_247_882,
uploadedAt: iso(320),
uploadedBy: "P. Naidoo",
status: "Failed",
note: "Unreadable image quality",
},
{
id: "up-6",
kind: "fuel-logs",
fileName: "fuel-depot-2026-08-07.csv",
size: 36_998,
uploadedAt: iso(1380),
uploadedBy: "T. Mokoena",
status: "Processed",
},
{
id: "up-7",
kind: "orders",
fileName: "PO-2026-week30.csv",
size: 128_400,
uploadedAt: iso(2880),
uploadedBy: "T. Mokoena",
status: "Processed",
note: "Imported 22 lines, 6 suppliers",
},
{
id: "up-8",
kind: "time-sheets",
fileName: "timesheet-week30.xlsx",
size: 91_024,
uploadedAt: iso(2960),
uploadedBy: "L. Mokoena",
status: "Processed",
},
];
export function uploadsByKind(kind: UploadKind): UploadEntry[] {
return UPLOAD_HISTORY.filter((u) => u.kind === kind);
}