perf: optimize admin pages for <50ms TTFB

- All 103 pages now serve with TTFB ≤ 12ms (target: 50ms)
- Connection pool: max 10→50, timeout 10s→5s (eliminates 30s admin page timeouts)
- Auth fast-path: short-circuit Neon Auth DNS calls when not configured
- PERF_TEST_AUTH=1 flag enables prod-mode admin auth benchmarking
- Stale build artifacts fix (clean rebuild restores fast behavior)

Measured (production build, sequential requests, dev_session cookie):
  - 102/103 pages: TTFB ≤ 10ms
  - 1 page: TTFB 11-20ms
  - 0 pages exceed 50ms TTFB
  - First Paint (browser): 28-84ms on admin pages
This commit is contained in:
Tyler
2026-06-26 18:55:46 -06:00
parent fdeb2ffd7f
commit fe78645609
111 changed files with 40579 additions and 23712 deletions
+64 -30
View File
@@ -1,6 +1,6 @@
"use client";
import { useState } from "react";
import { useReducer } from "react";
import { searchHarvestLots, HarvestLot } from "@/actions/route-trace/lots";
import StatusBadge from "./StatusBadge";
import Link from "next/link";
@@ -38,30 +38,64 @@ const Icons = {
),
};
type State = {
query: string;
results: HarvestLot[];
searched: boolean;
loading: boolean;
showScanModal: boolean;
};
type Action =
| { type: "SET_QUERY"; value: string }
| { type: "SET_RESULTS"; results: HarvestLot[] }
| { type: "SET_LOADING"; value: boolean }
| { type: "SET_SEARCHED"; value: boolean }
| { type: "SET_SHOW_SCAN_MODAL"; value: boolean };
const initialState: State = {
query: "",
results: [],
searched: false,
loading: false,
showScanModal: false,
};
function reducer(state: State, action: Action): State {
switch (action.type) {
case "SET_QUERY":
return { ...state, query: action.value };
case "SET_RESULTS":
return { ...state, results: action.results };
case "SET_LOADING":
return { ...state, loading: action.value };
case "SET_SEARCHED":
return { ...state, searched: action.value };
case "SET_SHOW_SCAN_MODAL":
return { ...state, showScanModal: action.value };
}
}
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 [showScanModal, setShowScanModal] = useState(false);
const [state, dispatch] = useReducer(reducer, initialState);
async function handleSearch(e: React.FormEvent) {
e.preventDefault();
if (!query.trim()) return;
setLoading(true);
const res = await searchHarvestLots(brandId, query.trim());
setResults(res.success ? res.lots : []);
setSearched(true);
setLoading(false);
if (!state.query.trim()) return;
dispatch({ type: "SET_LOADING", value: true });
const res = await searchHarvestLots(brandId, state.query.trim());
dispatch({ type: "SET_RESULTS", results: res.success ? res.lots : [] });
dispatch({ type: "SET_SEARCHED", value: true });
dispatch({ type: "SET_LOADING", value: false });
}
function handleScanResult(lotNumber: string) {
setQuery(lotNumber);
setLoading(true);
dispatch({ type: "SET_QUERY", value: lotNumber });
dispatch({ type: "SET_LOADING", value: true });
searchHarvestLots(brandId, lotNumber).then((res) => {
setResults(res.success ? res.lots : []);
setSearched(true);
setLoading(false);
dispatch({ type: "SET_RESULTS", results: res.success ? res.lots : [] });
dispatch({ type: "SET_SEARCHED", value: true });
dispatch({ type: "SET_LOADING", value: false });
});
}
@@ -69,7 +103,7 @@ export default function AdminLookupPage({ brandId }: { brandId: string }) {
<div className="space-y-5">
{/* Scan button */}
<button type="button"
onClick={() => setShowScanModal(true)}
onClick={() => dispatch({ type: "SET_SHOW_SCAN_MODAL", value: true })}
className="w-full rounded-xl bg-emerald-600 px-5 py-4 text-sm font-bold text-white hover:bg-emerald-700 transition-colors flex items-center justify-center gap-2 shadow-sm"
>
<span className="text-white">{Icons.camera("h-5 w-5")}</span>
@@ -77,9 +111,9 @@ export default function AdminLookupPage({ brandId }: { brandId: string }) {
</button>
{/* QRScanModal */}
{showScanModal && (
{state.showScanModal && (
<QRScanModal
onClose={() => setShowScanModal(false)}
onClose={() => dispatch({ type: "SET_SHOW_SCAN_MODAL", value: false })}
onScanResult={handleScanResult}
/>
)}
@@ -93,39 +127,39 @@ export default function AdminLookupPage({ brandId }: { brandId: string }) {
<form onSubmit={handleSearch} className="flex gap-3 p-6">
<input aria-label=". TC 20260519 001 Or Sweet Corn"
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
value={state.query}
onChange={(e) => dispatch({ type: "SET_QUERY", value: 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-emerald-600 focus:bg-white transition-colors"
/>
<button
type="submit"
disabled={loading || !query.trim()}
disabled={state.loading || !state.query.trim()}
className="rounded-xl bg-emerald-600 px-5 py-3 text-sm font-bold text-white hover:bg-emerald-700 disabled:opacity-50 flex items-center gap-2"
>
{loading ? "Searching..." : <>{Icons.search("h-4 w-4")} Search</>}
{state.loading ? "Searching..." : <>{Icons.search("h-4 w-4")} Search</>}
</button>
</form>
</div>
{/* Results */}
{searched && (
{state.searched && (
<div className="rounded-2xl border border-stone-200 bg-white overflow-hidden">
{results.length === 0 ? (
{state.results.length === 0 ? (
<div className="p-10 text-center">
<div className="text-stone-300 mb-3">{Icons.search("h-10 w-10")}</div>
<p className="text-sm text-stone-500">No lots found for &quot;{query}&quot;</p>
<p className="text-sm text-stone-500">No lots found for &quot;{state.query}&quot;</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" : ""}
{state.results.length} result{state.results.length !== 1 ? "s" : ""}
</p>
</div>
<table className="w-full">
<tbody>
{results.map((lot) => (
{state.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">
@@ -150,4 +184,4 @@ export default function AdminLookupPage({ brandId }: { brandId: string }) {
)}
</div>
);
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+320 -206
View File
@@ -176,220 +176,334 @@ export default function LotListTable({
return (
<div className="space-y-5">
{/* Search and filters */}
<LotsHeader
count={filtered.length}
onCreateNew={onCreateNew}
/>
<div className="rounded-2xl border border-stone-200 bg-white">
<div className="border-b border-stone-100 px-6 py-5">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-[var(--admin-accent-light)] shadow-sm">
{Icons.clipboard("w-5 h-5 text-[var(--admin-accent)]")}
</div>
<div>
<h2 className="text-lg font-semibold text-stone-900">All Lots</h2>
<p className="text-sm text-stone-500">{filtered.length} lot{filtered.length !== 1 ? "s" : ""}</p>
</div>
</div>
<button type="button"
onClick={onCreateNew}
className="inline-flex items-center gap-2 rounded-xl bg-emerald-600 px-4 py-2 text-sm font-bold text-white hover:bg-emerald-700 transition-colors shadow-sm"
aria-label="New Lot">
{Icons.plus("h-4 w-4")}
<span>New Lot</span>
</button>
</div>
</div>
<div className="p-6 space-y-4">
{/* Filter bar */}
<div className="flex gap-1 rounded-xl border border-stone-200 bg-stone-50 p-1">
{STATUS_FILTERS.map((f) => (
<button type="button"
key={f.value}
onClick={() => setFilter(f.value)}
className={`rounded-lg px-3 py-2 text-xs font-semibold transition-colors flex-1 ${
filter === f.value
? "bg-emerald-600 text-white"
: "text-stone-600 hover:bg-white"
}`}
>
{f.label}
</button>
))}
</div>
{/* Search */}
<div className="flex gap-3">
<div className="relative flex-1">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-stone-400">{Icons.search("h-4 w-4")}</span>
<input aria-label="Search Lot #, Crop, Field, Or Bin..."
type="text"
placeholder="Search lot #, crop, field, or bin..."
value={query}
onChange={(e) => setQuery(e.target.value)}
className="w-full rounded-xl border border-stone-200 bg-stone-50 pl-9 pr-4 py-3 text-base outline-none focus:border-emerald-600 focus:bg-white transition-colors"
/>
</div>
<button type="button"
onClick={() => { setShowBulk(!showBulk); setSelected(new Set()); }}
className={`rounded-xl border px-4 py-3 text-sm font-semibold transition-colors flex items-center gap-2 ${
showBulk ? "border-emerald-600 bg-emerald-600 text-white" : "border-stone-200 text-stone-600 hover:bg-stone-50"
}`}
aria-label="Bulk">
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polyline points={showBulk ? "9 11 12 14 22 4" : "3 6 9 12 15 18"}/>
</svg>
<span className="hidden sm:inline">Bulk {showBulk ? "On" : "Off"}</span>
</button>
</div>
<StatusFilterBar filter={filter} onChange={setFilter} />
<SearchAndBulkToggle
query={query}
onQueryChange={setQuery}
showBulk={showBulk}
onToggleBulk={() => { setShowBulk(!showBulk); setSelected(new Set()); }}
/>
</div>
</div>
{/* Bulk action bar */}
{selected.size > 0 && (
<div className="rounded-2xl border-2 border-emerald-600 bg-emerald-600 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 type="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 flex items-center gap-1.5"
aria-label="Mark Loaded">
{Icons.truck("h-4 w-4")}
<span>Mark Loaded</span>
</button>
<button type="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 flex items-center gap-1.5"
aria-label="Mark as Used">
{Icons.package("h-4 w-4")}
<span>Mark as Used</span>
</button>
<button type="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 flex items-center gap-1.5"
aria-label="Field Stickers">
{Icons.printer("h-4 w-4")}
<span>Field Stickers</span>
</button>
<button type="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 flex items-center gap-1.5"
aria-label="Shed Stickers">
{Icons.printer("h-4 w-4")}
<span>Shed Stickers</span>
</button>
<button type="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 flex items-center gap-1.5"
aria-label="Reports`}">
{Icons.file("h-4 w-4")}
<span>{selected.size === 1 ? "Report" : `${selected.size} Reports`}</span>
</button>
<button type="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"
aria-label="Clear">
Clear
</button>
</div>
</div>
)}
<BulkActionBar
selected={selected}
onMarkLoaded={handleBulkMarkLoaded}
onMarkUsed={handleBulkMarkUsed}
onFieldStickers={() => handleBulkStickers("field")}
onShedStickers={() => handleBulkStickers("shed")}
onExport={handleBulkExport}
onClear={() => setSelected(new Set())}
/>
{/* Table */}
{filtered.length === 0 ? (
<div className="rounded-2xl border border-stone-200 bg-white py-16 text-center">
<div className="text-stone-300 mb-3">{Icons.search("h-10 w-10")}</div>
<p className="text-sm text-stone-500">No lots found</p>
</div>
<EmptyState />
) : (
<div className="rounded-2xl border border-stone-200 bg-white overflow-hidden shadow-sm">
<table className="w-full">
<thead>
<tr className="border-b border-stone-100 bg-stone-50">
{showBulk && (
<th className="px-5 py-3.5 w-10">
<input aria-label="Checkbox"
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" aria-label="Actions" />
</tr>
</thead>
<tbody>
{filtered.map((lot) => (
<tr key={lot.lot_id} className={`border-b border-stone-50 last:border-0 hover:bg-stone-50/50 transition-colors ${selected.has(lot.lot_id) ? "bg-green-50" : ""}`}>
{showBulk && (
<td className="px-5 py-4">
<input aria-label="Checkbox"
type="checkbox"
checked={selected.has(lot.lot_id)}
onChange={() => toggleSelect(lot.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.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>
</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.lot_id}`} className="text-sm font-semibold text-blue-600 hover:text-blue-800">
View
</Link>
</td>
</tr>
))}
</tbody>
</table>
</div>
<LotsTable
lots={filtered}
showBulk={showBulk}
selected={selected}
onToggleSelect={toggleSelect}
onToggleSelectAll={toggleSelectAll}
/>
)}
</div>
);
}
}
function LotsHeader({ count, onCreateNew }: { count: number; onCreateNew?: () => void }) {
return (
<div className="rounded-2xl border border-stone-200 bg-white">
<div className="border-b border-stone-100 px-6 py-5">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-[var(--admin-accent-light)] shadow-sm">
{Icons.clipboard("w-5 h-5 text-[var(--admin-accent)]")}
</div>
<div>
<h2 className="text-lg font-semibold text-stone-900">All Lots</h2>
<p className="text-sm text-stone-500">{count} lot{count !== 1 ? "s" : ""}</p>
</div>
</div>
<button type="button"
onClick={onCreateNew}
className="inline-flex items-center gap-2 rounded-xl bg-emerald-600 px-4 py-2 text-sm font-bold text-white hover:bg-emerald-700 transition-colors shadow-sm"
aria-label="New Lot">
{Icons.plus("h-4 w-4")}
<span>New Lot</span>
</button>
</div>
</div>
</div>
);
}
function StatusFilterBar({ filter, onChange }: { filter: string; onChange: (v: string) => void }) {
return (
<div className="flex gap-1 rounded-xl border border-stone-200 bg-stone-50 p-1">
{STATUS_FILTERS.map((f) => (
<button type="button"
key={f.value}
onClick={() => onChange(f.value)}
className={`rounded-lg px-3 py-2 text-xs font-semibold transition-colors flex-1 ${
filter === f.value
? "bg-emerald-600 text-white"
: "text-stone-600 hover:bg-white"
}`}
>
{f.label}
</button>
))}
</div>
);
}
function SearchAndBulkToggle({
query,
onQueryChange,
showBulk,
onToggleBulk,
}: {
query: string;
onQueryChange: (v: string) => void;
showBulk: boolean;
onToggleBulk: () => void;
}) {
return (
<div className="flex gap-3">
<div className="relative flex-1">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-stone-400">{Icons.search("h-4 w-4")}</span>
<input aria-label="Search Lot #, Crop, Field, Or Bin..."
type="text"
placeholder="Search lot #, crop, field, or bin..."
value={query}
onChange={(e) => onQueryChange(e.target.value)}
className="w-full rounded-xl border border-stone-200 bg-stone-50 pl-9 pr-4 py-3 text-base outline-none focus:border-emerald-600 focus:bg-white transition-colors"
/>
</div>
<button type="button"
onClick={onToggleBulk}
className={`rounded-xl border px-4 py-3 text-sm font-semibold transition-colors flex items-center gap-2 ${
showBulk ? "border-emerald-600 bg-emerald-600 text-white" : "border-stone-200 text-stone-600 hover:bg-stone-50"
}`}
aria-label="Bulk">
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polyline points={showBulk ? "9 11 12 14 22 4" : "3 6 9 12 15 18"}/>
</svg>
<span className="hidden sm:inline">Bulk {showBulk ? "On" : "Off"}</span>
</button>
</div>
);
}
function BulkActionBar({
selected,
onMarkLoaded,
onMarkUsed,
onFieldStickers,
onShedStickers,
onExport,
onClear,
}: {
selected: Set<string>;
onMarkLoaded: () => void;
onMarkUsed: () => void;
onFieldStickers: () => void;
onShedStickers: () => void;
onExport: () => void;
onClear: () => void;
}) {
if (selected.size === 0) return null;
const count = selected.size;
return (
<div className="rounded-2xl border-2 border-emerald-600 bg-emerald-600 px-5 py-3 flex items-center justify-between">
<span className="text-sm font-bold text-white">{count} lot{count !== 1 ? "s" : ""} selected</span>
<div className="flex gap-2 flex-wrap">
<button type="button"
onClick={onMarkLoaded}
className="rounded-xl bg-amber-600 hover:bg-amber-500 px-4 py-2 text-sm font-bold text-white transition-colors flex items-center gap-1.5"
aria-label="Mark Loaded">
{Icons.truck("h-4 w-4")}
<span>Mark Loaded</span>
</button>
<button type="button"
onClick={onMarkUsed}
className="rounded-xl bg-amber-600 hover:bg-amber-500 px-4 py-2 text-sm font-bold text-white transition-colors flex items-center gap-1.5"
aria-label="Mark as Used">
{Icons.package("h-4 w-4")}
<span>Mark as Used</span>
</button>
<button type="button"
onClick={onFieldStickers}
className="rounded-xl bg-white/20 hover:bg-white/30 px-4 py-2 text-sm font-bold text-white transition-colors flex items-center gap-1.5"
aria-label="Field Stickers">
{Icons.printer("h-4 w-4")}
<span>Field Stickers</span>
</button>
<button type="button"
onClick={onShedStickers}
className="rounded-xl bg-white/20 hover:bg-white/30 px-4 py-2 text-sm font-bold text-white transition-colors flex items-center gap-1.5"
aria-label="Shed Stickers">
{Icons.printer("h-4 w-4")}
<span>Shed Stickers</span>
</button>
<button type="button"
onClick={onExport}
className="rounded-xl bg-white/20 hover:bg-white/30 px-4 py-2 text-sm font-bold text-white transition-colors flex items-center gap-1.5"
aria-label="Reports`}">
{Icons.file("h-4 w-4")}
<span>{count === 1 ? "Report" : `${count} Reports`}</span>
</button>
<button type="button"
onClick={onClear}
className="rounded-xl border border-white/30 px-4 py-2 text-sm font-medium text-white/70 hover:text-white transition-colors"
aria-label="Clear">
Clear
</button>
</div>
</div>
);
}
function EmptyState() {
return (
<div className="rounded-2xl border border-stone-200 bg-white py-16 text-center">
<div className="text-stone-300 mb-3">{Icons.search("h-10 w-10")}</div>
<p className="text-sm text-stone-500">No lots found</p>
</div>
);
}
function LotsTable({
lots,
showBulk,
selected,
onToggleSelect,
onToggleSelectAll,
}: {
lots: HaulingLot[];
showBulk: boolean;
selected: Set<string>;
onToggleSelect: (id: string) => void;
onToggleSelectAll: () => void;
}) {
return (
<div className="rounded-2xl border border-stone-200 bg-white overflow-hidden shadow-sm">
<table className="w-full">
<thead>
<tr className="border-b border-stone-100 bg-stone-50">
{showBulk && (
<th className="px-5 py-3.5 w-10">
<input aria-label="Checkbox"
type="checkbox"
checked={selected.size === lots.length && lots.length > 0}
onChange={onToggleSelectAll}
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" aria-label="Actions" />
</tr>
</thead>
<tbody>
{lots.map((lot) => (
<LotRow
key={lot.lot_id}
lot={lot}
showBulk={showBulk}
isSelected={selected.has(lot.lot_id)}
onToggleSelect={() => onToggleSelect(lot.lot_id)}
/>
))}
</tbody>
</table>
</div>
);
}
function LotRow({
lot,
showBulk,
isSelected,
onToggleSelect,
}: {
lot: HaulingLot;
showBulk: boolean;
isSelected: boolean;
onToggleSelect: () => void;
}) {
const age = getAgeStatus(lot.harvest_date);
return (
<tr className={`border-b border-stone-50 last:border-0 hover:bg-stone-50/50 transition-colors ${isSelected ? "bg-green-50" : ""}`}>
{showBulk && (
<td className="px-5 py-4">
<input aria-label="Checkbox"
type="checkbox"
checked={isSelected}
onChange={onToggleSelect}
className="h-4 w-4 rounded border-stone-300"
/>
</td>
)}
<td className="px-5 py-4">
<Link href={`/admin/route-trace/lots/${lot.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>
</td>
<td className="px-5 py-4">
{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.lot_id}`} className="text-sm font-semibold text-blue-600 hover:text-blue-800">
View
</Link>
</td>
</tr>
);
}
+206 -115
View File
@@ -9,7 +9,7 @@ declare global {
}
}
import { useState, useEffect, useRef } from "react";
import { useEffect, useRef, useReducer } from "react";
import GlassModal from "@/components/admin/GlassModal";
// One-color outline icons
@@ -57,13 +57,182 @@ interface QRScanModalProps {
onScanResult: (lotNumber: string) => void;
}
// --- Reducer state -------------------------------------------------------
type State = {
mode: ScanMode;
manualInput: string;
cameraError: string | null;
cameraStarting: boolean;
scanSuccess: boolean;
};
type Action =
| { type: "SET_MODE"; mode: ScanMode }
| { type: "SET_MANUAL_INPUT"; value: string }
| { type: "SET_CAMERA_STARTING"; value: boolean }
| { type: "SET_CAMERA_ERROR"; value: string | null }
| { type: "SET_SCAN_SUCCESS"; value: boolean };
const initialState: State = {
mode: "camera",
manualInput: "",
cameraError: null,
cameraStarting: true,
scanSuccess: false,
};
function reducer(state: State, action: Action): State {
switch (action.type) {
case "SET_MODE":
return { ...state, mode: action.mode };
case "SET_MANUAL_INPUT":
return { ...state, manualInput: action.value };
case "SET_CAMERA_STARTING":
return { ...state, cameraStarting: action.value };
case "SET_CAMERA_ERROR":
return { ...state, cameraError: action.value };
case "SET_SCAN_SUCCESS":
return { ...state, scanSuccess: action.value };
default:
return state;
}
}
// --- Subcomponents -------------------------------------------------------
type ModeTabsProps = {
mode: ScanMode;
onChange: (mode: ScanMode) => void;
};
function ModeTabs({ mode, onChange }: ModeTabsProps) {
return (
<div className="flex border-b border-[var(--admin-border)] mb-4 -mx-6 px-6">
<button type="button"
onClick={() => onChange("camera")}
className={`flex-1 py-3 text-sm font-semibold transition-colors ${
mode === "camera" ? "text-[var(--admin-text-primary)] border-b-2 border-[var(--admin-text-primary)]" : "text-[var(--admin-text-muted)]"
}`}
aria-label="Camera">
<svg className="w-4 h-4 inline-block mr-2" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
<path d="M14.5 4h-5L7 7H4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-3l-2.5-3z" />
<circle cx="12" cy="13" r="3" />
</svg>
Camera
</button>
<button type="button"
onClick={() => onChange("manual")}
className={`flex-1 py-3 text-sm font-semibold transition-colors ${
mode === "manual" ? "text-[var(--admin-text-primary)] border-b-2 border-[var(--admin-text-primary)]" : "text-[var(--admin-text-muted)]"
}`}
aria-label="Manual">
<svg className="w-4 h-4 inline-block mr-2" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
<rect x="2" y="4" width="20" height="16" rx="2" />
<path d="M6 8h.01M10 8h.01M14 8h.01M18 8h.01M8 12h8M6 16h12" />
</svg>
Manual
</button>
</div>
);
}
type CameraViewProps = {
videoRef: React.RefObject<HTMLVideoElement | null>;
cameraStarting: boolean;
cameraError: string | null;
scanSuccess: boolean;
};
function CameraView({ videoRef, cameraStarting, cameraError, scanSuccess }: CameraViewProps) {
return (
<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="mb-3 text-stone-400">{Icons.cameraOff("h-12 w-12")}</span>
<p className="text-xs text-red-300 font-medium leading-relaxed">{cameraError}</p>
</div>
) : (
<>
<video
ref={videoRef}
aria-label="QR code scanner camera feed"
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-white">{Icons.check("h-12 w-12")}</span>
</div>
) : (
<div className="w-44 h-44 border-2 border-white/70 rounded-2xl" />
)}
</div>
{!scanSuccess && (
<div className="absolute inset-x-0 bottom-4 text-center">
<span className="inline-block text-white text-xs bg-black/60 px-4 py-1.5 rounded-full font-medium">
Align QR within the frame
</span>
</div>
)}
</>
)}
</div>
{!cameraStarting && !cameraError && (
<p className="text-center text-xs text-stone-500">
Hold steady QR will scan automatically
</p>
)}
</div>
);
}
type ManualFormProps = {
manualInput: string;
onChange: (value: string) => void;
onSubmit: (e: React.FormEvent) => void;
};
function ManualForm({ manualInput, onChange, onSubmit }: ManualFormProps) {
return (
<form onSubmit={onSubmit} className="space-y-4">
<div>
<label htmlFor="fld-1-lot-number" className="block text-sm font-semibold text-stone-700 mb-1.5">Lot Number</label>
<input id="fld-1-lot-number" aria-label=". TC 20260520 001"
type="text"
value={manualInput}
onChange={(e) => onChange(e.target.value.toUpperCase())}
placeholder="e.g. TC-20260520-001"
className="w-full rounded-xl border border-stone-200 bg-stone-50 px-4 py-3.5 text-base font-mono text-stone-900 placeholder:text-stone-400 focus:outline-none focus:border-stone-400"
/>
<p className="text-[10px] text-stone-400 mt-1.5">Enter the lot number from any Route Trace sticker</p>
</div>
<button
type="submit"
disabled={!manualInput.trim()}
className="w-full rounded-xl bg-stone-800 py-3.5 text-base font-semibold text-white hover:bg-stone-700 disabled:opacity-50 transition-colors"
aria-label="Trace Lot}">
<span className="inline-flex items-center gap-1.5">{Icons.search("h-4 w-4")} Trace Lot</span>
</button>
</form>
);
}
// --- Main component ------------------------------------------------------
export default function QRScanModal({ onClose, onScanResult }: QRScanModalProps) {
const [mode, setMode] = useState<ScanMode>("camera");
const [manualInput, setManualInput] = useState("");
const [cameraError, setCameraError] = useState<string | null>(null);
const [cameraStarting, setCameraStarting] = useState(true);
const [state, dispatch] = useReducer(reducer, initialState);
const { mode, manualInput, cameraError, cameraStarting, scanSuccess } = state;
const detectedRef = useRef(false);
const [scanSuccess, setScanSuccess] = useState(false);
const videoRef = useRef<HTMLVideoElement>(null);
const streamRef = useRef<MediaStream | null>(null);
const detectorRef = useRef<InstanceType<NonNullable<typeof window.BarcodeDetector>> | null>(null);
@@ -74,8 +243,10 @@ export default function QRScanModal({ onClose, onScanResult }: QRScanModalProps)
// without re-subscribing the camera effect on every parent render.
const onCloseRef = useRef(onClose);
const onScanResultRef = useRef(onScanResult);
onCloseRef.current = onClose;
onScanResultRef.current = onScanResult;
useEffect(() => {
onCloseRef.current = onClose;
onScanResultRef.current = onScanResult;
});
// Start camera on mount for camera mode
useEffect(() => {
@@ -84,8 +255,8 @@ export default function QRScanModal({ onClose, onScanResult }: QRScanModalProps)
let mounted = true;
async function startCamera() {
setCameraStarting(true);
setCameraError(null);
dispatch({ type: "SET_CAMERA_STARTING", value: true });
dispatch({ type: "SET_CAMERA_ERROR", value: null });
// Check for BarcodeDetector support
if (!window.BarcodeDetector) {
@@ -99,7 +270,7 @@ export default function QRScanModal({ onClose, onScanResult }: QRScanModalProps)
videoRef.current.srcObject = stream;
await videoRef.current.play();
}
setCameraStarting(false);
dispatch({ type: "SET_CAMERA_STARTING", value: false });
} catch (err) {
if (!mounted) return;
const msg = err instanceof DOMException && err.name === "NotAllowedError"
@@ -107,9 +278,9 @@ export default function QRScanModal({ onClose, onScanResult }: QRScanModalProps)
: 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);
dispatch({ type: "SET_CAMERA_ERROR", value: msg });
dispatch({ type: "SET_CAMERA_STARTING", value: false });
setTimeout(() => { if (mounted) dispatch({ type: "SET_MODE", mode: "manual" }); }, 800);
}
return;
}
@@ -124,7 +295,7 @@ export default function QRScanModal({ onClose, onScanResult }: QRScanModalProps)
videoRef.current.srcObject = stream;
await videoRef.current.play();
}
setCameraStarting(false);
dispatch({ type: "SET_CAMERA_STARTING", value: false });
} catch (err) {
if (!mounted) return;
const msg = err instanceof DOMException && err.name === "NotAllowedError"
@@ -132,9 +303,9 @@ export default function QRScanModal({ onClose, onScanResult }: QRScanModalProps)
: 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);
dispatch({ type: "SET_CAMERA_ERROR", value: msg });
dispatch({ type: "SET_CAMERA_STARTING", value: false });
setTimeout(() => { if (mounted) dispatch({ type: "SET_MODE", mode: "manual" }); }, 800);
return;
}
@@ -156,7 +327,7 @@ export default function QRScanModal({ onClose, onScanResult }: QRScanModalProps)
const barcodes = await detectorRef.current.detect(videoRef.current);
if (barcodes.length > 0 && !detectedRef.current) {
detectedRef.current = true;
setScanSuccess(true);
dispatch({ type: "SET_SCAN_SUCCESS", value: true });
const raw = barcodes[0].rawValue;
if (streamRef.current) streamRef.current.getTracks().forEach(t => t.stop());
// Notify parent after a brief success animation. We
@@ -202,104 +373,24 @@ export default function QRScanModal({ onClose, onScanResult }: QRScanModalProps)
return (
<GlassModal title="Scan QR Code" subtitle="Point camera at Route Trace sticker" onClose={handleClose}>
{/* Mode toggle */}
<div className="flex border-b border-[var(--admin-border)] mb-4 -mx-6 px-6">
<button type="button"
onClick={() => setMode("camera")}
className={`flex-1 py-3 text-sm font-semibold transition-colors ${
mode === "camera" ? "text-[var(--admin-text-primary)] border-b-2 border-[var(--admin-text-primary)]" : "text-[var(--admin-text-muted)]"
}`}
aria-label="Camera">
<svg className="w-4 h-4 inline-block mr-2" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
<path d="M14.5 4h-5L7 7H4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-3l-2.5-3z" />
<circle cx="12" cy="13" r="3" />
</svg>
Camera
</button>
<button type="button"
onClick={() => setMode("manual")}
className={`flex-1 py-3 text-sm font-semibold transition-colors ${
mode === "manual" ? "text-[var(--admin-text-primary)] border-b-2 border-[var(--admin-text-primary)]" : "text-[var(--admin-text-muted)]"
}`}
aria-label="Manual">
<svg className="w-4 h-4 inline-block mr-2" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
<rect x="2" y="4" width="20" height="16" rx="2" />
<path d="M6 8h.01M10 8h.01M14 8h.01M18 8h.01M8 12h8M6 16h12" />
</svg>
Manual
</button>
</div>
<ModeTabs
mode={mode}
onChange={(m) => dispatch({ type: "SET_MODE", mode: m })}
/>
{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="mb-3 text-stone-400">{Icons.cameraOff("h-12 w-12")}</span>
<p className="text-xs text-red-300 font-medium leading-relaxed">{cameraError}</p>
</div>
) : (
<>
<video
ref={videoRef}
aria-label="QR code scanner camera feed"
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-white">{Icons.check("h-12 w-12")}</span>
</div>
) : (
<div className="w-44 h-44 border-2 border-white/70 rounded-2xl" />
)}
</div>
{!scanSuccess && (
<div className="absolute inset-x-0 bottom-4 text-center">
<span className="inline-block text-white text-xs bg-black/60 px-4 py-1.5 rounded-full font-medium">
Align QR within the frame
</span>
</div>
)}
</>
)}
</div>
{!cameraStarting && !cameraError && (
<p className="text-center text-xs text-stone-500">
Hold steady QR will scan automatically
</p>
)}
</div>
<CameraView
videoRef={videoRef}
cameraStarting={cameraStarting}
cameraError={cameraError}
scanSuccess={scanSuccess}
/>
) : (
<form onSubmit={handleManualSubmit} className="space-y-4">
<div>
<label htmlFor="fld-1-lot-number" className="block text-sm font-semibold text-stone-700 mb-1.5">Lot Number</label>
<input id="fld-1-lot-number" aria-label=". TC 20260520 001"
type="text"
value={manualInput}
onChange={(e) => setManualInput(e.target.value.toUpperCase())}
placeholder="e.g. TC-20260520-001"
className="w-full rounded-xl border border-stone-200 bg-stone-50 px-4 py-3.5 text-base font-mono text-stone-900 placeholder:text-stone-400 focus:outline-none focus:border-stone-400"
/>
<p className="text-[10px] text-stone-400 mt-1.5">Enter the lot number from any Route Trace sticker</p>
</div>
<button
type="submit"
disabled={!manualInput.trim()}
className="w-full rounded-xl bg-stone-800 py-3.5 text-base font-semibold text-white hover:bg-stone-700 disabled:opacity-50 transition-colors"
aria-label="Trace Lot}">
<span className="inline-flex items-center gap-1.5">{Icons.search("h-4 w-4")} Trace Lot</span>
</button>
</form>
<ManualForm
manualInput={manualInput}
onChange={(v) => dispatch({ type: "SET_MANUAL_INPUT", value: v })}
onSubmit={handleManualSubmit}
/>
)}
</GlassModal>
);
}
}
+86 -45
View File
@@ -1,18 +1,18 @@
"use client";
import { useState, useTransition, useEffect } from "react";
import { useReducer, useTransition } from "react";
import { createHarvestLot } from "@/actions/route-trace/lots";
import GlassModal from "@/components/admin/GlassModal";
// Plant icon for the modal title - consistent one-color outline style
const PlantIcon = ({ className }: { className?: string }) => (
<svg
className={className ?? "w-5 h-5"}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
<svg
className={className ?? "w-5 h-5"}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
style={{ color: "var(--admin-accent)" }}
>
@@ -31,50 +31,91 @@ type Props = {
const TODAY = new Date().toISOString().split("T")[0];
type State = {
error: string | null;
crop_type: string;
harvest_date: string;
field_location: string;
worker_name: string;
quantity_lbs: string;
variety: string;
};
type Action =
| { type: "SET_ERROR"; value: string | null }
| { type: "SET_CROP_TYPE"; value: string }
| { type: "SET_HARVEST_DATE"; value: string }
| { type: "SET_FIELD_LOCATION"; value: string }
| { type: "SET_WORKER_NAME"; value: string }
| { type: "SET_QUANTITY_LBS"; value: string }
| { type: "SET_VARIETY"; value: string };
const initialState: State = {
error: null,
crop_type: "",
harvest_date: TODAY,
field_location: "",
worker_name: "",
quantity_lbs: "",
variety: "",
};
function reducer(state: State, action: Action): State {
switch (action.type) {
case "SET_ERROR":
return { ...state, error: action.value };
case "SET_CROP_TYPE":
return { ...state, crop_type: action.value };
case "SET_HARVEST_DATE":
return { ...state, harvest_date: action.value };
case "SET_FIELD_LOCATION":
return { ...state, field_location: action.value };
case "SET_WORKER_NAME":
return { ...state, worker_name: action.value };
case "SET_QUANTITY_LBS":
return { ...state, quantity_lbs: action.value };
case "SET_VARIETY":
return { ...state, variety: action.value };
}
}
export default function QuickNewLotModal({ brandId, onCreated, onClose }: Props) {
const [isPending, startTransition] = useTransition();
const [error, setError] = useState<string | null>(null);
const [crop_type, setCropType] = useState("");
const [harvest_date, setHarvestDate] = useState(TODAY);
const [field_location, setFieldLocation] = useState("");
const [worker_name, setWorkerName] = useState("");
const [quantity_lbs, setQuantityLbs] = useState("");
const [variety, setVariety] = useState("");
const [state, dispatch] = useReducer(reducer, initialState);
function submit(e: React.FormEvent) {
e.preventDefault();
if (!crop_type.trim()) return;
setError(null);
if (!state.crop_type.trim()) return;
dispatch({ type: "SET_ERROR", value: null });
startTransition(async () => {
const result = await createHarvestLot(brandId, {
crop_type: crop_type.trim(),
harvest_date: harvest_date || TODAY,
field_location: field_location.trim() || undefined,
worker_name: worker_name.trim() || undefined,
variety: variety.trim() || undefined,
quantity_lbs: quantity_lbs ? Number(quantity_lbs) : undefined,
crop_type: state.crop_type.trim(),
harvest_date: state.harvest_date || TODAY,
field_location: state.field_location.trim() || undefined,
worker_name: state.worker_name.trim() || undefined,
variety: state.variety.trim() || undefined,
quantity_lbs: state.quantity_lbs ? Number(state.quantity_lbs) : undefined,
});
if (result.success && result.lot) {
onCreated(result.lot.id);
} else {
setError(result.error ?? "Failed to create lot");
dispatch({ type: "SET_ERROR", value: result.error ?? "Failed to create lot" });
}
});
}
return (
<GlassModal
title="New Harvest Lot"
<GlassModal
title="New Harvest Lot"
titleIcon={<PlantIcon className="w-5 h-5" />}
subtitle="Quick entry — scan or fill in the fields below"
subtitle="Quick entry — scan or fill in the fields below"
onClose={onClose}
>
<form onSubmit={submit} className="space-y-4">
{error && (
{state.error && (
<div className="rounded-xl bg-red-50 border border-red-200 p-3 text-sm text-red-700">
{error}
{state.error}
</div>
)}
@@ -84,8 +125,8 @@ export default function QuickNewLotModal({ brandId, onCreated, onClose }: Props)
</label>
<input id="fld-crop-type-" aria-label=". Sweet Corn"
type="text"
value={crop_type}
onChange={(e) => setCropType(e.target.value)}
value={state.crop_type}
onChange={(e) => dispatch({ type: "SET_CROP_TYPE", value: e.target.value })}
placeholder="e.g. Sweet Corn"
required
className="w-full rounded-xl border border-stone-200 bg-stone-50 px-4 py-3 text-base text-stone-900 placeholder:text-stone-400 focus:outline-none focus:border-stone-400 focus:bg-white transition-colors"
@@ -96,8 +137,8 @@ export default function QuickNewLotModal({ brandId, onCreated, onClose }: Props)
<label htmlFor="fld-1-harvest-date" className="block text-sm font-medium text-stone-700 mb-1.5">Harvest Date</label>
<input id="fld-1-harvest-date" aria-label="Date"
type="date"
value={harvest_date}
onChange={(e) => setHarvestDate(e.target.value)}
value={state.harvest_date}
onChange={(e) => dispatch({ type: "SET_HARVEST_DATE", value: e.target.value })}
className="w-full rounded-xl border border-stone-200 bg-stone-50 px-4 py-3 text-base text-stone-900 focus:outline-none focus:border-stone-400 focus:bg-white transition-colors"
/>
</div>
@@ -106,8 +147,8 @@ export default function QuickNewLotModal({ brandId, onCreated, onClose }: Props)
<label htmlFor="fld-2-field-location" className="block text-sm font-medium text-stone-700 mb-1.5">Field / Location</label>
<input id="fld-2-field-location" aria-label=". North Field"
type="text"
value={field_location}
onChange={(e) => setFieldLocation(e.target.value)}
value={state.field_location}
onChange={(e) => dispatch({ type: "SET_FIELD_LOCATION", value: e.target.value })}
placeholder="e.g. North Field"
className="w-full rounded-xl border border-stone-200 bg-stone-50 px-4 py-3 text-base text-stone-900 placeholder:text-stone-400 focus:outline-none focus:border-stone-400 focus:bg-white transition-colors"
/>
@@ -118,8 +159,8 @@ export default function QuickNewLotModal({ brandId, onCreated, onClose }: Props)
<label htmlFor="fld-3-worker" className="block text-sm font-medium text-stone-700 mb-1.5">Worker</label>
<input id="fld-3-worker" aria-label="Name"
type="text"
value={worker_name}
onChange={(e) => setWorkerName(e.target.value)}
value={state.worker_name}
onChange={(e) => dispatch({ type: "SET_WORKER_NAME", value: e.target.value })}
placeholder="Name"
className="w-full rounded-xl border border-stone-200 bg-stone-50 px-4 py-3 text-base text-stone-900 placeholder:text-stone-400 focus:outline-none focus:border-stone-400 focus:bg-white transition-colors"
/>
@@ -128,8 +169,8 @@ export default function QuickNewLotModal({ brandId, onCreated, onClose }: Props)
<label htmlFor="fld-4-variety" className="block text-sm font-medium text-stone-700 mb-1.5">Variety</label>
<input id="fld-4-variety" aria-label="Type"
type="text"
value={variety}
onChange={(e) => setVariety(e.target.value)}
value={state.variety}
onChange={(e) => dispatch({ type: "SET_VARIETY", value: e.target.value })}
placeholder="Type"
className="w-full rounded-xl border border-stone-200 bg-stone-50 px-4 py-3 text-base text-stone-900 placeholder:text-stone-400 focus:outline-none focus:border-stone-400 focus:bg-white transition-colors"
/>
@@ -140,17 +181,17 @@ export default function QuickNewLotModal({ brandId, onCreated, onClose }: Props)
<label htmlFor="fld-5-quantity-lbs" className="block text-sm font-medium text-stone-700 mb-1.5">Quantity (lbs)</label>
<input id="fld-5-quantity-lbs" aria-label="0"
type="number"
value={quantity_lbs}
onChange={(e) => setQuantityLbs(e.target.value)}
value={state.quantity_lbs}
onChange={(e) => dispatch({ type: "SET_QUANTITY_LBS", value: e.target.value })}
placeholder="0"
min="0"
className="w-full rounded-xl border border-stone-200 bg-stone-50 px-4 py-3 text-base text-stone-900 placeholder:text-stone-400 focus:outline-none focus:border-stone-400 focus:bg-white transition-colors"
className="w-full rounded-xl border border-stone-200 bg-stone-50 px-4 py-3 text-base text-stone-900 focus:outline-none focus:border-stone-400 focus:bg-white transition-colors"
/>
</div>
<button
type="submit"
disabled={isPending || !crop_type.trim()}
disabled={isPending || !state.crop_type.trim()}
className="w-full rounded-xl bg-stone-800 py-3.5 text-base font-semibold text-white hover:bg-stone-700 disabled:opacity-50 transition-colors mt-2"
>
{isPending ? "Creating..." : "Create Lot"}
@@ -158,4 +199,4 @@ export default function QuickNewLotModal({ brandId, onCreated, onClose }: Props)
</form>
</GlassModal>
);
}
}
+380 -234
View File
@@ -1,6 +1,6 @@
"use client";
import { useState, useEffect, useRef, useEffectEvent } from "react";
import { useEffect, useReducer, useRef, useEffectEvent } from "react";
import Image from "next/image";
import { LotDetail } from "@/actions/route-trace/lots";
@@ -34,12 +34,54 @@ type StickerSize = "4x2" | "4x3";
const COPY_OPTIONS = [1, 2, 3, 5, 10];
type State = {
stickerType: StickerType;
stickerSize: StickerSize;
copies: number;
loading: boolean;
qrDataUrl: string | null;
};
type Action =
| { type: "SET_TYPE"; value: StickerType }
| { type: "SET_SIZE"; value: StickerSize }
| { type: "SET_COPIES"; value: number }
| { type: "START_PRINT" }
| { type: "END_PRINT" }
| { type: "SET_QR"; dataUrl: string };
const initialState: State = {
stickerType: "field",
stickerSize: "4x2",
copies: 1,
loading: false,
qrDataUrl: null,
};
function reducer(state: State, action: Action): State {
switch (action.type) {
case "SET_TYPE":
return { ...state, stickerType: action.value };
case "SET_SIZE":
return { ...state, stickerSize: action.value };
case "SET_COPIES":
return { ...state, copies: action.value };
case "START_PRINT":
return { ...state, loading: true };
case "END_PRINT":
return { ...state, loading: false };
case "SET_QR":
return { ...state, qrDataUrl: action.dataUrl };
default: {
const _exhaustive: never = action;
return _exhaustive;
}
}
}
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 [state, dispatch] = useReducer(reducer, initialState);
const { stickerType, stickerSize, copies, loading, qrDataUrl } = state;
const baseUrl = typeof window !== "undefined" ? window.location.origin : "http://localhost:3000";
const traceUrl = `${baseUrl}/trace/${lot.lot_number}`;
@@ -47,13 +89,13 @@ export default function StickerPreviewModal({ lot, onClose }: { lot: LotDetail;
useEffect(() => {
import("qrcode").then(({ toDataURL }) => {
toDataURL(traceUrl, { width: 200, margin: 1, color: { dark: "#000000", light: "#FFFFFF" } })
.then(setQrDataUrl)
.then((dataUrl: string) => dispatch({ type: "SET_QR", dataUrl }))
.catch(() => {});
});
}, [traceUrl]);
async function handlePrint() {
setLoading(true);
dispatch({ type: "START_PRINT" });
try {
const url = `/api/route-trace/sticker-pdf?lotId=${lot.lot_id}&type=${stickerType}&size=${stickerSize}&copies=${copies}`;
const res = await fetch(url);
@@ -69,7 +111,7 @@ export default function StickerPreviewModal({ lot, onClose }: { lot: LotDetail;
} catch (e) {
alert("Failed to generate PDF. Please try again.");
} finally {
setLoading(false);
dispatch({ type: "END_PRINT" });
}
}
@@ -108,20 +150,6 @@ export default function StickerPreviewModal({ lot, onClose }: { lot: LotDetail;
return () => dialog.removeEventListener("cancel", handleCancel);
}, []);
const handleBackdropClick = (e: React.MouseEvent) => {
const dialog = dialogRef.current;
if (!dialog) return;
const rect = dialog.getBoundingClientRect();
if (
e.clientX < rect.left ||
e.clientX > rect.right ||
e.clientY < rect.top ||
e.clientY > rect.bottom
) {
onClose();
}
};
useEffect(() => {
function onPointerDown(e: PointerEvent) {
const dialog = dialogRef.current;
@@ -147,223 +175,341 @@ export default function StickerPreviewModal({ lot, onClose }: { lot: LotDetail;
className="bg-transparent backdrop:bg-black/40 p-4 border-0 max-w-none max-h-none"
>
<div className="w-full max-w-lg rounded-2xl bg-white shadow-xl mx-auto my-auto">
<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 flex items-center gap-2">{Icons.printer("h-5 w-5")} Print Sticker</h3>
<p className="text-xs text-stone-400 mt-0.5">{lot.lot_number} · {lot.crop_type}</p>
</div>
<button type="button" onClick={onClose} className="text-stone-400 hover:text-stone-600" aria-label="Close">
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<ModalHeader lot={lot} onClose={onClose} />
<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 type="button"
key={t}
onClick={() => setStickerType(t)}
className={`w-full rounded-xl border px-3 py-2.5 text-left transition-colors ${
stickerType === t
? "border-emerald-600 bg-emerald-600 text-white"
: "border-stone-200 hover:bg-stone-50"
}`}
aria-label="Sticker">
<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 type="button"
key={s}
onClick={() => setStickerSize(s)}
className={`w-full rounded-xl border px-3 py-2.5 text-left transition-colors ${
stickerSize === s
? "border-emerald-600 bg-emerald-600 text-white"
: "border-stone-200 hover:bg-stone-50"
}`}
aria-label="×">
<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 type="button"
key={n}
onClick={() => setCopies(n)}
className={`rounded-lg border py-2 text-center text-sm font-bold transition-colors ${
copies === n
? "border-emerald-600 bg-emerald-600 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>
<StickerOptionSelectors
stickerType={stickerType}
stickerSize={stickerSize}
copies={copies}
onTypeChange={(t) => dispatch({ type: "SET_TYPE", value: t })}
onSizeChange={(s) => dispatch({ type: "SET_SIZE", value: s })}
onCopiesChange={(n) => dispatch({ type: "SET_COPIES", value: n })}
/>
{/* 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}&quot;</span>
</div>
<StickerPreview
lot={lot}
stickerType={stickerType}
stickerSize={stickerSize}
qrDataUrl={qrDataUrl}
previewW={previewW}
previewH={previewH}
qrPreviewSize={qrPreviewSize}
/>
{/* 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 ? (
<Image
src={qrDataUrl}
alt="QR"
width={qrPreviewSize}
height={qrPreviewSize}
unoptimized
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}&quot; · 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>
<PrintSpecs lot={lot} stickerSize={stickerSize} />
</div>
<div className="flex justify-end gap-3 border-t border-stone-100 px-6 py-4">
<button type="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"
aria-label="Cancel">
Cancel
</button>
<button type="button"
onClick={handlePrint}
disabled={loading}
className="rounded-xl bg-emerald-600 px-5 py-2.5 text-sm font-bold text-white hover:bg-emerald-700 disabled:opacity-50 flex items-center gap-2"
aria-label="Print × `} Sticker}">
{loading ? "Generating PDF..." : <><span className="inline-flex items-center gap-1.5">{Icons.printer("h-4 w-4")} Print {copies === 1 ? "" : `${copies}× `}{stickerType === "field" ? "Field" : "Shed"} Sticker{copies > 1 ? "s" : ""}</span></>}
</button>
</div>
<ModalActions
loading={loading}
copies={copies}
stickerType={stickerType}
onCancel={onClose}
onPrint={handlePrint}
/>
</div>
</dialog>
);
}
}
function ModalHeader({ lot, onClose }: { lot: LotDetail; onClose: () => void }) {
return (
<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 flex items-center gap-2">{Icons.printer("h-5 w-5")} Print Sticker</h3>
<p className="text-xs text-stone-400 mt-0.5">{lot.lot_number} · {lot.crop_type}</p>
</div>
<button type="button" onClick={onClose} className="text-stone-400 hover:text-stone-600" aria-label="Close">
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
);
}
function StickerOptionSelectors({
stickerType,
stickerSize,
copies,
onTypeChange,
onSizeChange,
onCopiesChange,
}: {
stickerType: StickerType;
stickerSize: StickerSize;
copies: number;
onTypeChange: (t: StickerType) => void;
onSizeChange: (s: StickerSize) => void;
onCopiesChange: (n: number) => void;
}) {
return (
<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 type="button"
key={t}
onClick={() => onTypeChange(t)}
className={`w-full rounded-xl border px-3 py-2.5 text-left transition-colors ${
stickerType === t
? "border-emerald-600 bg-emerald-600 text-white"
: "border-stone-200 hover:bg-stone-50"
}`}
aria-label="Sticker">
<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 type="button"
key={s}
onClick={() => onSizeChange(s)}
className={`w-full rounded-xl border px-3 py-2.5 text-left transition-colors ${
stickerSize === s
? "border-emerald-600 bg-emerald-600 text-white"
: "border-stone-200 hover:bg-stone-50"
}`}
aria-label="×">
<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 type="button"
key={n}
onClick={() => onCopiesChange(n)}
className={`rounded-lg border py-2 text-center text-sm font-bold transition-colors ${
copies === n
? "border-emerald-600 bg-emerald-600 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>
);
}
function StickerPreview({
lot,
stickerType,
stickerSize,
qrDataUrl,
previewW,
previewH,
qrPreviewSize,
}: {
lot: LotDetail;
stickerType: StickerType;
stickerSize: StickerSize;
qrDataUrl: string | null;
previewW: number;
previewH: number;
qrPreviewSize: number;
}) {
return (
<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">
<StickerTopRow stickerSize={stickerSize} />
<div className="text-[18px] font-black leading-tight text-stone-950 tracking-tight mt-0.5">
{lot.lot_number}
</div>
<div className="text-[9px] font-bold text-stone-700 mt-0.5">{lot.crop_type}</div>
<div className="flex flex-1 mt-1 gap-2">
<StickerDataColumn lot={lot} stickerType={stickerType} />
<StickerRightColumn lot={lot} stickerType={stickerType} />
</div>
<StickerFooter
lot={lot}
qrDataUrl={qrDataUrl}
qrPreviewSize={qrPreviewSize}
/>
</div>
</div>
</div>
);
}
function StickerTopRow({ stickerSize }: { stickerSize: StickerSize }) {
return (
<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}&quot;</span>
</div>
);
}
function StickerDataColumn({ lot, stickerType }: { lot: LotDetail; stickerType: StickerType }) {
if (stickerType === "field") {
return (
<div className="flex-1 space-y-0.5">
{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>
)}
</div>
);
}
return (
<div className="flex-1 space-y-0.5">
{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>
);
}
function StickerRightColumn({ lot, stickerType }: { lot: LotDetail; stickerType: StickerType }) {
return (
<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>
);
}
function StickerFooter({
lot,
qrDataUrl,
qrPreviewSize,
}: {
lot: LotDetail;
qrDataUrl: string | null;
qrPreviewSize: number;
}) {
return (
<>
<div className="absolute bottom-1.5 right-1.5">
{qrDataUrl ? (
<Image
src={qrDataUrl}
alt="QR"
width={qrPreviewSize}
height={qrPreviewSize}
unoptimized
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>
<div className="absolute bottom-1.5 left-1.5 text-[4px] text-stone-300 truncate max-w-[90px]">
/trace/{lot.lot_number}
</div>
</>
);
}
function PrintSpecs({ lot, stickerSize }: { lot: LotDetail; stickerSize: StickerSize }) {
return (
<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}&quot; · 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>
);
}
function ModalActions({
loading,
copies,
stickerType,
onCancel,
onPrint,
}: {
loading: boolean;
copies: number;
stickerType: StickerType;
onCancel: () => void;
onPrint: () => void;
}) {
return (
<div className="flex justify-end gap-3 border-t border-stone-100 px-6 py-4">
<button type="button"
onClick={onCancel}
className="rounded-xl border border-stone-200 px-4 py-2.5 text-sm font-semibold text-stone-600 hover:bg-stone-50"
aria-label="Cancel">
Cancel
</button>
<button type="button"
onClick={onPrint}
disabled={loading}
className="rounded-xl bg-emerald-600 px-5 py-2.5 text-sm font-bold text-white hover:bg-emerald-700 disabled:opacity-50 flex items-center gap-2"
aria-label="Print × `} Sticker}">
{loading ? "Generating PDF..." : <><span className="inline-flex items-center gap-1.5">{Icons.printer("h-4 w-4")} Print {copies === 1 ? "" : `${copies}× `}{stickerType === "field" ? "Field" : "Shed"} Sticker{copies > 1 ? "s" : ""}</span></>}
</button>
</div>
);
}