Mobile view overhaul: bottom nav, card inventory, camera scanner, PWA
Build and push image / build (push) Successful in 1m25s
Build and push image / build (push) Successful in 1m25s
Replace the cramped horizontal sidebar with a 5-tab bottom nav (Home, Inventory, Scan, Custody, More) with an elevated scan button. Convert the 10-column inventory table to card-based list on mobile with scrollable filter pills and long-press selection. Add full-screen camera barcode scanner using html5-qrcode with post-scan action sheet. Set up PWA with vite-plugin-pwa for add-to-home-screen. Convert modals to bottom sheets, add pull-to-refresh, safe-area padding, and iOS input zoom fix. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
+6
-1
@@ -2,7 +2,12 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, maximum-scale=1" />
|
||||||
|
<meta name="theme-color" content="#f5efe6" />
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||||
|
<meta name="apple-mobile-web-app-title" content="Apothecary" />
|
||||||
|
<link rel="apple-touch-icon" href="/icons/icon-192.svg" />
|
||||||
<title>Apothecary — Personal Inventory</title>
|
<title>Apothecary — Personal Inventory</title>
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
|||||||
Generated
+4474
-89
File diff suppressed because it is too large
Load Diff
+3
-1
@@ -11,6 +11,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/react-query": "^5.62.7",
|
"@tanstack/react-query": "^5.62.7",
|
||||||
|
"html5-qrcode": "^2.3.8",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-router-dom": "^7.14.2"
|
"react-router-dom": "^7.14.2"
|
||||||
@@ -20,6 +21,7 @@
|
|||||||
"@types/react-dom": "^18.3.5",
|
"@types/react-dom": "^18.3.5",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
"typescript": "^5.7.2",
|
"typescript": "^5.7.2",
|
||||||
"vite": "^5.4.11"
|
"vite": "^5.4.11",
|
||||||
|
"vite-plugin-pwa": "^1.3.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 192 192" width="192" height="192">
|
||||||
|
<rect width="192" height="192" rx="32" fill="#f5efe6"/>
|
||||||
|
<circle cx="96" cy="92" r="44" fill="none" stroke="#2d2a26" stroke-width="2"/>
|
||||||
|
<text x="96" y="105" text-anchor="middle" font-family="Georgia, serif" font-size="52" font-style="italic" fill="#2d2a26">A</text>
|
||||||
|
<text x="96" y="156" text-anchor="middle" font-family="Georgia, serif" font-size="14" font-weight="500" letter-spacing="2" fill="#8b8579">APOTHECARY</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 524 B |
@@ -0,0 +1,6 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
|
||||||
|
<rect width="512" height="512" rx="80" fill="#f5efe6"/>
|
||||||
|
<circle cx="256" cy="240" r="110" fill="none" stroke="#2d2a26" stroke-width="3"/>
|
||||||
|
<text x="256" y="275" text-anchor="middle" font-family="Georgia, serif" font-size="130" font-style="italic" fill="#2d2a26">A</text>
|
||||||
|
<text x="256" y="410" text-anchor="middle" font-family="Georgia, serif" font-size="36" font-weight="500" letter-spacing="5" fill="#8b8579">APOTHECARY</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 530 B |
@@ -0,0 +1,5 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
|
||||||
|
<rect width="512" height="512" fill="#f5efe6"/>
|
||||||
|
<circle cx="256" cy="256" r="100" fill="none" stroke="#2d2a26" stroke-width="3"/>
|
||||||
|
<text x="256" y="290" text-anchor="middle" font-family="Georgia, serif" font-size="120" font-style="italic" fill="#2d2a26">A</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 363 B |
+87
-6
@@ -13,6 +13,13 @@ type DrawerBack =
|
|||||||
import { getStoredTimezone, TZ_STORAGE_KEY } from "./tz.js";
|
import { getStoredTimezone, TZ_STORAGE_KEY } from "./tz.js";
|
||||||
import { computeStats } from "./stats.js";
|
import { computeStats } from "./stats.js";
|
||||||
import { Sidebar } from "./components/Sidebar.js";
|
import { Sidebar } from "./components/Sidebar.js";
|
||||||
|
import { MobileBottomNav } from "./components/MobileBottomNav.js";
|
||||||
|
import { CameraScanner } from "./components/CameraScanner.js";
|
||||||
|
import { ScanAction } from "./components/ScanAction.js";
|
||||||
|
import { lookup } from "./components/ScanField.js";
|
||||||
|
import type { ScanResult } from "./components/ScanField.js";
|
||||||
|
import { useIsMobile } from "./hooks/useIsMobile.js";
|
||||||
|
import { usePullToRefresh } from "./hooks/usePullToRefresh.js";
|
||||||
import { Dashboard } from "./views/Dashboard.js";
|
import { Dashboard } from "./views/Dashboard.js";
|
||||||
import { Inventory } from "./views/Inventory.js";
|
import { Inventory } from "./views/Inventory.js";
|
||||||
import { SkusView } from "./views/SkusView.js";
|
import { SkusView } from "./views/SkusView.js";
|
||||||
@@ -87,6 +94,15 @@ export function App() {
|
|||||||
const [modalProduct, setModalProduct] = useState<Product | null>(null);
|
const [modalProduct, setModalProduct] = useState<Product | null>(null);
|
||||||
const [drawerBack, setDrawerBack] = useState<DrawerBack>(null);
|
const [drawerBack, setDrawerBack] = useState<DrawerBack>(null);
|
||||||
|
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
const [scannerOpen, setScannerOpen] = useState(false);
|
||||||
|
const [scanResult, setScanResult] = useState<ScanResult | null>(null);
|
||||||
|
const [scanNoMatch, setScanNoMatch] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const { pulling, refreshing } = usePullToRefresh(
|
||||||
|
() => queryClient.invalidateQueries({ queryKey: ["bootstrap"] }),
|
||||||
|
);
|
||||||
|
|
||||||
const [theme, setTheme] = useState<ThemeKey>(
|
const [theme, setTheme] = useState<ThemeKey>(
|
||||||
() => (localStorage.getItem("apothecary.theme") as ThemeKey | null) ?? "light",
|
() => (localStorage.getItem("apothecary.theme") as ThemeKey | null) ?? "light",
|
||||||
);
|
);
|
||||||
@@ -182,6 +198,19 @@ export function App() {
|
|||||||
setModal("edit");
|
setModal("edit");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleScan = (text: string) => {
|
||||||
|
const hit = lookup(text.trim().toLowerCase(), items, data?.products);
|
||||||
|
if (hit) {
|
||||||
|
setScanResult(hit);
|
||||||
|
setScanNoMatch(null);
|
||||||
|
setScannerOpen(false);
|
||||||
|
} else {
|
||||||
|
setScanResult(null);
|
||||||
|
setScanNoMatch(text.trim());
|
||||||
|
setScannerOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const openBulkEdit = (items: Item[]) => { setBulkItems(items); setModal("bulkEdit"); };
|
const openBulkEdit = (items: Item[]) => { setBulkItems(items); setModal("bulkEdit"); };
|
||||||
const openBulkConsume = (items: Item[]) => { setBulkItems(items); setModal("bulkConsume"); };
|
const openBulkConsume = (items: Item[]) => { setBulkItems(items); setModal("bulkConsume"); };
|
||||||
const openBulkCheckout = (items: Item[]) => { setBulkItems(items); setModal("bulkCheckout"); };
|
const openBulkCheckout = (items: Item[]) => { setBulkItems(items); setModal("bulkCheckout"); };
|
||||||
@@ -240,14 +269,29 @@ export function App() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="app-shell" data-screen-label="App">
|
<div className="app-shell" data-screen-label="App">
|
||||||
<Sidebar
|
{!isMobile && (
|
||||||
onAddProduct={openAdd}
|
<Sidebar
|
||||||
onMarkFinished={() => openConsume()}
|
onAddProduct={openAdd}
|
||||||
onAudit={() => openAudit()}
|
onMarkFinished={() => openConsume()}
|
||||||
onCheckout={() => openCheckout()}
|
onAudit={() => openAudit()}
|
||||||
/>
|
onCheckout={() => openCheckout()}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<main className="main parchment" style={{ minWidth: 0 }}>
|
<main className="main parchment" style={{ minWidth: 0 }}>
|
||||||
|
{isMobile && (pulling || refreshing) && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "center",
|
||||||
|
padding: "12px 0",
|
||||||
|
fontSize: 12,
|
||||||
|
color: "var(--ink-3)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{refreshing ? "Refreshing…" : "Pull to refresh"}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={
|
<Route path="/" element={
|
||||||
<Dashboard data={data} stats={stats} onAuditItem={openAudit} onAuditQueue={openAuditQueue} onSelectItem={setSelected} />
|
<Dashboard data={data} stats={stats} onAuditItem={openAudit} onAuditQueue={openAuditQueue} onSelectItem={setSelected} />
|
||||||
@@ -460,6 +504,43 @@ export function App() {
|
|||||||
{modal === "editSku" && modalProduct && (
|
{modal === "editSku" && modalProduct && (
|
||||||
<EditSkuModal data={data} product={modalProduct} onClose={() => setModal(null)} />
|
<EditSkuModal data={data} product={modalProduct} onClose={() => setModal(null)} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{isMobile && (
|
||||||
|
<MobileBottomNav
|
||||||
|
onScan={() => setScannerOpen(true)}
|
||||||
|
onAddProduct={openAdd}
|
||||||
|
onMarkFinished={() => openConsume()}
|
||||||
|
onAudit={() => openAudit()}
|
||||||
|
onCheckout={() => openCheckout()}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{scannerOpen && (
|
||||||
|
<CameraScanner
|
||||||
|
onScan={handleScan}
|
||||||
|
onClose={() => setScannerOpen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{data && (scanResult || scanNoMatch) && (
|
||||||
|
<ScanAction
|
||||||
|
result={scanResult}
|
||||||
|
data={data}
|
||||||
|
noMatchText={scanNoMatch}
|
||||||
|
onClose={() => { setScanResult(null); setScanNoMatch(null); }}
|
||||||
|
onViewItem={(i) => setSelected(i)}
|
||||||
|
onAudit={(i) => openAudit(i)}
|
||||||
|
onCheckout={(i) => openCheckout(i)}
|
||||||
|
onCheckin={(i) => openCheckin(i)}
|
||||||
|
onConsume={(i) => openConsume(i)}
|
||||||
|
onMarkGone={(i) => openMarkGone(i)}
|
||||||
|
onAddInventory={openAdd}
|
||||||
|
onViewSku={(p) => setSelectedSku(p)}
|
||||||
|
onCreateProduct={(sku) => {
|
||||||
|
setModal("addSku");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
export function BottomSheet({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
const [visible, setVisible] = useState(false);
|
||||||
|
const [closing, setClosing] = useState(false);
|
||||||
|
const sheetRef = useRef<HTMLDivElement>(null);
|
||||||
|
const startY = useRef(0);
|
||||||
|
const currentY = useRef(0);
|
||||||
|
const dragging = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setVisible(true);
|
||||||
|
setClosing(false);
|
||||||
|
}
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const animateClose = () => {
|
||||||
|
setClosing(true);
|
||||||
|
setTimeout(() => {
|
||||||
|
setVisible(false);
|
||||||
|
setClosing(false);
|
||||||
|
onClose();
|
||||||
|
}, 250);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTouchStart = (e: React.TouchEvent) => {
|
||||||
|
startY.current = e.touches[0]!.clientY;
|
||||||
|
currentY.current = 0;
|
||||||
|
dragging.current = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTouchMove = (e: React.TouchEvent) => {
|
||||||
|
if (!dragging.current) return;
|
||||||
|
const dy = e.touches[0]!.clientY - startY.current;
|
||||||
|
currentY.current = Math.max(0, dy);
|
||||||
|
if (sheetRef.current) {
|
||||||
|
sheetRef.current.style.transform = `translateY(${currentY.current}px)`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTouchEnd = () => {
|
||||||
|
dragging.current = false;
|
||||||
|
if (currentY.current > 80) {
|
||||||
|
animateClose();
|
||||||
|
} else if (sheetRef.current) {
|
||||||
|
sheetRef.current.style.transform = "";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!visible) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "fixed",
|
||||||
|
inset: 0,
|
||||||
|
zIndex: 40,
|
||||||
|
animation: closing ? "backdrop-out 250ms forwards" : "backdrop-in 200ms",
|
||||||
|
}}
|
||||||
|
onClick={animateClose}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
inset: 0,
|
||||||
|
background: "oklch(20% 0.02 60 / 0.4)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
ref={sheetRef}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onTouchStart={handleTouchStart}
|
||||||
|
onTouchMove={handleTouchMove}
|
||||||
|
onTouchEnd={handleTouchEnd}
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
bottom: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
background: "var(--surface)",
|
||||||
|
borderRadius: "var(--r-xl) var(--r-xl) 0 0",
|
||||||
|
paddingBottom: "env(safe-area-inset-bottom, 0px)",
|
||||||
|
animation: closing ? "sheet-out 250ms forwards" : "sheet-in 250ms cubic-bezier(.22,1,.36,1)",
|
||||||
|
maxHeight: "80vh",
|
||||||
|
overflowY: "auto",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 36,
|
||||||
|
height: 4,
|
||||||
|
borderRadius: 2,
|
||||||
|
background: "var(--ink-4)",
|
||||||
|
margin: "10px auto 6px",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { Html5Qrcode } from "html5-qrcode";
|
||||||
|
import { Icon } from "./primitives/index.js";
|
||||||
|
|
||||||
|
export function CameraScanner({
|
||||||
|
onScan,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
onScan: (decodedText: string) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const scannerRef = useRef<Html5Qrcode | null>(null);
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [torchOn, setTorchOn] = useState(false);
|
||||||
|
const [torchAvailable, setTorchAvailable] = useState(false);
|
||||||
|
const lastScan = useRef("");
|
||||||
|
const lastScanTime = useRef(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const id = "apothecary-scanner";
|
||||||
|
const scanner = new Html5Qrcode(id, { verbose: false });
|
||||||
|
scannerRef.current = scanner;
|
||||||
|
|
||||||
|
scanner
|
||||||
|
.start(
|
||||||
|
{ facingMode: "environment" },
|
||||||
|
{ fps: 10, qrbox: (w, h) => ({ width: Math.min(w - 40, 280), height: Math.min(h - 40, 280) }) },
|
||||||
|
(text) => {
|
||||||
|
const now = Date.now();
|
||||||
|
if (text === lastScan.current && now - lastScanTime.current < 3000) return;
|
||||||
|
lastScan.current = text;
|
||||||
|
lastScanTime.current = now;
|
||||||
|
onScan(text);
|
||||||
|
},
|
||||||
|
() => {},
|
||||||
|
)
|
||||||
|
.then(() => {
|
||||||
|
try {
|
||||||
|
const caps = scanner.getRunningTrackCameraCapabilities();
|
||||||
|
if (caps.torchFeature().isSupported()) {
|
||||||
|
setTorchAvailable(true);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// torch check can fail on some browsers
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((err: Error) => {
|
||||||
|
setError(err.message || "Camera access denied");
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (scanner.isScanning) {
|
||||||
|
scanner.stop().catch(() => {});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
const toggleTorch = () => {
|
||||||
|
try {
|
||||||
|
const caps = scannerRef.current?.getRunningTrackCameraCapabilities();
|
||||||
|
if (caps?.torchFeature().isSupported()) {
|
||||||
|
const next = !torchOn;
|
||||||
|
caps.torchFeature().apply(next);
|
||||||
|
setTorchOn(next);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "fixed",
|
||||||
|
inset: 0,
|
||||||
|
zIndex: 50,
|
||||||
|
background: "#000",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Top bar */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
padding: "env(safe-area-inset-top, 12px) 16px 12px",
|
||||||
|
position: "relative",
|
||||||
|
zIndex: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
width: 44,
|
||||||
|
height: 44,
|
||||||
|
borderRadius: "50%",
|
||||||
|
background: "rgba(0,0,0,0.5)",
|
||||||
|
border: "none",
|
||||||
|
cursor: "pointer",
|
||||||
|
color: "#fff",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon name="close" size={22} color="#fff" />
|
||||||
|
</button>
|
||||||
|
<div
|
||||||
|
className="serif"
|
||||||
|
style={{ color: "#fff", fontSize: 18, fontWeight: 500 }}
|
||||||
|
>
|
||||||
|
Scan
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "flex", gap: 8 }}>
|
||||||
|
{torchAvailable && (
|
||||||
|
<button
|
||||||
|
onClick={toggleTorch}
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
width: 44,
|
||||||
|
height: 44,
|
||||||
|
borderRadius: "50%",
|
||||||
|
background: torchOn ? "var(--sage)" : "rgba(0,0,0,0.5)",
|
||||||
|
border: "none",
|
||||||
|
cursor: "pointer",
|
||||||
|
color: "#fff",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon name="flash" size={20} color="#fff" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Camera viewport */}
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
style={{ flex: 1, position: "relative", overflow: "hidden" }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
id="apothecary-scanner"
|
||||||
|
style={{ width: "100%", height: "100%" }}
|
||||||
|
/>
|
||||||
|
{error && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
inset: 0,
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
gap: 16,
|
||||||
|
padding: 32,
|
||||||
|
color: "#fff",
|
||||||
|
textAlign: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon name="camera" size={48} color="rgba(255,255,255,0.5)" />
|
||||||
|
<div style={{ fontSize: 16, fontWeight: 500 }}>Camera unavailable</div>
|
||||||
|
<div style={{ fontSize: 13, opacity: 0.7, maxWidth: 300 }}>{error}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bottom hint */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: "16px 24px",
|
||||||
|
paddingBottom: "calc(16px + env(safe-area-inset-bottom, 0px))",
|
||||||
|
textAlign: "center",
|
||||||
|
color: "rgba(255,255,255,0.7)",
|
||||||
|
fontSize: 14,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Point at a barcode or QR code
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { NavLink, useLocation } from "react-router-dom";
|
||||||
|
import { Icon } from "./primitives/index.js";
|
||||||
|
import { NAV } from "./Sidebar.js";
|
||||||
|
import { BottomSheet } from "./BottomSheet.js";
|
||||||
|
|
||||||
|
const PRIMARY_TABS = [
|
||||||
|
{ path: "/", icon: "home", label: "Home" },
|
||||||
|
{ path: "/inventory", icon: "box", label: "Inventory" },
|
||||||
|
{ path: "/custody", icon: "pocket", label: "Custody" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const MORE_NAV = NAV.filter(
|
||||||
|
(n) => !["/", "/inventory", "/custody"].includes(n.path),
|
||||||
|
);
|
||||||
|
|
||||||
|
export function MobileBottomNav({
|
||||||
|
onScan,
|
||||||
|
onAddProduct,
|
||||||
|
onMarkFinished,
|
||||||
|
onAudit,
|
||||||
|
onCheckout,
|
||||||
|
}: {
|
||||||
|
onScan: () => void;
|
||||||
|
onAddProduct: () => void;
|
||||||
|
onMarkFinished: () => void;
|
||||||
|
onAudit: () => void;
|
||||||
|
onCheckout: () => void;
|
||||||
|
}) {
|
||||||
|
const [moreOpen, setMoreOpen] = useState(false);
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
const isMoreActive = MORE_NAV.some((n) => {
|
||||||
|
if (n.path === "/") return location.pathname === "/";
|
||||||
|
return location.pathname.startsWith(n.path);
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<nav className="mobile-nav">
|
||||||
|
{PRIMARY_TABS.map((tab) => (
|
||||||
|
<NavLink
|
||||||
|
key={tab.path}
|
||||||
|
to={tab.path}
|
||||||
|
end={tab.path === "/"}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
"mobile-nav-item" + (isActive ? " active" : "")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Icon name={tab.icon} size={22} />
|
||||||
|
<span className="mobile-nav-label">{tab.label}</span>
|
||||||
|
</NavLink>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="mobile-nav-scan"
|
||||||
|
onClick={onScan}
|
||||||
|
aria-label="Scan barcode"
|
||||||
|
>
|
||||||
|
<Icon name="barcode" size={24} color="#fff" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className={"mobile-nav-item" + (isMoreActive ? " active" : "")}
|
||||||
|
onClick={() => setMoreOpen(true)}
|
||||||
|
>
|
||||||
|
<Icon name="more" size={22} />
|
||||||
|
<span className="mobile-nav-label">More</span>
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<BottomSheet open={moreOpen} onClose={() => setMoreOpen(false)}>
|
||||||
|
<div style={{ padding: "8px 16px 12px" }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
textTransform: "uppercase",
|
||||||
|
letterSpacing: "0.1em",
|
||||||
|
color: "var(--ink-3)",
|
||||||
|
fontWeight: 500,
|
||||||
|
padding: "8px 8px 4px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Navigate
|
||||||
|
</div>
|
||||||
|
{MORE_NAV.map((n) => (
|
||||||
|
<NavLink
|
||||||
|
key={n.path}
|
||||||
|
to={n.path}
|
||||||
|
end={n.path === "/"}
|
||||||
|
onClick={() => setMoreOpen(false)}
|
||||||
|
style={({ isActive }) => ({
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 12,
|
||||||
|
padding: "14px 8px",
|
||||||
|
borderRadius: "var(--r-md)",
|
||||||
|
color: isActive ? "var(--ink)" : "var(--ink-2)",
|
||||||
|
fontWeight: isActive ? 600 : 500,
|
||||||
|
fontSize: 15,
|
||||||
|
textDecoration: "none",
|
||||||
|
background: isActive ? "var(--bg-2)" : "transparent",
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Icon name={n.icon} size={20} />
|
||||||
|
{n.label}
|
||||||
|
</NavLink>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: 1,
|
||||||
|
background: "var(--line)",
|
||||||
|
margin: "8px 0",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
textTransform: "uppercase",
|
||||||
|
letterSpacing: "0.1em",
|
||||||
|
color: "var(--ink-3)",
|
||||||
|
fontWeight: 500,
|
||||||
|
padding: "8px 8px 4px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Quick actions
|
||||||
|
</div>
|
||||||
|
{[
|
||||||
|
{ icon: "plus", label: "Add inventory", action: onAddProduct },
|
||||||
|
{ icon: "search", label: "Audit", action: onAudit },
|
||||||
|
{ icon: "pocket", label: "Check out", action: onCheckout },
|
||||||
|
{ icon: "check", label: "Mark consumed", action: onMarkFinished },
|
||||||
|
].map((a) => (
|
||||||
|
<button
|
||||||
|
key={a.label}
|
||||||
|
onClick={() => {
|
||||||
|
setMoreOpen(false);
|
||||||
|
a.action();
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 12,
|
||||||
|
padding: "14px 8px",
|
||||||
|
borderRadius: "var(--r-md)",
|
||||||
|
color: "var(--ink-2)",
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: 500,
|
||||||
|
background: "transparent",
|
||||||
|
border: "none",
|
||||||
|
cursor: "pointer",
|
||||||
|
width: "100%",
|
||||||
|
textAlign: "left",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon name={a.icon} size={20} />
|
||||||
|
{a.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</BottomSheet>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import type { Bootstrap, Item, Product } from "../types.js";
|
import type { Bootstrap, Item, Product } from "../types.js";
|
||||||
import { TYPES, helpers } from "../types.js";
|
import { TYPES, helpers } from "../types.js";
|
||||||
import { getToday, getStoredTimezone } from "../tz.js";
|
import { getToday, getStoredTimezone } from "../tz.js";
|
||||||
@@ -6,6 +6,8 @@ import { fmt, TYPE_GLYPHS } from "../format.js";
|
|||||||
import { Btn, Pill, Icon } from "./primitives/index.js";
|
import { Btn, Pill, Icon } from "./primitives/index.js";
|
||||||
import { useExitAnimation } from "../hooks/useExitAnimation.js";
|
import { useExitAnimation } from "../hooks/useExitAnimation.js";
|
||||||
import { useFocusTrap } from "../hooks/useFocusTrap.js";
|
import { useFocusTrap } from "../hooks/useFocusTrap.js";
|
||||||
|
import { useIsMobile } from "../hooks/useIsMobile.js";
|
||||||
|
import { BottomSheet } from "./BottomSheet.js";
|
||||||
|
|
||||||
// Right-side drawer for an inventory instance. Shows the asset id and
|
// Right-side drawer for an inventory instance. Shows the asset id and
|
||||||
// product context up top, then per-batch fields (price, THC, weight),
|
// product context up top, then per-batch fields (price, THC, weight),
|
||||||
@@ -48,6 +50,8 @@ export function ProductDetail({
|
|||||||
const isCheckedOut = item.status === "checked-out";
|
const isCheckedOut = item.status === "checked-out";
|
||||||
const { closing, triggerClose } = useExitAnimation(220, onClose);
|
const { closing, triggerClose } = useExitAnimation(220, onClose);
|
||||||
const trapRef = useFocusTrap<HTMLDivElement>();
|
const trapRef = useFocusTrap<HTMLDivElement>();
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
const [actionsOpen, setActionsOpen] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
@@ -137,7 +141,7 @@ export function ProductDetail({
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
padding: "20px 32px",
|
padding: isMobile ? "14px 16px" : "20px 32px",
|
||||||
borderBottom: "1px solid var(--line)",
|
borderBottom: "1px solid var(--line)",
|
||||||
display: "flex",
|
display: "flex",
|
||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
@@ -171,43 +175,50 @@ export function ProductDetail({
|
|||||||
<div className="smallcaps" style={{ color: "var(--ink-3)" }}>
|
<div className="smallcaps" style={{ color: "var(--ink-3)" }}>
|
||||||
Inventory · <span className="mono">{item.assetId}</span>
|
Inventory · <span className="mono">{item.assetId}</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: "flex", gap: 6, alignItems: "center" }}>
|
{isMobile ? (
|
||||||
{isActive && (
|
<div style={{ display: "flex", gap: 6, alignItems: "center" }}>
|
||||||
<Btn variant={overdue ? "sage" : "ghost"} icon="search" onClick={() => onAudit(item)}>
|
<Btn variant="ghost" icon="more" onClick={() => setActionsOpen(true)} />
|
||||||
Audit
|
<Btn variant="ghost" icon="close" onClick={triggerClose} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: "flex", gap: 6, alignItems: "center" }}>
|
||||||
|
{isActive && (
|
||||||
|
<Btn variant={overdue ? "sage" : "ghost"} icon="search" onClick={() => onAudit(item)}>
|
||||||
|
Audit
|
||||||
|
</Btn>
|
||||||
|
)}
|
||||||
|
{isActive && (
|
||||||
|
<Btn variant={overdue ? "ghost" : "secondary"} icon="pocket" onClick={() => onCheckout(item)}>
|
||||||
|
Check out
|
||||||
|
</Btn>
|
||||||
|
)}
|
||||||
|
{isCheckedOut && (
|
||||||
|
<Btn variant="sage" icon="pocket" onClick={() => onCheckin(item)}>
|
||||||
|
Check in
|
||||||
|
</Btn>
|
||||||
|
)}
|
||||||
|
<div style={{ width: 1, height: 20, background: "var(--line)", margin: "0 2px" }} />
|
||||||
|
{(isActive || isCheckedOut) && (
|
||||||
|
<Btn variant="ghost" icon="leaf" onClick={() => onConsume(item)}>
|
||||||
|
Consume
|
||||||
|
</Btn>
|
||||||
|
)}
|
||||||
|
{(isActive || isCheckedOut) && (
|
||||||
|
<Btn variant="ghost" icon="bin" onClick={() => onMarkGone(item)}>
|
||||||
|
Gone
|
||||||
|
</Btn>
|
||||||
|
)}
|
||||||
|
<Btn variant="ghost" icon="edit" onClick={() => onEdit(item)}>
|
||||||
|
Edit
|
||||||
</Btn>
|
</Btn>
|
||||||
)}
|
<Btn variant="ghost" icon="close" onClick={triggerClose} />
|
||||||
{isActive && (
|
</div>
|
||||||
<Btn variant={overdue ? "ghost" : "secondary"} icon="pocket" onClick={() => onCheckout(item)}>
|
)}
|
||||||
Check out
|
|
||||||
</Btn>
|
|
||||||
)}
|
|
||||||
{isCheckedOut && (
|
|
||||||
<Btn variant="sage" icon="pocket" onClick={() => onCheckin(item)}>
|
|
||||||
Check in
|
|
||||||
</Btn>
|
|
||||||
)}
|
|
||||||
<div style={{ width: 1, height: 20, background: "var(--line)", margin: "0 2px" }} />
|
|
||||||
{(isActive || isCheckedOut) && (
|
|
||||||
<Btn variant="ghost" icon="leaf" onClick={() => onConsume(item)}>
|
|
||||||
Consume
|
|
||||||
</Btn>
|
|
||||||
)}
|
|
||||||
{(isActive || isCheckedOut) && (
|
|
||||||
<Btn variant="ghost" icon="bin" onClick={() => onMarkGone(item)}>
|
|
||||||
Gone
|
|
||||||
</Btn>
|
|
||||||
)}
|
|
||||||
<Btn variant="ghost" icon="edit" onClick={() => onEdit(item)}>
|
|
||||||
Edit
|
|
||||||
</Btn>
|
|
||||||
<Btn variant="ghost" icon="close" onClick={triggerClose} />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ padding: "32px 32px 60px" }}>
|
<div style={{ padding: isMobile ? "20px 16px 60px" : "32px 32px 60px" }}>
|
||||||
<div style={{ display: "flex", alignItems: "baseline", gap: 16, marginBottom: 8 }}>
|
<div style={{ display: "flex", alignItems: "baseline", gap: 16, marginBottom: 8, flexWrap: "wrap" }}>
|
||||||
<div className="serif" style={{ fontSize: 18, color: "var(--ink-3)" }}>
|
<div className="serif" style={{ fontSize: 18, color: "var(--ink-3)" }}>
|
||||||
{TYPE_GLYPHS[item.type]} {item.type}
|
{TYPE_GLYPHS[item.type]} {item.type}
|
||||||
</div>
|
</div>
|
||||||
@@ -225,7 +236,7 @@ export function ProductDetail({
|
|||||||
<h1
|
<h1
|
||||||
className="serif"
|
className="serif"
|
||||||
style={{
|
style={{
|
||||||
fontSize: 48,
|
fontSize: isMobile ? 28 : 48,
|
||||||
margin: "0 0 4px",
|
margin: "0 0 4px",
|
||||||
fontWeight: 500,
|
fontWeight: 500,
|
||||||
letterSpacing: "-0.02em",
|
letterSpacing: "-0.02em",
|
||||||
@@ -437,7 +448,7 @@ export function ProductDetail({
|
|||||||
|
|
||||||
<div style={{ marginTop: 36 }}>
|
<div style={{ marginTop: 36 }}>
|
||||||
<div className="smallcaps" style={{ color: "var(--ink-3)", marginBottom: 12 }}>Details</div>
|
<div className="smallcaps" style={{ color: "var(--ink-3)", marginBottom: 12 }}>Details</div>
|
||||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "14px 32px" }}>
|
<div style={{ display: "grid", gridTemplateColumns: isMobile ? "1fr" : "1fr 1fr", gap: isMobile ? "0" : "14px 32px" }}>
|
||||||
{detailRows.map(([l, v], i) => (
|
{detailRows.map(([l, v], i) => (
|
||||||
<div
|
<div
|
||||||
key={i}
|
key={i}
|
||||||
@@ -498,7 +509,56 @@ export function ProductDetail({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{isMobile && (
|
||||||
|
<BottomSheet open={actionsOpen} onClose={() => setActionsOpen(false)}>
|
||||||
|
<div style={{ padding: "8px 16px 20px" }}>
|
||||||
|
{isActive && (
|
||||||
|
<MobileAction icon="search" label="Audit" onClick={() => { setActionsOpen(false); onAudit(item); }} />
|
||||||
|
)}
|
||||||
|
{isActive && (
|
||||||
|
<MobileAction icon="pocket" label="Check out" onClick={() => { setActionsOpen(false); onCheckout(item); }} />
|
||||||
|
)}
|
||||||
|
{isCheckedOut && (
|
||||||
|
<MobileAction icon="pocket" label="Check in" onClick={() => { setActionsOpen(false); onCheckin(item); }} />
|
||||||
|
)}
|
||||||
|
{(isActive || isCheckedOut) && (
|
||||||
|
<MobileAction icon="leaf" label="Mark consumed" onClick={() => { setActionsOpen(false); onConsume(item); }} />
|
||||||
|
)}
|
||||||
|
{(isActive || isCheckedOut) && (
|
||||||
|
<MobileAction icon="bin" label="Mark gone" onClick={() => { setActionsOpen(false); onMarkGone(item); }} />
|
||||||
|
)}
|
||||||
|
<MobileAction icon="edit" label="Edit" onClick={() => { setActionsOpen(false); onEdit(item); }} />
|
||||||
|
</div>
|
||||||
|
</BottomSheet>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function MobileAction({ icon, label, onClick }: { icon: string; label: string; onClick: () => void }) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 12,
|
||||||
|
padding: "14px 8px",
|
||||||
|
borderRadius: "var(--r-md)",
|
||||||
|
color: "var(--ink-2)",
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: 500,
|
||||||
|
background: "transparent",
|
||||||
|
border: "none",
|
||||||
|
cursor: "pointer",
|
||||||
|
width: "100%",
|
||||||
|
textAlign: "left",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon name={icon} size={20} />
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,175 @@
|
|||||||
|
import type { Item, Product, Bootstrap } from "../types.js";
|
||||||
|
import { helpers } from "../types.js";
|
||||||
|
import { remainingShort } from "../stats.js";
|
||||||
|
import { TYPE_GLYPHS } from "../format.js";
|
||||||
|
import { Icon, Pill } from "./primitives/index.js";
|
||||||
|
import { BottomSheet } from "./BottomSheet.js";
|
||||||
|
import type { ScanResult } from "./ScanField.js";
|
||||||
|
|
||||||
|
export function ScanAction({
|
||||||
|
result,
|
||||||
|
data,
|
||||||
|
noMatchText,
|
||||||
|
onClose,
|
||||||
|
onViewItem,
|
||||||
|
onAudit,
|
||||||
|
onCheckout,
|
||||||
|
onCheckin,
|
||||||
|
onConsume,
|
||||||
|
onMarkGone,
|
||||||
|
onAddInventory,
|
||||||
|
onViewSku,
|
||||||
|
onCreateProduct,
|
||||||
|
}: {
|
||||||
|
result: ScanResult | null;
|
||||||
|
data: Bootstrap;
|
||||||
|
noMatchText: string | null;
|
||||||
|
onClose: () => void;
|
||||||
|
onViewItem: (item: Item) => void;
|
||||||
|
onAudit: (item: Item) => void;
|
||||||
|
onCheckout: (item: Item) => void;
|
||||||
|
onCheckin: (item: Item) => void;
|
||||||
|
onConsume: (item: Item) => void;
|
||||||
|
onMarkGone: (item: Item) => void;
|
||||||
|
onAddInventory: () => void;
|
||||||
|
onViewSku: (product: Product) => void;
|
||||||
|
onCreateProduct: (sku: string) => void;
|
||||||
|
}) {
|
||||||
|
const open = result !== null || noMatchText !== null;
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
if (noMatchText) {
|
||||||
|
return (
|
||||||
|
<BottomSheet open onClose={onClose}>
|
||||||
|
<div style={{ padding: "12px 16px 20px" }}>
|
||||||
|
<div style={{ textAlign: "center", padding: "12px 0 16px" }}>
|
||||||
|
<div style={{ fontSize: 15, fontWeight: 500, color: "var(--ink)" }}>
|
||||||
|
No match found
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="mono"
|
||||||
|
style={{ fontSize: 13, color: "var(--ink-3)", marginTop: 4 }}
|
||||||
|
>
|
||||||
|
{noMatchText}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ActionButton
|
||||||
|
icon="plus"
|
||||||
|
label="Create new product with this SKU"
|
||||||
|
onClick={() => {
|
||||||
|
onClose();
|
||||||
|
onCreateProduct(noMatchText);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</BottomSheet>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result?.kind === "item") {
|
||||||
|
const item = result.item;
|
||||||
|
const brand = helpers.brandName(data, item.brandId);
|
||||||
|
const overdue = helpers.auditOverdue(item, new Date().toISOString().slice(0, 10));
|
||||||
|
return (
|
||||||
|
<BottomSheet open onClose={onClose}>
|
||||||
|
<div style={{ padding: "12px 16px 20px" }}>
|
||||||
|
{/* Item header */}
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 12, padding: "8px 0 16px" }}>
|
||||||
|
<div style={{ fontFamily: "var(--serif)", fontSize: 24, color: "var(--ink-3)" }}>
|
||||||
|
{TYPE_GLYPHS[item.type]}
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{ fontWeight: 500, fontSize: 16, color: "var(--ink)" }}>
|
||||||
|
{item.name}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 12, color: "var(--ink-3)", display: "flex", gap: 8, alignItems: "center" }}>
|
||||||
|
<span>{brand}</span>
|
||||||
|
<span style={{ color: "var(--ink-4)" }}>·</span>
|
||||||
|
<span className="mono">{item.assetId}</span>
|
||||||
|
<span style={{ color: "var(--ink-4)" }}>·</span>
|
||||||
|
<span className="mono">{remainingShort(item)}</span>
|
||||||
|
{overdue && <Pill tone="amber" style={{ fontSize: 10 }}>Audit due</Pill>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<ActionButton icon="arrow" label="View details" onClick={() => { onClose(); onViewItem(item); }} />
|
||||||
|
<ActionButton icon="search" label="Audit" onClick={() => { onClose(); onAudit(item); }} />
|
||||||
|
{item.status === "active" && (
|
||||||
|
<ActionButton icon="pocket" label="Check out" onClick={() => { onClose(); onCheckout(item); }} />
|
||||||
|
)}
|
||||||
|
{item.status === "checked-out" && (
|
||||||
|
<ActionButton icon="pocket" label="Check in" onClick={() => { onClose(); onCheckin(item); }} />
|
||||||
|
)}
|
||||||
|
{item.status === "active" && (
|
||||||
|
<ActionButton icon="check" label="Mark consumed" onClick={() => { onClose(); onConsume(item); }} />
|
||||||
|
)}
|
||||||
|
{item.status === "active" && (
|
||||||
|
<ActionButton icon="close" label="Mark gone" onClick={() => { onClose(); onMarkGone(item); }} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</BottomSheet>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result?.kind === "product") {
|
||||||
|
const product = result.product;
|
||||||
|
return (
|
||||||
|
<BottomSheet open onClose={onClose}>
|
||||||
|
<div style={{ padding: "12px 16px 20px" }}>
|
||||||
|
<div style={{ textAlign: "center", padding: "8px 0 16px" }}>
|
||||||
|
<div style={{ fontFamily: "var(--serif)", fontSize: 22, color: "var(--ink-3)", marginBottom: 4 }}>
|
||||||
|
{TYPE_GLYPHS[product.type]}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontWeight: 500, fontSize: 16, color: "var(--ink)" }}>
|
||||||
|
SKU matched
|
||||||
|
</div>
|
||||||
|
<div className="mono" style={{ fontSize: 13, color: "var(--ink-3)", marginTop: 2 }}>
|
||||||
|
{product.sku}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ActionButton icon="plus" label="Add inventory of this product" onClick={() => { onClose(); onAddInventory(); }} />
|
||||||
|
<ActionButton icon="barcode" label="View SKU details" onClick={() => { onClose(); onViewSku(product); }} />
|
||||||
|
</div>
|
||||||
|
</BottomSheet>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ActionButton({
|
||||||
|
icon,
|
||||||
|
label,
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
icon: string;
|
||||||
|
label: string;
|
||||||
|
onClick: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 12,
|
||||||
|
padding: "14px 8px",
|
||||||
|
borderRadius: "var(--r-md)",
|
||||||
|
color: "var(--ink-2)",
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: 500,
|
||||||
|
background: "transparent",
|
||||||
|
border: "none",
|
||||||
|
cursor: "pointer",
|
||||||
|
width: "100%",
|
||||||
|
textAlign: "left",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon name={icon} size={20} />
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -125,7 +125,7 @@ export function ScanField({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function lookup(
|
export function lookup(
|
||||||
trimmed: string,
|
trimmed: string,
|
||||||
items: Item[],
|
items: Item[],
|
||||||
products?: Product[],
|
products?: Product[],
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export type ViewKey =
|
|||||||
| "charts"
|
| "charts"
|
||||||
| "settings";
|
| "settings";
|
||||||
|
|
||||||
const NAV: { path: string; label: string; icon: string }[] = [
|
export const NAV: { path: string; label: string; icon: string }[] = [
|
||||||
{ path: "/", label: "Dashboard", icon: "home" },
|
{ path: "/", label: "Dashboard", icon: "home" },
|
||||||
{ path: "/inventory", label: "Inventory", icon: "box" },
|
{ path: "/inventory", label: "Inventory", icon: "box" },
|
||||||
{ path: "/skus", label: "SKUs", icon: "barcode" },
|
{ path: "/skus", label: "SKUs", icon: "barcode" },
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { createContext, useCallback, useContext, useRef, useState } from "react";
|
import { createContext, useCallback, useContext, useRef, useState } from "react";
|
||||||
import { Icon } from "./primitives/index.js";
|
import { Icon } from "./primitives/index.js";
|
||||||
|
import { useIsMobile } from "../hooks/useIsMobile.js";
|
||||||
|
|
||||||
type ToastType = "success" | "error";
|
type ToastType = "success" | "error";
|
||||||
|
|
||||||
@@ -56,6 +57,8 @@ export function ToastProvider({ children }: { children: React.ReactNode }) {
|
|||||||
startTimer(id);
|
startTimer(id);
|
||||||
}, [startTimer]);
|
}, [startTimer]);
|
||||||
|
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ToastContext.Provider value={{ toast }}>
|
<ToastContext.Provider value={{ toast }}>
|
||||||
{children}
|
{children}
|
||||||
@@ -65,8 +68,9 @@ export function ToastProvider({ children }: { children: React.ReactNode }) {
|
|||||||
aria-live="polite"
|
aria-live="polite"
|
||||||
style={{
|
style={{
|
||||||
position: "fixed",
|
position: "fixed",
|
||||||
bottom: 24,
|
bottom: isMobile ? "calc(72px + env(safe-area-inset-bottom, 0px) + 12px)" : 24,
|
||||||
right: 24,
|
right: isMobile ? 12 : 24,
|
||||||
|
left: isMobile ? 12 : "auto",
|
||||||
zIndex: 100,
|
zIndex: 100,
|
||||||
display: "flex",
|
display: "flex",
|
||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { createContext, useContext, useEffect, useRef } from "react";
|
import { createContext, useContext, useEffect, useRef } from "react";
|
||||||
import { useExitAnimation } from "../../hooks/useExitAnimation.js";
|
import { useExitAnimation } from "../../hooks/useExitAnimation.js";
|
||||||
|
import { useIsMobile } from "../../hooks/useIsMobile.js";
|
||||||
import { Btn } from "../primitives/index.js";
|
import { Btn } from "../primitives/index.js";
|
||||||
|
|
||||||
const ModalCloseCtx = createContext<(() => void) | null>(null);
|
const ModalCloseCtx = createContext<(() => void) | null>(null);
|
||||||
@@ -14,6 +15,7 @@ export function ModalBackdrop({
|
|||||||
const backdropRef = useRef<HTMLDivElement>(null);
|
const backdropRef = useRef<HTMLDivElement>(null);
|
||||||
const previousFocus = useRef<Element | null>(null);
|
const previousFocus = useRef<Element | null>(null);
|
||||||
const { closing, triggerClose } = useExitAnimation(180, onClose);
|
const { closing, triggerClose } = useExitAnimation(180, onClose);
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
previousFocus.current = document.activeElement;
|
previousFocus.current = document.activeElement;
|
||||||
@@ -77,7 +79,17 @@ export function ModalBackdrop({
|
|||||||
<ModalCloseCtx.Provider value={triggerClose}>
|
<ModalCloseCtx.Provider value={triggerClose}>
|
||||||
<div
|
<div
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
style={{
|
style={isMobile ? {
|
||||||
|
position: "absolute",
|
||||||
|
bottom: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
maxHeight: "95vh",
|
||||||
|
overflowY: "auto",
|
||||||
|
background: "var(--surface)",
|
||||||
|
borderRadius: "var(--r-xl) var(--r-xl) 0 0",
|
||||||
|
animation: closing ? "sheet-out 180ms ease-in forwards" : "sheet-in 200ms cubic-bezier(.22,1,.36,1)",
|
||||||
|
} : {
|
||||||
width: "100%",
|
width: "100%",
|
||||||
display: "flex",
|
display: "flex",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
@@ -103,10 +115,11 @@ export function ModalHeader({
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}) {
|
}) {
|
||||||
const animatedClose = useContext(ModalCloseCtx);
|
const animatedClose = useContext(ModalCloseCtx);
|
||||||
|
const isMobile = useIsMobile();
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
padding: "20px 32px",
|
padding: isMobile ? "16px 20px" : "20px 32px",
|
||||||
borderBottom: "1px solid var(--line)",
|
borderBottom: "1px solid var(--line)",
|
||||||
display: "flex",
|
display: "flex",
|
||||||
justifyContent: "space-between",
|
justifyContent: "space-between",
|
||||||
@@ -119,7 +132,7 @@ export function ModalHeader({
|
|||||||
{eyebrow}
|
{eyebrow}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<h2 className="serif" style={{ fontSize: 28, margin: "4px 0 0", fontWeight: 500 }}>
|
<h2 className="serif" style={{ fontSize: isMobile ? 22 : 28, margin: "4px 0 0", fontWeight: 500 }}>
|
||||||
{title}
|
{title}
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
@@ -129,16 +142,18 @@ export function ModalHeader({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ModalFooter({ children }: { children: React.ReactNode }) {
|
export function ModalFooter({ children }: { children: React.ReactNode }) {
|
||||||
|
const isMobile = useIsMobile();
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
padding: "16px 32px",
|
padding: isMobile ? "16px 20px" : "16px 32px",
|
||||||
|
paddingBottom: isMobile ? "calc(16px + env(safe-area-inset-bottom, 0px))" : "16px",
|
||||||
borderTop: "1px solid var(--line)",
|
borderTop: "1px solid var(--line)",
|
||||||
display: "flex",
|
display: "flex",
|
||||||
justifyContent: "space-between",
|
justifyContent: "space-between",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
background: "var(--bg-2)",
|
background: "var(--bg-2)",
|
||||||
borderRadius: "0 0 var(--r-lg) var(--r-lg)",
|
borderRadius: isMobile ? "0" : "0 0 var(--r-lg) var(--r-lg)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -26,6 +26,10 @@ const ICON_PATHS: Record<string, string> = {
|
|||||||
pocket: "M5 4h14v5l-2 3v5a3 3 0 01-3 3h-4a3 3 0 01-3-3v-5L5 9V4zM9 12v5M15 12v5",
|
pocket: "M5 4h14v5l-2 3v5a3 3 0 01-3 3h-4a3 3 0 01-3-3v-5L5 9V4zM9 12v5M15 12v5",
|
||||||
shop: "M4 9V4h16v5M4 9v11h16V9M4 9h16M10 20v-6h4v6",
|
shop: "M4 9V4h16v5M4 9v11h16V9M4 9h16M10 20v-6h4v6",
|
||||||
barcode: "M4 4v16M8 4v16M11 4v16M14 4v16M18 4v16M20 4v16",
|
barcode: "M4 4v16M8 4v16M11 4v16M14 4v16M18 4v16M20 4v16",
|
||||||
|
more: "M12 5h.01M12 12h.01M12 19h.01",
|
||||||
|
camera: "M23 19a2 2 0 01-2 2H3a2 2 0 01-2-2V8a2 2 0 012-2h4l2-3h6l2 3h4a2 2 0 012 2v11zM12 17a4 4 0 100-8 4 4 0 000 8z",
|
||||||
|
flash: "M13 2L3 14h9l-1 8 10-12h-9l1-8z",
|
||||||
|
flipCamera: "M16 3h5v5M8 21H3v-5M21 3l-7 7M3 21l7-7",
|
||||||
};
|
};
|
||||||
|
|
||||||
export function Icon({
|
export function Icon({
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { useSyncExternalStore } from "react";
|
||||||
|
|
||||||
|
const QUERY = "(max-width: 880px)";
|
||||||
|
|
||||||
|
function subscribe(cb: () => void) {
|
||||||
|
const mql = window.matchMedia(QUERY);
|
||||||
|
mql.addEventListener("change", cb);
|
||||||
|
return () => mql.removeEventListener("change", cb);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSnapshot() {
|
||||||
|
return window.matchMedia(QUERY).matches;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useIsMobile() {
|
||||||
|
return useSyncExternalStore(subscribe, getSnapshot, () => false);
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
export function usePullToRefresh(onRefresh: () => Promise<void> | void) {
|
||||||
|
const [pulling, setPulling] = useState(false);
|
||||||
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
const startY = useRef(0);
|
||||||
|
const pullDistance = useRef(0);
|
||||||
|
const indicatorRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let active = false;
|
||||||
|
|
||||||
|
const onTouchStart = (e: TouchEvent) => {
|
||||||
|
if (window.scrollY > 0) return;
|
||||||
|
startY.current = e.touches[0]!.clientY;
|
||||||
|
active = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onTouchMove = (e: TouchEvent) => {
|
||||||
|
if (!active || window.scrollY > 0) {
|
||||||
|
active = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const dy = e.touches[0]!.clientY - startY.current;
|
||||||
|
if (dy < 0) return;
|
||||||
|
pullDistance.current = Math.min(dy, 120);
|
||||||
|
if (pullDistance.current > 10) {
|
||||||
|
setPulling(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onTouchEnd = async () => {
|
||||||
|
if (!active) return;
|
||||||
|
active = false;
|
||||||
|
if (pullDistance.current > 60) {
|
||||||
|
setRefreshing(true);
|
||||||
|
setPulling(false);
|
||||||
|
await onRefresh();
|
||||||
|
setRefreshing(false);
|
||||||
|
} else {
|
||||||
|
setPulling(false);
|
||||||
|
}
|
||||||
|
pullDistance.current = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("touchstart", onTouchStart, { passive: true });
|
||||||
|
document.addEventListener("touchmove", onTouchMove, { passive: true });
|
||||||
|
document.addEventListener("touchend", onTouchEnd);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("touchstart", onTouchStart);
|
||||||
|
document.removeEventListener("touchmove", onTouchMove);
|
||||||
|
document.removeEventListener("touchend", onTouchEnd);
|
||||||
|
};
|
||||||
|
}, [onRefresh]);
|
||||||
|
|
||||||
|
return { pulling, refreshing, indicatorRef };
|
||||||
|
}
|
||||||
+71
-40
@@ -126,6 +126,14 @@
|
|||||||
from { transform: translateY(100%); opacity: 0; }
|
from { transform: translateY(100%); opacity: 0; }
|
||||||
to { transform: translateY(0); opacity: 1; }
|
to { transform: translateY(0); opacity: 1; }
|
||||||
}
|
}
|
||||||
|
@keyframes sheet-in {
|
||||||
|
from { transform: translateY(100%); }
|
||||||
|
to { transform: translateY(0); }
|
||||||
|
}
|
||||||
|
@keyframes sheet-out {
|
||||||
|
from { transform: translateY(0); }
|
||||||
|
to { transform: translateY(100%); }
|
||||||
|
}
|
||||||
@keyframes backdrop-out {
|
@keyframes backdrop-out {
|
||||||
from { opacity: 1; }
|
from { opacity: 1; }
|
||||||
to { opacity: 0; }
|
to { opacity: 0; }
|
||||||
@@ -151,51 +159,74 @@
|
|||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
.sidebar {
|
.sidebar {
|
||||||
position: fixed;
|
|
||||||
bottom: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
top: auto;
|
|
||||||
height: auto;
|
|
||||||
flex-direction: row;
|
|
||||||
padding: 8px 12px;
|
|
||||||
border-top: 1px solid var(--line);
|
|
||||||
border-right: none;
|
|
||||||
z-index: 30;
|
|
||||||
overflow-x: auto;
|
|
||||||
}
|
|
||||||
.brand,
|
|
||||||
.nav-section {
|
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
.nav-link {
|
|
||||||
white-space: nowrap;
|
|
||||||
padding: 8px 12px;
|
|
||||||
flex-direction: column;
|
|
||||||
font-size: 10px;
|
|
||||||
gap: 2px;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
.nav-label {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
.nav-divider {
|
|
||||||
display: block;
|
|
||||||
width: 1px;
|
|
||||||
height: 24px;
|
|
||||||
background: var(--line);
|
|
||||||
flex-shrink: 0;
|
|
||||||
align-self: center;
|
|
||||||
}
|
|
||||||
.nav-action {
|
|
||||||
border-style: solid;
|
|
||||||
border-color: var(--line);
|
|
||||||
}
|
|
||||||
.main {
|
.main {
|
||||||
padding-bottom: 60px;
|
padding-bottom: calc(72px + env(safe-area-inset-bottom, 0px));
|
||||||
|
}
|
||||||
|
input, select, textarea {
|
||||||
|
font-size: 16px !important;
|
||||||
}
|
}
|
||||||
.bulk-toolbar {
|
.bulk-toolbar {
|
||||||
left: 0 !important;
|
left: 0 !important;
|
||||||
bottom: 60px !important;
|
bottom: calc(72px + env(safe-area-inset-bottom, 0px)) !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Mobile bottom nav */
|
||||||
|
.mobile-nav {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: calc(64px + env(safe-area-inset-bottom, 0px));
|
||||||
|
padding-bottom: env(safe-area-inset-bottom, 0px);
|
||||||
|
background: var(--surface);
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-around;
|
||||||
|
z-index: 30;
|
||||||
|
}
|
||||||
|
.mobile-nav-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 3px;
|
||||||
|
min-width: 48px;
|
||||||
|
min-height: 44px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
color: var(--ink-3);
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: color 100ms;
|
||||||
|
}
|
||||||
|
.mobile-nav-item.active {
|
||||||
|
color: var(--sage);
|
||||||
|
}
|
||||||
|
.mobile-nav-label {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
.mobile-nav-scan {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 52px;
|
||||||
|
height: 52px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--sage);
|
||||||
|
border: 3px solid var(--surface);
|
||||||
|
color: #fff;
|
||||||
|
cursor: pointer;
|
||||||
|
margin-top: -20px;
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
transition: transform 100ms;
|
||||||
|
}
|
||||||
|
.mobile-nav-scan:active {
|
||||||
|
transform: scale(0.93);
|
||||||
|
}
|
||||||
|
|||||||
@@ -43,6 +43,9 @@
|
|||||||
--r-lg: 14px;
|
--r-lg: 14px;
|
||||||
--r-xl: 20px;
|
--r-xl: 20px;
|
||||||
|
|
||||||
|
/* Touch targets */
|
||||||
|
--touch-min: 44px;
|
||||||
|
|
||||||
/* Shadow — subtle */
|
/* Shadow — subtle */
|
||||||
--shadow-sm: 0 1px 2px oklch(20% 0.02 60 / 0.06);
|
--shadow-sm: 0 1px 2px oklch(20% 0.02 60 / 0.06);
|
||||||
--shadow-md: 0 2px 8px oklch(20% 0.02 60 / 0.08);
|
--shadow-md: 0 2px 8px oklch(20% 0.02 60 / 0.08);
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type { Stats } from "../stats.js";
|
|||||||
import { remainingShort } from "../stats.js";
|
import { remainingShort } from "../stats.js";
|
||||||
import { fmt } from "../format.js";
|
import { fmt } from "../format.js";
|
||||||
import { Btn, Card, Stat, Pill, Sparkline, BarChart, Donut } from "../components/primitives/index.js";
|
import { Btn, Card, Stat, Pill, Sparkline, BarChart, Donut } from "../components/primitives/index.js";
|
||||||
|
import { useIsMobile } from "../hooks/useIsMobile.js";
|
||||||
|
|
||||||
|
|
||||||
const TYPE_COLORS: Record<string, string> = {
|
const TYPE_COLORS: Record<string, string> = {
|
||||||
@@ -29,6 +30,7 @@ export function Dashboard({
|
|||||||
onAuditQueue: (items: Item[]) => void;
|
onAuditQueue: (items: Item[]) => void;
|
||||||
onSelectItem: (i: Item) => void;
|
onSelectItem: (i: Item) => void;
|
||||||
}) {
|
}) {
|
||||||
|
const isMobile = useIsMobile();
|
||||||
const series30 = stats.series30.map((d) => ({ value: d.grams, label: "" }));
|
const series30 = stats.series30.map((d) => ({ value: d.grams, label: "" }));
|
||||||
const last7Series = stats.series7.map((l) => l.grams);
|
const last7Series = stats.series7.map((l) => l.grams);
|
||||||
const last30Series = stats.series30.map((d) => d.grams);
|
const last30Series = stats.series30.map((d) => d.grams);
|
||||||
@@ -56,17 +58,19 @@ export function Dashboard({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
padding: "clamp(32px, 3vw, 64px) clamp(40px, 3vw, 80px) 80px",
|
padding: isMobile
|
||||||
|
? "20px 16px 80px"
|
||||||
|
: "clamp(32px, 3vw, 64px) clamp(40px, 3vw, 80px) 80px",
|
||||||
maxWidth: 2400,
|
maxWidth: 2400,
|
||||||
margin: "0 auto",
|
margin: "0 auto",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ marginBottom: 24 }}>
|
<div style={{ marginBottom: isMobile ? 16 : 24 }}>
|
||||||
<div className="smallcaps" style={{ color: "var(--ink-3)" }}>{greetingDate}</div>
|
<div className="smallcaps" style={{ color: "var(--ink-3)" }}>{greetingDate}</div>
|
||||||
<h1
|
<h1
|
||||||
className="serif"
|
className="serif"
|
||||||
style={{
|
style={{
|
||||||
fontSize: 48,
|
fontSize: isMobile ? 28 : 48,
|
||||||
margin: "8px 0 0",
|
margin: "8px 0 0",
|
||||||
fontWeight: 500,
|
fontWeight: 500,
|
||||||
letterSpacing: "-0.02em",
|
letterSpacing: "-0.02em",
|
||||||
@@ -101,7 +105,7 @@ export function Dashboard({
|
|||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: "grid",
|
display: "grid",
|
||||||
gridTemplateColumns: "repeat(auto-fit, minmax(260px, 1fr))",
|
gridTemplateColumns: isMobile ? "1fr 1fr" : "repeat(auto-fit, minmax(260px, 1fr))",
|
||||||
gap: 18,
|
gap: 18,
|
||||||
marginBottom: 18,
|
marginBottom: 18,
|
||||||
}}
|
}}
|
||||||
@@ -139,7 +143,7 @@ export function Dashboard({
|
|||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: "grid",
|
display: "grid",
|
||||||
gridTemplateColumns: "repeat(auto-fit, minmax(260px, 1fr))",
|
gridTemplateColumns: isMobile ? "1fr 1fr" : "repeat(auto-fit, minmax(260px, 1fr))",
|
||||||
gap: 18,
|
gap: 18,
|
||||||
marginBottom: 18,
|
marginBottom: 18,
|
||||||
}}
|
}}
|
||||||
|
|||||||
+601
-155
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import type { Bootstrap, Item } from "../types.js";
|
import type { Bootstrap, Item } from "../types.js";
|
||||||
import { TYPES, helpers, enrichItems } from "../types.js";
|
import { TYPES, helpers, enrichItems } from "../types.js";
|
||||||
import { getToday, getStoredTimezone } from "../tz.js";
|
import { getToday, getStoredTimezone } from "../tz.js";
|
||||||
@@ -7,6 +7,8 @@ import { fmt, TYPE_GLYPHS } from "../format.js";
|
|||||||
import { Btn, Card, Pill, Icon, Select, Checkbox, inputStyle } from "../components/primitives/index.js";
|
import { Btn, Card, Pill, Icon, Select, Checkbox, inputStyle } from "../components/primitives/index.js";
|
||||||
import { useSelection } from "../hooks/useSelection.js";
|
import { useSelection } from "../hooks/useSelection.js";
|
||||||
import { BulkToolbar } from "../components/BulkToolbar.js";
|
import { BulkToolbar } from "../components/BulkToolbar.js";
|
||||||
|
import { useIsMobile } from "../hooks/useIsMobile.js";
|
||||||
|
import { BottomSheet } from "../components/BottomSheet.js";
|
||||||
|
|
||||||
type FilterKey = "active" | "checked-out" | "consumed" | "gone" | "all";
|
type FilterKey = "active" | "checked-out" | "consumed" | "gone" | "all";
|
||||||
type SortKey = "recent" | "name" | "thc" | "remaining" | "price" | "audit";
|
type SortKey = "recent" | "name" | "thc" | "remaining" | "price" | "audit";
|
||||||
@@ -36,6 +38,7 @@ export function Inventory({
|
|||||||
onBulkGone: (items: Item[]) => void;
|
onBulkGone: (items: Item[]) => void;
|
||||||
}) {
|
}) {
|
||||||
const items = useMemo(() => enrichItems(data), [data]);
|
const items = useMemo(() => enrichItems(data), [data]);
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
const [filter, setFilter] = useState<FilterKey>("active");
|
const [filter, setFilter] = useState<FilterKey>("active");
|
||||||
const [typeFilter, setTypeFilter] = useState<string>("all");
|
const [typeFilter, setTypeFilter] = useState<string>("all");
|
||||||
@@ -147,116 +150,296 @@ export function Inventory({
|
|||||||
[items, selected],
|
[items, selected],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const [mobileFilterOpen, setMobileFilterOpen] = useState(false);
|
||||||
|
const [selectionMode, setSelectionMode] = useState(false);
|
||||||
|
const longPressTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selected.size === 0) setSelectionMode(false);
|
||||||
|
}, [selected.size]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
padding: "clamp(32px, 3vw, 64px) clamp(40px, 3vw, 80px) 80px",
|
padding: isMobile
|
||||||
|
? "20px 16px 80px"
|
||||||
|
: "clamp(32px, 3vw, 64px) clamp(40px, 3vw, 80px) 80px",
|
||||||
maxWidth: 2400,
|
maxWidth: 2400,
|
||||||
margin: "0 auto",
|
margin: "0 auto",
|
||||||
paddingBottom: selected.size > 0 ? 140 : 80,
|
paddingBottom: selected.size > 0 ? 140 : isMobile ? 80 : 80,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ display: "flex", alignItems: "baseline", justifyContent: "space-between", marginBottom: 24 }}>
|
{/* Header */}
|
||||||
|
<div style={{ display: "flex", alignItems: "baseline", justifyContent: "space-between", marginBottom: isMobile ? 16 : 24 }}>
|
||||||
<div>
|
<div>
|
||||||
<div className="smallcaps" style={{ color: "var(--ink-3)" }}>
|
<div className="smallcaps" style={{ color: "var(--ink-3)" }}>
|
||||||
{sorted.length} item{sorted.length === 1 ? "" : "s"}
|
{sorted.length} item{sorted.length === 1 ? "" : "s"}
|
||||||
</div>
|
</div>
|
||||||
<h1
|
<h1
|
||||||
className="serif"
|
className="serif"
|
||||||
style={{ fontSize: 44, margin: "6px 0 0", fontWeight: 500, letterSpacing: "-0.02em" }}
|
style={{ fontSize: isMobile ? 28 : 44, margin: "6px 0 0", fontWeight: 500, letterSpacing: "-0.02em" }}
|
||||||
>
|
>
|
||||||
Inventory
|
Inventory
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: "flex", gap: 8 }}>
|
{!isMobile && (
|
||||||
<Btn variant="secondary" icon="check" onClick={onAuditNew}>Audit</Btn>
|
<div style={{ display: "flex", gap: 8 }}>
|
||||||
<Btn variant="primary" icon="plus" onClick={onAddInventory}>Add inventory</Btn>
|
<Btn variant="secondary" icon="check" onClick={onAuditNew}>Audit</Btn>
|
||||||
</div>
|
<Btn variant="primary" icon="plus" onClick={onAddInventory}>Add inventory</Btn>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card style={{ marginBottom: 14, padding: 14 }}>
|
{/* Filter bar */}
|
||||||
<div style={{ display: "flex", gap: 12, alignItems: "center", flexWrap: "wrap" }}>
|
{isMobile ? (
|
||||||
<Segmented<FilterKey>
|
<div style={{ display: "flex", flexDirection: "column", gap: 10, marginBottom: 14 }}>
|
||||||
value={filter}
|
{/* Status filter pills - horizontally scrollable */}
|
||||||
options={[
|
<div style={{ display: "flex", gap: 6, overflowX: "auto", paddingBottom: 2 }}>
|
||||||
["active", "Active"],
|
{(
|
||||||
["checked-out", "Checked out"],
|
[
|
||||||
["consumed", "Consumed"],
|
["active", "Active"],
|
||||||
["gone", "Gone"],
|
["checked-out", "Checked out"],
|
||||||
["all", "All"],
|
["consumed", "Consumed"],
|
||||||
]}
|
["gone", "Gone"],
|
||||||
onChange={setFilter}
|
["all", "All"],
|
||||||
/>
|
] as [FilterKey, string][]
|
||||||
|
).map(([k, l]) => (
|
||||||
<Segmented<ViewKey>
|
|
||||||
value={view}
|
|
||||||
options={[
|
|
||||||
["flat", "Flat"],
|
|
||||||
["grouped", "By product"],
|
|
||||||
]}
|
|
||||||
onChange={setView}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
minWidth: 220,
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: 8,
|
|
||||||
background: "var(--bg-2)",
|
|
||||||
border: "1px solid var(--line)",
|
|
||||||
borderRadius: "var(--r-md)",
|
|
||||||
padding: "0 10px",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Icon name="search" size={14} color="var(--ink-3)" />
|
|
||||||
<input
|
|
||||||
placeholder="Search by name, brand, shop, SKU, asset id…"
|
|
||||||
value={search}
|
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
|
||||||
style={{
|
|
||||||
border: "none",
|
|
||||||
outline: "none",
|
|
||||||
background: "transparent",
|
|
||||||
padding: "8px 0",
|
|
||||||
fontSize: 13,
|
|
||||||
flex: 1,
|
|
||||||
color: "var(--ink)",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{search && (
|
|
||||||
<button
|
<button
|
||||||
onClick={() => setSearch("")}
|
key={k}
|
||||||
|
onClick={() => setFilter(k)}
|
||||||
style={{
|
style={{
|
||||||
background: "transparent",
|
padding: "7px 14px",
|
||||||
border: "none",
|
fontSize: 13,
|
||||||
|
fontWeight: 500,
|
||||||
|
borderRadius: 20,
|
||||||
|
border: filter === k ? "1px solid var(--sage)" : "1px solid var(--line)",
|
||||||
|
background: filter === k ? "var(--sage-soft)" : "var(--surface)",
|
||||||
|
color: filter === k ? "var(--sage)" : "var(--ink-2)",
|
||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
padding: 2,
|
whiteSpace: "nowrap",
|
||||||
display: "inline-flex",
|
flexShrink: 0,
|
||||||
color: "var(--ink-3)",
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Icon name="close" size={12} />
|
{l}
|
||||||
</button>
|
</button>
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Select
|
|
||||||
value={typeFilter}
|
|
||||||
onChange={(e) => setTypeFilter(e.target.value)}
|
|
||||||
style={{ ...inputStyle, width: "auto", padding: "8px 10px" }}
|
|
||||||
>
|
|
||||||
<option value="all">All types</option>
|
|
||||||
{TYPES.map((t) => (
|
|
||||||
<option key={t.id} value={t.id}>{t.id}</option>
|
|
||||||
))}
|
))}
|
||||||
</Select>
|
</div>
|
||||||
|
{/* Search + filter/sort buttons */}
|
||||||
|
<div style={{ display: "flex", gap: 8 }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 8,
|
||||||
|
background: "var(--bg-2)",
|
||||||
|
border: "1px solid var(--line)",
|
||||||
|
borderRadius: "var(--r-md)",
|
||||||
|
padding: "0 10px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon name="search" size={14} color="var(--ink-3)" />
|
||||||
|
<input
|
||||||
|
placeholder="Search…"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
style={{
|
||||||
|
border: "none",
|
||||||
|
outline: "none",
|
||||||
|
background: "transparent",
|
||||||
|
padding: "10px 0",
|
||||||
|
fontSize: 16,
|
||||||
|
flex: 1,
|
||||||
|
color: "var(--ink)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{search && (
|
||||||
|
<button
|
||||||
|
onClick={() => setSearch("")}
|
||||||
|
style={{ background: "transparent", border: "none", cursor: "pointer", padding: 4, display: "inline-flex", color: "var(--ink-3)" }}
|
||||||
|
>
|
||||||
|
<Icon name="close" size={14} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setMobileFilterOpen(true)}
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
width: 44,
|
||||||
|
height: 44,
|
||||||
|
borderRadius: "var(--r-md)",
|
||||||
|
border: "1px solid var(--line)",
|
||||||
|
background: (typeFilter !== "all" || sortBy !== "recent") ? "var(--sage-soft)" : "var(--surface)",
|
||||||
|
cursor: "pointer",
|
||||||
|
color: "var(--ink-2)",
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon name="filter" size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Card style={{ marginBottom: 14, padding: 14 }}>
|
||||||
|
<div style={{ display: "flex", gap: 12, alignItems: "center", flexWrap: "wrap" }}>
|
||||||
|
<Segmented<FilterKey>
|
||||||
|
value={filter}
|
||||||
|
options={[
|
||||||
|
["active", "Active"],
|
||||||
|
["checked-out", "Checked out"],
|
||||||
|
["consumed", "Consumed"],
|
||||||
|
["gone", "Gone"],
|
||||||
|
["all", "All"],
|
||||||
|
]}
|
||||||
|
onChange={setFilter}
|
||||||
|
/>
|
||||||
|
|
||||||
<Select
|
<Segmented<ViewKey>
|
||||||
value={sortBy}
|
value={view}
|
||||||
onChange={(e) => {
|
options={[
|
||||||
const key = e.target.value as SortKey;
|
["flat", "Flat"],
|
||||||
|
["grouped", "By product"],
|
||||||
|
]}
|
||||||
|
onChange={setView}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 220,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 8,
|
||||||
|
background: "var(--bg-2)",
|
||||||
|
border: "1px solid var(--line)",
|
||||||
|
borderRadius: "var(--r-md)",
|
||||||
|
padding: "0 10px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon name="search" size={14} color="var(--ink-3)" />
|
||||||
|
<input
|
||||||
|
placeholder="Search by name, brand, shop, SKU, asset id…"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
style={{
|
||||||
|
border: "none",
|
||||||
|
outline: "none",
|
||||||
|
background: "transparent",
|
||||||
|
padding: "8px 0",
|
||||||
|
fontSize: 13,
|
||||||
|
flex: 1,
|
||||||
|
color: "var(--ink)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{search && (
|
||||||
|
<button
|
||||||
|
onClick={() => setSearch("")}
|
||||||
|
style={{
|
||||||
|
background: "transparent",
|
||||||
|
border: "none",
|
||||||
|
cursor: "pointer",
|
||||||
|
padding: 2,
|
||||||
|
display: "inline-flex",
|
||||||
|
color: "var(--ink-3)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon name="close" size={12} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
value={typeFilter}
|
||||||
|
onChange={(e) => setTypeFilter(e.target.value)}
|
||||||
|
style={{ ...inputStyle, width: "auto", padding: "8px 10px" }}
|
||||||
|
>
|
||||||
|
<option value="all">All types</option>
|
||||||
|
{TYPES.map((t) => (
|
||||||
|
<option key={t.id} value={t.id}>{t.id}</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
value={sortBy}
|
||||||
|
onChange={(e) => {
|
||||||
|
const key = e.target.value as SortKey;
|
||||||
|
if (key === sortBy) {
|
||||||
|
setSortAsc((prev) => !prev);
|
||||||
|
} else {
|
||||||
|
setSortBy(key);
|
||||||
|
setSortAsc(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
style={{ ...inputStyle, width: "auto", padding: "8px 10px" }}
|
||||||
|
>
|
||||||
|
<option value="recent">Recent first</option>
|
||||||
|
<option value="name">Name (A–Z)</option>
|
||||||
|
<option value="thc">THC %</option>
|
||||||
|
<option value="remaining">Remaining</option>
|
||||||
|
<option value="price">Price</option>
|
||||||
|
<option value="audit">Audit overdue</option>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Item list */}
|
||||||
|
{isMobile ? (
|
||||||
|
<Card padded={false}>
|
||||||
|
{sorted.length === 0 && (
|
||||||
|
<div style={{ padding: 40, textAlign: "center", color: "var(--ink-3)", fontSize: 14 }}>
|
||||||
|
No items match these filters.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{view === "flat" &&
|
||||||
|
sorted.map((i) => (
|
||||||
|
<MobileItemCard
|
||||||
|
key={i.id}
|
||||||
|
i={i}
|
||||||
|
data={data}
|
||||||
|
onSelect={onSelectItem}
|
||||||
|
isSelected={selected.has(i.id)}
|
||||||
|
selectionMode={selectionMode}
|
||||||
|
onLongPress={() => {
|
||||||
|
setSelectionMode(true);
|
||||||
|
toggle(i.id, false);
|
||||||
|
}}
|
||||||
|
onToggle={() => toggle(i.id, false)}
|
||||||
|
longPressTimer={longPressTimer}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{view === "grouped" &&
|
||||||
|
groups.map((g) => (
|
||||||
|
<div key={g.productId}>
|
||||||
|
<MobileGroupHeader group={g} />
|
||||||
|
{g.items.map((i) => (
|
||||||
|
<MobileItemCard
|
||||||
|
key={i.id}
|
||||||
|
i={i}
|
||||||
|
data={data}
|
||||||
|
onSelect={onSelectItem}
|
||||||
|
isSelected={selected.has(i.id)}
|
||||||
|
selectionMode={selectionMode}
|
||||||
|
onLongPress={() => {
|
||||||
|
setSelectionMode(true);
|
||||||
|
toggle(i.id, false);
|
||||||
|
}}
|
||||||
|
onToggle={() => toggle(i.id, false)}
|
||||||
|
longPressTimer={longPressTimer}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<Card padded={false}>
|
||||||
|
<HeaderRow
|
||||||
|
sortBy={sortBy}
|
||||||
|
sortAsc={sortAsc}
|
||||||
|
onSort={(key) => {
|
||||||
if (key === sortBy) {
|
if (key === sortBy) {
|
||||||
setSortAsc((prev) => !prev);
|
setSortAsc((prev) => !prev);
|
||||||
} else {
|
} else {
|
||||||
@@ -264,78 +447,55 @@ export function Inventory({
|
|||||||
setSortAsc(false);
|
setSortAsc(false);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
style={{ ...inputStyle, width: "auto", padding: "8px 10px" }}
|
isAllSelected={isAllSelected}
|
||||||
>
|
isIndeterminate={isIndeterminate}
|
||||||
<option value="recent">Recent first</option>
|
onToggleAll={toggleAll}
|
||||||
<option value="name">Name (A–Z)</option>
|
/>
|
||||||
<option value="thc">THC %</option>
|
{sorted.length === 0 && (
|
||||||
<option value="remaining">Remaining</option>
|
<div style={{ padding: 60, textAlign: "center", color: "var(--ink-3)" }}>
|
||||||
<option value="price">Price</option>
|
No items match these filters.
|
||||||
<option value="audit">Audit overdue</option>
|
</div>
|
||||||
</Select>
|
)}
|
||||||
</div>
|
{view === "flat" &&
|
||||||
</Card>
|
sorted.map((i) => (
|
||||||
|
<ItemRow
|
||||||
<Card padded={false}>
|
key={i.id}
|
||||||
<HeaderRow
|
i={i}
|
||||||
sortBy={sortBy}
|
data={data}
|
||||||
sortAsc={sortAsc}
|
onSelect={onSelectItem}
|
||||||
onSort={(key) => {
|
isSelected={selected.has(i.id)}
|
||||||
if (key === sortBy) {
|
onToggle={toggle}
|
||||||
setSortAsc((prev) => !prev);
|
/>
|
||||||
} else {
|
))}
|
||||||
setSortBy(key);
|
{view === "grouped" &&
|
||||||
setSortAsc(false);
|
groups.map((g) => {
|
||||||
}
|
const groupIds = g.items.map((i) => i.id);
|
||||||
}}
|
const allIn = groupIds.length > 0 && groupIds.every((id) => selected.has(id));
|
||||||
isAllSelected={isAllSelected}
|
const someIn = !allIn && groupIds.some((id) => selected.has(id));
|
||||||
isIndeterminate={isIndeterminate}
|
return (
|
||||||
onToggleAll={toggleAll}
|
<div key={g.productId}>
|
||||||
/>
|
<GroupHeader
|
||||||
{sorted.length === 0 && (
|
group={g}
|
||||||
<div style={{ padding: 60, textAlign: "center", color: "var(--ink-3)" }}>
|
isGroupSelected={allIn}
|
||||||
No items match these filters.
|
isGroupIndeterminate={someIn}
|
||||||
</div>
|
onToggleGroup={() => toggleGroup(groupIds)}
|
||||||
)}
|
|
||||||
{view === "flat" &&
|
|
||||||
sorted.map((i) => (
|
|
||||||
<ItemRow
|
|
||||||
key={i.id}
|
|
||||||
i={i}
|
|
||||||
data={data}
|
|
||||||
onSelect={onSelectItem}
|
|
||||||
isSelected={selected.has(i.id)}
|
|
||||||
onToggle={toggle}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
{view === "grouped" &&
|
|
||||||
groups.map((g) => {
|
|
||||||
const groupIds = g.items.map((i) => i.id);
|
|
||||||
const allIn = groupIds.length > 0 && groupIds.every((id) => selected.has(id));
|
|
||||||
const someIn = !allIn && groupIds.some((id) => selected.has(id));
|
|
||||||
return (
|
|
||||||
<div key={g.productId}>
|
|
||||||
<GroupHeader
|
|
||||||
group={g}
|
|
||||||
isGroupSelected={allIn}
|
|
||||||
isGroupIndeterminate={someIn}
|
|
||||||
onToggleGroup={() => toggleGroup(groupIds)}
|
|
||||||
/>
|
|
||||||
{g.items.map((i) => (
|
|
||||||
<ItemRow
|
|
||||||
key={i.id}
|
|
||||||
i={i}
|
|
||||||
data={data}
|
|
||||||
onSelect={onSelectItem}
|
|
||||||
indented
|
|
||||||
isSelected={selected.has(i.id)}
|
|
||||||
onToggle={toggle}
|
|
||||||
/>
|
/>
|
||||||
))}
|
{g.items.map((i) => (
|
||||||
</div>
|
<ItemRow
|
||||||
);
|
key={i.id}
|
||||||
})}
|
i={i}
|
||||||
</Card>
|
data={data}
|
||||||
|
onSelect={onSelectItem}
|
||||||
|
indented
|
||||||
|
isSelected={selected.has(i.id)}
|
||||||
|
onToggle={toggle}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
{selected.size > 0 && (
|
{selected.size > 0 && (
|
||||||
<BulkToolbar
|
<BulkToolbar
|
||||||
@@ -349,6 +509,117 @@ export function Inventory({
|
|||||||
onBulkGone={() => onBulkGone(selectedItems)}
|
onBulkGone={() => onBulkGone(selectedItems)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Mobile filter/sort bottom sheet */}
|
||||||
|
<BottomSheet open={mobileFilterOpen} onClose={() => setMobileFilterOpen(false)}>
|
||||||
|
<div style={{ padding: "8px 16px 20px" }}>
|
||||||
|
<div style={{ fontSize: 11, textTransform: "uppercase", letterSpacing: "0.1em", color: "var(--ink-3)", fontWeight: 500, padding: "8px 0 8px" }}>
|
||||||
|
View
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "flex", gap: 8, marginBottom: 16 }}>
|
||||||
|
{(["flat", "grouped"] as ViewKey[]).map((v) => (
|
||||||
|
<button
|
||||||
|
key={v}
|
||||||
|
onClick={() => setView(v)}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
padding: "10px 14px",
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: 500,
|
||||||
|
borderRadius: "var(--r-md)",
|
||||||
|
border: view === v ? "1px solid var(--sage)" : "1px solid var(--line)",
|
||||||
|
background: view === v ? "var(--sage-soft)" : "var(--surface)",
|
||||||
|
color: view === v ? "var(--sage)" : "var(--ink-2)",
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{v === "flat" ? "Flat" : "By product"}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ fontSize: 11, textTransform: "uppercase", letterSpacing: "0.1em", color: "var(--ink-3)", fontWeight: 500, padding: "8px 0 8px" }}>
|
||||||
|
Type
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "flex", gap: 6, flexWrap: "wrap", marginBottom: 16 }}>
|
||||||
|
<button
|
||||||
|
onClick={() => setTypeFilter("all")}
|
||||||
|
style={{
|
||||||
|
padding: "8px 14px",
|
||||||
|
fontSize: 13,
|
||||||
|
borderRadius: 20,
|
||||||
|
border: typeFilter === "all" ? "1px solid var(--sage)" : "1px solid var(--line)",
|
||||||
|
background: typeFilter === "all" ? "var(--sage-soft)" : "var(--surface)",
|
||||||
|
color: typeFilter === "all" ? "var(--sage)" : "var(--ink-2)",
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
All
|
||||||
|
</button>
|
||||||
|
{TYPES.map((t) => (
|
||||||
|
<button
|
||||||
|
key={t.id}
|
||||||
|
onClick={() => setTypeFilter(t.id)}
|
||||||
|
style={{
|
||||||
|
padding: "8px 14px",
|
||||||
|
fontSize: 13,
|
||||||
|
borderRadius: 20,
|
||||||
|
border: typeFilter === t.id ? "1px solid var(--sage)" : "1px solid var(--line)",
|
||||||
|
background: typeFilter === t.id ? "var(--sage-soft)" : "var(--surface)",
|
||||||
|
color: typeFilter === t.id ? "var(--sage)" : "var(--ink-2)",
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{TYPE_GLYPHS[t.id]} {t.id}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ fontSize: 11, textTransform: "uppercase", letterSpacing: "0.1em", color: "var(--ink-3)", fontWeight: 500, padding: "8px 0 8px" }}>
|
||||||
|
Sort by
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 2 }}>
|
||||||
|
{(
|
||||||
|
[
|
||||||
|
["recent", "Recent first"],
|
||||||
|
["name", "Name (A–Z)"],
|
||||||
|
["thc", "THC %"],
|
||||||
|
["remaining", "Remaining"],
|
||||||
|
["price", "Price"],
|
||||||
|
["audit", "Audit overdue"],
|
||||||
|
] as [SortKey, string][]
|
||||||
|
).map(([k, l]) => (
|
||||||
|
<button
|
||||||
|
key={k}
|
||||||
|
onClick={() => {
|
||||||
|
if (k === sortBy) {
|
||||||
|
setSortAsc((prev) => !prev);
|
||||||
|
} else {
|
||||||
|
setSortBy(k);
|
||||||
|
setSortAsc(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
padding: "12px 8px",
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: sortBy === k ? 600 : 400,
|
||||||
|
borderRadius: "var(--r-md)",
|
||||||
|
border: "none",
|
||||||
|
background: sortBy === k ? "var(--bg-2)" : "transparent",
|
||||||
|
color: sortBy === k ? "var(--ink)" : "var(--ink-2)",
|
||||||
|
cursor: "pointer",
|
||||||
|
textAlign: "left",
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{l}
|
||||||
|
{sortBy === k && <span style={{ fontSize: 12 }}>{sortAsc ? "▲" : "▼"}</span>}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</BottomSheet>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -716,3 +987,178 @@ function ItemRow({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function MobileItemCard({
|
||||||
|
i,
|
||||||
|
data,
|
||||||
|
onSelect,
|
||||||
|
isSelected,
|
||||||
|
selectionMode,
|
||||||
|
onLongPress,
|
||||||
|
onToggle,
|
||||||
|
longPressTimer,
|
||||||
|
}: {
|
||||||
|
i: Item;
|
||||||
|
data: Bootstrap;
|
||||||
|
onSelect: (i: Item) => void;
|
||||||
|
isSelected: boolean;
|
||||||
|
selectionMode: boolean;
|
||||||
|
onLongPress: () => void;
|
||||||
|
onToggle: () => void;
|
||||||
|
longPressTimer: React.MutableRefObject<ReturnType<typeof setTimeout> | null>;
|
||||||
|
}) {
|
||||||
|
const pctRemaining = helpers.pctRemaining(i);
|
||||||
|
const overdue = helpers.auditOverdue(i, getToday(getStoredTimezone()));
|
||||||
|
const isInactive = i.status !== "active" && i.status !== "checked-out";
|
||||||
|
const brand = helpers.brandName(data, i.brandId);
|
||||||
|
const cfg = TYPES.find((t) => t.id === i.type);
|
||||||
|
|
||||||
|
const handlePointerDown = () => {
|
||||||
|
longPressTimer.current = setTimeout(onLongPress, 400);
|
||||||
|
};
|
||||||
|
const handlePointerUp = () => {
|
||||||
|
if (longPressTimer.current) {
|
||||||
|
clearTimeout(longPressTimer.current);
|
||||||
|
longPressTimer.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onClick={() => {
|
||||||
|
if (selectionMode) {
|
||||||
|
onToggle();
|
||||||
|
} else {
|
||||||
|
onSelect(i);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onPointerDown={handlePointerDown}
|
||||||
|
onPointerUp={handlePointerUp}
|
||||||
|
onPointerCancel={handlePointerUp}
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 12,
|
||||||
|
padding: "14px 16px",
|
||||||
|
borderBottom: "1px solid var(--line)",
|
||||||
|
cursor: "pointer",
|
||||||
|
opacity: isInactive ? 0.55 : 1,
|
||||||
|
background: isSelected ? "var(--sage-soft)" : undefined,
|
||||||
|
minHeight: 64,
|
||||||
|
WebkitTapHighlightColor: "transparent",
|
||||||
|
userSelect: "none",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{selectionMode && (
|
||||||
|
<div style={{ flexShrink: 0 }}>
|
||||||
|
<Checkbox checked={isSelected} onChange={() => {}} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ fontFamily: "var(--serif)", fontSize: 20, color: "var(--ink-3)", width: 22, flexShrink: 0, textAlign: "center" }}>
|
||||||
|
{TYPE_GLYPHS[i.type]}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 6, marginBottom: 3 }}>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontWeight: 500,
|
||||||
|
color: "var(--ink)",
|
||||||
|
fontSize: 15,
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{i.name}
|
||||||
|
</span>
|
||||||
|
{i.status === "consumed" && <Pill tone="terra" style={{ fontSize: 10, flexShrink: 0 }}>Consumed</Pill>}
|
||||||
|
{i.status === "gone" && <Pill tone="amber" style={{ fontSize: 10, flexShrink: 0 }}>Gone</Pill>}
|
||||||
|
{i.status === "checked-out" && <Pill tone="outline" style={{ fontSize: 10, flexShrink: 0 }}>Out</Pill>}
|
||||||
|
{i.status === "active" && overdue && <Pill tone="amber" style={{ fontSize: 10, flexShrink: 0 }}>Audit</Pill>}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 8, fontSize: 12, color: "var(--ink-3)" }}>
|
||||||
|
<span>{brand}</span>
|
||||||
|
<span style={{ color: "var(--ink-4)" }}>·</span>
|
||||||
|
<span className="mono">
|
||||||
|
{cfg?.showCannabinoidPct !== false ? `${i.thc.toFixed(1)}%` : `${i.unitWeight}mg`}
|
||||||
|
</span>
|
||||||
|
<span style={{ color: "var(--ink-4)" }}>·</span>
|
||||||
|
<span className="mono">{remainingShort(i)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{i.status === "active" && i.kind === "bulk" && (
|
||||||
|
<div style={{ width: 40, flexShrink: 0, display: "flex", flexDirection: "column", alignItems: "center", gap: 2 }}>
|
||||||
|
<div style={{ width: "100%", height: 4, background: "var(--bg-3)", borderRadius: 2 }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: `${pctRemaining * 100}%`,
|
||||||
|
height: "100%",
|
||||||
|
background:
|
||||||
|
pctRemaining < 0.25 ? "var(--terracotta)"
|
||||||
|
: pctRemaining < 0.5 ? "var(--amber)"
|
||||||
|
: "var(--sage)",
|
||||||
|
borderRadius: 2,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!selectionMode && (
|
||||||
|
<span style={{ color: "var(--ink-4)", fontSize: 16, flexShrink: 0 }}>›</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MobileGroupHeader({
|
||||||
|
group,
|
||||||
|
}: {
|
||||||
|
group: {
|
||||||
|
productId: string;
|
||||||
|
label: string;
|
||||||
|
sku: string;
|
||||||
|
type: string;
|
||||||
|
items: Item[];
|
||||||
|
};
|
||||||
|
}) {
|
||||||
|
const active = group.items.filter((i) => i.status === "active");
|
||||||
|
const cfg = TYPES.find((t) => t.id === group.type);
|
||||||
|
const totalRemaining = active.reduce((s, i) => {
|
||||||
|
if (i.kind === "bulk") return s + helpers.remaining(i);
|
||||||
|
return s + (i.countLastAudit ?? i.countOriginal);
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: "14px 16px 10px",
|
||||||
|
borderBottom: "1px solid var(--line)",
|
||||||
|
background: "var(--bg-2)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 2 }}>
|
||||||
|
<span style={{ fontFamily: "var(--serif)", fontSize: 18, color: "var(--ink-3)" }}>
|
||||||
|
{TYPE_GLYPHS[group.type]}
|
||||||
|
</span>
|
||||||
|
<span className="serif" style={{ fontSize: 18, fontWeight: 500 }}>
|
||||||
|
{group.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "flex", gap: 12, fontSize: 12, color: "var(--ink-3)" }}>
|
||||||
|
<span>
|
||||||
|
<span className="mono" style={{ color: "var(--ink-2)" }}>{active.length}</span> active
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<span className="mono" style={{ color: "var(--ink-2)" }}>
|
||||||
|
{totalRemaining.toFixed(2).replace(/\.?0+$/, "") || "0"}
|
||||||
|
</span>{" "}
|
||||||
|
{cfg?.unit ?? "g"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
+48
-1
@@ -1,8 +1,55 @@
|
|||||||
import { defineConfig } from "vite";
|
import { defineConfig } from "vite";
|
||||||
import react from "@vitejs/plugin-react";
|
import react from "@vitejs/plugin-react";
|
||||||
|
import { VitePWA } from "vite-plugin-pwa";
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [
|
||||||
|
react(),
|
||||||
|
VitePWA({
|
||||||
|
registerType: "autoUpdate",
|
||||||
|
manifest: {
|
||||||
|
name: "Apothecary",
|
||||||
|
short_name: "Apothecary",
|
||||||
|
description: "Personal cannabis inventory tracker",
|
||||||
|
theme_color: "#f5efe6",
|
||||||
|
background_color: "#f5efe6",
|
||||||
|
display: "standalone",
|
||||||
|
scope: "/",
|
||||||
|
start_url: "/",
|
||||||
|
icons: [
|
||||||
|
{ src: "/icons/icon-192.svg", sizes: "192x192", type: "image/svg+xml" },
|
||||||
|
{ src: "/icons/icon-512.svg", sizes: "512x512", type: "image/svg+xml" },
|
||||||
|
{ src: "/icons/maskable-512.svg", sizes: "512x512", type: "image/svg+xml", purpose: "maskable" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
workbox: {
|
||||||
|
globPatterns: ["**/*.{js,css,html,woff2}"],
|
||||||
|
runtimeCaching: [
|
||||||
|
{
|
||||||
|
urlPattern: /^https:\/\/fonts\.googleapis\.com/,
|
||||||
|
handler: "CacheFirst",
|
||||||
|
options: { cacheName: "google-fonts-stylesheets" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
urlPattern: /^https:\/\/fonts\.gstatic\.com/,
|
||||||
|
handler: "CacheFirst",
|
||||||
|
options: {
|
||||||
|
cacheName: "google-fonts-webfonts",
|
||||||
|
expiration: { maxEntries: 10 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
urlPattern: /\/api\//,
|
||||||
|
handler: "NetworkFirst",
|
||||||
|
options: {
|
||||||
|
cacheName: "api-cache",
|
||||||
|
networkTimeoutSeconds: 5,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
server: {
|
server: {
|
||||||
port: 5173,
|
port: 5173,
|
||||||
proxy: {
|
proxy: {
|
||||||
|
|||||||
Reference in New Issue
Block a user