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:
@@ -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 "{query}"</p>
|
||||
<p className="text-sm text-stone-500">No lots found for "{state.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" : ""}
|
||||
{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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}"</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}" · 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}"</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}" · 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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user