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:
parent
df459de0f5
commit
3506640e4a
5 changed files with 565 additions and 14 deletions
|
|
@ -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;
|
||||||
|
},
|
||||||
|
{}
|
||||||
|
),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
369
src/app/dashboard/uploads/page.tsx
Normal file
369
src/app/dashboard/uploads/page.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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() {
|
||||||
|
|
|
||||||
|
|
@ -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
117
src/lib/uploads.ts
Normal 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);
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue