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">
|
||||
<head>
|
||||
<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>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<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": {
|
||||
"@tanstack/react-query": "^5.62.7",
|
||||
"html5-qrcode": "^2.3.8",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^7.14.2"
|
||||
@@ -20,6 +21,7 @@
|
||||
"@types/react-dom": "^18.3.5",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"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 |
@@ -13,6 +13,13 @@ type DrawerBack =
|
||||
import { getStoredTimezone, TZ_STORAGE_KEY } from "./tz.js";
|
||||
import { computeStats } from "./stats.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 { Inventory } from "./views/Inventory.js";
|
||||
import { SkusView } from "./views/SkusView.js";
|
||||
@@ -87,6 +94,15 @@ export function App() {
|
||||
const [modalProduct, setModalProduct] = useState<Product | null>(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>(
|
||||
() => (localStorage.getItem("apothecary.theme") as ThemeKey | null) ?? "light",
|
||||
);
|
||||
@@ -182,6 +198,19 @@ export function App() {
|
||||
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 openBulkConsume = (items: Item[]) => { setBulkItems(items); setModal("bulkConsume"); };
|
||||
const openBulkCheckout = (items: Item[]) => { setBulkItems(items); setModal("bulkCheckout"); };
|
||||
@@ -240,14 +269,29 @@ export function App() {
|
||||
|
||||
return (
|
||||
<div className="app-shell" data-screen-label="App">
|
||||
{!isMobile && (
|
||||
<Sidebar
|
||||
onAddProduct={openAdd}
|
||||
onMarkFinished={() => openConsume()}
|
||||
onAudit={() => openAudit()}
|
||||
onCheckout={() => openCheckout()}
|
||||
/>
|
||||
)}
|
||||
|
||||
<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>
|
||||
<Route path="/" element={
|
||||
<Dashboard data={data} stats={stats} onAuditItem={openAudit} onAuditQueue={openAuditQueue} onSelectItem={setSelected} />
|
||||
@@ -460,6 +504,43 @@ export function App() {
|
||||
{modal === "editSku" && modalProduct && (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 { TYPES, helpers } from "../types.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 { useExitAnimation } from "../hooks/useExitAnimation.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
|
||||
// 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 { closing, triggerClose } = useExitAnimation(220, onClose);
|
||||
const trapRef = useFocusTrap<HTMLDivElement>();
|
||||
const isMobile = useIsMobile();
|
||||
const [actionsOpen, setActionsOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
@@ -137,7 +141,7 @@ export function ProductDetail({
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
padding: "20px 32px",
|
||||
padding: isMobile ? "14px 16px" : "20px 32px",
|
||||
borderBottom: "1px solid var(--line)",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
@@ -171,6 +175,12 @@ export function ProductDetail({
|
||||
<div className="smallcaps" style={{ color: "var(--ink-3)" }}>
|
||||
Inventory · <span className="mono">{item.assetId}</span>
|
||||
</div>
|
||||
{isMobile ? (
|
||||
<div style={{ display: "flex", gap: 6, alignItems: "center" }}>
|
||||
<Btn variant="ghost" icon="more" onClick={() => setActionsOpen(true)} />
|
||||
<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)}>
|
||||
@@ -203,11 +213,12 @@ export function ProductDetail({
|
||||
</Btn>
|
||||
<Btn variant="ghost" icon="close" onClick={triggerClose} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ padding: "32px 32px 60px" }}>
|
||||
<div style={{ display: "flex", alignItems: "baseline", gap: 16, marginBottom: 8 }}>
|
||||
<div style={{ padding: isMobile ? "20px 16px 60px" : "32px 32px 60px" }}>
|
||||
<div style={{ display: "flex", alignItems: "baseline", gap: 16, marginBottom: 8, flexWrap: "wrap" }}>
|
||||
<div className="serif" style={{ fontSize: 18, color: "var(--ink-3)" }}>
|
||||
{TYPE_GLYPHS[item.type]} {item.type}
|
||||
</div>
|
||||
@@ -225,7 +236,7 @@ export function ProductDetail({
|
||||
<h1
|
||||
className="serif"
|
||||
style={{
|
||||
fontSize: 48,
|
||||
fontSize: isMobile ? 28 : 48,
|
||||
margin: "0 0 4px",
|
||||
fontWeight: 500,
|
||||
letterSpacing: "-0.02em",
|
||||
@@ -437,7 +448,7 @@ export function ProductDetail({
|
||||
|
||||
<div style={{ marginTop: 36 }}>
|
||||
<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) => (
|
||||
<div
|
||||
key={i}
|
||||
@@ -498,7 +509,56 @@ export function ProductDetail({
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
items: Item[],
|
||||
products?: Product[],
|
||||
|
||||
@@ -12,7 +12,7 @@ export type ViewKey =
|
||||
| "charts"
|
||||
| "settings";
|
||||
|
||||
const NAV: { path: string; label: string; icon: string }[] = [
|
||||
export const NAV: { path: string; label: string; icon: string }[] = [
|
||||
{ path: "/", label: "Dashboard", icon: "home" },
|
||||
{ path: "/inventory", label: "Inventory", icon: "box" },
|
||||
{ path: "/skus", label: "SKUs", icon: "barcode" },
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createContext, useCallback, useContext, useRef, useState } from "react";
|
||||
import { Icon } from "./primitives/index.js";
|
||||
import { useIsMobile } from "../hooks/useIsMobile.js";
|
||||
|
||||
type ToastType = "success" | "error";
|
||||
|
||||
@@ -56,6 +57,8 @@ export function ToastProvider({ children }: { children: React.ReactNode }) {
|
||||
startTimer(id);
|
||||
}, [startTimer]);
|
||||
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={{ toast }}>
|
||||
{children}
|
||||
@@ -65,8 +68,9 @@ export function ToastProvider({ children }: { children: React.ReactNode }) {
|
||||
aria-live="polite"
|
||||
style={{
|
||||
position: "fixed",
|
||||
bottom: 24,
|
||||
right: 24,
|
||||
bottom: isMobile ? "calc(72px + env(safe-area-inset-bottom, 0px) + 12px)" : 24,
|
||||
right: isMobile ? 12 : 24,
|
||||
left: isMobile ? 12 : "auto",
|
||||
zIndex: 100,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createContext, useContext, useEffect, useRef } from "react";
|
||||
import { useExitAnimation } from "../../hooks/useExitAnimation.js";
|
||||
import { useIsMobile } from "../../hooks/useIsMobile.js";
|
||||
import { Btn } from "../primitives/index.js";
|
||||
|
||||
const ModalCloseCtx = createContext<(() => void) | null>(null);
|
||||
@@ -14,6 +15,7 @@ export function ModalBackdrop({
|
||||
const backdropRef = useRef<HTMLDivElement>(null);
|
||||
const previousFocus = useRef<Element | null>(null);
|
||||
const { closing, triggerClose } = useExitAnimation(180, onClose);
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
useEffect(() => {
|
||||
previousFocus.current = document.activeElement;
|
||||
@@ -77,7 +79,17 @@ export function ModalBackdrop({
|
||||
<ModalCloseCtx.Provider value={triggerClose}>
|
||||
<div
|
||||
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%",
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
@@ -103,10 +115,11 @@ export function ModalHeader({
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const animatedClose = useContext(ModalCloseCtx);
|
||||
const isMobile = useIsMobile();
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
padding: "20px 32px",
|
||||
padding: isMobile ? "16px 20px" : "20px 32px",
|
||||
borderBottom: "1px solid var(--line)",
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
@@ -119,7 +132,7 @@ export function ModalHeader({
|
||||
{eyebrow}
|
||||
</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}
|
||||
</h2>
|
||||
</div>
|
||||
@@ -129,16 +142,18 @@ export function ModalHeader({
|
||||
}
|
||||
|
||||
export function ModalFooter({ children }: { children: React.ReactNode }) {
|
||||
const isMobile = useIsMobile();
|
||||
return (
|
||||
<div
|
||||
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)",
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
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}
|
||||
|
||||
@@ -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",
|
||||
shop: "M4 9V4h16v5M4 9v11h16V9M4 9h16M10 20v-6h4v6",
|
||||
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({
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
+72
-41
@@ -126,6 +126,14 @@
|
||||
from { transform: translateY(100%); opacity: 0; }
|
||||
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 {
|
||||
from { opacity: 1; }
|
||||
to { opacity: 0; }
|
||||
@@ -151,51 +159,74 @@
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.sidebar {
|
||||
display: none;
|
||||
}
|
||||
.main {
|
||||
padding-bottom: calc(72px + env(safe-area-inset-bottom, 0px));
|
||||
}
|
||||
input, select, textarea {
|
||||
font-size: 16px !important;
|
||||
}
|
||||
.bulk-toolbar {
|
||||
left: 0 !important;
|
||||
bottom: calc(72px + env(safe-area-inset-bottom, 0px)) !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Mobile bottom nav */
|
||||
.mobile-nav {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: auto;
|
||||
height: auto;
|
||||
flex-direction: row;
|
||||
padding: 8px 12px;
|
||||
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);
|
||||
border-right: none;
|
||||
z-index: 30;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.brand,
|
||||
.nav-section {
|
||||
display: none;
|
||||
}
|
||||
.nav-link {
|
||||
white-space: nowrap;
|
||||
padding: 8px 12px;
|
||||
flex-direction: column;
|
||||
font-size: 10px;
|
||||
gap: 2px;
|
||||
display: flex;
|
||||
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 {
|
||||
padding-bottom: 60px;
|
||||
}
|
||||
.bulk-toolbar {
|
||||
left: 0 !important;
|
||||
bottom: 60px !important;
|
||||
}
|
||||
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-xl: 20px;
|
||||
|
||||
/* Touch targets */
|
||||
--touch-min: 44px;
|
||||
|
||||
/* Shadow — subtle */
|
||||
--shadow-sm: 0 1px 2px oklch(20% 0.02 60 / 0.06);
|
||||
--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 { fmt } from "../format.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> = {
|
||||
@@ -29,6 +30,7 @@ export function Dashboard({
|
||||
onAuditQueue: (items: Item[]) => void;
|
||||
onSelectItem: (i: Item) => void;
|
||||
}) {
|
||||
const isMobile = useIsMobile();
|
||||
const series30 = stats.series30.map((d) => ({ value: d.grams, label: "" }));
|
||||
const last7Series = stats.series7.map((l) => l.grams);
|
||||
const last30Series = stats.series30.map((d) => d.grams);
|
||||
@@ -56,17 +58,19 @@ export function Dashboard({
|
||||
return (
|
||||
<div
|
||||
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,
|
||||
margin: "0 auto",
|
||||
}}
|
||||
>
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<div style={{ marginBottom: isMobile ? 16 : 24 }}>
|
||||
<div className="smallcaps" style={{ color: "var(--ink-3)" }}>{greetingDate}</div>
|
||||
<h1
|
||||
className="serif"
|
||||
style={{
|
||||
fontSize: 48,
|
||||
fontSize: isMobile ? 28 : 48,
|
||||
margin: "8px 0 0",
|
||||
fontWeight: 500,
|
||||
letterSpacing: "-0.02em",
|
||||
@@ -101,7 +105,7 @@ export function Dashboard({
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(auto-fit, minmax(260px, 1fr))",
|
||||
gridTemplateColumns: isMobile ? "1fr 1fr" : "repeat(auto-fit, minmax(260px, 1fr))",
|
||||
gap: 18,
|
||||
marginBottom: 18,
|
||||
}}
|
||||
@@ -139,7 +143,7 @@ export function Dashboard({
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(auto-fit, minmax(260px, 1fr))",
|
||||
gridTemplateColumns: isMobile ? "1fr 1fr" : "repeat(auto-fit, minmax(260px, 1fr))",
|
||||
gap: 18,
|
||||
marginBottom: 18,
|
||||
}}
|
||||
|
||||
+451
-5
@@ -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 { TYPES, helpers, enrichItems } from "../types.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 { useSelection } from "../hooks/useSelection.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 SortKey = "recent" | "name" | "thc" | "remaining" | "price" | "audit";
|
||||
@@ -36,6 +38,7 @@ export function Inventory({
|
||||
onBulkGone: (items: Item[]) => void;
|
||||
}) {
|
||||
const items = useMemo(() => enrichItems(data), [data]);
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
const [filter, setFilter] = useState<FilterKey>("active");
|
||||
const [typeFilter, setTypeFilter] = useState<string>("all");
|
||||
@@ -147,33 +150,139 @@ export function Inventory({
|
||||
[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 (
|
||||
<div
|
||||
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,
|
||||
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 className="smallcaps" style={{ color: "var(--ink-3)" }}>
|
||||
{sorted.length} item{sorted.length === 1 ? "" : "s"}
|
||||
</div>
|
||||
<h1
|
||||
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
|
||||
</h1>
|
||||
</div>
|
||||
{!isMobile && (
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<Btn variant="secondary" icon="check" onClick={onAuditNew}>Audit</Btn>
|
||||
<Btn variant="primary" icon="plus" onClick={onAddInventory}>Add inventory</Btn>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Filter bar */}
|
||||
{isMobile ? (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 10, marginBottom: 14 }}>
|
||||
{/* Status filter pills - horizontally scrollable */}
|
||||
<div style={{ display: "flex", gap: 6, overflowX: "auto", paddingBottom: 2 }}>
|
||||
{(
|
||||
[
|
||||
["active", "Active"],
|
||||
["checked-out", "Checked out"],
|
||||
["consumed", "Consumed"],
|
||||
["gone", "Gone"],
|
||||
["all", "All"],
|
||||
] as [FilterKey, string][]
|
||||
).map(([k, l]) => (
|
||||
<button
|
||||
key={k}
|
||||
onClick={() => setFilter(k)}
|
||||
style={{
|
||||
padding: "7px 14px",
|
||||
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",
|
||||
whiteSpace: "nowrap",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{l}
|
||||
</button>
|
||||
))}
|
||||
</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>
|
||||
@@ -275,7 +384,57 @@ export function Inventory({
|
||||
</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}
|
||||
@@ -336,6 +495,7 @@ export function Inventory({
|
||||
);
|
||||
})}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{selected.size > 0 && (
|
||||
<BulkToolbar
|
||||
@@ -349,6 +509,117 @@ export function Inventory({
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -716,3 +987,178 @@ function ItemRow({
|
||||
</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 react from "@vitejs/plugin-react";
|
||||
import { VitePWA } from "vite-plugin-pwa";
|
||||
|
||||
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: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
|
||||
Reference in New Issue
Block a user