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