diff --git a/src/app/api/upload/route.ts b/src/app/api/upload/route.ts index 1a312ed..a82c54f 100644 --- a/src/app/api/upload/route.ts +++ b/src/app/api/upload/route.ts @@ -1,19 +1,47 @@ 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 - * (PDF or image) and returns a JSON descriptor that the dashboard can attach - * to a delivery note or fuel reading. + * 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 * - * NOTE: this is a stub — when we wire PocketBase the body will be forwarded - * to the `delivery_notes` / `fuel_attachments` collections. + * 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 }> = []; @@ -22,15 +50,19 @@ export async function POST(request: Request) { rejected.push({ name: "?", reason: "Not a file" }); continue; } - const isPdf = entry.type === "application/pdf"; - const isImage = entry.type.startsWith("image/"); - const isText = entry.type === "text/csv" || entry.type === "text/plain"; - if (!isPdf && !isImage && !isText) { - rejected.push({ name: entry.name, reason: `Unsupported type ${entry.type}` }); + 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 > 20 * 1024 * 1024) { - rejected.push({ name: entry.name, reason: "Exceeds 20MB limit" }); + 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 }); @@ -38,6 +70,7 @@ export async function POST(request: Request) { return NextResponse.json({ ok: true, + type: typeRaw, accepted, rejected, uploadedAt: new Date().toISOString(), @@ -48,7 +81,16 @@ export async function GET() { return NextResponse.json({ endpoint: "/api/upload", method: "POST", - body: "multipart/form-data with field 'file' (PDF or image, max 20MB)", - acceptedMime: ["application/pdf", "image/*"], + body: "multipart/form-data with fields 'type' (one of: orders, delivery-notes, fuel-logs, time-sheets) and 'file'.", + acceptedTypes: VALID_KINDS.reduce>( + (acc, k) => { + acc[k] = { + extensions: UPLOAD_ACCEPTANCE[k].extensions, + maxBytes: UPLOAD_ACCEPTANCE[k].maxBytes, + }; + return acc; + }, + {} + ), }); } diff --git a/src/app/dashboard/uploads/page.tsx b/src/app/dashboard/uploads/page.tsx new file mode 100644 index 0000000..5d0f87d --- /dev/null +++ b/src/app/dashboard/uploads/page.tsx @@ -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 = { + 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(null); + const [toast, setToast] = React.useState<{ kind: UploadKind; ok: number; fail: number } | null>(null); + const fileInputRefs = React.useRef>({ + 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).length; + const fail = (json.rejected as Array).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) => { + e.preventDefault(); + handleUpload(kind, e.dataTransfer.files); + }; + + return ( + <> + + +
+

+ 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. +

+ +
+ {BLOCKS.map((block) => { + const acc = UPLOAD_ACCEPTANCE[block.kind]; + const history = recent.filter((u) => u.kind === block.kind).slice(0, 4); + return ( + + +
+ + + +
+
+ {block.label} + + {acc.extensions.length} formats + +
+ {block.description} +
+
+
+ + +

{block.longDescription}

+ +
+ {acc.extensions.map((ext) => ( + + {ext} + + ))} + + Max {Math.round(acc.maxBytes / 1024 / 1024)} MB + +
+ + {/* Drop zone */} + + { + 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)} + /> + + + {/* Recent uploads for this kind */} +
+
+

+ Recent +

+ + {history.length} of {recent.filter((u) => u.kind === block.kind).length} + +
+ {history.length === 0 ? ( +

+ No uploads yet. +

+ ) : ( +
    + {history.map((u) => { + const meta = STATUS_META[u.status]; + const Icon = meta.icon; + return ( +
  • + {u.kind === "delivery-notes" ? ( + + ) : ( + + )} +
    +

    {u.fileName}

    +

    + {formatBytes(u.size)} · {u.uploadedBy} ·{" "} + {format(parseISO(u.uploadedAt), "d MMM, HH:mm")} +

    +
    + + + {meta.label} + +
  • + ); + })} +
+ )} +
+
+
+ ); + })} +
+ + {toast && ( +
0 ? "border-destructive" : "border-emerald-500" + )} + role="status" + aria-live="polite" + > + {toast.fail > 0 ? ( + + ) : ( + + )} +

+ {toast.ok} accepted · {toast.fail} rejected +

+ +
+ )} +
+ + ); +} diff --git a/src/components/layout/sidebar.tsx b/src/components/layout/sidebar.tsx index 59e48a7..4aa6a4f 100644 --- a/src/components/layout/sidebar.tsx +++ b/src/components/layout/sidebar.tsx @@ -10,6 +10,7 @@ import { Package, LogOut, Menu, + Upload, X, Users, } from "lucide-react"; @@ -33,6 +34,7 @@ const NAV: NavItem[] = [ { 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/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() { diff --git a/src/lib/types.ts b/src/lib/types.ts index 5d3511a..190f201 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -150,3 +150,24 @@ export interface SiteFinancialSummary { fuelCostThisMonth: 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; +} diff --git a/src/lib/uploads.ts b/src/lib/uploads.ts new file mode 100644 index 0000000..ec34a71 --- /dev/null +++ b/src/lib/uploads.ts @@ -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 = { + 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); +} \ No newline at end of file