Initial commit - Route Commerce platform

This commit is contained in:
2026-06-01 19:40:55 +00:00
commit 53a9671461
617 changed files with 106132 additions and 0 deletions
@@ -0,0 +1,143 @@
"use client";
import { useState } from "react";
import { searchHarvestLots, HarvestLot } from "@/actions/route-trace/lots";
import StatusBadge from "./StatusBadge";
import Link from "next/link";
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);
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);
setLoading(false);
}
function handleScanResult(lotNumber: string) {
setQuery(lotNumber);
setScanMode(false);
// Trigger search immediately
setLoading(true);
searchHarvestLots(brandId, lotNumber).then((res) => {
setResults(res.success ? res.lots : []);
setSearched(true);
setLoading(false);
});
}
return (
<div className="space-y-5">
{/* Search card */}
<div className="rounded-2xl border border-stone-200 bg-white shadow-sm">
<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>
</div>
<form onSubmit={handleSearch} className="flex gap-3 p-6">
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="e.g. TC-20260519-001 or Sweet Corn"
className="flex-1 rounded-xl border border-stone-200 bg-stone-50 px-4 py-3 text-base outline-none focus:border-stone-900 focus:bg-white transition-colors"
/>
<button
type="submit"
disabled={loading || !query.trim()}
className="rounded-xl bg-stone-900 px-5 py-3 text-sm font-bold text-white hover:bg-stone-800 disabled:opacity-50"
>
{loading ? "Searching..." : "🔍 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-stone-900 px-4 py-2 text-sm font-medium text-white hover:bg-stone-800"
>
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">
{results.length === 0 ? (
<div className="p-10 text-center">
<div className="text-3xl mb-3">🔍</div>
<p className="text-sm text-stone-500">No lots found for "{query}"</p>
</div>
) : (
<>
<div className="px-5 py-3 bg-stone-50 border-b border-stone-100">
<p className="text-xs font-semibold text-stone-500 uppercase tracking-wider">
{results.length} result{results.length !== 1 ? "s" : ""}
</p>
</div>
<table className="w-full">
<tbody>
{results.map((lot) => (
<tr key={lot.id} className="border-b border-stone-50 last:border-0 hover:bg-stone-50/50 transition-colors">
<td className="px-5 py-4">
<Link href={`/admin/route-trace/lots/${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-4 text-sm text-stone-700">{lot.crop_type}</td>
<td className="px-5 py-4 text-xs text-stone-400">{lot.harvest_date}</td>
<td className="px-5 py-4"><StatusBadge status={lot.status} /></td>
<td className="px-5 py-4 text-right">
<Link href={`/admin/route-trace/lots/${lot.id}`} className="text-sm font-semibold text-blue-600 hover:text-blue-800">
View
</Link>
</td>
</tr>
))}
</tbody>
</table>
</>
)}
</div>
)}
</div>
);
}
@@ -0,0 +1,91 @@
"use client";
import { useState } from "react";
type Props = {
brandId: string;
};
export default function FsmaReportModal({ brandId }: Props) {
const [open, setOpen] = useState(false);
const [startDate, setStartDate] = useState(() => {
const d = new Date();
d.setDate(d.getDate() - 30);
return d.toISOString().split("T")[0];
});
const [endDate, setEndDate] = useState(() => new Date().toISOString().split("T")[0]);
const [loading, setLoading] = useState(false);
async function handleDownload() {
setLoading(true);
try {
const url = `/api/route-trace/fsma-report?brandId=${brandId}&startDate=${startDate}&endDate=${endDate}&format=csv`;
window.location.href = url;
} finally {
setLoading(false);
setOpen(false);
}
}
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
</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>
<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>
<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-stone-900"
/>
</div>
<div>
<label className="block text-xs font-semibold text-stone-600 mb-1.5">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-stone-900"
/>
</div>
</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>
</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-stone-900 px-5 py-2 text-sm font-bold text-white hover:bg-stone-800 disabled:opacity-50 flex items-center gap-2"
>
{loading ? "Preparing..." : "📥 Download CSV"}
</button>
</div>
</div>
</div>
)}
</>
);
}
@@ -0,0 +1,291 @@
"use client";
import { useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { createHarvestLot } from "@/actions/route-trace/lots";
export default function LotCreateForm({ brandId }: { brandId: string }) {
const router = useRouter();
const [isPending, startTransition] = useTransition();
const [error, setError] = useState<string | null>(null);
const [notesOpen, setNotesOpen] = useState(false);
const [crop_type, setCropType] = useState("");
const [variety, setVariety] = useState("");
const [harvest_date, setHarvestDate] = useState(new Date().toISOString().split("T")[0]);
const [field_location, setFieldLocation] = useState("");
const [field_block, setFieldBlock] = useState("");
const [worker_name, setWorkerName] = useState("");
const [packer_name, setPackerName] = useState("");
const [quantity_lbs, setQuantityLbs] = useState("");
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 [pallets, setPallets] = useState("");
const [notes, setNotes] = useState("");
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError(null);
startTransition(async () => {
const result = await createHarvestLot(brandId, {
crop_type,
variety: variety || undefined,
harvest_date,
field_location: field_location || undefined,
worker_name: worker_name || undefined,
packer_name: packer_name || undefined,
quantity_lbs: quantity_lbs ? Number(quantity_lbs) : undefined,
notes: notes || undefined,
bin_id: bin_id || undefined,
container_id: container_id || undefined,
field_block: field_block || undefined,
yield_estimate_lbs: yield_estimate_lbs ? Number(yield_estimate_lbs) : undefined,
yield_unit: yield_unit === "custom" ? customYieldUnit.trim() || undefined : yield_unit,
pallets: pallets ? Number(pallets) : undefined,
});
if (result.success && result.lot) {
router.push(`/admin/route-trace/lots/${result.lot.id}`);
} else {
setError(result.error ?? "Failed to create lot");
}
});
}
return (
<div className="rounded-2xl border border-stone-200 bg-white shadow-sm">
<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>
</div>
<div>
<h2 className="text-lg font-bold text-stone-900">New Harvest Lot</h2>
<p className="text-sm text-stone-500">Quick entry scan or fill in the fields below</p>
</div>
</div>
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-5">
{error && (
<div className="rounded-xl bg-red-50 border border-red-200 p-4 text-sm text-red-700">{error}</div>
)}
{/* Required — large touch targets for field use */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-semibold text-stone-800 mb-2">Crop Type *</label>
<input
type="text"
value={crop_type}
onChange={(e) => setCropType(e.target.value)}
placeholder="e.g. Sweet Corn"
required
className="w-full rounded-xl border border-stone-200 bg-stone-50 px-4 py-4 text-lg font-medium outline-none focus:border-stone-900 focus:bg-white transition-colors"
/>
</div>
<div>
<label className="block text-sm font-semibold text-stone-800 mb-2">Variety</label>
<input
type="text"
value={variety}
onChange={(e) => setVariety(e.target.value)}
placeholder="e.g. Golden Bantam"
className="w-full rounded-xl border border-stone-200 bg-stone-50 px-4 py-4 text-lg font-medium outline-none focus:border-stone-900 focus:bg-white transition-colors"
/>
</div>
</div>
<div>
<label className="block text-sm font-semibold text-stone-800 mb-2">Harvest Date *</label>
<input
type="date"
value={harvest_date}
onChange={(e) => setHarvestDate(e.target.value)}
required
className="w-full rounded-xl border border-stone-200 bg-stone-50 px-4 py-4 text-lg font-medium outline-none focus:border-stone-900 focus:bg-white transition-colors"
/>
</div>
{/* Field & Location */}
<div className="border-t border-stone-100 pt-5">
<p className="text-xs font-semibold uppercase tracking-wider text-stone-400 mb-3">Field &amp; Location</p>
<div className="space-y-3">
<div>
<label className="block text-xs font-medium text-stone-600 mb-1.5">Field / Location *</label>
<input
type="text"
value={field_location}
onChange={(e) => setFieldLocation(e.target.value)}
placeholder="e.g. North Field"
required
className="w-full rounded-xl border border-stone-200 bg-stone-50 px-4 py-3.5 text-base outline-none focus:border-stone-900 focus:bg-white transition-colors"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-stone-600 mb-1.5">Field Block</label>
<input
type="text"
value={field_block}
onChange={(e) => setFieldBlock(e.target.value)}
placeholder="e.g. Block A"
className="w-full rounded-xl border border-stone-200 bg-stone-50 px-3 py-3 text-sm outline-none focus:border-stone-900 focus:bg-white transition-colors"
/>
</div>
<div>
<label className="block text-xs font-medium text-stone-600 mb-1.5">Worker / Harvest Lead</label>
<input
type="text"
value={worker_name}
onChange={(e) => setWorkerName(e.target.value)}
placeholder="e.g. Maria S."
className="w-full rounded-xl border border-stone-200 bg-stone-50 px-3 py-3 text-sm outline-none focus:border-stone-900 focus:bg-white transition-colors"
/>
</div>
</div>
</div>
</div>
{/* Quantity + Yield */}
<div className="border-t border-stone-100 pt-5">
<p className="text-xs font-semibold uppercase tracking-wider text-stone-400 mb-3">Weight &amp; Yield</p>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-stone-600 mb-1.5">Actual Qty</label>
<input
type="number"
value={quantity_lbs}
onChange={(e) => setQuantityLbs(e.target.value)}
placeholder="e.g. 4800"
min="0"
step="0.01"
className="w-full rounded-xl border border-stone-200 bg-stone-50 px-3 py-3.5 text-base outline-none focus:border-stone-900 focus:bg-white transition-colors"
/>
</div>
<div>
<label className="block text-xs font-medium text-stone-600 mb-1.5">Yield Est.</label>
<div className="flex rounded-xl border border-stone-200 bg-stone-50 overflow-hidden focus-within:border-stone-900 focus-within:bg-white transition-colors">
<input
type="number"
value={yield_estimate_lbs}
onChange={(e) => setYieldEstimateLbs(e.target.value)}
placeholder="e.g. 5000"
min="0"
step="0.01"
className="flex-1 px-3 py-3.5 text-base outline-none bg-transparent"
/>
<select
value={yield_unit}
onChange={(e) => setYieldUnit(e.target.value)}
className="px-2 py-3.5 text-sm font-semibold text-stone-600 bg-stone-100 outline-none border-l 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
type="text"
placeholder="Unit name…"
value={customYieldUnit}
onChange={(e) => setCustomYieldUnit(e.target.value)}
className="mt-1.5 w-full rounded-xl border border-stone-200 bg-stone-50 px-3 py-2 text-sm outline-none focus:border-stone-900 focus:bg-white transition-colors"
/>
)}
</div>
</div>
</div>
{/* Bin + Container */}
<div className="border-t border-stone-100 pt-5">
<p className="text-xs font-semibold uppercase tracking-wider text-stone-400 mb-3">Bin, Container &amp; Pallets</p>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-stone-600 mb-1.5">Bin ID</label>
<input
type="text"
value={bin_id}
onChange={(e) => setBinId(e.target.value)}
placeholder="e.g. BIN-001"
className="w-full rounded-xl border border-stone-200 bg-stone-50 px-3 py-3 text-sm outline-none focus:border-stone-900 focus:bg-white transition-colors"
/>
</div>
<div>
<label className="block text-xs font-medium text-stone-600 mb-1.5">Container ID</label>
<input
type="text"
value={container_id}
onChange={(e) => setContainerId(e.target.value)}
placeholder="e.g. CONT-A42"
className="w-full rounded-xl border border-stone-200 bg-stone-50 px-3 py-3 text-sm outline-none focus:border-stone-900 focus:bg-white transition-colors"
/>
</div>
</div>
<div className="mt-3 grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-stone-600 mb-1.5">Pallets</label>
<input
type="number"
value={pallets}
onChange={(e) => setPallets(e.target.value)}
placeholder="e.g. 4"
min="0"
className="w-full rounded-xl border border-stone-200 bg-stone-50 px-3 py-3 text-sm outline-none focus:border-stone-900 focus:bg-white transition-colors"
/>
</div>
<div>
<label className="block text-xs font-medium text-stone-600 mb-1.5">Packer Name</label>
<input
type="text"
value={packer_name}
onChange={(e) => setPackerName(e.target.value)}
placeholder="e.g. Jose R."
className="w-full rounded-xl border border-stone-200 bg-stone-50 px-3 py-3 text-sm outline-none focus:border-stone-900 focus:bg-white transition-colors"
/>
</div>
</div>
</div>
{/* Notes — collapsible */}
<div className="border-t border-stone-100 pt-5">
<button
type="button"
onClick={() => setNotesOpen(!notesOpen)}
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>
</button>
{notesOpen && (
<textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="Spray records, weather conditions, or other harvest details..."
rows={3}
className="mt-3 w-full rounded-xl border border-stone-200 bg-stone-50 px-3 py-3 text-sm outline-none focus:border-stone-900 focus:bg-white transition-colors resize-none"
/>
)}
</div>
{/* Save — big button */}
<div className="flex items-center justify-end border-t border-stone-100 pt-5">
<button
type="submit"
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"}
</button>
</div>
</form>
</div>
);
}
@@ -0,0 +1,550 @@
"use client";
import { useState, useTransition } from "react";
import Link from "next/link";
import StatusBadge from "./StatusBadge";
import StickerPreviewModal from "./StickerPreviewModal";
import { LotDetail, LotOrder, updateHarvestLotStatus } from "@/actions/route-trace/lots";
const STATUS_FLOW = ["active", "in_transit", "at_shed", "packed", "delivered"];
const EVENT_CONFIG: Record<string, { icon: string; bg: string; label: string }> = {
harvested: { icon: "🌱", bg: "bg-green-100 text-green-700", label: "Harvested" },
field_packed: { icon: "📦", bg: "bg-green-100 text-green-700", label: "Field Packed" },
bin_tagged: { icon: "🏷️", bg: "bg-amber-100 text-amber-700", label: "Bin Tagged" },
in_transit: { icon: "🚚", bg: "bg-amber-100 text-amber-700", label: "In Transit" },
at_shed: { icon: "🏭", bg: "bg-blue-100 text-blue-700", label: "At Shed" },
packed: { icon: "📋", bg: "bg-purple-100 text-purple-700", label: "Packed" },
delivered: { icon: "✅", bg: "bg-stone-100 text-stone-600", label: "Delivered" },
};
function CheckIcon({ className }: { className?: string }) {
return (
<svg className={className ?? "w-3.5 h-3.5"} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
);
}
export default function LotDetailPanel({
lot,
brandId,
orders = [],
}: {
lot: LotDetail;
brandId: string;
orders?: LotOrder[];
}) {
const [showSticker, setShowSticker] = useState(false);
const [showStatusModal, setShowStatusModal] = useState(false);
const [showBinModal, setShowBinModal] = useState(false);
const [showUsedModal, setShowUsedModal] = useState(false);
const [isPending, startTransition] = useTransition();
const [newStatus, setNewStatus] = useState("");
const [location, setLocation] = useState("");
const [notes, setNotes] = useState("");
const [binId, setBinId] = useState("");
const [usedOrderId, setUsedOrderId] = useState("");
const [usedQty, setUsedQty] = useState("");
const [usedNotes, setUsedNotes] = useState("");
const nextStatuses = STATUS_FLOW.slice(STATUS_FLOW.indexOf(lot.status) + 1);
const isInTransit = lot.status === "in_transit";
const canMarkLoaded = lot.status === "at_shed" || lot.status === "packed";
function handleUpdateStatus(e: React.FormEvent) {
e.preventDefault();
startTransition(async () => {
await updateHarvestLotStatus(lot.lot_id, newStatus, location || undefined, notes || undefined);
window.location.reload();
});
}
function handleAddBin(e: React.FormEvent) {
e.preventDefault();
startTransition(async () => {
await updateHarvestLotStatus(lot.lot_id, "bin_tagged", lot.field_location ?? undefined, notes || undefined, binId || undefined);
window.location.reload();
});
}
function handleMarkLoaded() {
startTransition(async () => {
await updateHarvestLotStatus(lot.lot_id, "in_transit", lot.field_location ?? undefined);
window.location.reload();
});
}
function handleMarkAtShed() {
startTransition(async () => {
await updateHarvestLotStatus(lot.lot_id, "at_shed", lot.field_location ?? undefined);
window.location.reload();
});
}
function handleMarkUsedInOrder(e: React.FormEvent) {
e.preventDefault();
if (!usedOrderId.trim()) return;
startTransition(async () => {
const { markLotUsedInOrder } = await import("@/actions/route-trace/lots");
await markLotUsedInOrder(
lot.lot_id,
usedOrderId.trim(),
usedQty ? Number(usedQty) : undefined,
usedNotes || undefined
);
window.location.reload();
});
}
return (
<div className="space-y-5">
{/* Header card */}
<div className="rounded-2xl border border-stone-200 bg-white shadow-sm">
<div className="flex items-start justify-between border-b border-stone-100 px-6 py-5">
<div>
<div className="flex items-center gap-3">
<h1 className="text-2xl font-black text-stone-900">{lot.lot_number}</h1>
<StatusBadge status={lot.status} />
</div>
<p className="mt-1.5 text-sm text-stone-500">
{lot.crop_type}{lot.variety ? ` · ${lot.variety}` : ""} · Harvested {lot.harvest_date}
</p>
</div>
<div className="flex gap-2 flex-wrap flex-col items-end">
{(isInTransit || canMarkLoaded) && (
<div className="flex gap-2 mb-1">
{isInTransit && (
<button
onClick={handleMarkAtShed}
disabled={isPending}
className="rounded-xl border-2 border-blue-300 bg-blue-50 px-4 py-2.5 text-sm font-bold text-blue-700 hover:bg-blue-100 transition-colors disabled:opacity-50 flex items-center gap-1.5 shadow-sm"
>
{isPending ? "..." : "🏭"} Mark at Shed
</button>
)}
{canMarkLoaded && (
<button
onClick={handleMarkLoaded}
disabled={isPending}
className="rounded-xl border-2 border-amber-300 bg-amber-50 px-4 py-2.5 text-sm font-bold text-amber-700 hover:bg-amber-100 transition-colors disabled:opacity-50 flex items-center gap-1.5 shadow-sm"
>
{isPending ? "..." : "🚚"} Mark Loaded
</button>
)}
</div>
)}
<div className="flex gap-2">
<button
onClick={() => setShowSticker(true)}
className="rounded-xl border border-stone-200 px-4 py-2.5 text-sm font-semibold text-stone-600 hover:bg-stone-50 transition-colors"
>
🖨 Print Sticker
</button>
{nextStatuses.length > 0 && (
<button
onClick={() => { setNewStatus(nextStatuses[0]); setShowStatusModal(true); }}
className="rounded-xl bg-blue-600 px-4 py-2.5 text-sm font-semibold text-white hover:bg-blue-700 transition-colors"
>
Update Status
</button>
)}
</div>
</div>
</div>
{/* Info grid */}
<div className="grid grid-cols-2 md:grid-cols-4">
{[
{ label: "Field / Location", value: lot.field_location ?? "—" },
{ label: "Field Block", value: lot.field_block ?? "—" },
{ label: "Worker", value: lot.worker_name ?? "—" },
{ label: "Packer", value: lot.packer_name ?? "—" },
{ label: "Quantity", value: lot.quantity_lbs != null ? `${lot.quantity_lbs.toLocaleString()} ${lot.yield_unit ?? "lbs"}` : "—" },
{ label: "Yield Est.", value: lot.yield_estimate_lbs != null ? `${lot.yield_estimate_lbs.toLocaleString()} ${lot.yield_unit ?? "lbs"}` : "—" },
{ label: "Pallets", value: lot.pallets != null ? String(lot.pallets) : "—" },
{ label: "Bin ID", value: lot.bin_id ?? "—" },
{ label: "Container ID", value: lot.container_id ?? "—" },
{ label: "Variety", value: lot.variety ?? "—" },
].map((item) => (
<div key={item.label} className="border-r border-b border-stone-100 px-5 py-4 last:border-r-0 even:right-0">
<p className="text-xs font-medium text-stone-400 uppercase tracking-wide">{item.label}</p>
<p className="mt-1 text-sm font-bold text-stone-800">{item.value}</p>
</div>
))}
</div>
{lot.notes && (
<div className="border-t border-stone-100 px-5 py-4">
<p className="text-xs font-medium text-stone-400 uppercase tracking-wide mb-1">Notes</p>
<p className="text-sm text-stone-600">{lot.notes}</p>
</div>
)}
{/* Quick actions */}
<div className="flex gap-3 border-t border-stone-100 px-6 py-4 flex-wrap">
<button
onClick={() => setShowBinModal(true)}
className="rounded-xl border border-amber-200 bg-white px-4 py-2 text-sm font-semibold text-amber-700 hover:bg-amber-50 transition-colors"
>
🏷 Add Bin / Tag
</button>
<a
href={`/api/route-trace/trace-report?lotId=${lot.lot_id}&format=csv`}
download
className="rounded-xl border border-stone-200 bg-white px-4 py-2 text-sm font-semibold text-stone-600 hover:bg-stone-50 transition-colors flex items-center gap-1.5"
>
📄 Download Report
</a>
<Link
href={`/trace/${lot.lot_number}`}
target="_blank"
className="rounded-xl border border-stone-200 bg-white px-4 py-2 text-sm font-semibold text-stone-600 hover:bg-stone-50 transition-colors"
>
🔍 View Public Trace
</Link>
</div>
</div>
{/* Fulfilled orders */}
<div className="rounded-2xl border border-stone-200 bg-white shadow-sm overflow-hidden">
<div className="border-b border-stone-100 px-6 py-4 bg-stone-50/50 flex items-center justify-between">
<div className="flex items-center gap-2">
<span>📦</span>
<h2 className="text-base font-semibold text-stone-900">Order Fulfillment</h2>
</div>
{orders.length > 0 && (
<div className="flex items-center gap-2">
<span className="rounded-full bg-green-100 text-green-700 text-xs font-bold px-2.5 py-0.5">
{orders.length} {orders.length === 1 ? "order" : "orders"}
</span>
<span className="text-xs text-stone-400">
{orders.reduce((sum, o) => sum + Number(o.item_quantity ?? 0), 0).toLocaleString()} lbs total
</span>
</div>
)}
</div>
{/* Used / Remaining balance */}
{lot.quantity_lbs != null && lot.quantity_lbs > 0 && (
<div className="px-6 py-4 border-b border-stone-100 bg-gradient-to-r from-stone-50 to-white">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-stone-700">Lot Balance</span>
{lot.quantity_used_lbs != null && lot.quantity_used_lbs > 0 ? (
<span className="rounded-full bg-amber-100 text-amber-700 text-xs font-bold px-2 py-0.5">
Partially Used
</span>
) : (
<span className="rounded-full bg-green-100 text-green-700 text-xs font-bold px-2 py-0.5">
Fully Available
</span>
)}
</div>
<div className="flex items-center gap-3 text-sm">
{lot.quantity_used_lbs != null && lot.quantity_used_lbs > 0 && (
<span className="text-stone-500">
<span className="font-bold text-stone-800">{Number(lot.quantity_used_lbs).toLocaleString()}</span> lbs used
</span>
)}
<span className="font-bold text-stone-800">
{Math.max(0, Number(lot.quantity_lbs) - Number(lot.quantity_used_lbs ?? 0)).toLocaleString()}
</span>
<span className="text-stone-400">lbs remaining</span>
</div>
</div>
<div className="w-full bg-stone-100 rounded-full h-3 overflow-hidden">
<div
className="h-full rounded-full transition-all duration-500"
style={{
width: `${Math.min(100, ((Number(lot.quantity_used_lbs ?? 0) / Number(lot.quantity_lbs)) * 100))}%`,
background: Number(lot.quantity_used_lbs ?? 0) > 0
? "linear-gradient(90deg, #f59e0b 0%, #d97706 100%)"
: "linear-gradient(90deg, #22c55e 0%, #16a34a 100%)",
}}
/>
</div>
</div>
)}
{orders.length === 0 ? (
<div className="px-6 py-5 text-center">
<p className="text-sm text-stone-400">This lot has not been assigned to any orders yet.</p>
<button
onClick={() => setShowUsedModal(true)}
className="mt-3 rounded-xl bg-stone-100 px-4 py-2 text-sm font-semibold text-stone-600 hover:bg-stone-200 transition-colors"
>
Mark as Used in Order
</button>
</div>
) : (
<div className="p-5 space-y-3">
{orders.map((order) => (
<Link
key={order.id}
href={`/admin/orders/${order.id}`}
className="flex items-center justify-between rounded-xl border border-stone-100 bg-stone-50/50 px-5 py-4 hover:bg-stone-100/60 transition-colors"
>
<div className="flex items-center gap-4">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-green-100 flex-shrink-0">
<span className="text-base">📦</span>
</div>
<div>
<p className="text-sm font-bold text-stone-900">{order.customer_name}</p>
<p className="text-xs text-stone-400 mt-0.5">{order.stop_name} · {order.order_date}</p>
</div>
</div>
<div className="text-right flex items-center gap-3">
{order.item_quantity != null && (
<div>
<p className="text-lg font-black text-stone-900">{Number(order.item_quantity).toLocaleString()}</p>
<p className="text-xs text-stone-400">lbs allocated</p>
</div>
)}
{order.lot_quantity_used != null && order.lot_quantity_used > 0 && (
<div className="border-l border-stone-200 pl-3">
<p className="text-lg font-black text-amber-700">{Number(order.lot_quantity_used).toLocaleString()}</p>
<p className="text-xs text-stone-400">lbs used</p>
</div>
)}
<span className={`flex items-center gap-1 rounded-full px-2.5 py-1 text-xs font-bold ${
order.fulfillment === "pickup" ? "bg-blue-50 text-blue-600" :
order.fulfillment === "ship" ? "bg-green-50 text-green-600" :
"bg-stone-100 text-stone-500"
}`}>
{order.fulfillment ?? "mixed"}
</span>
</div>
</Link>
))}
<button
onClick={() => setShowUsedModal(true)}
className="w-full mt-2 rounded-xl border border-dashed border-stone-300 py-2.5 text-xs font-semibold text-stone-500 hover:bg-stone-50 transition-colors"
>
+ Mark as Used in Another Order
</button>
</div>
)}
</div>
{/* Event timeline */}
<div className="rounded-2xl border border-stone-200 bg-white shadow-sm overflow-hidden">
<div className="border-b border-stone-100 px-6 py-4 bg-stone-50/50">
<h2 className="text-base font-semibold text-stone-900">Trace Timeline</h2>
<p className="mt-0.5 text-xs text-stone-400">Complete history field to delivery</p>
</div>
{lot.events.length === 0 ? (
<div className="p-8 text-center text-sm text-stone-400">No events recorded yet.</div>
) : (
<div className="p-6">
<div className="relative">
{lot.events.map((event, idx) => {
const cfg = EVENT_CONFIG[event.event_type] ?? { icon: "📋", bg: "bg-stone-100 text-stone-600", label: event.event_type };
const isLast = idx === lot.events.length - 1;
return (
<div key={event.id} className="flex gap-4">
{/* Connector line */}
{!isLast && (
<div className="absolute left-[18px] top-9 w-px bg-stone-200" style={{ height: `calc(100% - 36px)` }} />
)}
<div className="relative flex flex-col items-center">
<div className={`flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-full text-base ring-2 ring-white ${cfg.bg}`}>
{cfg.icon}
</div>
</div>
<div className={`pb-6 pt-0.5 ${isLast ? "!pb-0" : ""}`}>
<div className="flex items-baseline gap-2">
<span className="text-sm font-semibold text-stone-800">{cfg.label}</span>
<span className="text-xs text-stone-400">
{new Date(event.event_time).toLocaleDateString("en-US", { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" })}
</span>
</div>
{event.location && (
<p className="mt-0.5 text-xs text-stone-500">📍 {event.location}</p>
)}
{event.bin_id && (
<p className="mt-0.5 text-xs font-bold text-amber-700">🏷 Bin: {event.bin_id}</p>
)}
{event.notes && (
<p className="mt-0.5 text-xs text-stone-400 italic">{event.notes}</p>
)}
{event.created_by_name && (
<p className="mt-0.5 text-xs text-stone-300">by {event.created_by_name}</p>
)}
</div>
</div>
);
})}
</div>
</div>
)}
</div>
{/* Status update modal */}
{showStatusModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<div className="w-full max-w-md rounded-2xl bg-white shadow-xl">
<form onSubmit={handleUpdateStatus}>
<div className="border-b border-stone-100 px-6 py-4">
<h3 className="text-base font-semibold text-stone-900">Update Status</h3>
</div>
<div className="space-y-4 p-6">
<div>
<label className="block text-sm font-semibold text-stone-700 mb-1.5">New Status</label>
<select
value={newStatus}
onChange={(e) => setNewStatus(e.target.value)}
required
className="w-full rounded-xl border border-stone-200 bg-stone-50 px-3 py-3 text-sm outline-none focus:border-stone-900"
>
<option value="">Select status...</option>
{STATUS_FLOW.filter((s) => s !== lot.status).map((s) => {
const c = EVENT_CONFIG[s];
return (
<option key={s} value={s}>
{c?.icon} {c?.label ?? s}
</option>
);
})}
</select>
</div>
<div>
<label className="block text-sm font-semibold text-stone-700 mb-1.5">Location</label>
<input
type="text"
value={location}
onChange={(e) => setLocation(e.target.value)}
placeholder="e.g. Warehouse A"
className="w-full rounded-xl border border-stone-200 bg-stone-50 px-3 py-3 text-sm outline-none focus:border-stone-900"
/>
</div>
<div>
<label className="block text-sm font-semibold text-stone-700 mb-1.5">Notes</label>
<textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="Any notes..."
rows={2}
className="w-full rounded-xl border border-stone-200 bg-stone-50 px-3 py-3 text-sm outline-none focus:border-stone-900 resize-none"
/>
</div>
</div>
<div className="flex justify-end gap-3 border-t border-stone-100 px-6 py-4">
<button type="button" onClick={() => setShowStatusModal(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 type="submit" disabled={isPending || !newStatus} className="rounded-xl bg-blue-600 px-4 py-2 text-sm font-semibold text-white hover:bg-blue-700 disabled:opacity-50">
{isPending ? "Saving..." : "Save"}
</button>
</div>
</form>
</div>
</div>
)}
{/* Add Bin modal */}
{showBinModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<div className="w-full max-w-md rounded-2xl bg-white shadow-xl">
<form onSubmit={handleAddBin}>
<div className="border-b border-stone-100 px-6 py-4">
<h3 className="text-base font-semibold text-stone-900">🏷 Add Bin / Tag</h3>
<p className="mt-0.5 text-xs text-stone-400">Log a bin or pallet tag event for this lot</p>
</div>
<div className="space-y-4 p-6">
<div>
<label className="block text-sm font-semibold text-stone-700 mb-1.5">Bin or Container ID *</label>
<input
type="text"
value={binId}
onChange={(e) => setBinId(e.target.value)}
placeholder="e.g. BIN-042"
required
className="w-full rounded-xl border border-stone-200 bg-stone-50 px-3 py-3 text-sm outline-none focus:border-stone-900"
/>
</div>
<div>
<label className="block text-sm font-semibold text-stone-700 mb-1.5">Notes</label>
<textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="Weight, count, or other details..."
rows={2}
className="w-full rounded-xl border border-stone-200 bg-stone-50 px-3 py-3 text-sm outline-none focus:border-stone-900 resize-none"
/>
</div>
</div>
<div className="flex justify-end gap-3 border-t border-stone-100 px-6 py-4">
<button type="button" onClick={() => setShowBinModal(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 type="submit" disabled={isPending || !binId} className="rounded-xl bg-amber-600 px-4 py-2 text-sm font-semibold text-white hover:bg-amber-700 disabled:opacity-50">
{isPending ? "Adding..." : "Add Bin Tag"}
</button>
</div>
</form>
</div>
</div>
)}
{showSticker && <StickerPreviewModal lot={lot} onClose={() => setShowSticker(false)} />}
{/* Mark as Used in Order modal */}
{showUsedModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<div className="w-full max-w-md rounded-2xl bg-white shadow-xl">
<form onSubmit={handleMarkUsedInOrder}>
<div className="border-b border-stone-100 px-6 py-4">
<h3 className="text-base font-semibold text-stone-900">📦 Mark as Used in Order</h3>
<p className="mt-0.5 text-xs text-stone-400">Record which order this lot was assigned to</p>
</div>
<div className="space-y-4 p-6">
<div>
<label className="block text-sm font-semibold text-stone-700 mb-1.5">Order ID *</label>
<input
type="text"
value={usedOrderId}
onChange={(e) => setUsedOrderId(e.target.value.toUpperCase())}
placeholder="e.g. 4f3e2a1b-..."
required
className="w-full rounded-xl border border-stone-200 bg-stone-50 px-3 py-3 text-sm font-mono outline-none focus:border-stone-900"
/>
</div>
<div>
<label className="block text-sm font-semibold text-stone-700 mb-1.5">Quantity Used (lbs)</label>
<input
type="number"
value={usedQty}
onChange={(e) => setUsedQty(e.target.value)}
placeholder="Leave blank to auto-detect from order items"
min="0"
className="w-full rounded-xl border border-stone-200 bg-stone-50 px-3 py-3 text-sm outline-none focus:border-stone-900"
/>
</div>
<div>
<label className="block text-sm font-semibold text-stone-700 mb-1.5">Notes</label>
<textarea
value={usedNotes}
onChange={(e) => setUsedNotes(e.target.value)}
placeholder="Any notes..."
rows={2}
className="w-full rounded-xl border border-stone-200 bg-stone-50 px-3 py-3 text-sm outline-none focus:border-stone-900 resize-none"
/>
</div>
</div>
<div className="flex justify-end gap-3 border-t border-stone-100 px-6 py-4">
<button type="button" onClick={() => setShowUsedModal(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 type="submit" disabled={isPending || !usedOrderId.trim()} className="rounded-xl bg-stone-900 px-4 py-2 text-sm font-semibold text-white hover:bg-stone-800 disabled:opacity-50">
{isPending ? "Saving..." : "Mark as Used"}
</button>
</div>
</form>
</div>
</div>
)}
</div>
);
}
+290
View File
@@ -0,0 +1,290 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import StatusBadge from "./StatusBadge";
import { HarvestLot, updateHarvestLotStatus } from "@/actions/route-trace/lots";
const STATUS_FILTERS = [
{ value: "", label: "All" },
{ value: "active", label: "Active" },
{ value: "in_transit", label: "In Transit" },
{ value: "at_shed", label: "At Shed" },
{ value: "packed", label: "Packed" },
{ value: "delivered", label: "Delivered" },
];
function getAgeStatus(harvestDate: string): { label: string; className: string; days: number } | 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", days };
if (days <= 7) return { label: `${days}d`, className: "bg-amber-100 text-amber-700", days };
if (days <= 14) return { label: `${days}d`, className: "bg-orange-100 text-orange-700", days };
return { label: `${days}d`, className: "bg-red-100 text-red-700", days };
}
export default function LotListTable({
initialLots,
brandId,
}: {
initialLots: HarvestLot[];
brandId: string;
}) {
const [filter, setFilter] = useState("");
const [query, setQuery] = useState("");
const [selected, setSelected] = useState<Set<string>>(new Set());
const [showBulk, setShowBulk] = useState(false);
const filtered = initialLots.filter((lot) => {
if (filter && lot.status !== filter) return false;
if (query) {
const q = query.toLowerCase();
if (
!lot.lot_number.toLowerCase().includes(q) &&
!lot.crop_type.toLowerCase().includes(q) &&
!(lot.field_location ?? "").toLowerCase().includes(q) &&
!(lot.bin_id ?? "").toLowerCase().includes(q)
)
return false;
}
return true;
});
function toggleSelect(id: string) {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}
function toggleSelectAll() {
if (selected.size === filtered.length) {
setSelected(new Set());
} else {
setSelected(new Set(filtered.map((l) => l.id)));
}
}
async function handleBulkStickers(type: "field" | "shed") {
if (selected.size === 0) return;
const ids = [...selected];
for (const id of ids) {
window.open(`/api/route-trace/sticker-pdf?lotId=${id}&type=${type}&size=4x2&copies=1`, "_blank");
}
}
function handleBulkExport() {
if (selected.size === 0) return;
const ids = [...selected];
ids.forEach((id) => {
window.location.href = `/api/route-trace/trace-report?lotId=${id}&format=csv`;
});
}
function handleBulkMarkUsed() {
if (selected.size === 0) return;
const ids = [...selected];
if (ids.length === 1) {
window.location.href = `/admin/route-trace/lots/${ids[0]}`;
} else {
window.location.href = `/admin/route-trace/lots?bulk_mark=${ids.join(",")}`;
}
}
async function handleBulkMarkLoaded() {
if (selected.size === 0) return;
const ids = [...selected];
for (const id of ids) {
await updateHarvestLotStatus(id, "in_transit");
}
window.location.reload();
}
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) => (
<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-stone-900 text-white"
: "text-stone-600 hover:bg-stone-50"
}`}
>
{f.label}
</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-stone-900"
/>
<button
onClick={() => { setShowBulk(!showBulk); setSelected(new Set()); }}
className={`rounded-xl border px-4 py-2.5 text-sm font-semibold transition-colors ${
showBulk ? "border-stone-900 bg-stone-900 text-white" : "border-stone-200 text-stone-600 hover:bg-stone-50"
}`}
>
Bulk {showBulk ? "On" : "Off"}
</button>
</div>
</div>
{/* Bulk action bar */}
{selected.size > 0 && (
<div className="rounded-2xl border-2 border-stone-900 bg-stone-900 px-5 py-3 flex items-center justify-between">
<span className="text-sm font-bold text-white">{selected.size} lot{selected.size !== 1 ? "s" : ""} selected</span>
<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"
>
🚚 Mark Loaded
</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"
>
📦 Mark as Used
</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"
>
🖨 Field Stickers
</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"
>
🖨 Shed Stickers
</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"
>
📄 {selected.size === 1 ? "Report" : `${selected.size} Reports`}
</button>
<button
onClick={() => setSelected(new Set())}
className="rounded-xl border border-white/30 px-4 py-2 text-sm font-medium text-white/70 hover:text-white transition-colors"
>
Clear
</button>
</div>
</div>
)}
{/* 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>
) : (
<div className="overflow-hidden rounded-2xl border border-stone-200 bg-white shadow-sm">
<table className="w-full">
<thead>
<tr className="border-b border-stone-100 bg-stone-50">
{showBulk && (
<th className="px-5 py-3.5 w-10">
<input
type="checkbox"
checked={selected.size === filtered.length && filtered.length > 0}
onChange={toggleSelectAll}
className="h-4 w-4 rounded border-stone-300"
/>
</th>
)}
{["Lot #", "Crop / Variety", "Harvest Date", "Age", "Field", "Bins", "Qty", "Status"].map((h) => (
<th key={h} className="px-5 py-3.5 text-left text-xs font-semibold text-stone-400 uppercase tracking-wider">
{h}
</th>
))}
<th className="px-5 py-3.5" />
</tr>
</thead>
<tbody>
{filtered.map((lot) => (
<tr key={lot.id} className={`border-b border-stone-50 last:border-0 hover:bg-stone-50/50 transition-colors ${selected.has(lot.id) ? "bg-green-50" : ""}`}>
{showBulk && (
<td className="px-5 py-4">
<input
type="checkbox"
checked={selected.has(lot.id)}
onChange={() => toggleSelect(lot.id)}
className="h-4 w-4 rounded border-stone-300"
/>
</td>
)}
<td className="px-5 py-4">
<Link href={`/admin/route-trace/lots/${lot.id}`} className="font-mono text-sm font-black text-stone-900 hover:text-blue-600">
{lot.lot_number}
</Link>
</td>
<td className="px-5 py-4">
<div className="text-sm font-semibold text-stone-700">{lot.crop_type}</div>
{lot.variety && <div className="text-xs text-stone-400">{lot.variety}</div>}
</td>
<td className="px-5 py-4">
{(() => {
const age = getAgeStatus(lot.harvest_date);
return age ? (
<span className={`inline-flex rounded-full px-2 py-0.5 text-xs font-bold ${age.className}`}>
{age.label}
</span>
) : (
<span className="text-stone-300 text-xs"></span>
);
})()}
</td>
<td className="px-5 py-4">
<div className="text-sm text-stone-700">{lot.field_location ?? "—"}</div>
{lot.field_block && <div className="text-xs text-stone-400">Block: {lot.field_block}</div>}
</td>
<td className="px-5 py-4">
<div className="flex gap-1 flex-wrap">
{lot.bin_id && (
<span className="inline-flex rounded-full bg-amber-100 px-2 py-0.5 text-xs font-bold text-amber-700">
{lot.bin_id}
</span>
)}
{lot.pallets != null && (
<span className="inline-flex rounded-full bg-stone-100 px-2 py-0.5 text-xs font-medium text-stone-600">
{lot.pallets} PLT
</span>
)}
{!lot.bin_id && lot.pallets == null && <span className="text-stone-300 text-xs"></span>}
</div>
</td>
<td className="px-5 py-4 text-sm text-stone-600">
{lot.quantity_lbs != null ? `${lot.quantity_lbs.toLocaleString()} ${lot.yield_unit ?? "lbs"}` : "—"}
</td>
<td className="px-5 py-4">
<StatusBadge status={lot.status} />
</td>
<td className="px-5 py-4 text-right">
<Link href={`/admin/route-trace/lots/${lot.id}`} className="text-sm font-semibold text-blue-600 hover:text-blue-800">
View
</Link>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}
@@ -0,0 +1,12 @@
"use client";
import { useState } from "react";
import ShareTraceButton from "@/components/route-trace/ShareTraceButton";
export default function PublicTraceActions({ lotNumber }: { lotNumber: string }) {
return (
<div className="flex items-center justify-center gap-2 mb-4">
<ShareTraceButton lotNumber={lotNumber} />
</div>
);
}
+261
View File
@@ -0,0 +1,261 @@
"use client";
// BarcodeDetector is not yet in TypeScript's lib — declare it inline
declare global {
interface Window {
BarcodeDetector?: new (options: { formats: string[] }) => {
detect(source: HTMLVideoElement | HTMLCanvasElement | ImageBitmap): Promise<Array<{ rawValue: string }>>;
};
}
}
import { useState, useEffect, useRef } from "react";
import { useRouter } from "next/navigation";
type ScanMode = "camera" | "manual";
export default function QRScanModal({ onClose }: { onClose: () => void }) {
const router = useRouter();
const [mode, setMode] = useState<ScanMode>("camera");
const [manualInput, setManualInput] = useState("");
const [cameraError, setCameraError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [cameraStarting, setCameraStarting] = useState(true);
const [detected, setDetected] = useState(false);
const [scanSuccess, setScanSuccess] = useState(false);
const videoRef = useRef<HTMLVideoElement>(null);
const streamRef = useRef<MediaStream | null>(null);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const detectorRef = useRef<any>(null);
const scanRef = useRef<number>(0);
// Start camera on mount for camera mode
useEffect(() => {
if (mode !== "camera") return;
let mounted = true;
async function startCamera() {
setCameraStarting(true);
setCameraError(null);
// Check for BarcodeDetector support
if (!window.BarcodeDetector) {
try {
const stream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: "environment", width: { ideal: 1280 }, height: { ideal: 720 } },
});
if (!mounted) { stream.getTracks().forEach(t => t.stop()); return; }
streamRef.current = stream;
if (videoRef.current) {
videoRef.current.srcObject = stream;
await videoRef.current.play();
}
setCameraStarting(false);
} catch (err) {
if (!mounted) return;
const msg = err instanceof DOMException && err.name === "NotAllowedError"
? "Camera access denied. Please allow camera permission."
: err instanceof DOMException && err.name === "NotFoundError"
? "No camera found. Use manual entry."
: "Camera not available. Use manual entry.";
setCameraError(msg);
setCameraStarting(false);
setTimeout(() => { if (mounted) setMode("manual"); }, 800);
}
return;
}
try {
const stream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: "environment", width: { ideal: 1280 }, height: { ideal: 720 } },
});
if (!mounted) { stream.getTracks().forEach(t => t.stop()); return; }
streamRef.current = stream;
if (videoRef.current) {
videoRef.current.srcObject = stream;
await videoRef.current.play();
}
setCameraStarting(false);
} catch (err) {
if (!mounted) return;
const msg = err instanceof DOMException && err.name === "NotAllowedError"
? "Camera access denied. Please allow camera permission."
: err instanceof DOMException && err.name === "NotFoundError"
? "No camera found. Use manual entry."
: "Camera not available. Use manual entry.";
setCameraError(msg);
setCameraStarting(false);
setTimeout(() => { if (mounted) setMode("manual"); }, 800);
return;
}
// Initialize BarcodeDetector
try {
detectorRef.current = new window.BarcodeDetector({ formats: ["qr_code"] });
} catch {
detectorRef.current = null;
}
// Scan loop
const scan = async () => {
if (!mounted || detected || !detectorRef.current || !videoRef.current) return;
if (videoRef.current.readyState < 2) {
scanRef.current = requestAnimationFrame(scan);
return;
}
try {
const barcodes = await detectorRef.current.detect(videoRef.current);
if (barcodes.length > 0 && !detected) {
setDetected(true);
setScanSuccess(true);
const raw = barcodes[0].rawValue;
if (streamRef.current) streamRef.current.getTracks().forEach(t => t.stop());
setTimeout(() => {
onClose();
router.push(`/trace/${encodeURIComponent(raw)}`);
}, 800);
return;
}
} catch {
// Ignore detection errors
}
scanRef.current = requestAnimationFrame(scan);
};
scanRef.current = requestAnimationFrame(scan);
}
startCamera();
return () => {
mounted = false;
if (scanRef.current) cancelAnimationFrame(scanRef.current);
if (streamRef.current) streamRef.current.getTracks().forEach((t) => t.stop());
};
}, [mode, detected, onClose, router]);
function handleManualSubmit(e: React.FormEvent) {
e.preventDefault();
const trimmed = manualInput.trim();
if (!trimmed) return;
setIsLoading(true);
onClose();
router.push(`/trace/${encodeURIComponent(trimmed)}`);
}
function handleClose() {
if (scanRef.current) cancelAnimationFrame(scanRef.current);
if (streamRef.current) streamRef.current.getTracks().forEach((t) => t.stop());
onClose();
}
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()}>
{/* 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-stone-900 border-b-2 border-stone-900" : "text-stone-400"
}`}
>
📷 Camera
</button>
<button
onClick={() => setMode("manual")}
className={`flex-1 py-3 text-sm font-semibold transition-colors ${
mode === "manual" ? "text-stone-900 border-b-2 border-stone-900" : "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>
)}
</>
)}
</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-stone-900"
/>
<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-900 py-3.5 text-base font-bold text-white hover:bg-stone-800 disabled:opacity-50 transition-colors"
>
{isLoading ? "Loading..." : "🔍 Trace Lot"}
</button>
</form>
)}
</div>
</div>
</div>
);
}
@@ -0,0 +1,405 @@
"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,660 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import RouteTraceNav from "./RouteTraceNav";
import QRScanModal from "./QRScanModal";
import QuickNewLotDrawer from "./QuickNewLotDrawer";
import FsmaReportModal from "./FsmaReportModal";
import {
RouteTraceStats,
HaulingLot,
FieldYieldSummary,
InventoryByCrop,
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" },
};
const EVENT_ICON_SHORT: Record<string, string> = {
harvested: "🌱",
field_packed: "📦",
bin_tagged: "🏷",
in_transit: "🚚",
at_shed: "🏭",
packed: "📋",
delivered: "✅",
marked_used: "📋",
};
const EVENT_LABEL: Record<string, string> = {
harvested: "Harvested",
field_packed: "Field Packed",
bin_tagged: "Bin Tagged",
in_transit: "In Transit",
at_shed: "Received at Shed",
packed: "Packed",
delivered: "Delivered",
marked_used: "Used in Order",
};
type LotWithAction = HaulingLot & { suggestedAction: string; suggestedIcon: string };
function buildHaulingBoard(lots: HaulingLot[]): Record<string, LotWithAction[]> {
const board: Record<string, LotWithAction[]> = {
in_transit: [],
at_shed: [],
packed: [],
};
for (const lot of lots) {
let suggestedAction = "";
let suggestedIcon = "";
if (lot.status === "active" || lot.status === "in_transit") {
suggestedAction = "Mark Loaded";
suggestedIcon = "🚚";
} else if (lot.status === "at_shed") {
suggestedAction = "Mark Received";
suggestedIcon = "🏭";
} else if (lot.status === "packed") {
suggestedAction = "Mark Delivered";
suggestedIcon = "✅";
}
const entry: LotWithAction = { ...lot, suggestedAction, suggestedIcon };
if (board[lot.status]) {
board[lot.status].push(entry);
}
}
return board;
}
// ── Activity Feed ────────────────────────────────────────────────────────────────
function timeAgo(dateStr: string): string {
const diff = Date.now() - new Date(dateStr).getTime();
const mins = Math.floor(diff / 60000);
if (mins < 1) return "just now";
if (mins < 60) return `${mins}m ago`;
const hrs = Math.floor(mins / 60);
if (hrs < 24) return `${hrs}h ago`;
return `${Math.floor(hrs / 24)}d ago`;
}
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="flex items-center gap-2">
<span className="text-sm"></span>
<h2 className="text-sm font-semibold text-stone-900">Recent Activity</h2>
</div>
<span className="text-[10px] text-stone-400 font-medium">Last 10 events</span>
</div>
<div className="divide-y divide-stone-50">
{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 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 === "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] ?? "📋"}
</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">
{EVENT_LABEL[evt.event_type] ?? evt.event_type}
</p>
<span className="text-[10px] text-stone-400 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>
<Link
href={`/admin/route-trace/lots/${evt.lot_id}`}
className="text-xs font-mono font-bold text-blue-600 hover:text-blue-800"
>
{evt.lot_number}
</Link>
{evt.location && (
<>
<span className="text-stone-300">·</span>
<span className="text-xs text-stone-400 truncate">{evt.location}</span>
</>
)}
</div>
</div>
</div>
))}
</div>
</div>
);
}
// ── Hauling Column ─────────────────────────────────────────────────────────────
function HaulingColumn({
title,
icon,
bgHeader,
lots,
emptyMessage,
}: {
title: string;
icon: string;
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">
{lots.length}
</span>
</div>
{lots.length === 0 ? (
<div className="p-5 text-center text-xs text-stone-400">{emptyMessage}</div>
) : (
<div className="divide-y divide-stone-100">
{lots.map((lot) => (
<div key={lot.lot_id} className="px-4 py-3 hover:bg-stone-50/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"
>
{lot.lot_number}
</Link>
<p className="text-xs text-stone-500 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">
{lot.bin_id && (
<span className="inline-flex rounded-full bg-amber-50 text-amber-700 text-[10px] font-bold px-1.5 py-0.5">
BIN {lot.bin_id}
</span>
)}
{lot.pallets && (
<span className="inline-flex text-[10px] text-stone-400 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">
{Number(lot.quantity_lbs).toLocaleString()}
<span className="text-[10px] text-stone-400 font-normal ml-0.5">{lot.yield_unit ?? "lbs"}</span>
</p>
)}
</div>
</div>
{lot.suggestedAction && (
<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"
>
{lot.suggestedIcon} {lot.suggestedAction}
</Link>
</div>
)}
{lot.destination_stop_name && (
<p className="mt-1 text-[10px] text-stone-400"> {lot.destination_stop_name}</p>
)}
</div>
))}
</div>
)}
</div>
);
}
// ── Inventory by Crop ──────────────────────────────────────────────────────────
function InventoryByCropSection({ inventoryByCrop }: { inventoryByCrop: InventoryByCrop[] }) {
if (inventoryByCrop.length === 0) return null;
const byCrop: Record<string, InventoryByCrop[]> = {};
for (const row of inventoryByCrop) {
if (!byCrop[row.crop_type]) byCrop[row.crop_type] = [];
byCrop[row.crop_type].push(row);
}
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="flex items-center gap-2">
<span>📦</span>
<h2 className="text-sm font-semibold text-stone-900">Inventory by Crop</h2>
</div>
<span className="text-[10px] text-stone-400">Harvested · In Transit · At Shed · Packed</span>
</div>
<div className="divide-y divide-stone-100">
{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);
const lotCount = rows.reduce((s, r) => s + Number(r.lot_count ?? 0), 0);
const variance = totalEstimate > 0 ? Math.round(((totalActual - totalEstimate) / totalEstimate) * 100) : null;
const byStatus: Record<string, number> = {};
for (const r of rows) byStatus[r.status] = Number(r.total_lbs ?? 0);
const active = byStatus["active"] ?? 0;
const inTransit = byStatus["in_transit"] ?? 0;
const atShed = byStatus["at_shed"] ?? 0;
const packed = byStatus["packed"] ?? 0;
return (
<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>
</div>
<div className="flex items-center gap-4">
<div className="text-right">
<p className="text-xl font-black text-stone-900">
{totalActual > 0 ? Number(totalActual).toLocaleString() : "—"}
</p>
<p className="text-[10px] text-stone-400">{rows[0]?.yield_unit ?? "lbs"} actual</p>
</div>
{totalEstimate > 0 && (
<div className="text-right">
<p className="text-sm font-semibold text-stone-600">
{Number(totalEstimate).toLocaleString()}
<span className="text-[10px] text-stone-400 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"}`}>
{variance !== null ? `${variance > 0 ? "+" : ""}${variance}%` : "—"}
</p>
</div>
)}
</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>
<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>
<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>
<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>
</div>
</div>
);
})}
</div>
</div>
);
}
// ── Field Yield Table ─────────────────────────────────────────────────────────
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="flex items-center gap-2">
<span>📊</span>
<h2 className="text-sm font-semibold text-stone-900">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>
</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">
<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>}
</td>
<td className="px-6 py-3.5 text-right">
<span className="text-sm font-semibold text-stone-600">
{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">
{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">
{row.active_lots}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
// ── Main Dashboard ────────────────────────────────────────────────────────────
export default function RouteTraceDashboard({
stats,
recentLots,
haulingLots,
fieldYield,
inventoryByCrop,
recentActivity,
brandId,
}: {
stats: RouteTraceStats;
recentLots: Array<{ lot_number: string; crop_type: string; status: string; harvest_date: string; id: string }>;
haulingLots: HaulingLot[];
fieldYield: FieldYieldSummary[];
inventoryByCrop: InventoryByCrop[];
recentActivity: RecentLotEvent[];
brandId: string;
}) {
const [search, setSearch] = useState("");
const [showScan, setShowScan] = useState(false);
const [showQuickNew, setShowQuickNew] = useState(false);
const [searchCrop, setSearchCrop] = useState("");
const haulingBoard = buildHaulingBoard(haulingLots);
const totalHaulingLots = haulingBoard.in_transit.length + haulingBoard.at_shed.length + haulingBoard.packed.length;
const hasAnyLots = totalHaulingLots > 0;
const statEntries = [
{ key: "active_count", value: stats.active_count ?? 0 },
{ key: "in_transit_count", value: stats.in_transit_count ?? 0 },
{ key: "at_shed_count", value: stats.at_shed_count ?? 0 },
{ key: "total_lots_today", value: stats.total_lots_today ?? 0 },
] as const;
return (
<div className="space-y-5">
<RouteTraceNav activeTab="dashboard" />
{/* ── Stat cards ── */}
<div className="grid grid-cols-2 md:grid-cols-4 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>
<p className={`mt-1.5 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>
<p className="mt-1.5 text-3xl font-black text-stone-700">{stats.total_lots ?? 0}</p>
</div>
</div>
{/* ── Activity feed ── */}
<ActivityFeed events={recentActivity} />
{/* ── 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>
<div>
<p className="text-sm font-bold text-blue-900">Compliance Summary</p>
<p className="text-[10px] text-blue-600/70">This period</p>
</div>
</div>
<div className="flex items-center gap-6">
{(() => {
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);
const cropCount = inventoryByCrop.length;
return [
{ label: "Total Lots", value: totalLots.toLocaleString() },
{ label: "Total lbs", value: totalLbs > 0 ? totalLbs.toLocaleString() : "—" },
{ label: "Crops", value: String(cropCount) },
];
})().map((m) => (
<div key={m.label} className="text-center">
<p className="text-lg font-black text-blue-800">{m.value}</p>
<p className="text-[10px] text-blue-500/70 font-bold">{m.label}</p>
</div>
))}
</div>
</div>
)}
{/* ── Inventory by crop ── */}
<InventoryByCropSection inventoryByCrop={inventoryByCrop} />
{/* ── Quick actions ── */}
<div className="flex 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"
>
🌱 Quick New Lot
</button>
<button
onClick={() => setShowScan(true)}
className="rounded-xl bg-stone-900 px-4 py-2.5 text-sm font-bold text-white hover:bg-stone-800 transition-colors flex items-center gap-2"
>
📷 Scan QR
</button>
<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"
>
📊 Compliance Snapshot
</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"
>
All Lots
</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">
{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"
/>
<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"
>
<option value="">All Crops</option>
{[...new Set(haulingLots.map((l) => l.crop_type))].sort().map((c) => (
<option key={c} value={c}>{c}</option>
))}
</select>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
<HaulingColumn
title="In Transit"
icon="🚚"
bgHeader="bg-amber-50"
lots={haulingBoard.in_transit.filter(
(l) =>
(!search ||
l.lot_number.toLowerCase().includes(search.toLowerCase()) ||
l.crop_type.toLowerCase().includes(search.toLowerCase()) ||
(l.field_location ?? "").toLowerCase().includes(search.toLowerCase())) &&
(!searchCrop || l.crop_type === searchCrop)
)}
emptyMessage="No lots in transit"
/>
<HaulingColumn
title="At Shed"
icon="🏭"
bgHeader="bg-blue-50"
lots={haulingBoard.at_shed.filter(
(l) =>
(!search ||
l.lot_number.toLowerCase().includes(search.toLowerCase()) ||
l.crop_type.toLowerCase().includes(search.toLowerCase()) ||
(l.field_location ?? "").toLowerCase().includes(search.toLowerCase())) &&
(!searchCrop || l.crop_type === searchCrop)
)}
emptyMessage="No lots at shed"
/>
<HaulingColumn
title="Packed"
icon="📦"
bgHeader="bg-purple-50"
lots={haulingBoard.packed.filter(
(l) =>
(!search ||
l.lot_number.toLowerCase().includes(search.toLowerCase()) ||
l.crop_type.toLowerCase().includes(search.toLowerCase()) ||
(l.field_location ?? "").toLowerCase().includes(search.toLowerCase())) &&
(!searchCrop || l.crop_type === searchCrop)
)}
emptyMessage="No lots packed"
/>
</div>
</div>
)}
{/* ── Field Yield Summary ── */}
<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.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.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)} />}
{showQuickNew && (
<QuickNewLotDrawer
brandId=""
onCreated={(lotId) => {
setShowQuickNew(false);
window.location.href = `/admin/route-trace/lots/${lotId}`;
}}
onClose={() => setShowQuickNew(false)}
/>
)}
</div>
);
}
@@ -0,0 +1,37 @@
"use client";
const TABS = [
{ id: "dashboard", label: "Dashboard", href: "/admin/route-trace" },
{ id: "lots", label: "Lots", href: "/admin/route-trace/lots" },
{ id: "lookup", label: "Lookup", href: "/admin/route-trace/lookup" },
{ id: "settings", label: "Settings", href: "/admin/route-trace/settings" },
];
export default function RouteTraceNav({
activeTab,
}: {
activeTab: "dashboard" | "lots" | "lookup" | "settings";
}) {
return (
<div className="border-b border-stone-200 mb-6">
<nav className="flex gap-1 -mb-px">
{TABS.map((tab) => {
const isActive = tab.id === activeTab;
return (
<a
key={tab.id}
href={tab.href}
className={`px-4 py-2.5 text-sm font-medium border-b-2 transition-colors whitespace-nowrap rounded-t-lg ${
isActive
? "border-stone-900 text-stone-900 bg-white"
: "border-transparent text-stone-500 hover:text-stone-700 hover:border-stone-300"
}`}
>
{tab.label}
</a>
);
})}
</nav>
</div>
);
}
@@ -0,0 +1,47 @@
"use client";
import { useState } from "react";
export default function ShareTraceButton({ lotNumber }: { lotNumber: string }) {
const [copied, setCopied] = useState(false);
async function handleShare() {
const url = `${window.location.origin}/trace/${lotNumber}`;
try {
await navigator.clipboard.writeText(url);
} catch {
// Fallback
const input = document.createElement("input");
input.value = url;
document.body.appendChild(input);
input.select();
document.execCommand("copy");
document.body.removeChild(input);
}
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
return (
<button
onClick={handleShare}
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"
>
{copied ? (
<>
<svg className="w-4 h-4 text-green-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
<span className="text-green-700">Copied!</span>
</>
) : (
<>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.368 2.684 3 3 0 00-5.368-2.684z" />
</svg>
Share Trace
</>
)}
</button>
);
}
@@ -0,0 +1,21 @@
"use client";
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" },
} 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-slate-100", text: "text-slate-700" };
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" />
{config.label}
</span>
);
}
@@ -0,0 +1,275 @@
"use client";
import { useState, useEffect } from "react";
import { LotDetail } from "@/actions/route-trace/lots";
type StickerType = "field" | "shed";
type StickerSize = "4x2" | "4x3";
const COPY_OPTIONS = [1, 2, 3, 5, 10];
export default function StickerPreviewModal({ lot, onClose }: { lot: LotDetail; onClose: () => void }) {
const [stickerType, setStickerType] = useState<StickerType>("field");
const [stickerSize, setStickerSize] = useState<StickerSize>("4x2");
const [copies, setCopies] = useState(1);
const [loading, setLoading] = useState(false);
const [qrDataUrl, setQrDataUrl] = useState<string | null>(null);
const baseUrl = typeof window !== "undefined" ? window.location.origin : "http://localhost:3000";
const traceUrl = `${baseUrl}/trace/${lot.lot_number}`;
useEffect(() => {
import("qrcode").then(({ toDataURL }) => {
toDataURL(traceUrl, { width: 200, margin: 1, color: { dark: "#000000", light: "#FFFFFF" } })
.then(setQrDataUrl)
.catch(() => {});
});
}, [traceUrl]);
async function handlePrint() {
setLoading(true);
try {
const url = `/api/route-trace/sticker-pdf?lotId=${lot.lot_id}&type=${stickerType}&size=${stickerSize}&copies=${copies}`;
const res = await fetch(url);
if (!res.ok) throw new Error("Failed to generate PDF");
const blob = await res.blob();
const blobUrl = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = blobUrl;
a.download = `${lot.lot_number}-${stickerType}-${stickerSize}.pdf`;
a.target = "_blank";
a.click();
URL.revokeObjectURL(blobUrl);
} catch (e) {
alert("Failed to generate PDF. Please try again.");
} finally {
setLoading(false);
}
}
// Actual thermal label dimensions in points: 4x2" = 288×144, 4x3" = 288×216
const LABEL_W = 288;
const LABEL_H = stickerSize === "4x3" ? 216 : 144;
// Scale preview to ~1.8x so it's readable in the modal
const scale = 1.8;
const previewW = LABEL_W * scale;
const previewH = LABEL_H * scale;
const qrSize = stickerSize === "4x3" ? 130 : 110;
const qrPreviewSize = qrSize * scale * 0.55; // scaled down for preview
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40" onClick={onClose}>
<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>
<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>
</div>
<div className="p-6 space-y-5">
{/* Type + Size selectors */}
<div className="grid grid-cols-3 gap-4">
<div>
<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 desc = t === "field" ? "After harvest" : "At shed arrival";
return (
<button
key={t}
onClick={() => setStickerType(t)}
className={`w-full rounded-xl border px-3 py-2.5 text-left transition-colors ${
stickerType === t
? "border-stone-900 bg-stone-900 text-white"
: "border-stone-200 hover:bg-stone-50"
}`}
>
<div className="text-sm font-semibold">{label} Sticker</div>
<div className={`text-[10px] mt-0.5 ${stickerType === t ? "text-stone-300" : "text-stone-400"}`}>{desc}</div>
</button>
);
})}
</div>
</div>
<div>
<p className="mb-2 text-xs font-semibold text-stone-500 uppercase tracking-wider">Label Size</p>
<div className="space-y-1.5">
{(["4x2", "4x3"] as StickerSize[]).map((s) => {
const w = s === "4x2" ? "2 in" : "3 in";
const h = s === "4x2" ? "2 in" : "3 in";
return (
<button
key={s}
onClick={() => setStickerSize(s)}
className={`w-full rounded-xl border px-3 py-2.5 text-left transition-colors ${
stickerSize === s
? "border-stone-900 bg-stone-900 text-white"
: "border-stone-200 hover:bg-stone-50"
}`}
>
<div className="text-sm font-semibold">{w} × {h}</div>
<div className={`text-[10px] mt-0.5 ${stickerSize === s ? "text-stone-300" : "text-stone-400"}`}>
{s === "4x2" ? "Field sticker" : "Shed sticker"}
</div>
</button>
);
})}
</div>
</div>
<div>
<p className="mb-2 text-xs font-semibold text-stone-500 uppercase tracking-wider">Copies</p>
<div className="grid grid-cols-5 gap-1.5">
{COPY_OPTIONS.map((n) => (
<button
key={n}
onClick={() => setCopies(n)}
className={`rounded-lg border py-2 text-center text-sm font-bold transition-colors ${
copies === n
? "border-stone-900 bg-stone-900 text-white"
: "border-stone-200 text-stone-600 hover:bg-stone-50"
}`}
>
{n}
</button>
))}
</div>
<p className="mt-1.5 text-[10px] text-stone-400 text-center">labels per sheet</p>
</div>
</div>
{/* Sticker preview — actual proportions, scaled */}
<div className="flex flex-col items-center gap-2">
<p className="text-xs font-semibold text-stone-500 uppercase tracking-wider">Preview</p>
<div
className="relative rounded-lg border-2 border-stone-300 overflow-hidden bg-white"
style={{ width: previewW, height: previewH }}
>
<div className="absolute inset-0 p-3.5 flex flex-col text-black select-none">
{/* Brand row */}
<div className="flex items-center justify-between">
<span className="text-[6px] font-bold uppercase tracking-widest text-stone-400">Route Trace</span>
<span className="text-[5px] text-stone-300">{stickerSize}"</span>
</div>
{/* Lot number — dominant */}
<div className="text-[18px] font-black leading-tight text-stone-950 tracking-tight mt-0.5">
{lot.lot_number}
</div>
{/* Crop */}
<div className="text-[9px] font-bold text-stone-700 mt-0.5">{lot.crop_type}</div>
{/* Data rows */}
<div className="flex flex-1 mt-1 gap-2">
<div className="flex-1 space-y-0.5">
{stickerType === "field" ? (
<>
{lot.harvest_date && <div className="text-[6px] text-stone-600">Harvested: {lot.harvest_date}</div>}
{lot.quantity_lbs && (
<div className="text-[8px] font-black text-stone-950 mt-1">
{Number(lot.quantity_lbs).toLocaleString()} lbs
</div>
)}
{lot.field_location && <div className="text-[6px] text-stone-600">{lot.field_location}</div>}
{lot.field_block && <div className="text-[6px] text-stone-500">Block: {lot.field_block}</div>}
{lot.worker_name && <div className="text-[6px] text-stone-600">{lot.worker_name}</div>}
{lot.yield_estimate_lbs && (
<div className="text-[6px] text-stone-600">Est: {Number(lot.yield_estimate_lbs).toLocaleString()} {lot.yield_unit ?? "lbs"}</div>
)}
</>
) : (
<>
{lot.quantity_lbs && (
<div className="text-[10px] font-black text-stone-950">
{Number(lot.quantity_lbs).toLocaleString()} {lot.yield_unit ?? "lbs"}
</div>
)}
{lot.harvest_date && <div className="text-[6px] text-stone-600">Packed: {lot.harvest_date}</div>}
{lot.field_location && <div className="text-[6px] text-stone-600">From: {lot.field_location}</div>}
{lot.worker_name && <div className="text-[6px] text-stone-600">{lot.worker_name}</div>}
{lot.destination_stop_id && (
<div className="text-[6px] text-stone-600">Dest: #{lot.destination_stop_id.slice(0, 8)}</div>
)}
</>
)}
</div>
{/* Right — bin/container */}
<div className="w-20 text-right space-y-0.5">
{lot.bin_id && (
<div className="text-[6px] font-black text-stone-950">BIN {lot.bin_id}</div>
)}
{lot.container_id && (
<div className="text-[6px] font-black text-stone-950">CONT {lot.container_id}</div>
)}
{lot.pallets && (
<div className="text-[5px] text-stone-500">{lot.pallets} PLT</div>
)}
{lot.field_block && stickerType === "shed" && (
<div className="text-[5px] text-stone-400">Block: {lot.field_block}</div>
)}
</div>
</div>
{/* QR — bottom right */}
<div className="absolute bottom-1.5 right-1.5">
{qrDataUrl ? (
<img
src={qrDataUrl}
alt="QR"
className="rounded border border-stone-300"
style={{ width: qrPreviewSize, height: qrPreviewSize }}
/>
) : (
<div
className="bg-stone-100 border border-stone-300 rounded flex items-center justify-center"
style={{ width: qrPreviewSize, height: qrPreviewSize }}
>
<span className="text-[5px] text-stone-400">QR</span>
</div>
)}
</div>
{/* /trace url — bottom left */}
<div className="absolute bottom-1.5 left-1.5 text-[4px] text-stone-300 truncate max-w-[90px]">
/trace/{lot.lot_number}
</div>
</div>
</div>
</div>
{/* Print specs */}
<div className="rounded-xl bg-stone-50 border border-stone-200 px-4 py-2.5 flex items-center justify-between">
<div>
<p className="text-xs font-semibold text-stone-700">Direct Thermal · {stickerSize}" · Black on White · Large QR</p>
<p className="text-[10px] text-stone-400 mt-0.5">Helvetica Bold · High-contrast QR · No margins · 2 per sheet</p>
</div>
<div className="text-right">
<p className="text-xs font-bold text-stone-600">{lot.lot_number}</p>
<p className="text-[10px] text-stone-400">2 per sheet</p>
</div>
</div>
</div>
<div className="flex justify-end gap-3 border-t border-stone-100 px-6 py-4">
<button
onClick={onClose}
className="rounded-xl border border-stone-200 px-4 py-2.5 text-sm font-semibold text-stone-600 hover:bg-stone-50"
>
Cancel
</button>
<button
onClick={handlePrint}
disabled={loading}
className="rounded-xl bg-stone-900 px-5 py-2.5 text-sm font-bold text-white hover:bg-stone-800 disabled:opacity-50 flex items-center gap-2"
>
{loading ? "Generating PDF..." : `🖨 Print ${copies === 1 ? "" : `${copies}× `}${stickerType === "field" ? "Field" : "Shed"} Sticker${copies > 1 ? "s" : ""}`}
</button>
</div>
</div>
</div>
);
}