Admin AI Tools page: unified design system styling, provider selector with OpenAI turd emoji, free-text model input with exact ID warning

This commit is contained in:
2026-06-02 02:21:11 +00:00
parent 809e0061ca
commit 15e939ad7e
116 changed files with 14991 additions and 5326 deletions
+56 -46
View File
@@ -4,19 +4,51 @@ import { useState } from "react";
import { searchHarvestLots, HarvestLot } from "@/actions/route-trace/lots";
import StatusBadge from "./StatusBadge";
import Link from "next/link";
import QRScanModal from "./QRScanModal";
function getAgeStatus(harvestDate: string): { label: string; className: string } | null {
const harvested = new Date(harvestDate + "T00:00:00");
const now = new Date();
const days = Math.floor((now.getTime() - harvested.getTime()) / (1000 * 60 * 60 * 24));
if (days < 0) return null;
if (days <= 3) return { label: `${days}d`, className: "bg-green-100 text-green-700" };
if (days <= 7) return { label: `${days}d`, className: "bg-amber-100 text-amber-700" };
if (days <= 14) return { label: `${days}d`, className: "bg-orange-100 text-orange-700" };
return { label: `${days}d`, className: "bg-red-100 text-red-700" };
}
// One-color outline icons
const Icons = {
camera: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M14.5 4h-5L7 7H4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-3l-2.5-3z" />
<circle cx="12" cy="13" r="3" />
</svg>
),
search: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="11" cy="11" r="8"/>
<path d="m21 21-4.3-4.3"/>
</svg>
),
plus: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M5 12h14M12 5v14"/>
</svg>
),
};
export default function AdminLookupPage({ brandId }: { brandId: string }) {
const [query, setQuery] = useState("");
const [results, setResults] = useState<HarvestLot[]>([]);
const [searched, setSearched] = useState(false);
const [loading, setLoading] = useState(false);
const [scanMode, setScanMode] = useState(false);
const [showScanModal, setShowScanModal] = useState(false);
async function handleSearch(e: React.FormEvent) {
e.preventDefault();
if (!query.trim()) return;
setLoading(true);
setScanMode(false);
const res = await searchHarvestLots(brandId, query.trim());
setResults(res.success ? res.lots : []);
setSearched(true);
@@ -25,8 +57,6 @@ export default function AdminLookupPage({ brandId }: { brandId: string }) {
function handleScanResult(lotNumber: string) {
setQuery(lotNumber);
setScanMode(false);
// Trigger search immediately
setLoading(true);
searchHarvestLots(brandId, lotNumber).then((res) => {
setResults(res.success ? res.lots : []);
@@ -37,8 +67,25 @@ export default function AdminLookupPage({ brandId }: { brandId: string }) {
return (
<div className="space-y-5">
{/* Scan button */}
<button
onClick={() => setShowScanModal(true)}
className="w-full rounded-xl bg-emerald-600 px-5 py-4 text-sm font-bold text-white hover:bg-emerald-700 transition-colors flex items-center justify-center gap-2 shadow-sm"
>
<span className="text-white">{Icons.camera("h-5 w-5")}</span>
Scan QR Code
</button>
{/* QRScanModal */}
{showScanModal && (
<QRScanModal
onClose={() => setShowScanModal(false)}
onScanResult={handleScanResult}
/>
)}
{/* Search card */}
<div className="rounded-2xl border border-stone-200 bg-white shadow-sm">
<div className="rounded-2xl border border-stone-200 bg-white">
<div className="border-b border-stone-100 px-6 py-5">
<h2 className="text-lg font-semibold text-stone-900">Trace Lookup</h2>
<p className="mt-1 text-sm text-stone-500">Search by lot number or crop type to find a harvest lot.</p>
@@ -54,56 +101,19 @@ export default function AdminLookupPage({ brandId }: { brandId: string }) {
<button
type="submit"
disabled={loading || !query.trim()}
className="rounded-xl bg-emerald-600 px-5 py-3 text-sm font-bold text-white hover:bg-emerald-700 disabled:opacity-50"
className="rounded-xl bg-emerald-600 px-5 py-3 text-sm font-bold text-white hover:bg-emerald-700 disabled:opacity-50 flex items-center gap-2"
>
{loading ? "Searching..." : "🔍 Search"}
{loading ? "Searching..." : <>{Icons.search("h-4 w-4")} Search</>}
</button>
</form>
{scanMode && (
<div className="px-6 pb-6">
<div className="rounded-xl border-2 border-dashed border-stone-300 bg-stone-50 p-8 text-center">
<div className="text-4xl mb-3">📷</div>
<p className="text-sm font-semibold text-stone-700">Camera scan ready</p>
<p className="text-xs text-stone-400 mt-1">Point camera at a Route Trace QR code to look up a lot</p>
<div className="mt-4 flex justify-center gap-3">
<button
onClick={() => setScanMode(false)}
className="rounded-lg border border-stone-200 bg-white px-4 py-2 text-sm font-medium text-stone-600 hover:bg-stone-50"
>
Cancel
</button>
<button
onClick={() => {
// Simulate scan with manual entry fallback
const input = prompt("Enter lot number from QR scan:");
if (input) handleScanResult(input.trim());
}}
className="rounded-lg bg-emerald-600 px-4 py-2 text-sm font-medium text-white hover:bg-emerald-700"
>
Enter Manually
</button>
</div>
</div>
</div>
)}
{!scanMode && (
<div className="px-6 pb-6">
<button
onClick={() => setScanMode(true)}
className="rounded-xl border border-stone-200 bg-white px-4 py-2.5 text-sm font-semibold text-stone-600 hover:bg-stone-50 transition-colors flex items-center gap-2"
>
📷 Scan QR Code
</button>
</div>
)}
</div>
{/* Results */}
{searched && (
<div className="rounded-2xl border border-stone-200 bg-white overflow-hidden shadow-sm">
<div className="rounded-2xl border border-stone-200 bg-white overflow-hidden">
{results.length === 0 ? (
<div className="p-10 text-center">
<div className="text-3xl mb-3">🔍</div>
<div className="text-stone-300 mb-3">{Icons.search("h-10 w-10")}</div>
<p className="text-sm text-stone-500">No lots found for "{query}"</p>
</div>
) : (
+541 -40
View File
@@ -1,12 +1,169 @@
"use client";
import { useState } from "react";
import { useState, useEffect } from "react";
import { formatDate } from "@/lib/format-date";
type Props = {
brandId: string;
// One-color outline icons
const Icons = {
clipboard: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="8" y="2" width="8" height="4" rx="1" ry="1"/>
<path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/>
<path d="M12 11h4M12 16h4M8 11h.01M8 16h.01"/>
</svg>
),
refresh: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/>
<path d="M21 3v5h-5"/>
<path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/>
<path d="M8 16H3v5"/>
</svg>
),
package: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M16.5 9.4 7.55 4.24"/>
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/>
<polyline points="3.29 7 12 12 20.71 7"/>
<line x1="12" x2="12" y1="22" y2="12"/>
</svg>
),
check: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M20 6 9 17l-5-5"/>
</svg>
),
alert: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z"/>
<path d="M12 9v4"/>
<path d="M12 17h.01"/>
</svg>
),
clock: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10"/>
<polyline points="12 6 12 12 16 14"/>
</svg>
),
scale: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="m16 16 3-8 3 8c-.87.65-1.92 1-3 1s-2.13-.35-3-1Z"/>
<path d="m2 16 3-8 3 8c-.87.65-1.92 1-3 1s-2.13-.35-3-1Z"/>
<path d="M7 21h10"/>
<path d="M12 3v18"/>
<path d="M3 7h2c2 0 5-1 7-2 2 1 5 2 7 2h2"/>
</svg>
),
chart: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="12" x2="12" y1="20" y2="10"/>
<line x1="18" x2="18" y1="20" y2="4"/>
<line x1="6" x2="6" y1="20" y2="16"/>
</svg>
),
search: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="11" cy="11" r="8"/>
<path d="m21 21-4.3-4.3"/>
</svg>
),
download: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="7 10 12 15 17 10"/>
<line x1="12" x2="12" y1="15" y2="3"/>
</svg>
),
};
export default function FsmaReportModal({ brandId }: Props) {
interface LotCompliance {
lot_id: string;
lot_number: string;
crop_type: string;
variety: string | null;
harvest_date: string;
field_location: string | null;
field_block: string | null;
worker_name: string | null;
packer_name: string | null;
quantity_lbs: number | null;
quantity_used_lbs: number | null;
yield_estimate_lbs: number | null;
yield_unit: string | null;
bin_id: string | null;
status: string;
event_count: number;
has_traceability: boolean;
compliance_status: "compliant" | "non_compliant" | "pending";
issues: string[];
}
interface ComplianceSnapshot {
lots: LotCompliance[];
summary: {
total_lots: number;
compliant: number;
non_compliant: number;
pending: number;
total_weight: number;
used_weight: number;
remaining_weight: number;
crops: string[];
};
}
const STATUS_BADGE: Record<string, { bg: string; text: string; dot: string }> = {
compliant: { bg: "bg-green-50", text: "text-green-700", dot: "bg-green-500" },
non_compliant: { bg: "bg-red-50", text: "text-red-700", dot: "bg-red-500" },
pending: { bg: "bg-amber-50", text: "text-amber-700", dot: "bg-amber-500" },
};
const LOT_STATUS_CONFIG: Record<string, { label: string; bg: string; text: string }> = {
active: { label: "Active", bg: "bg-green-50", text: "text-green-700" },
in_transit: { label: "In Transit", bg: "bg-amber-50", text: "text-amber-700" },
at_shed: { label: "At Shed", bg: "bg-blue-50", text: "text-blue-700" },
packed: { label: "Packed", bg: "bg-purple-50", text: "text-purple-700" },
delivered: { label: "Delivered", bg: "bg-stone-100", text: "text-stone-600" },
};
function ComplianceBadge({ status }: { status: "compliant" | "non_compliant" | "pending" }) {
const cfg = STATUS_BADGE[status];
return (
<span className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-bold ${cfg.bg} ${cfg.text}`}>
<span className={`h-1.5 w-1.5 rounded-full ${cfg.dot}`} />
{status === "compliant" ? "Compliant" : status === "non_compliant" ? "Issue" : "Pending"}
</span>
);
}
function SummaryCard({ icon, label, value, subtext, color }: {
icon: React.ReactNode;
label: string;
value: string | number;
subtext?: string;
color: "green" | "red" | "amber" | "blue" | "stone";
}) {
const colors = {
green: "bg-green-50 border-green-100 text-green-800",
red: "bg-red-50 border-red-100 text-red-800",
amber: "bg-amber-50 border-amber-100 text-amber-800",
blue: "bg-blue-50 border-blue-100 text-blue-800",
stone: "bg-stone-50 border-stone-200 text-stone-800",
};
return (
<div className={`rounded-xl border px-4 py-3 ${colors[color]}`}>
<div className="flex items-center gap-1.5">
<span className="text-sm">{icon}</span>
<span className="text-[10px] font-bold uppercase tracking-wider opacity-70">{label}</span>
</div>
<p className="text-2xl font-black mt-0.5">{value}</p>
{subtext && <p className="text-[10px] opacity-60 mt-0.5">{subtext}</p>}
</div>
);
}
export default function FsmaReportModal({ brandId }: { brandId: string }) {
const [open, setOpen] = useState(false);
const [startDate, setStartDate] = useState(() => {
const d = new Date();
@@ -15,77 +172,421 @@ export default function FsmaReportModal({ brandId }: Props) {
});
const [endDate, setEndDate] = useState(() => new Date().toISOString().split("T")[0]);
const [loading, setLoading] = useState(false);
const [data, setData] = useState<ComplianceSnapshot | null>(null);
const [search, setSearch] = useState("");
const [filterStatus, setFilterStatus] = useState<string>("all");
const [showOnlyIssues, setShowOnlyIssues] = useState(false);
async function handleDownload() {
useEffect(() => {
if (open && startDate && endDate) {
fetchComplianceData();
}
}, [open, startDate, endDate]);
async function fetchComplianceData() {
setLoading(true);
try {
const url = `/api/route-trace/fsma-report?brandId=${brandId}&startDate=${startDate}&endDate=${endDate}&format=csv`;
window.location.href = url;
const res = await fetch(
`/api/route-trace/fsma-compliance?brandId=${brandId}&startDate=${startDate}&endDate=${endDate}`
);
if (res.ok) {
const json = await res.json();
setData(json);
} else {
setData(null);
}
} catch {
setData(null);
} finally {
setLoading(false);
setOpen(false);
}
}
function handleDownload() {
const url = `/api/route-trace/fsma-report?brandId=${brandId}&startDate=${startDate}&endDate=${endDate}&format=csv`;
window.location.href = url;
setOpen(false);
}
const filteredLots = data?.lots.filter((lot) => {
const matchesSearch =
!search ||
lot.lot_number.toLowerCase().includes(search.toLowerCase()) ||
lot.crop_type.toLowerCase().includes(search.toLowerCase()) ||
(lot.field_location ?? "").toLowerCase().includes(search.toLowerCase());
const matchesStatus = filterStatus === "all" || lot.status === filterStatus;
const matchesIssues = !showOnlyIssues || lot.compliance_status !== "compliant";
return matchesSearch && matchesStatus && matchesIssues;
}) ?? [];
return (
<>
<button
onClick={() => setOpen(true)}
className="rounded-xl border border-stone-200 bg-white px-5 py-3 text-sm font-semibold text-stone-600 hover:bg-stone-50 transition-colors"
>
📋 FSMA Report
{Icons.clipboard("h-4 w-4 mr-1.5")} FSMA Report
</button>
{open && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40" onClick={() => setOpen(false)}>
<div className="w-full max-w-sm rounded-2xl bg-white shadow-xl" onClick={(e) => e.stopPropagation()}>
<div className="border-b border-stone-100 px-6 py-4">
<h3 className="text-base font-bold text-stone-900">📋 FSMA Compliance Report</h3>
<p className="text-xs text-stone-400 mt-0.5">One-up / one-down traceability for a date range</p>
<div
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/30"
onClick={(e) => e.target === e.currentTarget && setOpen(false)}
style={{ backdropFilter: "blur(4px)" }}
>
<div
className="absolute inset-0"
style={{
background: "linear-gradient(180deg, rgba(0,0,0,0.3) 0%, rgba(0,0,0,0.2) 100%)",
backdropFilter: "blur(60px) saturate(180%)",
}}
/>
{/* Modal card */}
<div
className="relative w-full max-w-5xl rounded-2xl max-h-[90vh] flex flex-col"
style={{
background: "rgba(255, 255, 255, 0.92)",
backdropFilter: "blur(40px) saturate(200%)",
WebkitBackdropFilter: "blur(40px) saturate(200%)",
border: "1px solid rgba(255, 255, 255, 0.5)",
boxShadow: `
0 25px 50px -12px rgba(0, 0, 0, 0.25),
0 12px 24px -8px rgba(0, 0, 0, 0.15)
`,
}}
>
{/* Top gradient border */}
<div
className="absolute top-0 left-0 right-0 rounded-t-2xl overflow-hidden"
style={{
background: "linear-gradient(180deg, rgba(120, 113, 108, 0.1) 0%, transparent 100%)",
height: "1px",
}}
/>
{/* Header */}
<div
className="flex items-center justify-between px-6 py-5"
style={{ borderBottom: "1px solid rgba(0, 0, 0, 0.06)" }}
>
<div>
<h2 className="text-xl font-semibold text-stone-950" style={{ letterSpacing: "-0.02em" }}>
<div className="flex items-center gap-2"><span className="text-lg">{Icons.clipboard("h-5 w-5")}</span> FSMA Compliance Snapshot</div>
</h2>
<p className="mt-1 text-sm text-stone-500">
One-up / one-down traceability report
</p>
</div>
<button
onClick={() => setOpen(false)}
className="flex h-9 w-9 items-center justify-center rounded-full transition-all"
style={{ background: "rgba(0, 0, 0, 0.04)", color: "rgba(0, 0, 0, 0.4)" }}
>
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div className="p-6 space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-semibold text-stone-600 mb-1.5">Start Date</label>
{/* Content */}
<div className="flex-1 overflow-auto p-6">
{/* Date Range Selector */}
<div className="flex items-end gap-4 mb-6">
<div className="flex-1">
<label className="block text-xs font-semibold text-stone-500 mb-1.5 uppercase tracking-wider">
Start Date
</label>
<input
type="date"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
className="w-full rounded-xl border border-stone-200 bg-stone-50 px-3 py-3 text-sm outline-none focus:border-emerald-600"
className="w-full rounded-xl border border-stone-200 bg-white px-3 py-2.5 text-sm outline-none focus:border-stone-400"
/>
</div>
<div>
<label className="block text-xs font-semibold text-stone-600 mb-1.5">End Date</label>
<div className="flex-1">
<label className="block text-xs font-semibold text-stone-500 mb-1.5 uppercase tracking-wider">
End Date
</label>
<input
type="date"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
className="w-full rounded-xl border border-stone-200 bg-stone-50 px-3 py-3 text-sm outline-none focus:border-emerald-600"
className="w-full rounded-xl border border-stone-200 bg-white px-3 py-2.5 text-sm outline-none focus:border-stone-400"
/>
</div>
<button
onClick={fetchComplianceData}
className="rounded-xl bg-emerald-600 px-4 py-2.5 text-sm font-bold text-white hover:bg-emerald-700 transition-colors"
>
{Icons.refresh("h-4 w-4 mr-1.5")} Refresh
</button>
</div>
<div className="rounded-xl bg-stone-50 border border-stone-200 px-4 py-3">
<p className="text-xs text-stone-500">Includes all lots harvested between the selected dates with full trace events.</p>
</div>
{loading ? (
<div className="flex items-center justify-center py-16">
<div className="flex items-center gap-3 text-stone-500">
<svg className="h-5 w-5 animate-spin" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
<span className="text-sm font-medium">Loading compliance data</span>
</div>
</div>
) : data ? (
<>
{/* Summary Cards */}
<div className="grid grid-cols-3 md:grid-cols-6 gap-3 mb-6">
<SummaryCard
icon={Icons.package("h-4 w-4")}
label="Total Lots"
value={data.summary.total_lots}
color="stone"
/>
<SummaryCard
icon={Icons.check("h-4 w-4")}
label="Compliant"
value={data.summary.compliant}
subtext={`${data.summary.total_lots > 0 ? Math.round((data.summary.compliant / data.summary.total_lots) * 100) : 0}%`}
color="green"
/>
<SummaryCard
icon={Icons.alert("h-4 w-4")}
label="Issues"
value={data.summary.non_compliant}
color="red"
/>
<SummaryCard
icon={Icons.clock("h-4 w-4")}
label="Pending"
value={data.summary.pending}
color="amber"
/>
<SummaryCard
icon={Icons.scale("h-4 w-4")}
label="Total Weight"
value={data.summary.total_weight > 0 ? `${(data.summary.total_weight / 1000).toFixed(1)}k` : "—"}
subtext={data.summary.total_weight > 0 ? "lbs" : ""}
color="blue"
/>
<SummaryCard
icon={Icons.chart("h-4 w-4")}
label="Crops"
value={data.summary.crops.length}
color="stone"
/>
</div>
{/* Filters */}
<div className="flex items-center gap-3 mb-4">
<div className="relative flex-1">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-stone-400">{Icons.search("h-4 w-4")}</span>
<input
type="text"
placeholder="Search lot number, crop, field…"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full pl-9 pr-4 py-2 rounded-xl border border-stone-200 bg-white text-sm outline-none focus:border-stone-400"
/>
</div>
<select
value={filterStatus}
onChange={(e) => setFilterStatus(e.target.value)}
className="rounded-xl border border-stone-200 bg-white px-3 py-2 text-sm outline-none focus:border-stone-400"
>
<option value="all">All Status</option>
<option value="active">Active</option>
<option value="in_transit">In Transit</option>
<option value="at_shed">At Shed</option>
<option value="packed">Packed</option>
<option value="delivered">Delivered</option>
</select>
<label className="flex items-center gap-2 rounded-xl border border-red-200 bg-red-50 px-3 py-2 cursor-pointer">
<input
type="checkbox"
checked={showOnlyIssues}
onChange={(e) => setShowOnlyIssues(e.target.checked)}
className="rounded"
/>
<span className="text-xs font-semibold text-red-700 flex items-center gap-1">{Icons.alert("h-3.5 w-3.5")} Issues only</span>
</label>
</div>
{/* Table */}
<div className="rounded-xl border border-stone-200 bg-white overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-stone-100 bg-stone-50/50">
<th className="px-4 py-3 text-left text-[10px] font-bold text-stone-500 uppercase tracking-widest">
Compliance
</th>
<th className="px-4 py-3 text-left text-[10px] font-bold text-stone-500 uppercase tracking-widest">
Lot #
</th>
<th className="px-4 py-3 text-left text-[10px] font-bold text-stone-500 uppercase tracking-widest">
Crop
</th>
<th className="px-4 py-3 text-left text-[10px] font-bold text-stone-500 uppercase tracking-widest">
Harvest Date
</th>
<th className="px-4 py-3 text-left text-[10px] font-bold text-stone-500 uppercase tracking-widest">
Field
</th>
<th className="px-4 py-3 text-right text-[10px] font-bold text-stone-500 uppercase tracking-widest">
Weight
</th>
<th className="px-4 py-3 text-left text-[10px] font-bold text-stone-500 uppercase tracking-widest">
Status
</th>
<th className="px-4 py-3 text-center text-[10px] font-bold text-stone-500 uppercase tracking-widest">
Events
</th>
<th className="px-4 py-3 text-left text-[10px] font-bold text-stone-500 uppercase tracking-widest">
Issues
</th>
</tr>
</thead>
<tbody>
{filteredLots.length === 0 ? (
<tr>
<td colSpan={9} className="px-4 py-12 text-center text-sm text-stone-400">
{data.summary.total_lots === 0
? "No lots found for this date range"
: "No lots match your filters"}
</td>
</tr>
) : (
filteredLots.map((lot) => {
const lotStatusCfg = LOT_STATUS_CONFIG[lot.status] ?? LOT_STATUS_CONFIG.active;
return (
<tr
key={lot.lot_id}
className="border-b border-stone-50 last:border-0 hover:bg-stone-50/40 transition-colors"
>
<td className="px-4 py-3">
<ComplianceBadge status={lot.compliance_status} />
</td>
<td className="px-4 py-3">
<span className="font-mono text-sm font-bold text-stone-900">
{lot.lot_number}
</span>
</td>
<td className="px-4 py-3">
<span className="text-sm font-semibold text-stone-700">
{lot.crop_type}
</span>
{lot.variety && (
<span className="ml-1 text-xs text-stone-400">{lot.variety}</span>
)}
</td>
<td className="px-4 py-3">
<span className="text-sm text-stone-600">
{formatDate(lot.harvest_date)}
</span>
</td>
<td className="px-4 py-3">
<span className="text-sm text-stone-600">
{lot.field_location ?? "—"}
</span>
{lot.field_block && lot.field_block !== "N/A" && (
<span className="ml-1 text-xs text-stone-400">
· Block {lot.field_block}
</span>
)}
</td>
<td className="px-4 py-3 text-right">
<span className="text-sm font-semibold text-stone-700">
{lot.quantity_lbs
? Number(lot.quantity_lbs).toLocaleString()
: "—"}
</span>
<span className="text-[10px] text-stone-400 ml-1">
{lot.yield_unit ?? "lbs"}
</span>
</td>
<td className="px-4 py-3">
<span className={`inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-[10px] font-bold ${lotStatusCfg.bg} ${lotStatusCfg.text}`}>
<span className="h-1 w-1 rounded-full bg-current" />
{lotStatusCfg.label}
</span>
</td>
<td className="px-4 py-3 text-center">
<span className={`inline-flex items-center justify-center rounded-full h-6 w-6 text-xs font-bold ${
lot.event_count > 0 ? "bg-emerald-50 text-emerald-700" : "bg-stone-100 text-stone-500"
}`}>
{lot.event_count}
</span>
</td>
<td className="px-4 py-3">
{lot.issues.length > 0 ? (
<div className="flex flex-wrap gap-1">
{lot.issues.map((issue, i) => (
<span
key={i}
className="inline-flex items-center rounded bg-red-50 px-1.5 py-0.5 text-[10px] font-medium text-red-600"
>
{issue}
</span>
))}
</div>
) : (
<span className="text-xs text-stone-400"></span>
)}
</td>
</tr>
);
})
)}
</tbody>
</table>
</div>
</div>
{/* Results count */}
<p className="mt-3 text-xs text-stone-400 text-center">
Showing {filteredLots.length} of {data.summary.total_lots} lots
{data.summary.crops.length > 0 && ` • Crops: ${data.summary.crops.join(", ")}`}
</p>
</>
) : (
<div className="flex flex-col items-center justify-center py-16">
<div className="text-stone-300 mb-3">{Icons.clipboard("h-12 w-12")}</div>
<p className="text-sm text-stone-500">No compliance data available</p>
<p className="text-xs text-stone-400 mt-1">Select a date range and try again</p>
</div>
)}
</div>
<div className="flex justify-end gap-3 border-t border-stone-100 px-6 py-4">
<button
onClick={() => setOpen(false)}
className="rounded-xl border border-stone-200 px-4 py-2 text-sm font-semibold text-stone-600 hover:bg-stone-50"
>
Cancel
</button>
<button
onClick={handleDownload}
disabled={loading}
className="rounded-xl bg-emerald-600 px-5 py-2 text-sm font-bold text-white hover:bg-emerald-700 disabled:opacity-50 flex items-center gap-2"
>
{loading ? "Preparing..." : "📥 Download CSV"}
</button>
{/* Footer */}
<div
className="flex items-center justify-between px-6 py-4"
style={{ borderTop: "1px solid rgba(0, 0, 0, 0.06)" }}
>
<div className="flex items-center gap-2">
<span className="text-xs text-stone-400">
FSMA Food Safety Modernization Act Produce Traceability
</span>
</div>
<div className="flex items-center gap-3">
<button
onClick={() => setOpen(false)}
className="rounded-xl border border-stone-200 px-4 py-2 text-sm font-semibold text-stone-600 hover:bg-stone-50 transition-colors"
>
Close
</button>
<button
onClick={handleDownload}
disabled={loading || !data}
className="rounded-xl bg-blue-600 px-5 py-2 text-sm font-bold text-white hover:bg-blue-700 disabled:opacity-50 transition-colors flex items-center gap-2"
>
<span className="flex items-center gap-1.5 text-sm font-semibold">{Icons.download("h-4 w-4")} Download CSV</span>
</button>
</div>
</div>
</div>
</div>
)}
</>
);
}
}
+22 -3
View File
@@ -4,6 +4,25 @@ import { useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { createHarvestLot } from "@/actions/route-trace/lots";
// One-color outline icons
const Icons = {
plant: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 22V12M12 12C12 12 7 10 7 5c0-2.5 2.5-5 5-5s5 2.5 5 5c0 5-5 7-5 7z" />
</svg>
),
chevronUp: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="m18 15-6-6-6 6"/>
</svg>
),
chevronDown: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="m6 9 6 6 6-6"/>
</svg>
),
};
export default function LotCreateForm({ brandId }: { brandId: string }) {
const router = useRouter();
const [isPending, startTransition] = useTransition();
@@ -59,7 +78,7 @@ export default function LotCreateForm({ brandId }: { brandId: string }) {
<div className="border-b border-stone-100 px-6 py-5">
<div className="flex items-center gap-3">
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-green-100">
<span className="text-base">🌱</span>
<span className="text-green-600">{Icons.plant("h-5 w-5")}</span>
</div>
<div>
<h2 className="text-lg font-bold text-stone-900">New Harvest Lot</h2>
@@ -262,7 +281,7 @@ export default function LotCreateForm({ brandId }: { brandId: string }) {
className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wider text-stone-400 hover:text-stone-600"
>
<span>Notes</span>
<span>{notesOpen ? "▲" : "▼"}</span>
<span className="text-stone-500">{notesOpen ? Icons.chevronUp("h-4 w-4") : Icons.chevronDown("h-4 w-4")}</span>
</button>
{notesOpen && (
<textarea
@@ -282,7 +301,7 @@ export default function LotCreateForm({ brandId }: { brandId: string }) {
disabled={isPending || !crop_type || !harvest_date || !field_location}
className="rounded-xl bg-green-600 px-8 py-4 text-base font-bold text-white hover:bg-green-700 transition-colors disabled:opacity-50 flex items-center gap-2 shadow-sm"
>
{isPending ? "Creating..." : "🌱 Create Lot"}
{isPending ? "Creating..." : <><span className="inline-flex items-center gap-1.5">{Icons.plant("h-4 w-4")} Create Lot</span></>}
</button>
</div>
</form>
File diff suppressed because it is too large Load Diff
+139 -42
View File
@@ -5,6 +5,67 @@ import Link from "next/link";
import StatusBadge from "./StatusBadge";
import { HaulingLot, updateHarvestLotStatus } from "@/actions/route-trace/lots";
// One-color outline icons matching the design system
const Icons = {
clipboard: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="8" y="2" width="8" height="4" rx="1" ry="1"/>
<path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/>
<path d="M12 11h4M12 16h4M8 11h.01M8 16h.01"/>
</svg>
),
search: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="11" cy="11" r="8"/>
<path d="m21 21-4.3-4.3"/>
</svg>
),
plus: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M5 12h14M12 5v14"/>
</svg>
),
truck: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M14 18V6a2 2 0 0 0-2-2H4a2 2 0 0 0-2 2v11a1 1 0 0 0 1 1h2"/>
<path d="M15 18H9"/>
<path d="M19 18h2a1 1 0 0 0 1-1v-3.65a1 1 0 0 0-.22-.624l-3.48-4.35A1 1 0 0 0 17.52 8H14"/>
<circle cx="17" cy="18" r="2"/>
<circle cx="7" cy="18" r="2"/>
</svg>
),
package: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M16.5 9.4 7.55 4.24"/>
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/>
<polyline points="3.29 7 12 12 20.71 7"/>
<line x1="12" x2="12" y1="22" y2="12"/>
</svg>
),
printer: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"/>
<path d="M6 9V3a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v6"/>
<rect x="6" y="14" width="12" height="8" rx="1"/>
</svg>
),
file: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"/>
<polyline points="14 2 14 8 20 8"/>
<line x1="16" x2="8" y1="13" y2="13"/>
<line x1="16" x2="8" y1="17" y2="17"/>
<line x1="10" x2="8" y1="9" y2="9"/>
</svg>
),
camera: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M14.5 4h-5L7 7H4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-3l-2.5-3z" />
<circle cx="12" cy="13" r="3" />
</svg>
),
};
const STATUS_FILTERS = [
{ value: "", label: "All" },
{ value: "active", label: "Active" },
@@ -105,40 +166,70 @@ export default function LotListTable({
}
return (
<div className="space-y-4">
{/* Filter bar */}
<div className="flex items-center justify-between gap-4 flex-wrap">
<div className="flex gap-1 rounded-xl border border-stone-200 bg-white p-1">
{STATUS_FILTERS.map((f) => (
<div className="space-y-5">
{/* Search and filters */}
<div className="rounded-2xl border border-stone-200 bg-white">
<div className="border-b border-stone-100 px-6 py-5">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-[var(--admin-accent-light)] shadow-sm">
{Icons.clipboard("w-5 h-5 text-[var(--admin-accent)]")}
</div>
<div>
<h2 className="text-lg font-semibold text-stone-900">All Lots</h2>
<p className="text-sm text-stone-500">{filtered.length} lot{filtered.length !== 1 ? "s" : ""}</p>
</div>
</div>
<Link
href="/admin/route-trace/lots/new"
className="inline-flex items-center gap-2 rounded-xl bg-emerald-600 px-4 py-2 text-sm font-bold text-white hover:bg-emerald-700 transition-colors shadow-sm"
>
{Icons.plus("h-4 w-4")}
<span>New Lot</span>
</Link>
</div>
</div>
<div className="p-6 space-y-4">
{/* Filter bar */}
<div className="flex gap-1 rounded-xl border border-stone-200 bg-stone-50 p-1">
{STATUS_FILTERS.map((f) => (
<button
key={f.value}
onClick={() => setFilter(f.value)}
className={`rounded-lg px-3 py-2 text-xs font-semibold transition-colors flex-1 ${
filter === f.value
? "bg-emerald-600 text-white"
: "text-stone-600 hover:bg-white"
}`}
>
{f.label}
</button>
))}
</div>
{/* Search */}
<div className="flex gap-3">
<div className="relative flex-1">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-stone-400">{Icons.search("h-4 w-4")}</span>
<input
type="text"
placeholder="Search lot #, crop, field, or bin..."
value={query}
onChange={(e) => setQuery(e.target.value)}
className="w-full rounded-xl border border-stone-200 bg-stone-50 pl-9 pr-4 py-3 text-base outline-none focus:border-emerald-600 focus:bg-white transition-colors"
/>
</div>
<button
key={f.value}
onClick={() => setFilter(f.value)}
className={`rounded-lg px-3 py-2 text-xs font-semibold transition-colors ${
filter === f.value
? "bg-emerald-600 text-white"
: "text-stone-600 hover:bg-stone-50"
onClick={() => { setShowBulk(!showBulk); setSelected(new Set()); }}
className={`rounded-xl border px-4 py-3 text-sm font-semibold transition-colors flex items-center gap-2 ${
showBulk ? "border-emerald-600 bg-emerald-600 text-white" : "border-stone-200 text-stone-600 hover:bg-stone-50"
}`}
>
{f.label}
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polyline points={showBulk ? "9 11 12 14 22 4" : "3 6 9 12 15 18"}/>
</svg>
<span className="hidden sm:inline">Bulk {showBulk ? "On" : "Off"}</span>
</button>
))}
</div>
<div className="flex items-center gap-3">
<input
type="text"
placeholder="Search lot #, crop, field, or bin..."
value={query}
onChange={(e) => setQuery(e.target.value)}
className="w-56 rounded-xl border border-stone-200 bg-white px-4 py-2.5 text-sm outline-none focus:border-emerald-600"
/>
<button
onClick={() => { setShowBulk(!showBulk); setSelected(new Set()); }}
className={`rounded-xl border px-4 py-2.5 text-sm font-semibold transition-colors ${
showBulk ? "border-emerald-600 bg-emerald-600 text-white" : "border-stone-200 text-stone-600 hover:bg-stone-50"
}`}
>
Bulk {showBulk ? "On" : "Off"}
</button>
</div>
</div>
</div>
@@ -149,33 +240,38 @@ export default function LotListTable({
<div className="flex gap-2 flex-wrap">
<button
onClick={handleBulkMarkLoaded}
className="rounded-xl bg-amber-600 hover:bg-amber-500 px-4 py-2 text-sm font-bold text-white transition-colors"
className="rounded-xl bg-amber-600 hover:bg-amber-500 px-4 py-2 text-sm font-bold text-white transition-colors flex items-center gap-1.5"
>
🚚 Mark Loaded
{Icons.truck("h-4 w-4")}
<span>Mark Loaded</span>
</button>
<button
onClick={handleBulkMarkUsed}
className="rounded-xl bg-amber-600 hover:bg-amber-500 px-4 py-2 text-sm font-bold text-white transition-colors"
className="rounded-xl bg-amber-600 hover:bg-amber-500 px-4 py-2 text-sm font-bold text-white transition-colors flex items-center gap-1.5"
>
📦 Mark as Used
{Icons.package("h-4 w-4")}
<span>Mark as Used</span>
</button>
<button
onClick={() => handleBulkStickers("field")}
className="rounded-xl bg-white/20 hover:bg-white/30 px-4 py-2 text-sm font-bold text-white transition-colors"
className="rounded-xl bg-white/20 hover:bg-white/30 px-4 py-2 text-sm font-bold text-white transition-colors flex items-center gap-1.5"
>
🖨 Field Stickers
{Icons.printer("h-4 w-4")}
<span>Field Stickers</span>
</button>
<button
onClick={() => handleBulkStickers("shed")}
className="rounded-xl bg-white/20 hover:bg-white/30 px-4 py-2 text-sm font-bold text-white transition-colors"
className="rounded-xl bg-white/20 hover:bg-white/30 px-4 py-2 text-sm font-bold text-white transition-colors flex items-center gap-1.5"
>
🖨 Shed Stickers
{Icons.printer("h-4 w-4")}
<span>Shed Stickers</span>
</button>
<button
onClick={handleBulkExport}
className="rounded-xl bg-white/20 hover:bg-white/30 px-4 py-2 text-sm font-bold text-white transition-colors"
className="rounded-xl bg-white/20 hover:bg-white/30 px-4 py-2 text-sm font-bold text-white transition-colors flex items-center gap-1.5"
>
📄 {selected.size === 1 ? "Report" : `${selected.size} Reports`}
{Icons.file("h-4 w-4")}
<span>{selected.size === 1 ? "Report" : `${selected.size} Reports`}</span>
</button>
<button
onClick={() => setSelected(new Set())}
@@ -190,10 +286,11 @@ export default function LotListTable({
{/* Table */}
{filtered.length === 0 ? (
<div className="rounded-2xl border border-stone-200 bg-white py-16 text-center">
<p className="text-sm text-stone-400">No lots found</p>
<div className="text-stone-300 mb-3">{Icons.search("h-10 w-10")}</div>
<p className="text-sm text-stone-500">No lots found</p>
</div>
) : (
<div className="overflow-hidden rounded-2xl border border-stone-200 bg-white shadow-sm">
<div className="rounded-2xl border border-stone-200 bg-white overflow-hidden shadow-sm">
<table className="w-full">
<thead>
<tr className="border-b border-stone-100 bg-stone-50">
+141 -103
View File
@@ -11,10 +11,50 @@ declare global {
import { useState, useEffect, useRef } from "react";
import { useRouter } from "next/navigation";
import GlassModal from "@/components/admin/GlassModal";
// One-color outline icons
const Icons = {
camera: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M14.5 4h-5L7 7H4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-3l-2.5-3z" />
<circle cx="12" cy="13" r="3" />
</svg>
),
keyboard: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="2" y="4" width="20" height="16" rx="2" />
<path d="M6 8h.01M10 8h.01M14 8h.01M18 8h.01M8 12h8M6 16h12" />
</svg>
),
check: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M20 6 9 17l-5-5"/>
</svg>
),
search: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="11" cy="11" r="8"/>
<path d="m21 21-4.3-4.3"/>
</svg>
),
cameraOff: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M10.66 6H14a2 2 0 0 1 2 2v2.34l1 1L22 8v8"/>
<path d="M16 16H4a2 2 0 0 1-2-2V9a2 2 0 0 1 2-2h3l2-3h2l2 3h3"/>
<line x1="2" x2="22" y1="2" y2="22"/>
</svg>
),
};
type ScanMode = "camera" | "manual";
export default function QRScanModal({ onClose }: { onClose: () => void }) {
interface QRScanModalProps {
onClose: () => void;
onScanResult?: (lotNumber: string) => void;
}
export default function QRScanModal({ onClose, onScanResult }: QRScanModalProps) {
const router = useRouter();
const [mode, setMode] = useState<ScanMode>("camera");
const [manualInput, setManualInput] = useState("");
@@ -113,7 +153,11 @@ export default function QRScanModal({ onClose }: { onClose: () => void }) {
if (streamRef.current) streamRef.current.getTracks().forEach(t => t.stop());
setTimeout(() => {
onClose();
router.push(`/trace/${encodeURIComponent(raw)}`);
if (onScanResult) {
onScanResult(raw);
} else {
router.push(`/trace/${encodeURIComponent(raw)}`);
}
}, 800);
return;
}
@@ -151,111 +195,105 @@ export default function QRScanModal({ onClose }: { onClose: () => void }) {
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60" onClick={handleClose}>
<div className="w-full max-w-sm mx-4 rounded-2xl bg-white shadow-2xl overflow-hidden" onClick={(e) => e.stopPropagation()}>
<GlassModal title="Scan QR Code" subtitle="Point camera at Route Trace sticker" onClose={handleClose}>
{/* Mode toggle */}
<div className="flex border-b border-[var(--admin-border)] mb-4 -mx-6 px-6">
<button
onClick={() => setMode("camera")}
className={`flex-1 py-3 text-sm font-semibold transition-colors ${
mode === "camera" ? "text-[var(--admin-text-primary)] border-b-2 border-[var(--admin-text-primary)]" : "text-[var(--admin-text-muted)]"
}`}
>
<svg className="w-4 h-4 inline-block mr-2" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
<path d="M14.5 4h-5L7 7H4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-3l-2.5-3z" />
<circle cx="12" cy="13" r="3" />
</svg>
Camera
</button>
<button
onClick={() => setMode("manual")}
className={`flex-1 py-3 text-sm font-semibold transition-colors ${
mode === "manual" ? "text-[var(--admin-text-primary)] border-b-2 border-[var(--admin-text-primary)]" : "text-[var(--admin-text-muted)]"
}`}
>
<svg className="w-4 h-4 inline-block mr-2" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
<rect x="2" y="4" width="20" height="16" rx="2" />
<path d="M6 8h.01M10 8h.01M14 8h.01M18 8h.01M8 12h8M6 16h12" />
</svg>
Manual
</button>
</div>
{/* Header */}
<div className="flex items-center justify-between px-5 py-4 border-b border-stone-100">
<div>
<h3 className="text-base font-bold text-stone-900">Scan QR Code</h3>
<p className="text-xs text-stone-400 mt-0.5">Lot sticker trace page</p>
</div>
<button onClick={handleClose} className="text-stone-400 hover:text-stone-600 text-xl leading-none"></button>
</div>
{/* Mode toggle */}
<div className="flex border-b border-stone-100">
<button
onClick={() => setMode("camera")}
className={`flex-1 py-3 text-sm font-semibold transition-colors ${
mode === "camera" ? "text-emerald-700 border-b-2 border-emerald-600" : "text-stone-400"
}`}
>
📷 Camera
</button>
<button
onClick={() => setMode("manual")}
className={`flex-1 py-3 text-sm font-semibold transition-colors ${
mode === "manual" ? "text-emerald-700 border-b-2 border-emerald-600" : "text-stone-400"
}`}
>
Manual
</button>
</div>
<div className="p-5">
{mode === "camera" ? (
<div className="space-y-4">
<div className="relative rounded-xl overflow-hidden bg-stone-900 aspect-square">
{cameraStarting ? (
<div className="absolute inset-0 flex flex-col items-center justify-center text-white text-sm text-center">
<div className="w-10 h-10 border-2 border-white/30 border-t-white rounded-full animate-spin mb-3" />
<p className="text-xs text-white/70">Starting camera...</p>
</div>
) : cameraError ? (
<div className="absolute inset-0 flex flex-col items-center justify-center text-white text-sm text-center px-6">
<span className="text-3xl mb-3">📷</span>
<p className="text-xs text-red-300 font-medium leading-relaxed">{cameraError}</p>
</div>
) : (
<>
<video
ref={videoRef}
className="w-full h-full object-cover"
playsInline
muted
/>
{/* Viewfinder overlay */}
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
{scanSuccess ? (
<div className="w-44 h-44 rounded-2xl bg-green-500 flex items-center justify-center">
<span className="text-5xl"></span>
</div>
) : (
<div className="w-44 h-44 border-2 border-white/70 rounded-2xl" />
)}
</div>
{!scanSuccess && (
<div className="absolute inset-x-0 bottom-4 text-center">
<span className="inline-block text-white text-xs bg-black/60 px-4 py-1.5 rounded-full font-medium">
Align QR within the frame
</span>
</div>
)}
</>
)}
{mode === "camera" ? (
<div className="space-y-4">
<div className="relative rounded-xl overflow-hidden bg-stone-900 aspect-square">
{cameraStarting ? (
<div className="absolute inset-0 flex flex-col items-center justify-center text-white text-sm text-center">
<div className="w-10 h-10 border-2 border-white/30 border-t-white rounded-full animate-spin mb-3" />
<p className="text-xs text-white/70">Starting camera...</p>
</div>
{!cameraStarting && !cameraError && (
<p className="text-center text-xs text-stone-400">
Hold steady QR will scan automatically
</p>
)}
</div>
) : (
<form onSubmit={handleManualSubmit} className="space-y-4">
<div>
<label className="block text-sm font-semibold text-stone-700 mb-1.5">Lot Number</label>
<input
type="text"
value={manualInput}
onChange={(e) => setManualInput(e.target.value.toUpperCase())}
placeholder="e.g. TC-20260520-001"
autoFocus
className="w-full rounded-xl border border-stone-200 bg-stone-50 px-4 py-3.5 text-base font-mono text-stone-900 placeholder:text-stone-400 focus:outline-none focus:border-emerald-600"
) : cameraError ? (
<div className="absolute inset-0 flex flex-col items-center justify-center text-white text-sm text-center px-6">
<span className="mb-3 text-stone-400">{Icons.cameraOff("h-12 w-12")}</span>
<p className="text-xs text-red-300 font-medium leading-relaxed">{cameraError}</p>
</div>
) : (
<>
<video
ref={videoRef}
className="w-full h-full object-cover"
playsInline
muted
/>
<p className="text-[10px] text-stone-400 mt-1.5">Enter the lot number from any Route Trace sticker</p>
</div>
<button
type="submit"
disabled={!manualInput.trim() || isLoading}
className="w-full rounded-xl bg-emerald-600 py-3.5 text-base font-bold text-white hover:bg-emerald-700 disabled:opacity-50 transition-colors"
>
{isLoading ? "Loading..." : "🔍 Trace Lot"}
</button>
</form>
{/* Viewfinder overlay */}
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
{scanSuccess ? (
<div className="w-44 h-44 rounded-2xl bg-green-500 flex items-center justify-center">
<span className="text-white">{Icons.check("h-12 w-12")}</span>
</div>
) : (
<div className="w-44 h-44 border-2 border-white/70 rounded-2xl" />
)}
</div>
{!scanSuccess && (
<div className="absolute inset-x-0 bottom-4 text-center">
<span className="inline-block text-white text-xs bg-black/60 px-4 py-1.5 rounded-full font-medium">
Align QR within the frame
</span>
</div>
)}
</>
)}
</div>
{!cameraStarting && !cameraError && (
<p className="text-center text-xs text-stone-500">
Hold steady QR will scan automatically
</p>
)}
</div>
</div>
</div>
) : (
<form onSubmit={handleManualSubmit} className="space-y-4">
<div>
<label className="block text-sm font-semibold text-stone-700 mb-1.5">Lot Number</label>
<input
type="text"
value={manualInput}
onChange={(e) => setManualInput(e.target.value.toUpperCase())}
placeholder="e.g. TC-20260520-001"
autoFocus
className="w-full rounded-xl border border-stone-200 bg-stone-50 px-4 py-3.5 text-base font-mono text-stone-900 placeholder:text-stone-400 focus:outline-none focus:border-stone-400"
/>
<p className="text-[10px] text-stone-400 mt-1.5">Enter the lot number from any Route Trace sticker</p>
</div>
<button
type="submit"
disabled={!manualInput.trim() || isLoading}
className="w-full rounded-xl bg-stone-800 py-3.5 text-base font-semibold text-white hover:bg-stone-700 disabled:opacity-50 transition-colors"
>
{isLoading ? "Loading..." : <><span className="inline-flex items-center gap-1.5">{Icons.search("h-4 w-4")} Trace Lot</span></>}
</button>
</form>
)}
</GlassModal>
);
}
@@ -1,405 +0,0 @@
"use client";
import { useState, useTransition, useRef, useEffect } from "react";
import { createHarvestLot } from "@/actions/route-trace/lots";
type Props = {
brandId: string;
onCreated: (lotId: string) => void;
onClose: () => void;
};
const K = {
fields: "rt_recent_fields",
crops: "rt_recent_crops",
lots: "rt_recent_lots",
defaults: "rt_last_lot_defaults",
};
function get<T>(key: string, fallback: T): T {
try { return JSON.parse(localStorage.getItem(key) ?? "null") ?? fallback; } catch { return fallback; }
}
function save(key: string, val: unknown) {
localStorage.setItem(key, JSON.stringify(val));
}
function pushUnique<T>(arr: T[], val: T, max = 5): T[] {
const filtered = arr.filter((x) => x !== val);
return [val, ...filtered].slice(0, max);
}
type LastDefaults = {
worker_name?: string;
variety?: string;
quantity_lbs?: string;
};
function getLastDefaults(): LastDefaults {
return get<LastDefaults>(K.defaults, {});
}
const TODAY = new Date().toISOString().split("T")[0];
export default function QuickNewLotDrawer({ brandId, onCreated, onClose }: Props) {
const [isPending, startTransition] = useTransition();
const [error, setError] = useState<string | null>(null);
const [showAdvanced, setShowAdvanced] = useState(false);
const [recentFields] = useState<string[]>(() => get(K.fields, []));
const [recentCrops] = useState<string[]>(() => get(K.crops, []));
const lastDefaults = getLastDefaults();
const [crop_type, setCropType] = useState("");
const [harvest_date, setHarvestDate] = useState(TODAY);
const [field_location, setFieldLocation] = useState("");
const [worker_name, setWorkerName] = useState(lastDefaults.worker_name ?? "");
const [quantity_lbs, setQuantityLbs] = useState(lastDefaults.quantity_lbs ?? "");
const [yield_estimate_lbs, setYieldEstimateLbs] = useState("");
const [yield_unit, setYieldUnit] = useState("lbs");
const [customYieldUnit, setCustomYieldUnit] = useState("");
const [bin_id, setBinId] = useState("");
const [container_id, setContainerId] = useState("");
const [field_block, setFieldBlock] = useState("");
const [variety, setVariety] = useState(lastDefaults.variety ?? "");
const cropRef = useRef<HTMLInputElement>(null);
const fieldRef = useRef<HTMLInputElement>(null);
const workerRef = useRef<HTMLInputElement>(null);
const qtyRef = useRef<HTMLInputElement>(null);
const yieldRef = useRef<HTMLInputElement>(null);
const yieldUnitRef = useRef<HTMLSelectElement>(null);
const customUnitRef = useRef<HTMLInputElement>(null);
const binRef = useRef<HTMLInputElement>(null);
const containerRef = useRef<HTMLInputElement>(null);
const blockRef = useRef<HTMLInputElement>(null);
const varietyRef = useRef<HTMLInputElement>(null);
useEffect(() => { cropRef.current?.focus(); }, []);
function submit(e: React.FormEvent) {
e.preventDefault();
if (!crop_type.trim()) return;
setError(null);
if (field_location.trim()) save(K.fields, pushUnique(get<string[]>(K.fields, []), field_location.trim()));
save(K.crops, pushUnique(get<string[]>(K.crops, []), crop_type.trim()));
save(K.defaults, {
worker_name: worker_name.trim() || undefined,
variety: variety.trim() || undefined,
quantity_lbs: quantity_lbs || undefined,
} as LastDefaults);
startTransition(async () => {
const result = await createHarvestLot(brandId, {
crop_type: crop_type.trim(),
harvest_date: harvest_date || TODAY,
field_location: field_location.trim() || undefined,
worker_name: worker_name.trim() || undefined,
variety: variety.trim() || undefined,
quantity_lbs: quantity_lbs ? Number(quantity_lbs) : undefined,
yield_estimate_lbs: yield_estimate_lbs ? Number(yield_estimate_lbs) : undefined,
yield_unit: yield_unit === "custom" ? customYieldUnit.trim() || undefined : yield_unit,
bin_id: bin_id.trim() || undefined,
container_id: container_id.trim() || undefined,
field_block: field_block.trim() || undefined,
});
if (result.success && result.lot) {
const cached: Array<{ id: string; lot_number: string; crop_type: string; harvest_date: string; status: string }> = get(K.lots, []);
save(K.lots, [{ id: result.lot.id, lot_number: result.lot.lot_number, crop_type: crop_type.trim(), harvest_date: harvest_date, status: "active" }, ...cached].slice(0, 20));
onCreated(result.lot.id);
} else {
setError(result.error ?? "Failed to create lot");
}
});
}
// Enter on field → jump to worker; Enter on worker → jump to qty; etc.
function handleFieldKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
if (e.key === "Enter") { e.preventDefault(); workerRef.current?.focus(); }
}
function handleWorkerKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
if (e.key === "Enter") { e.preventDefault(); qtyRef.current?.focus(); }
}
function handleYieldKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
if (e.key === "Enter") { e.preventDefault(); yieldUnitRef.current?.focus(); }
}
function handleYieldUnitKeyDown(e: React.KeyboardEvent<HTMLSelectElement>) {
if (e.key === "Enter") { e.preventDefault(); binRef.current?.focus(); }
}
return (
<div className="fixed inset-0 z-50 flex justify-end" onClick={onClose}>
<div className="absolute inset-0 bg-black/40" />
<div
className="relative ml-auto w-full max-w-md bg-white shadow-2xl flex flex-col overflow-hidden"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between border-b border-stone-100 px-6 py-5">
<div className="flex items-center gap-3.5">
<div className="flex h-10 w-10 items-center justify-center rounded-2xl bg-green-600 shadow-sm">
<span className="text-base">🌱</span>
</div>
<div>
<h2 className="text-lg font-black text-stone-900">New Lot</h2>
<p className="text-xs text-stone-400">
{recentCrops.length > 0 ? `${recentCrops[0]} — quick reuse` : "3 fields to save"}
</p>
</div>
</div>
<button
onClick={onClose}
className="flex h-10 w-10 items-center justify-center rounded-xl text-stone-400 hover:bg-stone-100 hover:text-stone-600 transition-colors"
>
</button>
</div>
<form
id="qnl-form"
onSubmit={submit}
className="flex-1 overflow-y-auto px-6 py-5 space-y-5"
>
{error && (
<div className="rounded-2xl bg-red-50 border-2 border-red-200 p-4 text-sm text-red-700 font-medium">
{error}
</div>
)}
{/* Offline badge */}
<div className="flex items-center gap-2.5 rounded-2xl bg-blue-50 border border-blue-200 px-4 py-3">
<span className="text-sm">📶</span>
<p className="text-xs text-blue-600 leading-snug font-medium">
Works offline recent crops &amp; fields saved on device
</p>
</div>
{/* ── Primary: Crop ── */}
<div>
<label className="block text-xs font-bold text-stone-500 uppercase tracking-widest mb-2">
Crop Type <span className="text-red-500">*</span>
</label>
<input
ref={cropRef}
list="recent-crops"
type="text"
value={crop_type}
onChange={(e) => setCropType(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") { e.preventDefault(); fieldRef.current?.focus(); }
}}
placeholder="e.g. Sweet Corn"
required
className="w-full rounded-2xl border-2 border-stone-200 bg-stone-50 px-5 py-5 text-2xl font-black text-stone-900 placeholder:text-stone-300 outline-none focus:border-green-600 focus:bg-white transition-colors"
/>
<datalist id="recent-crops">
{recentCrops.map((c) => <option key={c} value={c} />)}
</datalist>
</div>
{/* ── Primary: Harvest Date ── */}
<div>
<label className="block text-xs font-bold text-stone-500 uppercase tracking-widest mb-2">
Harvest Date
</label>
<input
type="date"
value={harvest_date}
onChange={(e) => setHarvestDate(e.target.value)}
className="w-full rounded-2xl border-2 border-stone-200 bg-stone-50 px-5 py-5 text-xl font-bold text-stone-800 outline-none focus:border-green-600 focus:bg-white transition-colors"
/>
</div>
{/* ── Primary: Field ── */}
<div>
<label className="block text-xs font-bold text-stone-500 uppercase tracking-widest mb-2">
Field / Location
</label>
<input
ref={fieldRef}
list="recent-fields"
type="text"
value={field_location}
onChange={(e) => setFieldLocation(e.target.value)}
onKeyDown={handleFieldKeyDown}
placeholder="e.g. North Field"
className="w-full rounded-2xl border-2 border-stone-200 bg-stone-50 px-5 py-5 text-xl font-bold text-stone-800 outline-none focus:border-green-600 focus:bg-white transition-colors"
/>
<datalist id="recent-fields">
{recentFields.map((f) => <option key={f} value={f} />)}
</datalist>
</div>
{/* ── Advanced toggle — big touch target ── */}
<button
type="button"
onClick={() => setShowAdvanced(!showAdvanced)}
className="w-full flex items-center justify-between rounded-2xl border-2 border-dashed border-stone-300 px-5 py-4 text-sm font-bold text-stone-500 hover:border-green-400 hover:text-green-700 hover:bg-green-50 transition-all"
>
<span className="flex items-center gap-2">
<span className="text-base">{showAdvanced ? "▲" : "▼"}</span>
{showAdvanced ? "Hide optional fields" : "+ Yield, Worker, Bin, Block…"}
</span>
{showAdvanced && (
<span className="text-xs text-stone-400 font-medium">collapse</span>
)}
</button>
{showAdvanced && (
<div className="space-y-4 rounded-2xl border-2 border-stone-100 bg-stone-50 p-5">
<div>
<label className="block text-xs font-bold text-stone-500 uppercase tracking-widest mb-2">
Worker / Harvest Lead
</label>
<input
ref={workerRef}
type="text"
placeholder="Maria S."
value={worker_name}
onChange={(e) => setWorkerName(e.target.value)}
onKeyDown={handleWorkerKeyDown}
className="w-full rounded-xl border-2 border-stone-200 bg-white px-4 py-4 text-base font-semibold text-stone-800 outline-none focus:border-green-600 focus:bg-white transition-colors"
/>
</div>
<div>
<label className="block text-xs font-bold text-stone-500 uppercase tracking-widest mb-2">
Variety
</label>
<input
ref={varietyRef}
type="text"
placeholder="Golden Bantam"
value={variety}
onChange={(e) => setVariety(e.target.value)}
className="w-full rounded-xl border-2 border-stone-200 bg-white px-4 py-4 text-base font-semibold text-stone-800 outline-none focus:border-green-600 focus:bg-white transition-colors"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-bold text-stone-500 uppercase tracking-widest mb-2">
Qty
</label>
<input
ref={qtyRef}
type="number"
placeholder="4800"
min="0"
value={quantity_lbs}
onChange={(e) => setQuantityLbs(e.target.value)}
className="w-full rounded-xl border-2 border-stone-200 bg-white px-4 py-4 text-base font-bold text-stone-800 outline-none focus:border-green-600 focus:bg-white transition-colors"
/>
</div>
<div>
<label className="block text-xs font-bold text-stone-500 uppercase tracking-widest mb-2">
Yield Est.
</label>
<div className="flex rounded-xl border-2 border-stone-200 bg-white overflow-hidden focus-within:border-green-600 transition-colors">
<input
ref={yieldRef}
type="number"
placeholder="5000"
min="0"
value={yield_estimate_lbs}
onChange={(e) => setYieldEstimateLbs(e.target.value)}
onKeyDown={handleYieldKeyDown}
className="flex-1 px-3 py-4 text-base font-bold text-stone-800 outline-none bg-transparent"
/>
<select
ref={yieldUnitRef}
value={yield_unit}
onChange={(e) => setYieldUnit(e.target.value)}
onKeyDown={handleYieldUnitKeyDown}
className="px-2 py-4 text-sm font-bold text-stone-600 bg-stone-50 outline-none border-l-2 border-stone-200 cursor-pointer"
>
<option value="lbs">lbs</option>
<option value="bushel">bu</option>
<option value="box">bx</option>
<option value="sack">sk</option>
<option value="crate">cr</option>
<option value="bin">bn</option>
<option value="pallet">plt</option>
<option value="custom">custom</option>
</select>
</div>
{yield_unit === "custom" && (
<input
ref={customUnitRef}
type="text"
placeholder="Unit name…"
value={customYieldUnit}
onChange={(e) => setCustomYieldUnit(e.target.value)}
className="mt-1.5 w-full rounded-xl border-2 border-stone-200 bg-white px-3 py-2 text-sm font-semibold text-stone-700 outline-none focus:border-green-600 transition-colors"
/>
)}
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-bold text-stone-500 uppercase tracking-widest mb-2">
Bin ID
</label>
<input
ref={binRef}
type="text"
placeholder="BIN-001"
value={bin_id}
onChange={(e) => setBinId(e.target.value)}
className="w-full rounded-xl border-2 border-stone-200 bg-white px-4 py-4 text-base font-bold text-stone-800 outline-none focus:border-green-600 focus:bg-white transition-colors"
/>
</div>
<div>
<label className="block text-xs font-bold text-stone-500 uppercase tracking-widest mb-2">
Container ID
</label>
<input
ref={containerRef}
type="text"
placeholder="CONT-001"
value={container_id}
onChange={(e) => setContainerId(e.target.value)}
className="w-full rounded-xl border-2 border-stone-200 bg-white px-4 py-4 text-base font-bold text-stone-800 outline-none focus:border-green-600 focus:bg-white transition-colors"
/>
</div>
</div>
<div>
<label className="block text-xs font-bold text-stone-500 uppercase tracking-widest mb-2">
Field Block
</label>
<input
ref={blockRef}
type="text"
placeholder="e.g. A-3"
value={field_block}
onChange={(e) => setFieldBlock(e.target.value)}
className="w-full rounded-xl border-2 border-stone-200 bg-white px-4 py-4 text-base font-bold text-stone-800 outline-none focus:border-green-600 focus:bg-white transition-colors"
/>
</div>
</div>
)}
</form>
{/* Big save button — always visible */}
<div className="border-t border-stone-100 px-6 py-5 bg-white">
<button
type="submit"
form="qnl-form"
disabled={isPending || !crop_type.trim()}
className="w-full rounded-2xl bg-green-600 py-5 text-lg font-black text-white hover:bg-green-700 active:bg-green-800 transition-colors disabled:opacity-40 flex items-center justify-center gap-3 shadow-sm"
>
{isPending ? (
<>
<span className="text-base"></span>
Saving
</>
) : (
<>
<span className="text-xl">🌱</span>
Create Lot
</>
)}
</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,172 @@
"use client";
import { useState, useTransition, useEffect } from "react";
import { createHarvestLot } from "@/actions/route-trace/lots";
import GlassModal from "@/components/admin/GlassModal";
// Plant icon for the modal title - consistent one-color outline style
const PlantIcon = ({ className }: { className?: string }) => (
<svg
className={className ?? "w-5 h-5"}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
style={{ color: "var(--admin-accent)" }}
>
<path d="M7 20h10"/>
<path d="M10 20c5.5-2.5.8-6.4 3-10"/>
<path d="M9.5 9.4c1.1.8 1.8 2.2 2.3 3.7-2 .4-3.5.4-4.8-.3-1.2-.6-2.3-1.9-3-4.2 2.8-.5 4.4 0 5.5.8z"/>
<path d="M14.1 6a7 7 0 0 0-1.1 4c1.9-.1 3.3-.6 4.3-1.4 1-1 1.6-2.3 1.7-4.6-2.7.1-4 1-4.9 2z"/>
</svg>
);
type Props = {
brandId: string;
onCreated: (lotId: string) => void;
onClose: () => void;
};
const TODAY = new Date().toISOString().split("T")[0];
export default function QuickNewLotModal({ brandId, onCreated, onClose }: Props) {
const [isPending, startTransition] = useTransition();
const [error, setError] = useState<string | null>(null);
const [crop_type, setCropType] = useState("");
const [harvest_date, setHarvestDate] = useState(TODAY);
const [field_location, setFieldLocation] = useState("");
const [worker_name, setWorkerName] = useState("");
const [quantity_lbs, setQuantityLbs] = useState("");
const [variety, setVariety] = useState("");
function submit(e: React.FormEvent) {
e.preventDefault();
if (!crop_type.trim()) return;
setError(null);
startTransition(async () => {
const result = await createHarvestLot(brandId, {
crop_type: crop_type.trim(),
harvest_date: harvest_date || TODAY,
field_location: field_location.trim() || undefined,
worker_name: worker_name.trim() || undefined,
variety: variety.trim() || undefined,
quantity_lbs: quantity_lbs ? Number(quantity_lbs) : undefined,
});
if (result.success && result.lot) {
onCreated(result.lot.id);
} else {
setError(result.error ?? "Failed to create lot");
}
});
}
return (
<GlassModal
title="New Harvest Lot"
titleIcon={<PlantIcon className="w-5 h-5" />}
subtitle="Quick entry — scan or fill in the fields below"
onClose={onClose}
>
<form onSubmit={submit} className="space-y-4">
{error && (
<div className="rounded-xl bg-red-50 border border-red-200 p-3 text-sm text-red-700">
{error}
</div>
)}
<div>
<label className="block text-sm font-medium text-stone-700 mb-1.5">
Crop Type <span className="text-red-500">*</span>
</label>
<input
type="text"
value={crop_type}
onChange={(e) => setCropType(e.target.value)}
placeholder="e.g. Sweet Corn"
required
autoFocus
className="w-full rounded-xl border border-stone-200 bg-stone-50 px-4 py-3 text-base text-stone-900 placeholder:text-stone-400 focus:outline-none focus:border-stone-400 focus:bg-white transition-colors"
/>
</div>
<div>
<label className="block text-sm font-medium text-stone-700 mb-1.5">
Harvest Date
</label>
<input
type="date"
value={harvest_date}
onChange={(e) => setHarvestDate(e.target.value)}
className="w-full rounded-xl border border-stone-200 bg-stone-50 px-4 py-3 text-base text-stone-900 focus:outline-none focus:border-stone-400 focus:bg-white transition-colors"
/>
</div>
<div>
<label className="block text-sm font-medium text-stone-700 mb-1.5">
Field / Location
</label>
<input
type="text"
value={field_location}
onChange={(e) => setFieldLocation(e.target.value)}
placeholder="e.g. North Field"
className="w-full rounded-xl border border-stone-200 bg-stone-50 px-4 py-3 text-base text-stone-900 placeholder:text-stone-400 focus:outline-none focus:border-stone-400 focus:bg-white transition-colors"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium text-stone-700 mb-1.5">
Worker
</label>
<input
type="text"
value={worker_name}
onChange={(e) => setWorkerName(e.target.value)}
placeholder="Name"
className="w-full rounded-xl border border-stone-200 bg-stone-50 px-4 py-3 text-base text-stone-900 placeholder:text-stone-400 focus:outline-none focus:border-stone-400 focus:bg-white transition-colors"
/>
</div>
<div>
<label className="block text-sm font-medium text-stone-700 mb-1.5">
Variety
</label>
<input
type="text"
value={variety}
onChange={(e) => setVariety(e.target.value)}
placeholder="Type"
className="w-full rounded-xl border border-stone-200 bg-stone-50 px-4 py-3 text-base text-stone-900 placeholder:text-stone-400 focus:outline-none focus:border-stone-400 focus:bg-white transition-colors"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-stone-700 mb-1.5">
Quantity (lbs)
</label>
<input
type="number"
value={quantity_lbs}
onChange={(e) => setQuantityLbs(e.target.value)}
placeholder="0"
min="0"
className="w-full rounded-xl border border-stone-200 bg-stone-50 px-4 py-3 text-base text-stone-900 placeholder:text-stone-400 focus:outline-none focus:border-stone-400 focus:bg-white transition-colors"
/>
</div>
<button
type="submit"
disabled={isPending || !crop_type.trim()}
className="w-full rounded-xl bg-stone-800 py-3.5 text-base font-semibold text-white hover:bg-stone-700 disabled:opacity-50 transition-colors mt-2"
>
{isPending ? "Creating..." : "Create Lot"}
</button>
</form>
</GlassModal>
);
}
+279 -220
View File
@@ -2,9 +2,8 @@
import { useState } from "react";
import Link from "next/link";
import RouteTraceNav from "./RouteTraceNav";
import QRScanModal from "./QRScanModal";
import QuickNewLotDrawer from "./QuickNewLotDrawer";
import QuickNewLotModal from "./QuickNewLotModal";
import FsmaReportModal from "./FsmaReportModal";
import {
RouteTraceStats,
@@ -14,22 +13,145 @@ import {
RecentLotEvent,
} from "@/actions/route-trace/lots";
const STATUS_CONFIG: Record<string, { label: string; icon: string; bg: string; text: string }> = {
active_count: { label: "Active Lots", icon: "🌱", bg: "bg-green-50", text: "text-green-700" },
in_transit_count: { label: "In Transit", icon: "🚚", bg: "bg-amber-50", text: "text-amber-700" },
at_shed_count: { label: "At Shed", icon: "🏭", bg: "bg-blue-50", text: "text-blue-700" },
total_lots_today: { label: "Harvested Today", icon: "🌾", bg: "bg-stone-50", text: "text-stone-700" },
// SVG Icons - consistent stroke style with one-color outlines
const Icons = {
plant: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M7 20h10"/>
<path d="M10 20c5.5-2.5.8-6.4 3-10"/>
<path d="M9.5 9.4c1.1.8 1.8 2.2 2.3 3.7-2 .4-3.5.4-4.8-.3-1.2-.6-2.3-1.9-3-4.2 2.8-.5 4.4 0 5.5.8z"/>
<path d="M14.1 6a7 7 0 0 0-1.1 4c1.9-.1 3.3-.6 4.3-1.4 1-1 1.6-2.3 1.7-4.6-2.7.1-4 1-4.9 2z"/>
</svg>
),
truck: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M10 17h4"/>
<path d="M3 17h2m14 0h2M5 17H3"/>
<rect x="1" y="3" width="15" height="13" rx="1"/>
<path d="M16 8h4l3 3v5h-7V8z"/>
<circle cx="5.5" cy="18.5" r="2.5"/>
<circle cx="18.5" cy="18.5" r="2.5"/>
</svg>
),
warehouse: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 21h18"/>
<path d="M5 21V7l8-4v18"/>
<path d="M19 21V11l-6-4"/>
<path d="M9 9v.01M9 12v.01M9 15v.01M9 18v.01"/>
</svg>
),
wheat: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M2 22l10-10"/>
<path d="M16 8l-4 4"/>
<path d="M22 2L12 12"/>
<path d="M22 6l-6 6"/>
<path d="M7 17l-5 5"/>
<path d="M12 2l3 3"/>
</svg>
),
clipboard: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="8" y="2" width="8" height="4" rx="1" ry="1"/>
<path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/>
<path d="M12 11h4"/>
<path d="M12 16h4"/>
<path d="M8 11h.01"/>
<path d="M8 16h.01"/>
</svg>
),
box: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"/>
<path d="m3.3 7 8.7 5 8.7-5"/>
<path d="M12 22V12"/>
</svg>
),
check: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M20 6 9 17l-5-5"/>
</svg>
),
camera: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M14.5 4h-5L7 7H4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-3l-2.5-3z"/>
<circle cx="12" cy="13" r="3"/>
</svg>
),
zap: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M13 2 3 14h9l-1 8 10-12h-9l1-8z"/>
</svg>
),
chart: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 3v18h18"/>
<path d="M18 17V9"/>
<path d="M13 17V5"/>
<path d="M8 17v-3"/>
</svg>
),
search: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="11" cy="11" r="8"/>
<path d="m21 21-4.3-4.3"/>
</svg>
),
plus: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M5 12h14"/>
<path d="M12 5v14"/>
</svg>
),
arrowRight: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M5 12h14"/>
<path d="m12 5 7 7-7 7"/>
</svg>
),
tag: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 2H9a5 5 0 0 0-5 5v10a5 5 0 0 0 5 5h6a5 5 0 0 0 5-5v-3"/>
<circle cx="15" cy="9" r="1"/>
</svg>
),
file: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<path d="M14 2v6h6"/>
<path d="M16 13H8"/>
<path d="M16 17H8"/>
<path d="M10 9H8"/>
</svg>
),
fileText: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<path d="M14 2v6h6"/>
<path d="M16 13H8"/>
<path d="M16 17H8"/>
<path d="M10 9H8"/>
</svg>
),
};
const EVENT_ICON_SHORT: Record<string, string> = {
harvested: "🌱",
field_packed: "📦",
bin_tagged: "🏷",
in_transit: "🚚",
at_shed: "🏭",
packed: "📋",
delivered: "✅",
marked_used: "📋",
const STATUS_CONFIG: Record<string, { label: string; icon: React.ReactNode; bg: string; text: string; iconColor: string }> = {
active_count: { label: "Active Lots", icon: Icons.plant("w-4 h-4"), bg: "bg-[#e8f5e9]", text: "text-[#2e7d32]", iconColor: "#4caf50" },
in_transit_count: { label: "In Transit", icon: Icons.truck("w-4 h-4"), bg: "bg-[#fff8e1]", text: "text-[#f57c00]", iconColor: "#ffb74d" },
at_shed_count: { label: "At Shed", icon: Icons.warehouse("w-4 h-4"), bg: "bg-[#e3f2fd]", text: "text-[#1565c0]", iconColor: "#64b5f6" },
total_lots_today: { label: "Harvested Today", icon: Icons.wheat("w-4 h-4"), bg: "bg-[var(--admin-accent-light)]", text: "text-[var(--admin-accent-text)]", iconColor: "var(--admin-accent)" },
};
const EVENT_ICON: Record<string, React.ReactNode> = {
harvested: Icons.plant("w-4 h-4"),
field_packed: Icons.box("w-4 h-4"),
bin_tagged: Icons.tag("w-4 h-4"),
in_transit: Icons.truck("w-4 h-4"),
at_shed: Icons.warehouse("w-4 h-4"),
packed: Icons.clipboard("w-4 h-4"),
delivered: Icons.check("w-4 h-4"),
marked_used: Icons.fileText("w-4 h-4"),
};
const EVENT_LABEL: Record<string, string> = {
@@ -43,7 +165,7 @@ const EVENT_LABEL: Record<string, string> = {
marked_used: "Used in Order",
};
type LotWithAction = HaulingLot & { suggestedAction: string; suggestedIcon: string };
type LotWithAction = HaulingLot & { suggestedAction: string; suggestedIcon: React.ReactNode };
function buildHaulingBoard(lots: HaulingLot[]): Record<string, LotWithAction[]> {
const board: Record<string, LotWithAction[]> = {
@@ -54,16 +176,16 @@ function buildHaulingBoard(lots: HaulingLot[]): Record<string, LotWithAction[]>
for (const lot of lots) {
let suggestedAction = "";
let suggestedIcon = "";
let suggestedIcon: React.ReactNode = null;
if (lot.status === "active" || lot.status === "in_transit") {
suggestedAction = "Mark Loaded";
suggestedIcon = "🚚";
suggestedIcon = Icons.truck("w-4 h-4");
} else if (lot.status === "at_shed") {
suggestedAction = "Mark Received";
suggestedIcon = "🏭";
suggestedIcon = Icons.warehouse("w-4 h-4");
} else if (lot.status === "packed") {
suggestedAction = "Mark Delivered";
suggestedIcon = "✅";
suggestedIcon = Icons.check("w-4 h-4");
}
const entry: LotWithAction = { ...lot, suggestedAction, suggestedIcon };
@@ -91,48 +213,48 @@ function ActivityFeed({ events }: { events: RecentLotEvent[] }) {
if (events.length === 0) return null;
return (
<div className="rounded-2xl border border-stone-200 bg-white shadow-sm overflow-hidden">
<div className="border-b border-stone-100 px-5 py-3.5 flex items-center justify-between bg-stone-50/40">
<div className="rounded-2xl border border-[var(--admin-border)] bg-white shadow-[var(--admin-shadow-sm)] overflow-hidden">
<div className="border-b border-[var(--admin-border-light)] px-5 py-3.5 flex items-center justify-between bg-[var(--admin-bg-subtle)]">
<div className="flex items-center gap-2">
<span className="text-sm"></span>
<h2 className="text-sm font-semibold text-stone-900">Recent Activity</h2>
<span className="text-[var(--admin-accent)]">{Icons.zap("w-4 h-4")}</span>
<h2 className="text-sm font-semibold text-[var(--admin-text-primary)]">Recent Activity</h2>
</div>
<span className="text-[10px] text-stone-400 font-medium">Last 10 events</span>
<span className="text-[10px] text-[var(--admin-text-muted)] font-medium">Last 10 events</span>
</div>
<div className="divide-y divide-stone-50">
<div className="divide-y divide-[var(--admin-border-light)]">
{events.map((evt) => (
<div key={evt.event_id} className="px-5 py-3.5 hover:bg-stone-50/40 transition-colors flex items-start gap-3.5">
<div key={evt.event_id} className="px-5 py-3.5 hover:bg-[var(--admin-bg-subtle)]/40 transition-colors flex items-start gap-3.5">
<div className={`flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full text-sm ${
evt.event_type === "harvested" || evt.event_type === "field_packed" ? "bg-green-100 text-green-700" :
evt.event_type === "harvested" || evt.event_type === "field_packed" ? "bg-[var(--admin-accent-light)] text-[var(--admin-accent-text)]" :
evt.event_type === "in_transit" || evt.event_type === "bin_tagged" ? "bg-amber-100 text-amber-700" :
evt.event_type === "at_shed" ? "bg-blue-100 text-blue-700" :
evt.event_type === "packed" ? "bg-purple-100 text-purple-700" :
"bg-stone-100 text-stone-600"
}`}>
{EVENT_ICON_SHORT[evt.event_type] ?? "📋"}
{EVENT_ICON[evt.event_type] ?? Icons.fileText("w-4 h-4")}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-baseline justify-between gap-2">
<p className="text-sm font-semibold text-stone-800 leading-tight">
<p className="text-sm font-semibold text-[var(--admin-text-primary)] leading-tight">
{EVENT_LABEL[evt.event_type] ?? evt.event_type}
</p>
<span className="text-[10px] text-stone-400 flex-shrink-0 font-medium">
<span className="text-[10px] text-[var(--admin-text-muted)] flex-shrink-0 font-medium">
{timeAgo(evt.event_time)}
</span>
</div>
<div className="flex items-center gap-2 mt-0.5">
<span className="text-xs font-bold text-stone-600">{evt.crop_type}</span>
<span className="text-stone-300">·</span>
<span className="text-xs font-bold text-[var(--admin-text-secondary)]">{evt.crop_type}</span>
<span className="text-[var(--admin-border)]">·</span>
<Link
href={`/admin/route-trace/lots/${evt.lot_id}`}
className="text-xs font-mono font-bold text-blue-600 hover:text-blue-800"
className="text-xs font-mono font-bold text-[var(--admin-accent)] hover:opacity-80"
>
{evt.lot_number}
</Link>
{evt.location && (
<>
<span className="text-stone-300">·</span>
<span className="text-xs text-stone-400 truncate">{evt.location}</span>
<span className="text-[var(--admin-border)]">·</span>
<span className="text-xs text-[var(--admin-text-muted)] truncate">{evt.location}</span>
</>
)}
</div>
@@ -153,35 +275,35 @@ function HaulingColumn({
emptyMessage,
}: {
title: string;
icon: string;
icon: React.ReactNode;
bgHeader: string;
lots: LotWithAction[];
emptyMessage: string;
}) {
return (
<div className="rounded-2xl border border-stone-200 bg-white shadow-sm overflow-hidden flex-1 min-w-[260px]">
<div className={`px-4 py-3 border-b border-stone-100 flex items-center gap-2 ${bgHeader}`}>
<span className="text-lg">{icon}</span>
<h3 className="text-sm font-semibold text-stone-900">{title}</h3>
<span className="ml-auto rounded-full bg-white/80 text-stone-600 text-xs font-bold px-2 py-0.5">
<div className="rounded-2xl border border-[var(--admin-border)] bg-white shadow-[var(--admin-shadow-sm)] overflow-hidden flex-1 min-w-[260px]">
<div className={`px-4 py-3 border-b border-[var(--admin-border-light)] flex items-center gap-2 ${bgHeader}`}>
<span className="text-[var(--admin-text-secondary)]">{icon}</span>
<h3 className="text-sm font-semibold text-[var(--admin-text-primary)]">{title}</h3>
<span className="ml-auto rounded-full bg-white/80 text-[var(--admin-text-secondary)] text-xs font-bold px-2 py-0.5">
{lots.length}
</span>
</div>
{lots.length === 0 ? (
<div className="p-5 text-center text-xs text-stone-400">{emptyMessage}</div>
<div className="p-5 text-center text-xs text-[var(--admin-text-muted)]">{emptyMessage}</div>
) : (
<div className="divide-y divide-stone-100">
<div className="divide-y divide-[var(--admin-border-light)]">
{lots.map((lot) => (
<div key={lot.lot_id} className="px-4 py-3 hover:bg-stone-50/40 transition-colors">
<div key={lot.lot_id} className="px-4 py-3 hover:bg-[var(--admin-bg-subtle)]/40 transition-colors">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<Link
href={`/admin/route-trace/lots/${lot.lot_id}`}
className="font-mono text-sm font-bold text-stone-900 hover:text-blue-600 truncate block"
className="font-mono text-sm font-bold text-[var(--admin-text-primary)] hover:text-[var(--admin-accent)] truncate block"
>
{lot.lot_number}
</Link>
<p className="text-xs text-stone-500 mt-0.5 truncate">
<p className="text-xs text-[var(--admin-text-muted)] mt-0.5 truncate">
{lot.crop_type} · {lot.field_location ?? "No field"}
</p>
<div className="flex items-center gap-1.5 mt-1 flex-wrap">
@@ -191,15 +313,15 @@ function HaulingColumn({
</span>
)}
{lot.pallets && (
<span className="inline-flex text-[10px] text-stone-400 font-medium">{lot.pallets} PLT</span>
<span className="inline-flex text-[10px] text-[var(--admin-text-muted)] font-medium">{lot.pallets} PLT</span>
)}
</div>
</div>
<div className="text-right flex-shrink-0">
{lot.quantity_lbs && (
<p className="text-sm font-semibold text-stone-700">
<p className="text-sm font-semibold text-[var(--admin-text-secondary)]">
{Number(lot.quantity_lbs).toLocaleString()}
<span className="text-[10px] text-stone-400 font-normal ml-0.5">{lot.yield_unit ?? "lbs"}</span>
<span className="text-[10px] text-[var(--admin-text-muted)] font-normal ml-0.5">{lot.yield_unit ?? "lbs"}</span>
</p>
)}
</div>
@@ -208,14 +330,14 @@ function HaulingColumn({
<div className="mt-2">
<Link
href={`/admin/route-trace/lots/${lot.lot_id}`}
className="inline-flex items-center gap-1 rounded-lg bg-stone-100 px-2.5 py-1.5 text-xs font-semibold text-stone-600 hover:bg-stone-200 transition-colors"
className="inline-flex items-center gap-1.5 rounded-lg bg-[var(--admin-bg-subtle)] px-2.5 py-1.5 text-xs font-semibold text-[var(--admin-text-secondary)] hover:bg-[var(--admin-border-light)] transition-colors"
>
{lot.suggestedIcon} {lot.suggestedAction}
</Link>
</div>
)}
{lot.destination_stop_name && (
<p className="mt-1 text-[10px] text-stone-400"> {lot.destination_stop_name}</p>
<p className="mt-1 text-[10px] text-[var(--admin-text-muted)]"> {lot.destination_stop_name}</p>
)}
</div>
))}
@@ -238,15 +360,15 @@ function InventoryByCropSection({ inventoryByCrop }: { inventoryByCrop: Inventor
const cropEntries = Object.entries(byCrop).slice(0, 12);
return (
<div className="rounded-2xl border border-stone-200 bg-white overflow-hidden shadow-sm">
<div className="border-b border-stone-100 px-6 py-4 flex items-center justify-between bg-stone-50/40">
<div className="rounded-2xl border border-[var(--admin-border)] bg-white overflow-hidden shadow-[var(--admin-shadow-sm)]">
<div className="border-b border-[var(--admin-border-light)] px-6 py-4 flex items-center justify-between bg-[var(--admin-bg-subtle)]">
<div className="flex items-center gap-2">
<span>📦</span>
<h2 className="text-sm font-semibold text-stone-900">Inventory by Crop</h2>
<span className="text-[var(--admin-text-secondary)]">{Icons.box("w-4 h-4")}</span>
<h2 className="text-sm font-semibold text-[var(--admin-text-primary)]">Inventory by Crop</h2>
</div>
<span className="text-[10px] text-stone-400">Harvested · In Transit · At Shed · Packed</span>
<span className="text-[10px] text-[var(--admin-text-muted)]">Harvested · In Transit · At Shed · Packed</span>
</div>
<div className="divide-y divide-stone-100">
<div className="divide-y divide-[var(--admin-border-light)]">
{cropEntries.map(([crop, rows]) => {
const totalActual = rows.reduce((s, r) => s + Number(r.total_lbs ?? 0), 0);
const totalEstimate = rows.reduce((s, r) => s + Number(r.total_estimate ?? 0), 0);
@@ -263,23 +385,23 @@ function InventoryByCropSection({ inventoryByCrop }: { inventoryByCrop: Inventor
<div key={crop} className="px-6 py-4">
<div className="flex items-center justify-between mb-1">
<div>
<p className="text-sm font-bold text-stone-900">{crop}</p>
<p className="text-[10px] text-stone-400">{lotCount} lot{lotCount !== 1 ? "s" : ""}</p>
<p className="text-sm font-bold text-[var(--admin-text-primary)]">{crop}</p>
<p className="text-[10px] text-[var(--admin-text-muted)]">{lotCount} lot{lotCount !== 1 ? "s" : ""}</p>
</div>
<div className="flex items-center gap-4">
<div className="text-right">
<p className="text-xl font-black text-stone-900">
<p className="text-xl font-black text-[var(--admin-text-primary)]">
{totalActual > 0 ? Number(totalActual).toLocaleString() : "—"}
</p>
<p className="text-[10px] text-stone-400">{rows[0]?.yield_unit ?? "lbs"} actual</p>
<p className="text-[10px] text-[var(--admin-text-muted)]">{rows[0]?.yield_unit ?? "lbs"} actual</p>
</div>
{totalEstimate > 0 && (
<div className="text-right">
<p className="text-sm font-semibold text-stone-600">
<p className="text-sm font-semibold text-[var(--admin-text-muted)]">
{Number(totalEstimate).toLocaleString()}
<span className="text-[10px] text-stone-400 ml-0.5">est</span>
<span className="text-[10px] text-[var(--admin-text-muted)] ml-0.5">est</span>
</p>
<p className={`text-xs font-bold ${variance !== null ? (variance >= 0 ? "text-green-600" : "text-red-600") : "text-stone-400"}`}>
<p className={`text-xs font-bold ${variance !== null ? (variance >= 0 ? "text-green-600" : "text-red-600") : "text-[var(--admin-text-muted)]"}`}>
{variance !== null ? `${variance > 0 ? "+" : ""}${variance}%` : "—"}
</p>
</div>
@@ -287,21 +409,21 @@ function InventoryByCropSection({ inventoryByCrop }: { inventoryByCrop: Inventor
</div>
</div>
<div className="flex gap-1">
<div className="flex-1 bg-stone-50 rounded-lg px-2 py-1.5 text-center min-w-0">
<p className="text-sm font-black text-stone-800">{active > 0 ? Number(active).toLocaleString() : "—"}</p>
<p className="text-[9px] text-stone-400">🌱</p>
<div className="flex-1 bg-[var(--admin-bg-subtle)] rounded-lg px-2 py-1.5 text-center min-w-0">
<p className="text-sm font-black text-[var(--admin-text-primary)]">{active > 0 ? Number(active).toLocaleString() : "—"}</p>
<p className="text-[9px] text-[var(--admin-text-muted)]">{Icons.plant("w-3 h-3")}</p>
</div>
<div className="flex-1 bg-stone-50 rounded-lg px-2 py-1.5 text-center min-w-0">
<p className="text-sm font-black text-stone-800">{inTransit > 0 ? Number(inTransit).toLocaleString() : "—"}</p>
<p className="text-[9px] text-stone-400">🚚</p>
<div className="flex-1 bg-[var(--admin-bg-subtle)] rounded-lg px-2 py-1.5 text-center min-w-0">
<p className="text-sm font-black text-[var(--admin-text-primary)]">{inTransit > 0 ? Number(inTransit).toLocaleString() : "—"}</p>
<p className="text-[9px] text-[var(--admin-text-muted)]">{Icons.truck("w-3 h-3")}</p>
</div>
<div className="flex-1 bg-stone-50 rounded-lg px-2 py-1.5 text-center min-w-0">
<p className="text-sm font-black text-stone-800">{atShed > 0 ? Number(atShed).toLocaleString() : "—"}</p>
<p className="text-[9px] text-stone-400">🏭</p>
<div className="flex-1 bg-[var(--admin-bg-subtle)] rounded-lg px-2 py-1.5 text-center min-w-0">
<p className="text-sm font-black text-[var(--admin-text-primary)]">{atShed > 0 ? Number(atShed).toLocaleString() : "—"}</p>
<p className="text-[9px] text-[var(--admin-text-muted)]">{Icons.warehouse("w-3 h-3")}</p>
</div>
<div className="flex-1 bg-stone-50 rounded-lg px-2 py-1.5 text-center min-w-0">
<p className="text-sm font-black text-stone-800">{packed > 0 ? Number(packed).toLocaleString() : "—"}</p>
<p className="text-[9px] text-stone-400">📦</p>
<div className="flex-1 bg-[var(--admin-bg-subtle)] rounded-lg px-2 py-1.5 text-center min-w-0">
<p className="text-sm font-black text-[var(--admin-text-primary)]">{packed > 0 ? Number(packed).toLocaleString() : "—"}</p>
<p className="text-[9px] text-[var(--admin-text-muted)]">{Icons.box("w-3 h-3")}</p>
</div>
</div>
</div>
@@ -317,41 +439,41 @@ function FieldYieldTable({ fieldYield }: { fieldYield: FieldYieldSummary[] }) {
if (fieldYield.length === 0) return null;
return (
<div className="rounded-2xl border border-stone-200 bg-white overflow-hidden shadow-sm">
<div className="border-b border-stone-100 px-6 py-4 flex items-center justify-between bg-stone-50/40">
<div className="rounded-2xl border border-[var(--admin-border)] bg-white overflow-hidden shadow-[var(--admin-shadow-sm)]">
<div className="border-b border-[var(--admin-border-light)] px-6 py-4 flex items-center justify-between bg-[var(--admin-bg-subtle)]">
<div className="flex items-center gap-2">
<span>📊</span>
<h2 className="text-sm font-semibold text-stone-900">Field Yield Summary</h2>
<span className="text-[var(--admin-text-secondary)]">{Icons.chart("w-4 h-4")}</span>
<h2 className="text-sm font-semibold text-[var(--admin-text-primary)]">Field Yield Summary</h2>
</div>
</div>
<table className="w-full">
<thead>
<tr className="border-b border-stone-100">
<th className="px-6 py-3 text-left text-[10px] font-bold text-stone-400 uppercase tracking-widest">Field</th>
<th className="px-6 py-3 text-right text-[10px] font-bold text-stone-400 uppercase tracking-widest">Est. Yield</th>
<th className="px-6 py-3 text-right text-[10px] font-bold text-stone-400 uppercase tracking-widest">Actual</th>
<th className="px-6 py-3 text-right text-[10px] font-bold text-stone-400 uppercase tracking-widest">Lots</th>
<tr className="border-b border-[var(--admin-border-light)]">
<th className="px-6 py-3 text-left text-[10px] font-bold text-[var(--admin-text-muted)] uppercase tracking-widest">Field</th>
<th className="px-6 py-3 text-right text-[10px] font-bold text-[var(--admin-text-muted)] uppercase tracking-widest">Est. Yield</th>
<th className="px-6 py-3 text-right text-[10px] font-bold text-[var(--admin-text-muted)] uppercase tracking-widest">Actual</th>
<th className="px-6 py-3 text-right text-[10px] font-bold text-[var(--admin-text-muted)] uppercase tracking-widest">Lots</th>
</tr>
</thead>
<tbody>
{fieldYield.map((row, idx) => (
<tr key={idx} className="border-b border-stone-50 last:border-0 hover:bg-stone-50/30 transition-colors">
<tr key={idx} className="border-b border-[var(--admin-border-light)] last:border-0 hover:bg-[var(--admin-bg-subtle)]/30 transition-colors">
<td className="px-6 py-3.5">
<p className="text-sm font-semibold text-stone-800">{row.field_location}</p>
{row.field_block !== "N/A" && <p className="text-[10px] text-stone-400">Block {row.field_block}</p>}
<p className="text-sm font-semibold text-[var(--admin-text-primary)]">{row.field_location}</p>
{row.field_block !== "N/A" && <p className="text-[10px] text-[var(--admin-text-muted)]">Block {row.field_block}</p>}
</td>
<td className="px-6 py-3.5 text-right">
<span className="text-sm font-semibold text-stone-600">
<span className="text-sm font-semibold text-[var(--admin-text-muted)]">
{row.total_yield_estimate > 0 ? `${Number(row.total_yield_estimate).toLocaleString()} ${row.yield_unit ?? "lbs"}` : "—"}
</span>
</td>
<td className="px-6 py-3.5 text-right">
<span className="text-sm text-stone-700">
<span className="text-sm text-[var(--admin-text-secondary)]">
{row.total_quantity_lbs > 0 ? `${Number(row.total_quantity_lbs).toLocaleString()} ${row.yield_unit ?? "lbs"}` : "—"}
</span>
</td>
<td className="px-6 py-3.5 text-right">
<span className="inline-flex items-center rounded-full bg-green-50 text-green-700 px-2.5 py-0.5 text-xs font-bold">
<span className="inline-flex items-center rounded-full bg-[var(--admin-accent-light)] text-[var(--admin-accent-text)] px-2.5 py-0.5 text-xs font-bold">
{row.active_lots}
</span>
</td>
@@ -381,9 +503,9 @@ export default function RouteTraceDashboard({
recentActivity: RecentLotEvent[];
brandId: string;
}) {
const [search, setSearch] = useState("");
const [showScan, setShowScan] = useState(false);
const [showQuickNew, setShowQuickNew] = useState(false);
const [search, setSearch] = useState("");
const [searchCrop, setSearchCrop] = useState("");
const haulingBoard = buildHaulingBoard(haulingLots);
@@ -399,30 +521,28 @@ export default function RouteTraceDashboard({
] as const;
return (
<div className="space-y-5">
<RouteTraceNav activeTab="dashboard" />
<div className="space-y-4 sm:space-y-5">
{/* ── Stat cards ── */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-2 sm:gap-3">
{statEntries.map(({ key, value }) => {
const cfg = STATUS_CONFIG[key];
return (
<div key={key} className={`rounded-2xl ${cfg.bg} border border-transparent px-5 py-4`}>
<div className="flex items-center gap-1.5">
<span className="text-lg">{cfg.icon}</span>
<span className="text-[10px] font-bold text-stone-500 uppercase tracking-wider">{cfg.label}</span>
<div key={key} className={`rounded-xl sm:rounded-2xl ${cfg.bg} border border-transparent px-3 sm:px-5 py-3 sm:py-4`}>
<div className="flex items-center gap-1 sm:gap-1.5">
<span className={`${cfg.iconColor} scale-75 sm:scale-100`}>{cfg.icon}</span>
<span className="text-[8px] sm:text-[10px] font-bold text-[var(--admin-text-muted)] uppercase tracking-wider truncate">{cfg.label}</span>
</div>
<p className={`mt-1.5 text-3xl font-black ${cfg.text}`}>{value}</p>
<p className={`mt-1 sm:mt-1.5 text-xl sm:text-3xl font-black ${cfg.text}`}>{value}</p>
</div>
);
})}
{/* Total lots — spans across */}
<div className="rounded-2xl bg-stone-100 border border-stone-200 px-5 py-4">
<div className="flex items-center gap-1.5">
<span className="text-lg">📋</span>
<span className="text-[10px] font-bold text-stone-500 uppercase tracking-wider">Total Lots</span>
<div className="rounded-xl sm:rounded-2xl bg-[var(--admin-bg-subtle)] border border-[var(--admin-border)] px-3 sm:px-5 py-3 sm:py-4">
<div className="flex items-center gap-1 sm:gap-1.5">
<span className="text-[var(--admin-text-secondary)]">{Icons.clipboard("w-3.5 h-3.5 sm:w-4 sm:h-4")}</span>
<span className="text-[8px] sm:text-[10px] font-bold text-[var(--admin-text-muted)] uppercase tracking-wider truncate">Total Lots</span>
</div>
<p className="mt-1.5 text-3xl font-black text-stone-700">{stats.total_lots ?? 0}</p>
<p className="mt-1 sm:mt-1.5 text-xl sm:text-3xl font-black text-[var(--admin-text-secondary)]">{stats.total_lots ?? 0}</p>
</div>
</div>
@@ -431,17 +551,17 @@ export default function RouteTraceDashboard({
{/* ── Compliance summary ── */}
{inventoryByCrop.length > 0 && (
<div className="rounded-2xl border border-blue-200 bg-blue-50/50 px-5 py-4 flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-blue-100">
<span className="text-sm">📋</span>
<div className="rounded-xl sm:rounded-2xl border border-blue-200 bg-blue-50/50 px-4 sm:px-5 py-3 sm:py-4 flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3">
<div className="flex items-center gap-2 sm:gap-3">
<div className="flex h-8 w-8 sm:h-9 sm:w-9 items-center justify-center rounded-lg sm:rounded-xl bg-blue-100">
<span className="text-blue-600">{Icons.file("w-4 h-4")}</span>
</div>
<div>
<p className="text-sm font-bold text-blue-900">Compliance Summary</p>
<p className="text-[10px] text-blue-600/70">This period</p>
<p className="text-xs sm:text-sm font-bold text-blue-900">Compliance Summary</p>
<p className="text-[10px] sm:text-[10px] text-blue-600/70 hidden sm:block">This period</p>
</div>
</div>
<div className="flex items-center gap-6">
<div className="flex items-center gap-4 sm:gap-6 w-full sm:w-auto justify-between sm:justify-end">
{(() => {
const totalLots = inventoryByCrop.reduce((s, r) => s + Number(r.lot_count ?? 0), 0);
const totalLbs = inventoryByCrop.reduce((s, r) => s + Number(r.total_lbs ?? 0), 0);
@@ -465,69 +585,59 @@ export default function RouteTraceDashboard({
<InventoryByCropSection inventoryByCrop={inventoryByCrop} />
{/* ── Quick actions ── */}
<div className="flex gap-2.5 flex-wrap">
<div className="flex gap-2 sm:gap-2.5 flex-wrap">
<button
onClick={() => setShowQuickNew(true)}
className="rounded-xl bg-green-600 px-4 py-2.5 text-sm font-bold text-white hover:bg-green-700 active:bg-green-800 transition-colors flex items-center gap-2 shadow-sm"
className="rounded-xl bg-[var(--admin-accent)] px-3 sm:px-4 py-2 text-xs sm:text-sm font-bold text-white hover:opacity-90 active:opacity-80 transition-colors flex items-center gap-1.5 sm:gap-2 shadow-[var(--admin-shadow-sm)]"
>
🌱 Quick New Lot
{Icons.plus("w-3.5 h-3.5 sm:w-4 sm:h-4")} <span className="hidden xs:inline">Quick New Lot</span><span className="xs:hidden">New Lot</span>
</button>
<button
onClick={() => setShowScan(true)}
className="rounded-xl bg-emerald-600 px-4 py-2.5 text-sm font-bold text-white hover:bg-emerald-700 transition-colors flex items-center gap-2"
className="rounded-xl bg-[var(--admin-text-secondary)] px-3 sm:px-4 py-2 text-xs sm:text-sm font-bold text-white hover:opacity-90 transition-colors flex items-center gap-1.5 sm:gap-2"
>
📷 Scan QR
{Icons.camera("w-3.5 h-3.5 sm:w-4 sm:h-4")} <span className="hidden sm:inline">Scan QR</span>
</button>
<FsmaReportModal brandId={brandId} />
<button
onClick={() => {
const end = new Date();
const start = new Date();
start.setDate(start.getDate() - 30);
const fmt = (d: Date) => d.toISOString().split("T")[0];
window.location.href = `/api/route-trace/fsma-report?brandId=${brandId}&startDate=${fmt(start)}&endDate=${fmt(end)}&format=csv`;
}}
className="rounded-xl border border-blue-200 bg-blue-50 px-4 py-2.5 text-sm font-bold text-blue-700 hover:bg-blue-100 transition-colors flex items-center gap-2"
onClick={() => setShowQuickNew(true)}
className="rounded-xl border border-[var(--admin-border)] bg-white px-3 sm:px-4 py-2 text-xs sm:text-sm font-semibold text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg-subtle)] transition-colors"
>
📊 Compliance Snapshot
{Icons.plus("w-3.5 h-3.5 sm:w-4 sm:h-4")} <span className="hidden sm:inline">New Lot</span>
</button>
<Link
href="/admin/route-trace/lots/new"
className="rounded-xl border border-stone-200 bg-white px-4 py-2.5 text-sm font-semibold text-stone-600 hover:bg-stone-50 transition-colors"
>
+ New Lot
</Link>
<Link
href="/admin/route-trace/lots"
className="rounded-xl border border-stone-200 bg-white px-4 py-2.5 text-sm font-semibold text-stone-600 hover:bg-stone-50 transition-colors"
className="rounded-xl border border-[var(--admin-border)] bg-white px-3 sm:px-4 py-2 text-xs sm:text-sm font-semibold text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg-subtle)] transition-colors flex items-center gap-1.5 sm:gap-2"
>
All Lots
All Lots {Icons.arrowRight("w-3.5 h-3.5 sm:w-4 sm:h-4")}
</Link>
<FsmaReportModal brandId={brandId} />
</div>
{/* ── Hauling board ── */}
{hasAnyLots && (
<div>
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2.5">
<h2 className="text-sm font-bold text-stone-700 uppercase tracking-wider">Hauling Board</h2>
<span className="rounded-full bg-stone-100 text-stone-600 text-[10px] font-bold px-2.5 py-0.5">
<div className="flex flex-col xs:flex-row items-start xs:items-center justify-between gap-2 mb-3">
<div className="flex items-center gap-2 sm:gap-2.5">
<h2 className="text-xs sm:text-sm font-bold text-[var(--admin-text-secondary)] uppercase tracking-wider">Hauling Board</h2>
<span className="rounded-full bg-[var(--admin-bg-subtle)] text-[var(--admin-text-muted)] text-[10px] font-bold px-2 py-0.5">
{totalHaulingLots} lots
</span>
</div>
<div className="flex items-center gap-2">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-stone-400 text-xs">🔍</span>
<input
type="text"
placeholder="Lot, crop, field…"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-8 pr-3 py-1.5 rounded-lg border border-stone-200 bg-white text-xs text-stone-700 placeholder:text-stone-400 focus:outline-none focus:border-stone-400 w-40"
/>
<div className="flex items-center gap-2 w-full xs:w-auto">
<div className="relative flex-1 xs:flex-initial">
<span className="absolute left-2.5 sm:left-3 top-1/2 -translate-y-1/2 text-[var(--admin-text-muted)]">{Icons.search("w-3 h-3 sm:w-3.5 sm:h-3.5")}</span>
<input
type="text"
placeholder="Lot, crop, field…"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full xs:w-32 sm:w-40 pl-7 sm:pl-8 pr-3 py-1.5 sm:py-2 rounded-lg border border-[var(--admin-border)] bg-white text-xs text-[var(--admin-text-primary)] placeholder:text-[var(--admin-text-muted)] focus:outline-none focus:border-[var(--admin-accent)]"
/>
</div>
<select
value={searchCrop}
onChange={(e) => setSearchCrop(e.target.value)}
className="rounded-lg border border-stone-200 bg-white text-xs text-stone-700 px-2 py-1.5 focus:outline-none focus:border-stone-400"
className="rounded-lg border border-[var(--admin-border)] bg-white text-xs text-[var(--admin-text-primary)] px-2 py-1.5 sm:py-2 focus:outline-none focus:border-[var(--admin-accent)] flex-shrink-0"
>
<option value="">All Crops</option>
{[...new Set(haulingLots.map((l) => l.crop_type))].sort().map((c) => (
@@ -539,7 +649,7 @@ export default function RouteTraceDashboard({
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
<HaulingColumn
title="In Transit"
icon="🚚"
icon={Icons.truck("w-4 h-4")}
bgHeader="bg-amber-50"
lots={haulingBoard.in_transit.filter(
(l) =>
@@ -553,7 +663,7 @@ export default function RouteTraceDashboard({
/>
<HaulingColumn
title="At Shed"
icon="🏭"
icon={Icons.warehouse("w-4 h-4")}
bgHeader="bg-blue-50"
lots={haulingBoard.at_shed.filter(
(l) =>
@@ -567,8 +677,8 @@ export default function RouteTraceDashboard({
/>
<HaulingColumn
title="Packed"
icon="📦"
bgHeader="bg-purple-50"
icon={Icons.clipboard("w-4 h-4")}
bgHeader="bg-[var(--admin-accent-light)]"
lots={haulingBoard.packed.filter(
(l) =>
(!search ||
@@ -583,78 +693,27 @@ export default function RouteTraceDashboard({
</div>
)}
{/* ── Field Yield Summary ── */}
{/* ── Field Yield Table ── */}
<FieldYieldTable fieldYield={fieldYield} />
{/* ── Recent Lots ── */}
<div className="rounded-2xl border border-stone-200 bg-white overflow-hidden shadow-sm">
<div className="border-b border-stone-100 px-5 py-3.5 flex items-center justify-between bg-stone-50/40">
<h2 className="text-sm font-semibold text-stone-900">Recent Lots</h2>
<Link href="/admin/route-trace/lots" className="text-[11px] text-blue-600 hover:text-blue-800 font-bold">
View all
</Link>
</div>
{recentLots.length === 0 ? (
<div className="p-10 text-center">
<div className="text-3xl mb-3">🌱</div>
<p className="text-sm text-stone-500">No lots yet. Create your first lot to get started.</p>
<Link
href="/admin/route-trace/lots/new"
className="mt-4 inline-flex items-center gap-1.5 rounded-lg bg-green-600 px-4 py-2 text-sm font-medium text-white hover:bg-green-700 transition-colors"
>
+ Create First Lot
</Link>
</div>
) : (
<table className="w-full">
<thead>
<tr className="border-b border-stone-100">
<th className="px-5 py-3 text-left text-[10px] font-bold text-stone-400 uppercase tracking-widest">Lot #</th>
<th className="px-5 py-3 text-left text-[10px] font-bold text-stone-400 uppercase tracking-widest">Crop</th>
<th className="px-5 py-3 text-left text-[10px] font-bold text-stone-400 uppercase tracking-widest">Harvest Date</th>
<th className="px-5 py-3 text-left text-[10px] font-bold text-stone-400 uppercase tracking-widest">Status</th>
</tr>
</thead>
<tbody>
{recentLots.map((lot) => (
<tr key={lot.lot_id} className="border-b border-stone-50 last:border-0 hover:bg-stone-50/40 transition-colors">
<td className="px-5 py-3.5">
<Link href={`/admin/route-trace/lots/${lot.lot_id}`} className="font-mono text-sm font-bold text-stone-900 hover:text-blue-600">
{lot.lot_number}
</Link>
</td>
<td className="px-5 py-3.5 text-sm font-semibold text-stone-700">{lot.crop_type}</td>
<td className="px-5 py-3.5 text-sm text-stone-500">{lot.harvest_date}</td>
<td className="px-5 py-3.5">
<span className={`inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-[10px] font-bold ${
lot.status === "active" ? "bg-green-50 text-green-700" :
lot.status === "in_transit" ? "bg-amber-50 text-amber-700" :
lot.status === "at_shed" ? "bg-blue-50 text-blue-700" :
lot.status === "packed" ? "bg-purple-50 text-purple-700" :
"bg-stone-50 text-stone-600"
}`}>
<span className="h-1.5 w-1.5 rounded-full bg-current" />
{lot.status.replace("_", " ")}
</span>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
{showScan && <QRScanModal onClose={() => setShowScan(false)} />}
{/* ── Quick New Lot Modal ── */}
{showQuickNew && (
<QuickNewLotDrawer
brandId=""
<QuickNewLotModal
brandId={brandId}
onCreated={(lotId) => {
setShowQuickNew(false);
window.location.href = `/admin/route-trace/lots/${lotId}`;
// Refresh would happen here in production
console.log("Lot created:", lotId);
}}
onClose={() => setShowQuickNew(false)}
/>
)}
{/* ── QR Scan Modal ── */}
{showScan && (
<QRScanModal
onClose={() => setShowScan(false)}
/>
)}
</div>
);
}
+104 -69
View File
@@ -1,10 +1,11 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import RouteTraceDashboard from "./RouteTraceDashboard";
import AdminLookupPage from "./AdminLookupPage";
import LotListTable from "./LotListTable";
import Link from "next/link";
import RouteTraceSettings from "./RouteTraceSettings";
import {
RouteTraceStats,
HaulingLot,
@@ -15,13 +16,62 @@ import {
type Tab = "dashboard" | "lots" | "lookup" | "settings";
const TABS: { id: Tab; label: string }[] = [
{ id: "dashboard", label: "Dashboard" },
{ id: "lots", label: "Lots" },
{ id: "lookup", label: "Lookup" },
{ id: "settings", label: "Settings" },
const TABS: { id: Tab; label: string; description: string }[] = [
{ id: "dashboard", label: "Dashboard", description: "Overview & stats" },
{ id: "lots", label: "Lots", description: "All lot records" },
{ id: "lookup", label: "Lookup", description: "Trace by lot number" },
{ id: "settings", label: "Settings", description: "Configuration" },
];
// Icon components
const Icons = {
dashboard: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="3" width="7" height="7" rx="1"/>
<rect x="14" y="3" width="7" height="7" rx="1"/>
<rect x="3" y="14" width="7" height="7" rx="1"/>
<rect x="14" y="14" width="7" height="7" rx="1"/>
</svg>
),
clipboard: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="8" y="2" width="8" height="4" rx="1" ry="1"/>
<path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/>
<path d="M12 11h4M12 16h4M8 11h.01M8 16h.01"/>
</svg>
),
settings: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/>
<circle cx="12" cy="12" r="3"/>
</svg>
),
search: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="11" cy="11" r="8"/>
<path d="m21 21-4.3-4.3"/>
</svg>
),
plus: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4v16m8-8H4" />
</svg>
),
arrowRight: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M5 12h14"/>
<path d="m12 5 7 7-7 7"/>
</svg>
),
leaf: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M11 20A7 7 0 0 1 9.8 6.1C15.5 5 17 4.48 19 2c1 2 2 4.18 2 8 0 5.5-4.78 10-10 10Z"/>
<path d="M2 21c0-3 1.85-5.36 5.08-6C9.5 14.52 12 13 13 12"/>
</svg>
),
};
type Props = {
stats: RouteTraceStats;
recentLots: HaulingLot[];
@@ -46,49 +96,36 @@ export default function RouteTracePage({
const [activeTab, setActiveTab] = useState<Tab>("dashboard");
return (
<div className="min-h-screen bg-stone-50 px-6 py-8">
<div className="mx-auto max-w-6xl">
{/* Header */}
<div className="mb-6">
<div className="flex items-center justify-between mb-2">
<div>
<h1 className="text-2xl font-semibold text-stone-950 tracking-tight">Route Trace</h1>
<p className="mt-1 text-sm text-stone-500">Track lots from field to delivery</p>
</div>
{activeTab === "lots" && (
<Link
href="/admin/route-trace/lots/new"
className="flex items-center gap-2 rounded-xl bg-emerald-600 hover:bg-emerald-500 px-4 py-2.5 text-sm font-semibold text-white transition-colors shadow-sm shadow-emerald-200"
>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4v16m8-8H4" />
</svg>
New Lot
</Link>
)}
</div>
<div className="min-h-screen bg-[var(--admin-bg)]">
{/* Tab navigation */}
<div className="px-4 sm:px-6 md:px-8 pt-4 sm:pt-6">
<nav className="grid grid-cols-4 gap-1 p-1.5 rounded-xl bg-white border border-stone-200">
{TABS.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`relative flex flex-col sm:flex-row items-center justify-center gap-1 sm:gap-2 rounded-lg px-2 sm:px-4 py-2.5 sm:py-3 text-[10px] sm:text-sm font-semibold transition-colors ${
activeTab === tab.id
? "bg-emerald-600 text-white"
: "text-stone-500 hover:text-stone-700 hover:bg-stone-50"
}`}
>
{tab.id === "dashboard" && Icons.dashboard("h-4 w-4")}
{tab.id === "lots" && Icons.clipboard("h-4 w-4")}
{tab.id === "lookup" && Icons.search("h-4 w-4")}
{tab.id === "settings" && Icons.settings("h-4 w-4")}
<span className="hidden sm:inline">{tab.label}</span>
<span className="sm:hidden">{tab.label.substring(0, 3)}</span>
{activeTab === tab.id && (
<div className="absolute bottom-1 sm:bottom-1.5 left-1/2 -translate-x-1/2 h-0.5 w-6 sm:w-12 bg-white rounded-full" />
)}
</button>
))}
</nav>
</div>
{/* Tab nav */}
<div className="border-b border-stone-200 mt-4">
<nav className="flex gap-1 -mb-px">
{TABS.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`px-5 py-3 text-sm font-medium border-b-2 transition-colors rounded-t-lg ${
activeTab === tab.id
? "border-emerald-500 text-emerald-700 bg-white"
: "border-transparent text-stone-500 hover:text-stone-700 hover:bg-stone-50"
}`}
>
{tab.label}
</button>
))}
</nav>
</div>
</div>
{/* Tab content */}
{/* Tab content */}
<div className="px-4 sm:px-6 md:px-8 py-4 sm:py-6">
{activeTab === "dashboard" && (
<RouteTraceDashboard
stats={stats}
@@ -102,40 +139,38 @@ export default function RouteTracePage({
)}
{activeTab === "lots" && (
<div className="rounded-2xl bg-white border border-stone-200 shadow-sm overflow-hidden">
<div className="px-5 py-4 border-b border-stone-100 bg-stone-50">
<p className="text-sm font-medium text-stone-700">{lots.length} lot{lots.length !== 1 ? "s" : ""} total</p>
<div>
<div className="rounded-2xl border border-[var(--admin-border)] bg-white overflow-hidden">
<LotListTable initialLots={lots} brandId={brandId} />
</div>
<LotListTable initialLots={lots} brandId={brandId} />
</div>
)}
{activeTab === "lookup" && (
<div className="rounded-2xl bg-white border border-stone-200 shadow-sm p-6">
<AdminLookupPage brandId={brandId} />
<div>
<div className="rounded-2xl border border-[var(--admin-border)] bg-white">
<AdminLookupPage brandId={brandId} />
</div>
</div>
)}
{activeTab === "settings" && (
<div className="rounded-2xl bg-white border border-stone-200 shadow-sm p-6">
<div className="text-center py-12">
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-stone-100 mx-auto mb-4">
<svg className="h-8 w-8 text-stone-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2c3.95 0 6.2 3.25 6.2 6.2 0 1.06-.34 2.06-.95 2.83l2.86 2.86c.77.77 1.2 1.82 1.2 2.93 0 2.11-1.71 3.82-3.82 3.82-1.11 0-2.17-.45-2.93-1.21l-2.86-2.86A3.8 3.8 0 0011.91 18c-.76-.61-1.25-1.5-1.25-2.5 0-1.22.55-2.32 1.41-3.07l.54-.54c.43-.43 1.12-.43 1.55 0 .43.43.43 1.12 0 1.55l-.54.54c.76.86 1.22 1.95 1.22 3.18 0 2.1-1.71 3.82-3.82 3.82-1.23 0-2.32-.56-3.18-1.22l-.54-.54c-.43-.43-1.12-.43-1.55 0-.43.43-.43 1.12 0 1.55l.54.54c.86.76 1.95 1.22 3.18 1.22 2.1 0 3.82-1.71 3.82-3.82 0-1.23-.45-2.32-1.22-3.18l-.54-.54c-.43-.43-.43-1.12 0-1.55.43-.43 1.12-.43 1.55 0l.54.54c.61.76.95 1.76.95 2.83 0 1-.49 1.89-1.25 2.5-.65.42-1.44.68-2.28.68-1.93 0-3.5-1.57-3.5-3.5 0-.78.26-1.52.72-2.09l2.86-2.86c.39-.39.6-.91.6-1.46 0-.55-.21-1.07-.6-1.46-.39-.39-.91-.6-1.46-.6-.55 0-1.07.21-1.46.6l-2.86 2.86c-.58.46-1.31.72-2.09.72-.78 0-1.52-.26-2.09-.72l-.54-.54c-.39-.39-.6-.91-.6-1.46s.21-1.07.6-1.46c.39-.39.91-.6 1.46-.6.55 0 1.07.21 1.46.6l.54.54c.46.58.72 1.31.72 2.09 0 .78-.26 1.52-.72 2.09l-2.86 2.86c-.39.39-.6.91-.6 1.46 0 .55.21 1.07.6 1.46.39.39.91.6 1.46.6.55 0 1.07-.21 1.46-.6l2.86-2.86c.58-.46 1.31-.72 2.09-.72.78 0 1.52.26 2.09.72l.54.54c.39.39.6.91.6 1.46 0 .55-.21 1.07-.6 1.46-.39.39-.91.6-1.46.6-.55 0-1.07-.21-1.46-.6l-.54-.54c-.76-.86-1.22-1.95-1.22-3.18 0-1.23.56-2.32 1.22-3.18l.54-.54c.43-.43 1.12-.43 1.55 0 .43.43.43 1.12 0 1.55l-.54.54c-.76.86-1.22 1.95-1.22 3.18 0 2.1 1.71 3.82 3.82 3.82 1.23 0 2.32-.56 3.18-1.22l.54-.54c.43-.43 1.12-.43 1.55 0 .43.43.43 1.12 0 1.55l-.54.54c-.86.76-1.95 1.22-3.18 1.22-1.1 0-2.16-.26-3.07-.75V4.01c0-.55.21-1.07.6-1.46.39-.39.91-.6 1.46-.6h.01" />
</svg>
<div>
<div className="rounded-2xl border border-[var(--admin-border)] bg-white p-4 sm:p-6">
<div className="flex items-center gap-2 sm:gap-3 mb-6">
<div className="flex h-8 w-8 sm:h-10 sm:w-10 items-center justify-center rounded-lg sm:rounded-xl bg-[var(--admin-text-primary)]">
{Icons.settings("w-4 h-4 sm:w-5 sm:h-5 text-[var(--admin-bg)]")}
</div>
<div>
<h2 className="text-sm sm:text-lg font-bold text-[var(--admin-text-primary)]">Route Trace Settings</h2>
<p className="text-[10px] sm:text-xs text-[var(--admin-text-muted)]">Configure your traceability workflow</p>
</div>
</div>
<h2 className="text-lg font-semibold text-stone-700 mb-2">Route Trace Settings</h2>
<p className="text-sm text-stone-500 mb-4">Configure your traceability workflow and defaults</p>
<Link
href="/admin/settings/apps"
className="inline-flex items-center gap-2 rounded-xl border border-stone-200 bg-white px-4 py-2.5 text-sm font-medium text-stone-600 hover:bg-stone-50 transition-colors"
>
Manage Add-ons
</Link>
<RouteTraceSettings />
</div>
</div>
)}
</div>
</div>
);
}
}
@@ -0,0 +1,147 @@
"use client";
import { useState } from "react";
const Icons = {
settings: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/>
<circle cx="12" cy="12" r="3"/>
</svg>
),
tag: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 2H2v10l9.29 9.29c.94.94 2.48.94 3.42 0l6.58-6.58c.94-.94.94-2.48 0-3.42L12 2Z"/>
<path d="M7 7h.01"/>
</svg>
),
calendar: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"/>
<line x1="16" x2="16" y1="2" y2="6"/>
<line x1="8" x2="8" y1="2" y2="6"/>
<line x1="3" x2="21" y1="10" y2="10"/>
</svg>
),
link: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>
</svg>
),
save: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/>
<polyline points="17 21 17 13 7 13 7 21"/>
<polyline points="7 3 7 8 15 8"/>
</svg>
),
};
export default function RouteTraceSettings() {
const [lotPrefix, setLotPrefix] = useState("TC");
const [defaultHarvestDays, setDefaultHarvestDays] = useState("0");
const [saved, setSaved] = useState(false);
function handleSave(e: React.FormEvent) {
e.preventDefault();
// TODO: Save to backend
setSaved(true);
setTimeout(() => setSaved(false), 2000);
}
return (
<form onSubmit={handleSave} className="space-y-6">
{/* Lot Number Format */}
<div className="rounded-xl border border-stone-200 bg-white p-6">
<div className="flex items-center gap-3 mb-4">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-emerald-100">
{Icons.tag("h-5 w-5 text-emerald-600")}
</div>
<div>
<h3 className="text-base font-semibold text-stone-900">Lot Number Format</h3>
<p className="text-sm text-stone-500">Configure how lot numbers are generated</p>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-stone-700 mb-1.5">Prefix</label>
<input
type="text"
value={lotPrefix}
onChange={(e) => setLotPrefix(e.target.value.toUpperCase())}
placeholder="TC"
className="w-full rounded-lg border border-stone-200 bg-stone-50 px-3 py-2.5 text-sm outline-none focus:border-emerald-600 focus:bg-white transition-colors"
/>
<p className="text-xs text-stone-400 mt-1.5">Example: TC-20260520-001</p>
</div>
</div>
</div>
{/* Harvest Defaults */}
<div className="rounded-xl border border-stone-200 bg-white p-6">
<div className="flex items-center gap-3 mb-4">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-amber-100">
{Icons.calendar("h-5 w-5 text-amber-600")}
</div>
<div>
<h3 className="text-base font-semibold text-stone-900">Harvest Defaults</h3>
<p className="text-sm text-stone-500">Default values for new lots</p>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-stone-700 mb-1.5">Default Harvest Days Ago</label>
<input
type="number"
value={defaultHarvestDays}
onChange={(e) => setDefaultHarvestDays(e.target.value)}
min="0"
max="30"
className="w-full rounded-lg border border-stone-200 bg-stone-50 px-3 py-2.5 text-sm outline-none focus:border-emerald-600 focus:bg-white transition-colors"
/>
<p className="text-xs text-stone-400 mt-1.5">0 = today, 1 = yesterday</p>
</div>
</div>
</div>
{/* QR Code Base URL */}
<div className="rounded-xl border border-stone-200 bg-white p-6">
<div className="flex items-center gap-3 mb-4">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-blue-100">
{Icons.link("h-5 w-5 text-blue-600")}
</div>
<div>
<h3 className="text-base font-semibold text-stone-900">Trace URL</h3>
<p className="text-sm text-stone-500">Public trace page URL prefix</p>
</div>
</div>
<div>
<label className="block text-sm font-medium text-stone-700 mb-1.5">Base URL</label>
<input
type="text"
defaultValue={typeof window !== "undefined" ? window.location.origin : ""}
placeholder="https://yourdomain.com"
className="w-full rounded-lg border border-stone-200 bg-stone-50 px-3 py-2.5 text-sm outline-none focus:border-emerald-600 focus:bg-white transition-colors"
/>
<p className="text-xs text-stone-400 mt-1.5">QR codes will link to: baseurl/trace/LOTNUMBER</p>
</div>
</div>
{/* Save Button */}
<div className="flex justify-end">
<button
type="submit"
className={`inline-flex items-center gap-2 rounded-xl px-6 py-3 text-sm font-bold transition-colors ${
saved
? "bg-green-600 text-white"
: "bg-emerald-600 text-white hover:bg-emerald-700"
}`}
>
{Icons.save("h-4 w-4")}
{saved ? "Saved!" : "Save Settings"}
</button>
</div>
</form>
);
}
+66 -8
View File
@@ -1,20 +1,78 @@
"use client";
// Custom SVG icons for status badges - consistent one-color outline style
const StatusIcon = ({ status }: { status: string }) => {
switch (status) {
case "active":
return (
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M7 20h10M10 20c5.5-2.5.8-6.4 3-10M9.5 9.4c1.1.8 1.8 2.2 2.3 3.7-2 .4-3.5.4-4.8-.3-1.2-.6-2.3-1.9-3-4.2 2.8-.5 4.4 0 5.5.8zM14.1 6a7 7 0 0 0-1.1 4c1.9-.1 3.3-.6 4.3-1.4 1-1 1.6-2.3 1.7-4.6-2.7.1-4 1-4.9 2z"/>
</svg>
);
case "in_transit":
return (
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<rect x="1" y="3" width="15" height="13" rx="1"/>
<path d="M16 8h4l3 3v5h-7V8z"/>
<circle cx="5.5" cy="18.5" r="2.5"/>
<circle cx="18.5" cy="18.5" r="2.5"/>
</svg>
);
case "at_shed":
return (
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 21h18M5 21V7l8-4v18M19 21V11l-6-4M9 9v.01M9 12v.01M9 15v.01M9 18v.01"/>
</svg>
);
case "packed":
return (
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<rect x="8" y="2" width="8" height="4" rx="1" ry="1"/>
<path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/>
<path d="M12 11h4M12 16h4M8 11h.01M8 16h.01"/>
</svg>
);
case "delivered":
return (
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M20 6 9 17l-5-5"/>
</svg>
);
default:
return (
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10"/>
<path d="M12 8v4M12 16h.01"/>
</svg>
);
}
};
const STATUS_CONFIG = {
active: { label: "Active", bg: "bg-green-100", text: "text-green-800" },
in_transit: { label: "In Transit", bg: "bg-amber-100", text: "text-amber-800" },
at_shed: { label: "At Shed", bg: "bg-blue-100", text: "text-blue-800" },
packed: { label: "Packed", bg: "bg-purple-100", text: "text-purple-800" },
delivered: { label: "Delivered", bg: "bg-stone-100", text: "text-stone-700" },
active: { label: "Active", bg: "bg-[#e8f5e9]", text: "text-[#2e7d32]", ring: "#4caf50" },
in_transit: { label: "In Transit", bg: "bg-[#fff8e1]", text: "text-[#f57c00]", ring: "#ffb74d" },
at_shed: { label: "At Shed", bg: "bg-[#e3f2fd]", text: "text-[#1565c0]", ring: "#64b5f6" },
packed: { label: "Packed", bg: "bg-[var(--admin-accent-light)]", text: "text-[var(--admin-accent-text)]", ring: "var(--admin-accent)" },
delivered: { label: "Delivered", bg: "bg-[var(--admin-bg-subtle)]", text: "text-[var(--admin-text-secondary)]", ring: "var(--admin-text-muted)" },
} as const;
type Status = keyof typeof STATUS_CONFIG;
export default function StatusBadge({ status }: { status: string }) {
const config = STATUS_CONFIG[status as Status] ?? { label: status, bg: "bg-stone-100", text: "text-stone-700" };
const config = STATUS_CONFIG[status as Status] ?? { label: status, bg: "bg-[var(--admin-bg-subtle)]", text: "text-[var(--admin-text-secondary)]", ring: "var(--admin-text-muted)" };
return (
<span className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium ${config.bg} ${config.text}`}>
<span className="h-1.5 w-1.5 rounded-full currentColor" />
<span className={`inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-xs font-bold ${config.bg} ${config.text} shadow-sm`}>
<span className="relative flex h-2 w-2">
<span
className="absolute inline-flex h-full w-full rounded-full opacity-75 animate-ping"
style={{ backgroundColor: config.ring, animationDuration: "2s" }}
/>
<span
className="relative inline-flex h-2 w-2 rounded-full"
style={{ backgroundColor: config.ring }}
/>
</span>
<StatusIcon status={status} />
{config.label}
</span>
);
@@ -3,6 +3,31 @@
import { useState, useEffect } from "react";
import { LotDetail } from "@/actions/route-trace/lots";
// One-color outline icons
const Icons = {
printer: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"/>
<path d="M6 9V3a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v6"/>
<rect x="6" y="14" width="12" height="8" rx="1"/>
</svg>
),
leaf: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M11 20A7 7 0 0 1 9.8 6.1C15.5 5 17 4.48 19 2c1 2 2 4.18 2 8 0 5.5-4.78 10-10 10Z"/>
<path d="M2 21c0-3 1.85-5.36 5.08-6C9.5 14.52 12 13 13 12"/>
</svg>
),
warehouse: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M22 8.35V20a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V8.35A2 2 0 0 1 3.26 6.5l8-3.2a2 2 0 0 1 1.48 0l8 3.2A2 2 0 0 1 22 8.35Z"/>
<path d="M6 18h12"/>
<path d="M6 14h12"/>
<rect x="6" y="10" width="12" height="4" rx="1"/>
</svg>
),
};
type StickerType = "field" | "shed";
type StickerSize = "4x2" | "4x3";
@@ -62,7 +87,7 @@ export default function StickerPreviewModal({ lot, onClose }: { lot: LotDetail;
<div className="w-full max-w-lg rounded-2xl bg-white shadow-xl" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-between border-b border-stone-100 px-6 py-4">
<div>
<h3 className="text-base font-semibold text-stone-900">🖨 Print Sticker</h3>
<h3 className="text-base font-semibold text-stone-900 flex items-center gap-2">{Icons.printer("h-5 w-5")} Print Sticker</h3>
<p className="text-xs text-stone-400 mt-0.5">{lot.lot_number} · {lot.crop_type}</p>
</div>
<button onClick={onClose} className="text-stone-400 hover:text-stone-600 text-xl leading-none"></button>
@@ -75,7 +100,7 @@ export default function StickerPreviewModal({ lot, onClose }: { lot: LotDetail;
<p className="mb-2 text-xs font-semibold text-stone-500 uppercase tracking-wider">Sticker Type</p>
<div className="space-y-1.5">
{(["field", "shed"] as StickerType[]).map((t) => {
const label = t === "field" ? "🌱 Field" : "🏭 Shed";
const label = t === "field" ? "Field" : "Shed";
const desc = t === "field" ? "After harvest" : "At shed arrival";
return (
<button
@@ -266,7 +291,7 @@ export default function StickerPreviewModal({ lot, onClose }: { lot: LotDetail;
disabled={loading}
className="rounded-xl bg-emerald-600 px-5 py-2.5 text-sm font-bold text-white hover:bg-emerald-700 disabled:opacity-50 flex items-center gap-2"
>
{loading ? "Generating PDF..." : `🖨 Print ${copies === 1 ? "" : `${copies}× `}${stickerType === "field" ? "Field" : "Shed"} Sticker${copies > 1 ? "s" : ""}`}
{loading ? "Generating PDF..." : <><span className="inline-flex items-center gap-1.5">{Icons.printer("h-4 w-4")} Print {copies === 1 ? "" : `${copies}× `}{stickerType === "field" ? "Field" : "Shed"} Sticker{copies > 1 ? "s" : ""}</span></>}
</button>
</div>
</div>