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