Initial commit - Route Commerce platform
This commit is contained in:
@@ -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 & 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 & 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 & 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 & 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user