Compare commits
39 Commits
bae0386766
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 3674ecf328 | |||
| 5fa1e34914 | |||
| d7da7afe5e | |||
| ddaeea0223 | |||
| c9094d39ec | |||
| 11f4c0537d | |||
| 52564d1e2f | |||
| 214a6ddaec | |||
| 1194cafb37 | |||
| fc7b3d5de2 | |||
| 538e5079ab | |||
| 9e31a6ad00 | |||
| 8f09504f26 | |||
| 6c8bed9679 | |||
| 82a72805cf | |||
| 00a76a10d7 | |||
| 3bdf857099 | |||
| 564cae253a | |||
| b088953133 | |||
| c91fa1a192 | |||
| 69ffc5ed26 | |||
| ffc05ca526 | |||
| a1be29ab6e | |||
| e9e66ab1cb | |||
| 4044de7bfc | |||
| 946e96c3ea | |||
| d44c23ef6d | |||
| 5c67f1e2e0 | |||
| fdfaa4503d | |||
| 9aea9535e6 | |||
| a3559062db | |||
| e7fd9af62c | |||
| 04bf009a83 | |||
| c031058d1d | |||
| bc81cc8d18 | |||
| e50e8ef1fe | |||
| b5141f139d | |||
| 50d61a78d5 | |||
| cb26a8e634 |
@@ -14,7 +14,6 @@ server/data.db-shm
|
|||||||
server/data.db-wal
|
server/data.db-wal
|
||||||
web/dist
|
web/dist
|
||||||
web/tsconfig.tsbuildinfo
|
web/tsconfig.tsbuildinfo
|
||||||
weed-tracker
|
|
||||||
*.log
|
*.log
|
||||||
.env
|
.env
|
||||||
.env.local
|
.env.local
|
||||||
|
|||||||
@@ -18,3 +18,4 @@ web/dist
|
|||||||
|
|
||||||
# Claude Code local settings
|
# Claude Code local settings
|
||||||
.claude/settings.local.json
|
.claude/settings.local.json
|
||||||
|
.gstack/
|
||||||
|
|||||||
@@ -13,10 +13,47 @@ db.pragma("foreign_keys = ON");
|
|||||||
|
|
||||||
archiveLegacyIfPresent();
|
archiveLegacyIfPresent();
|
||||||
archiveV1IfPresent();
|
archiveV1IfPresent();
|
||||||
|
migrateAddCheckoutDate();
|
||||||
|
migrateAddContainerWeight();
|
||||||
|
migrateAddPrevBinId();
|
||||||
|
migrateAddBinCheckFields();
|
||||||
|
|
||||||
const schema = readFileSync(join(__dirname, "schema.sql"), "utf8");
|
const schema = readFileSync(join(__dirname, "schema.sql"), "utf8");
|
||||||
db.exec(schema);
|
db.exec(schema);
|
||||||
|
|
||||||
|
function migrateAddCheckoutDate(): void {
|
||||||
|
const cols = db
|
||||||
|
.prepare(`PRAGMA table_info(inventory_items)`)
|
||||||
|
.all() as { name: string }[];
|
||||||
|
if (cols.length === 0 || cols.some((c) => c.name === "checkout_date")) return;
|
||||||
|
db.exec(`ALTER TABLE inventory_items ADD COLUMN checkout_date TEXT`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function migrateAddContainerWeight(): void {
|
||||||
|
const cols = db
|
||||||
|
.prepare(`PRAGMA table_info(inventory_items)`)
|
||||||
|
.all() as { name: string }[];
|
||||||
|
if (cols.length === 0 || cols.some((c) => c.name === "container_weight")) return;
|
||||||
|
db.exec(`ALTER TABLE inventory_items ADD COLUMN container_weight REAL`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function migrateAddPrevBinId(): void {
|
||||||
|
const cols = db
|
||||||
|
.prepare(`PRAGMA table_info(inventory_items)`)
|
||||||
|
.all() as { name: string }[];
|
||||||
|
if (cols.length === 0 || cols.some((c) => c.name === "prev_bin_id")) return;
|
||||||
|
db.exec(`ALTER TABLE inventory_items ADD COLUMN prev_bin_id TEXT REFERENCES bins(id)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function migrateAddBinCheckFields(): void {
|
||||||
|
const cols = db
|
||||||
|
.prepare(`PRAGMA table_info(bins)`)
|
||||||
|
.all() as { name: string }[];
|
||||||
|
if (cols.length === 0 || cols.some((c) => c.name === "cadence_days")) return;
|
||||||
|
db.exec(`ALTER TABLE bins ADD COLUMN cadence_days INTEGER NOT NULL DEFAULT 30`);
|
||||||
|
db.exec(`ALTER TABLE bins ADD COLUMN last_checked TEXT`);
|
||||||
|
}
|
||||||
|
|
||||||
// One-shot migration: the original schema put per-instance fields (weight,
|
// One-shot migration: the original schema put per-instance fields (weight,
|
||||||
// bin_id, etc.) directly on `products`. The split schema separates products
|
// bin_id, etc.) directly on `products`. The split schema separates products
|
||||||
// (catalog) from inventory_items (instance). When we detect the old shape,
|
// (catalog) from inventory_items (instance). When we detect the old shape,
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ type InventoryRow = {
|
|||||||
cbd: number;
|
cbd: number;
|
||||||
total_cannabinoids: number;
|
total_cannabinoids: number;
|
||||||
weight: number;
|
weight: number;
|
||||||
|
container_weight: number | null;
|
||||||
last_audit_weight: number | null;
|
last_audit_weight: number | null;
|
||||||
count_original: number;
|
count_original: number;
|
||||||
count_last_audit: number | null;
|
count_last_audit: number | null;
|
||||||
@@ -32,6 +33,8 @@ type InventoryRow = {
|
|||||||
status: string;
|
status: string;
|
||||||
consumed_date: string | null;
|
consumed_date: string | null;
|
||||||
gone_date: string | null;
|
gone_date: string | null;
|
||||||
|
checkout_date: string | null;
|
||||||
|
prev_bin_id: string | null;
|
||||||
rating: number | null;
|
rating: number | null;
|
||||||
notes: string | null;
|
notes: string | null;
|
||||||
};
|
};
|
||||||
@@ -67,7 +70,14 @@ bootstrapRouter.get("/bootstrap", (_req, res) => {
|
|||||||
.all();
|
.all();
|
||||||
const shops = db.prepare("SELECT * FROM shops ORDER BY id").all();
|
const shops = db.prepare("SELECT * FROM shops ORDER BY id").all();
|
||||||
const brands = db.prepare("SELECT * FROM brands ORDER BY id").all();
|
const brands = db.prepare("SELECT * FROM brands ORDER BY id").all();
|
||||||
const bins = db.prepare("SELECT id, name, capacity FROM bins ORDER BY id").all();
|
const binsRaw = db.prepare("SELECT id, name, capacity, cadence_days, last_checked FROM bins ORDER BY id").all() as { id: string; name: string; capacity: number; cadence_days: number; last_checked: string | null }[];
|
||||||
|
const bins = binsRaw.map((b) => ({
|
||||||
|
id: b.id,
|
||||||
|
name: b.name,
|
||||||
|
capacity: b.capacity,
|
||||||
|
cadenceDays: b.cadence_days,
|
||||||
|
lastChecked: b.last_checked,
|
||||||
|
}));
|
||||||
const strains = db
|
const strains = db
|
||||||
.prepare<[], StrainRow>("SELECT * FROM strains ORDER BY name COLLATE NOCASE")
|
.prepare<[], StrainRow>("SELECT * FROM strains ORDER BY name COLLATE NOCASE")
|
||||||
.all();
|
.all();
|
||||||
@@ -100,6 +110,7 @@ bootstrapRouter.get("/bootstrap", (_req, res) => {
|
|||||||
cbd: i.cbd,
|
cbd: i.cbd,
|
||||||
totalCannabinoids: i.total_cannabinoids,
|
totalCannabinoids: i.total_cannabinoids,
|
||||||
weight: i.weight,
|
weight: i.weight,
|
||||||
|
containerWeight: i.container_weight,
|
||||||
lastAuditWeight: i.last_audit_weight,
|
lastAuditWeight: i.last_audit_weight,
|
||||||
countOriginal: i.count_original,
|
countOriginal: i.count_original,
|
||||||
countLastAudit: i.count_last_audit,
|
countLastAudit: i.count_last_audit,
|
||||||
@@ -108,6 +119,8 @@ bootstrapRouter.get("/bootstrap", (_req, res) => {
|
|||||||
status: i.status,
|
status: i.status,
|
||||||
consumedDate: i.consumed_date,
|
consumedDate: i.consumed_date,
|
||||||
goneDate: i.gone_date,
|
goneDate: i.gone_date,
|
||||||
|
checkoutDate: i.checkout_date,
|
||||||
|
prevBinId: i.prev_bin_id,
|
||||||
rating: i.rating,
|
rating: i.rating,
|
||||||
notes: i.notes,
|
notes: i.notes,
|
||||||
audits: (auditsByInventory.get(i.id) ?? []).map((a) => ({
|
audits: (auditsByInventory.get(i.id) ?? []).map((a) => ({
|
||||||
|
|||||||
@@ -103,20 +103,21 @@ catalogRouter.delete("/shops/:id", (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
catalogRouter.post("/bins", (req, res) => {
|
catalogRouter.post("/bins", (req, res) => {
|
||||||
const { name, capacity } = req.body as { name: string; capacity?: number };
|
const { name, capacity, cadenceDays } = req.body as { name: string; capacity?: number; cadenceDays?: number };
|
||||||
if (!name?.trim()) return res.status(400).json({ error: "name required" });
|
if (!name?.trim()) return res.status(400).json({ error: "name required" });
|
||||||
const id = nextId("bin", "bins");
|
const id = nextId("bin", "bins");
|
||||||
const cap = Number.isFinite(capacity) && (capacity as number) > 0 ? Math.floor(capacity as number) : 10;
|
const cap = Number.isFinite(capacity) && (capacity as number) > 0 ? Math.floor(capacity as number) : 10;
|
||||||
db.prepare("INSERT INTO bins (id, name, capacity) VALUES (?, ?, ?)").run(id, name.trim(), cap);
|
const cad = Number.isFinite(cadenceDays) && (cadenceDays as number) > 0 ? Math.floor(cadenceDays as number) : 30;
|
||||||
res.json({ id, name: name.trim(), capacity: cap });
|
db.prepare("INSERT INTO bins (id, name, capacity, cadence_days) VALUES (?, ?, ?, ?)").run(id, name.trim(), cap, cad);
|
||||||
|
res.json({ id, name: name.trim(), capacity: cap, cadenceDays: cad, lastChecked: null });
|
||||||
});
|
});
|
||||||
|
|
||||||
catalogRouter.patch("/bins/:id", (req, res) => {
|
catalogRouter.patch("/bins/:id", (req, res) => {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const { name, capacity } = req.body as { name?: string; capacity?: number };
|
const { name, capacity, cadenceDays } = req.body as { name?: string; capacity?: number; cadenceDays?: number };
|
||||||
const existing = db
|
const existing = db
|
||||||
.prepare<[string], { id: string; name: string; capacity: number }>(
|
.prepare<[string], { id: string; name: string; capacity: number; cadence_days: number; last_checked: string | null }>(
|
||||||
"SELECT id, name, capacity FROM bins WHERE id = ?",
|
"SELECT id, name, capacity, cadence_days, last_checked FROM bins WHERE id = ?",
|
||||||
)
|
)
|
||||||
.get(id);
|
.get(id);
|
||||||
if (!existing) return res.status(404).json({ error: "bin not found" });
|
if (!existing) return res.status(404).json({ error: "bin not found" });
|
||||||
@@ -126,9 +127,13 @@ catalogRouter.patch("/bins/:id", (req, res) => {
|
|||||||
Number.isFinite(capacity) && (capacity as number) > 0
|
Number.isFinite(capacity) && (capacity as number) > 0
|
||||||
? Math.floor(capacity as number)
|
? Math.floor(capacity as number)
|
||||||
: existing.capacity;
|
: existing.capacity;
|
||||||
|
const nextCadence =
|
||||||
|
Number.isFinite(cadenceDays) && (cadenceDays as number) > 0
|
||||||
|
? Math.floor(cadenceDays as number)
|
||||||
|
: existing.cadence_days;
|
||||||
|
|
||||||
db.prepare("UPDATE bins SET name = ?, capacity = ? WHERE id = ?").run(nextName, nextCapacity, id);
|
db.prepare("UPDATE bins SET name = ?, capacity = ?, cadence_days = ? WHERE id = ?").run(nextName, nextCapacity, nextCadence, id);
|
||||||
res.json({ id, name: nextName, capacity: nextCapacity });
|
res.json({ id, name: nextName, capacity: nextCapacity, cadenceDays: nextCadence, lastChecked: existing.last_checked });
|
||||||
});
|
});
|
||||||
|
|
||||||
// Deleting a bin unassigns any inventory items that reference it (bin_id → NULL),
|
// Deleting a bin unassigns any inventory items that reference it (bin_id → NULL),
|
||||||
|
|||||||
+291
-55
@@ -15,6 +15,7 @@ type CreateBody = {
|
|||||||
weight?: number;
|
weight?: number;
|
||||||
countOriginal?: number;
|
countOriginal?: number;
|
||||||
unitWeight?: number;
|
unitWeight?: number;
|
||||||
|
containerWeight?: number | null;
|
||||||
purchaseDate: string;
|
purchaseDate: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -53,14 +54,14 @@ inventoryRouter.post("/inventory", (req, res) => {
|
|||||||
id, asset_id, product_id,
|
id, asset_id, product_id,
|
||||||
shop_id, bin_id,
|
shop_id, bin_id,
|
||||||
price, thc, cbd, total_cannabinoids,
|
price, thc, cbd, total_cannabinoids,
|
||||||
weight, last_audit_weight,
|
weight, container_weight, last_audit_weight,
|
||||||
count_original, count_last_audit, unit_weight,
|
count_original, count_last_audit, unit_weight,
|
||||||
purchase_date, status
|
purchase_date, status
|
||||||
) VALUES (
|
) VALUES (
|
||||||
@id, @assetId, @productId,
|
@id, @assetId, @productId,
|
||||||
@shopId, @binId,
|
@shopId, @binId,
|
||||||
@price, @thc, @cbd, @totalCannabinoids,
|
@price, @thc, @cbd, @totalCannabinoids,
|
||||||
@weight, @lastAuditWeight,
|
@weight, @containerWeight, @lastAuditWeight,
|
||||||
@countOriginal, @countLastAudit, @unitWeight,
|
@countOriginal, @countLastAudit, @unitWeight,
|
||||||
@purchaseDate, 'active'
|
@purchaseDate, 'active'
|
||||||
)`,
|
)`,
|
||||||
@@ -75,6 +76,7 @@ inventoryRouter.post("/inventory", (req, res) => {
|
|||||||
cbd: body.cbd ?? 0,
|
cbd: body.cbd ?? 0,
|
||||||
totalCannabinoids: body.totalCannabinoids ?? 0,
|
totalCannabinoids: body.totalCannabinoids ?? 0,
|
||||||
weight: isDiscrete ? 0 : body.weight ?? 0,
|
weight: isDiscrete ? 0 : body.weight ?? 0,
|
||||||
|
containerWeight: isDiscrete ? null : body.containerWeight ?? null,
|
||||||
lastAuditWeight: isDiscrete ? null : body.weight ?? 0,
|
lastAuditWeight: isDiscrete ? null : body.weight ?? 0,
|
||||||
countOriginal: isDiscrete ? body.countOriginal ?? 0 : 0,
|
countOriginal: isDiscrete ? body.countOriginal ?? 0 : 0,
|
||||||
countLastAudit: isDiscrete ? body.countOriginal ?? 0 : null,
|
countLastAudit: isDiscrete ? body.countOriginal ?? 0 : null,
|
||||||
@@ -85,6 +87,8 @@ inventoryRouter.post("/inventory", (req, res) => {
|
|||||||
res.json({ id, assetId });
|
res.json({ id, assetId });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ─── Shared helpers (used by individual routes + batch) ───────────
|
||||||
|
|
||||||
type UpdateBody = Partial<{
|
type UpdateBody = Partial<{
|
||||||
shopId: string | null;
|
shopId: string | null;
|
||||||
binId: string | null;
|
binId: string | null;
|
||||||
@@ -93,41 +97,40 @@ type UpdateBody = Partial<{
|
|||||||
cbd: number;
|
cbd: number;
|
||||||
totalCannabinoids: number;
|
totalCannabinoids: number;
|
||||||
weight: number;
|
weight: number;
|
||||||
|
containerWeight: number | null;
|
||||||
countOriginal: number;
|
countOriginal: number;
|
||||||
unitWeight: number;
|
unitWeight: number;
|
||||||
purchaseDate: string;
|
purchaseDate: string;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
inventoryRouter.patch("/inventory/:id", (req, res) => {
|
type ItemRow = {
|
||||||
const { id } = req.params;
|
id: string;
|
||||||
const body = req.body as UpdateBody;
|
shop_id: string | null;
|
||||||
|
bin_id: string | null;
|
||||||
type Row = {
|
product_id: string;
|
||||||
id: string;
|
price: number;
|
||||||
shop_id: string | null;
|
thc: number;
|
||||||
bin_id: string | null;
|
cbd: number;
|
||||||
product_id: string;
|
total_cannabinoids: number;
|
||||||
price: number;
|
weight: number;
|
||||||
thc: number;
|
container_weight: number | null;
|
||||||
cbd: number;
|
last_audit_weight: number | null;
|
||||||
total_cannabinoids: number;
|
count_original: number;
|
||||||
weight: number;
|
count_last_audit: number | null;
|
||||||
last_audit_weight: number | null;
|
unit_weight: number;
|
||||||
count_original: number;
|
purchase_date: string;
|
||||||
count_last_audit: number | null;
|
};
|
||||||
unit_weight: number;
|
|
||||||
purchase_date: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
|
function doUpdate(id: string, body: UpdateBody): void {
|
||||||
const existing = db
|
const existing = db
|
||||||
.prepare<[string], Row>(
|
.prepare<[string], ItemRow>(
|
||||||
`SELECT id, shop_id, bin_id, product_id, price, thc, cbd,
|
`SELECT id, shop_id, bin_id, product_id, price, thc, cbd,
|
||||||
total_cannabinoids, weight, last_audit_weight, count_original,
|
total_cannabinoids, weight, container_weight, last_audit_weight,
|
||||||
count_last_audit, unit_weight, purchase_date
|
count_original, count_last_audit, unit_weight, purchase_date
|
||||||
FROM inventory_items WHERE id = ?`,
|
FROM inventory_items WHERE id = ?`,
|
||||||
)
|
)
|
||||||
.get(id);
|
.get(id);
|
||||||
if (!existing) return res.status(404).json({ error: "inventory item not found" });
|
if (!existing) throw new Error("inventory item not found");
|
||||||
|
|
||||||
const product = db
|
const product = db
|
||||||
.prepare<[string], { kind: string }>(`SELECT kind FROM products WHERE id = ?`)
|
.prepare<[string], { kind: string }>(`SELECT kind FROM products WHERE id = ?`)
|
||||||
@@ -162,6 +165,12 @@ inventoryRouter.patch("/inventory/:id", (req, res) => {
|
|||||||
!isDiscrete && Number.isFinite(body.weight) && (body.weight as number) >= 0
|
!isDiscrete && Number.isFinite(body.weight) && (body.weight as number) >= 0
|
||||||
? (body.weight as number)
|
? (body.weight as number)
|
||||||
: existing.weight;
|
: existing.weight;
|
||||||
|
const nextContainerWeight =
|
||||||
|
body.containerWeight === undefined
|
||||||
|
? existing.container_weight
|
||||||
|
: body.containerWeight != null && Number.isFinite(body.containerWeight)
|
||||||
|
? body.containerWeight
|
||||||
|
: null;
|
||||||
const nextCountOriginal =
|
const nextCountOriginal =
|
||||||
isDiscrete && Number.isFinite(body.countOriginal) && (body.countOriginal as number) >= 0
|
isDiscrete && Number.isFinite(body.countOriginal) && (body.countOriginal as number) >= 0
|
||||||
? Math.floor(body.countOriginal as number)
|
? Math.floor(body.countOriginal as number)
|
||||||
@@ -171,8 +180,6 @@ inventoryRouter.patch("/inventory/:id", (req, res) => {
|
|||||||
? (body.unitWeight as number)
|
? (body.unitWeight as number)
|
||||||
: existing.unit_weight;
|
: existing.unit_weight;
|
||||||
|
|
||||||
// Mirror the original size into the "last audit" field while no audits
|
|
||||||
// exist — keeps the next audit's prev_value accurate after an edit.
|
|
||||||
const nextLastAuditWeight =
|
const nextLastAuditWeight =
|
||||||
!isDiscrete && auditCount === 0 ? nextWeight : existing.last_audit_weight;
|
!isDiscrete && auditCount === 0 ? nextWeight : existing.last_audit_weight;
|
||||||
const nextCountLastAudit =
|
const nextCountLastAudit =
|
||||||
@@ -187,6 +194,7 @@ inventoryRouter.patch("/inventory/:id", (req, res) => {
|
|||||||
cbd = @cbd,
|
cbd = @cbd,
|
||||||
total_cannabinoids = @totalCannabinoids,
|
total_cannabinoids = @totalCannabinoids,
|
||||||
weight = @weight,
|
weight = @weight,
|
||||||
|
container_weight = @containerWeight,
|
||||||
last_audit_weight = @lastAuditWeight,
|
last_audit_weight = @lastAuditWeight,
|
||||||
count_original = @countOriginal,
|
count_original = @countOriginal,
|
||||||
count_last_audit = @countLastAudit,
|
count_last_audit = @countLastAudit,
|
||||||
@@ -202,61 +210,172 @@ inventoryRouter.patch("/inventory/:id", (req, res) => {
|
|||||||
cbd: nextCbd,
|
cbd: nextCbd,
|
||||||
totalCannabinoids: nextTotalCanna,
|
totalCannabinoids: nextTotalCanna,
|
||||||
weight: nextWeight,
|
weight: nextWeight,
|
||||||
|
containerWeight: nextContainerWeight,
|
||||||
lastAuditWeight: nextLastAuditWeight,
|
lastAuditWeight: nextLastAuditWeight,
|
||||||
countOriginal: nextCountOriginal,
|
countOriginal: nextCountOriginal,
|
||||||
countLastAudit: nextCountLastAudit,
|
countLastAudit: nextCountLastAudit,
|
||||||
unitWeight: nextUnitWeight,
|
unitWeight: nextUnitWeight,
|
||||||
purchaseDate: nextPurchaseDate,
|
purchaseDate: nextPurchaseDate,
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
res.json({ ok: true });
|
function doCheckout(id: string, date: string): void {
|
||||||
|
const result = db
|
||||||
|
.prepare(
|
||||||
|
`UPDATE inventory_items
|
||||||
|
SET status = 'checked-out', checkout_date = ?, prev_bin_id = bin_id, bin_id = NULL
|
||||||
|
WHERE id = ? AND status = 'active'`,
|
||||||
|
)
|
||||||
|
.run(date, id);
|
||||||
|
if (result.changes === 0) throw new Error("not found or not active");
|
||||||
|
}
|
||||||
|
|
||||||
|
function doCheckin(id: string, date: string, binId: string, remainingWeight?: number): void {
|
||||||
|
const item = db
|
||||||
|
.prepare<
|
||||||
|
[string],
|
||||||
|
{ product_id: string; last_audit_weight: number | null; weight: number }
|
||||||
|
>(
|
||||||
|
`SELECT product_id, last_audit_weight, weight
|
||||||
|
FROM inventory_items WHERE id = ? AND status = 'checked-out'`,
|
||||||
|
)
|
||||||
|
.get(id);
|
||||||
|
if (!item) throw new Error("not found or not checked-out");
|
||||||
|
|
||||||
|
const product = db
|
||||||
|
.prepare<[string], { kind: string }>(`SELECT kind FROM products WHERE id = ?`)
|
||||||
|
.get(item.product_id);
|
||||||
|
const isBulk = product?.kind === "bulk";
|
||||||
|
|
||||||
|
db.prepare(
|
||||||
|
`UPDATE inventory_items
|
||||||
|
SET status = 'active', bin_id = ?, checkout_date = NULL, prev_bin_id = NULL
|
||||||
|
WHERE id = ?`,
|
||||||
|
).run(binId, id);
|
||||||
|
|
||||||
|
if (isBulk && remainingWeight != null && Number.isFinite(remainingWeight)) {
|
||||||
|
const prev = item.last_audit_weight ?? item.weight;
|
||||||
|
db.prepare(
|
||||||
|
`INSERT INTO audits (inventory_id, date, mode, value, prev_value, confirmed_by)
|
||||||
|
VALUES (?, ?, 'weigh', ?, ?, 'checkin')`,
|
||||||
|
).run(id, date, remainingWeight, prev);
|
||||||
|
db.prepare(`UPDATE inventory_items SET last_audit_weight = ? WHERE id = ?`).run(
|
||||||
|
remainingWeight,
|
||||||
|
id,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function doFinish(id: string, date: string, rating?: number, notes?: string): void {
|
||||||
|
const result = db
|
||||||
|
.prepare(
|
||||||
|
`UPDATE inventory_items
|
||||||
|
SET status = 'consumed', consumed_date = ?, rating = ?, notes = ?, bin_id = NULL, checkout_date = NULL
|
||||||
|
WHERE id = ? AND status IN ('active', 'checked-out')`,
|
||||||
|
)
|
||||||
|
.run(date, rating ?? null, notes ?? null, id);
|
||||||
|
if (result.changes === 0) throw new Error("not found or not active");
|
||||||
|
}
|
||||||
|
|
||||||
|
function doGone(id: string, date: string, reason: string, notes?: string): void {
|
||||||
|
const combinedNotes = notes ? `${reason}: ${notes}` : reason;
|
||||||
|
const result = db
|
||||||
|
.prepare(
|
||||||
|
`UPDATE inventory_items
|
||||||
|
SET status = 'gone', gone_date = ?, notes = ?, bin_id = NULL, checkout_date = NULL
|
||||||
|
WHERE id = ? AND status IN ('active', 'checked-out')`,
|
||||||
|
)
|
||||||
|
.run(date, combinedNotes, id);
|
||||||
|
if (result.changes === 0) throw new Error("not found or not active");
|
||||||
|
db.prepare(
|
||||||
|
`INSERT INTO audits (inventory_id, date, mode, value, prev_value, confirmed_by)
|
||||||
|
VALUES (?, ?, 'presence', 0, NULL, 'lost')`,
|
||||||
|
).run(id, date);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Individual routes (thin wrappers around helpers) ─────────────
|
||||||
|
|
||||||
|
inventoryRouter.patch("/inventory/:id", (req, res) => {
|
||||||
|
try {
|
||||||
|
doUpdate(req.params.id, req.body as UpdateBody);
|
||||||
|
res.json({ ok: true });
|
||||||
|
} catch (e: any) {
|
||||||
|
res.status(404).json({ error: e.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
inventoryRouter.post("/inventory/:id/checkout", (req, res) => {
|
||||||
|
try {
|
||||||
|
doCheckout(req.params.id, (req.body as { date: string }).date);
|
||||||
|
res.json({ ok: true });
|
||||||
|
} catch (e: any) {
|
||||||
|
res.status(404).json({ error: e.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
inventoryRouter.post("/inventory/:id/checkin", (req, res) => {
|
||||||
|
const { date, binId, remainingWeight } = req.body as {
|
||||||
|
date: string;
|
||||||
|
binId: string;
|
||||||
|
remainingWeight?: number;
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
doCheckin(req.params.id, date, binId, remainingWeight);
|
||||||
|
res.json({ ok: true });
|
||||||
|
} catch (e: any) {
|
||||||
|
res.status(404).json({ error: e.message });
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
inventoryRouter.post("/inventory/:id/finish", (req, res) => {
|
inventoryRouter.post("/inventory/:id/finish", (req, res) => {
|
||||||
const { id } = req.params;
|
|
||||||
const { date, rating, notes } = req.body as {
|
const { date, rating, notes } = req.body as {
|
||||||
date: string;
|
date: string;
|
||||||
rating?: number;
|
rating?: number;
|
||||||
notes?: string;
|
notes?: string;
|
||||||
};
|
};
|
||||||
const result = db
|
try {
|
||||||
.prepare(
|
doFinish(req.params.id, date, rating, notes);
|
||||||
`UPDATE inventory_items
|
res.json({ ok: true });
|
||||||
SET status = 'consumed', consumed_date = ?, rating = ?, notes = ?, bin_id = NULL
|
} catch (e: any) {
|
||||||
WHERE id = ? AND status = 'active'`,
|
res.status(404).json({ error: e.message });
|
||||||
)
|
}
|
||||||
.run(date, rating ?? null, notes ?? null, id);
|
|
||||||
if (result.changes === 0) return res.status(404).json({ error: "not found or not active" });
|
|
||||||
res.json({ ok: true });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
inventoryRouter.post("/inventory/:id/gone", (req, res) => {
|
inventoryRouter.post("/inventory/:id/gone", (req, res) => {
|
||||||
const { id } = req.params;
|
|
||||||
const { date, reason, notes } = req.body as {
|
const { date, reason, notes } = req.body as {
|
||||||
date: string;
|
date: string;
|
||||||
reason: string;
|
reason: string;
|
||||||
notes?: string;
|
notes?: string;
|
||||||
};
|
};
|
||||||
const combinedNotes = notes ? `${reason}: ${notes}` : reason;
|
try {
|
||||||
const tx = db.transaction(() => {
|
doGone(req.params.id, date, reason, notes);
|
||||||
|
res.json({ ok: true });
|
||||||
|
} catch (e: any) {
|
||||||
|
res.status(404).json({ error: e.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
inventoryRouter.post("/inventory/:id/reactivate", (req, res) => {
|
||||||
|
const { binId } = req.body as { binId: string };
|
||||||
|
try {
|
||||||
const result = db
|
const result = db
|
||||||
.prepare(
|
.prepare(
|
||||||
`UPDATE inventory_items
|
`UPDATE inventory_items
|
||||||
SET status = 'gone', gone_date = ?, notes = ?, bin_id = NULL
|
SET status = 'active',
|
||||||
WHERE id = ? AND status = 'active'`,
|
bin_id = ?,
|
||||||
|
consumed_date = NULL,
|
||||||
|
gone_date = NULL,
|
||||||
|
checkout_date = NULL,
|
||||||
|
prev_bin_id = NULL
|
||||||
|
WHERE id = ? AND status IN ('consumed', 'gone', 'checked-out')`,
|
||||||
)
|
)
|
||||||
.run(date, combinedNotes, id);
|
.run(binId, req.params.id);
|
||||||
if (result.changes === 0) throw new Error("not found");
|
if (result.changes === 0) {
|
||||||
db.prepare(
|
return res.status(404).json({ error: "not found or already active" });
|
||||||
`INSERT INTO audits (inventory_id, date, mode, value, prev_value, confirmed_by)
|
}
|
||||||
VALUES (?, ?, 'presence', 0, NULL, 'lost')`,
|
|
||||||
).run(id, date);
|
|
||||||
});
|
|
||||||
try {
|
|
||||||
tx();
|
|
||||||
res.json({ ok: true });
|
res.json({ ok: true });
|
||||||
} catch {
|
} catch (e: any) {
|
||||||
res.status(404).json({ error: "not found or not active" });
|
res.status(400).json({ error: e.message });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -315,3 +434,120 @@ inventoryRouter.post("/inventory/:id/audit", (req, res) => {
|
|||||||
tx();
|
tx();
|
||||||
res.json({ ok: true });
|
res.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ─── Bin check endpoint ──────────────────────────────────────────
|
||||||
|
|
||||||
|
inventoryRouter.post("/bins/:id/check", (req, res) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
const { date, verifiedItemIds, goneItemIds } = req.body as {
|
||||||
|
date: string;
|
||||||
|
verifiedItemIds: string[];
|
||||||
|
goneItemIds: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const bin = db
|
||||||
|
.prepare<[string], { id: string }>("SELECT id FROM bins WHERE id = ?")
|
||||||
|
.get(id);
|
||||||
|
if (!bin) return res.status(404).json({ error: "bin not found" });
|
||||||
|
|
||||||
|
const tx = db.transaction(() => {
|
||||||
|
for (const itemId of verifiedItemIds) {
|
||||||
|
const item = db
|
||||||
|
.prepare<
|
||||||
|
[string],
|
||||||
|
{ product_id: string; weight: number; last_audit_weight: number | null; count_original: number; count_last_audit: number | null }
|
||||||
|
>(
|
||||||
|
`SELECT product_id, weight, last_audit_weight, count_original, count_last_audit FROM inventory_items WHERE id = ?`,
|
||||||
|
)
|
||||||
|
.get(itemId);
|
||||||
|
if (!item) continue;
|
||||||
|
|
||||||
|
const product = db
|
||||||
|
.prepare<[string], { kind: string }>(`SELECT kind FROM products WHERE id = ?`)
|
||||||
|
.get(item.product_id);
|
||||||
|
const isDiscrete = product?.kind === "discrete";
|
||||||
|
|
||||||
|
const prev = isDiscrete
|
||||||
|
? item.count_last_audit ?? item.count_original
|
||||||
|
: item.last_audit_weight ?? item.weight;
|
||||||
|
|
||||||
|
db.prepare(
|
||||||
|
`INSERT INTO audits (inventory_id, date, mode, value, prev_value, confirmed_by)
|
||||||
|
VALUES (?, ?, 'presence', ?, ?, 'bin-check')`,
|
||||||
|
).run(itemId, date, prev, prev);
|
||||||
|
if (isDiscrete) {
|
||||||
|
db.prepare(
|
||||||
|
`UPDATE inventory_items SET count_last_audit = ? WHERE id = ?`,
|
||||||
|
).run(prev, itemId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const itemId of goneItemIds) {
|
||||||
|
try {
|
||||||
|
doGone(itemId, date, "missing from bin check");
|
||||||
|
} catch {
|
||||||
|
// item may already be gone
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
db.prepare("UPDATE bins SET last_checked = ? WHERE id = ?").run(date, id);
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
tx();
|
||||||
|
res.json({ ok: true, verified: verifiedItemIds.length, gone: goneItemIds.length });
|
||||||
|
} catch (e: any) {
|
||||||
|
res.status(400).json({ error: e.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Batch endpoint ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
type BatchOp =
|
||||||
|
| { action: "update"; id: string; fields: UpdateBody }
|
||||||
|
| { action: "checkout"; id: string; date: string }
|
||||||
|
| { action: "checkin"; id: string; date: string; binId: string }
|
||||||
|
| { action: "finish"; id: string; date: string; rating?: number; notes?: string }
|
||||||
|
| { action: "gone"; id: string; date: string; reason: string; notes?: string };
|
||||||
|
|
||||||
|
inventoryRouter.post("/inventory/batch", (req, res) => {
|
||||||
|
const { ops } = req.body as { ops: BatchOp[] };
|
||||||
|
|
||||||
|
if (!Array.isArray(ops) || ops.length === 0) {
|
||||||
|
return res.status(400).json({ error: "ops array required" });
|
||||||
|
}
|
||||||
|
if (ops.length > 200) {
|
||||||
|
return res.status(400).json({ error: "too many operations (max 200)" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const tx = db.transaction(() => {
|
||||||
|
for (const op of ops) {
|
||||||
|
switch (op.action) {
|
||||||
|
case "update":
|
||||||
|
doUpdate(op.id, op.fields);
|
||||||
|
break;
|
||||||
|
case "checkout":
|
||||||
|
doCheckout(op.id, op.date);
|
||||||
|
break;
|
||||||
|
case "checkin":
|
||||||
|
doCheckin(op.id, op.date, op.binId);
|
||||||
|
break;
|
||||||
|
case "finish":
|
||||||
|
doFinish(op.id, op.date, op.rating, op.notes);
|
||||||
|
break;
|
||||||
|
case "gone":
|
||||||
|
doGone(op.id, op.date, op.reason, op.notes);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
throw new Error(`unknown action: ${(op as any).action}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
tx();
|
||||||
|
res.json({ ok: true, count: ops.length });
|
||||||
|
} catch (e: any) {
|
||||||
|
res.status(400).json({ error: e.message ?? "batch failed" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -91,6 +91,7 @@ productsRouter.post("/products", (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
type UpdateBody = Partial<{
|
type UpdateBody = Partial<{
|
||||||
|
sku: string;
|
||||||
type: string;
|
type: string;
|
||||||
kind: "bulk" | "discrete";
|
kind: "bulk" | "discrete";
|
||||||
strainId: string | null;
|
strainId: string | null;
|
||||||
@@ -105,13 +106,21 @@ productsRouter.patch("/products/:id", (req, res) => {
|
|||||||
const existing = db
|
const existing = db
|
||||||
.prepare<
|
.prepare<
|
||||||
[string],
|
[string],
|
||||||
{ id: string; type: string; kind: string; strain_id: string; brand_id: string | null }
|
{ id: string; sku: string; type: string; kind: string; strain_id: string; brand_id: string | null }
|
||||||
>(
|
>(
|
||||||
`SELECT id, type, kind, strain_id, brand_id FROM products WHERE id = ?`,
|
`SELECT id, sku, type, kind, strain_id, brand_id FROM products WHERE id = ?`,
|
||||||
)
|
)
|
||||||
.get(id);
|
.get(id);
|
||||||
if (!existing) return res.status(404).json({ error: "product not found" });
|
if (!existing) return res.status(404).json({ error: "product not found" });
|
||||||
|
|
||||||
|
const nextSku = typeof body.sku === "string" && body.sku.trim() ? body.sku.trim() : existing.sku;
|
||||||
|
if (nextSku !== existing.sku) {
|
||||||
|
const duplicate = db
|
||||||
|
.prepare<[string, string], { id: string }>("SELECT id FROM products WHERE sku = ? AND id != ?")
|
||||||
|
.get(nextSku, id);
|
||||||
|
if (duplicate) return res.status(409).json({ error: "sku already exists" });
|
||||||
|
}
|
||||||
|
|
||||||
const nextType = typeof body.type === "string" && body.type ? body.type : existing.type;
|
const nextType = typeof body.type === "string" && body.type ? body.type : existing.type;
|
||||||
const nextKind: "bulk" | "discrete" =
|
const nextKind: "bulk" | "discrete" =
|
||||||
body.kind === "bulk" || body.kind === "discrete"
|
body.kind === "bulk" || body.kind === "discrete"
|
||||||
@@ -144,8 +153,8 @@ productsRouter.patch("/products/:id", (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
db.prepare(
|
db.prepare(
|
||||||
`UPDATE products SET type = ?, kind = ?, strain_id = ?, brand_id = ? WHERE id = ?`,
|
`UPDATE products SET sku = ?, type = ?, kind = ?, strain_id = ?, brand_id = ? WHERE id = ?`,
|
||||||
).run(nextType, nextKind, nextStrainId, nextBrandId, id);
|
).run(nextSku, nextType, nextKind, nextStrainId, nextBrandId, id);
|
||||||
|
|
||||||
res.json({ ok: true });
|
res.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -15,7 +15,9 @@ CREATE TABLE IF NOT EXISTS bins (
|
|||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
location TEXT,
|
location TEXT,
|
||||||
capacity INTEGER NOT NULL DEFAULT 10
|
capacity INTEGER NOT NULL DEFAULT 10,
|
||||||
|
cadence_days INTEGER NOT NULL DEFAULT 30,
|
||||||
|
last_checked TEXT
|
||||||
);
|
);
|
||||||
|
|
||||||
-- Strains: one row per cannabis strain (catalog-level). UNIQUE on name only,
|
-- Strains: one row per cannabis strain (catalog-level). UNIQUE on name only,
|
||||||
@@ -64,6 +66,7 @@ CREATE TABLE IF NOT EXISTS inventory_items (
|
|||||||
cbd REAL DEFAULT 0,
|
cbd REAL DEFAULT 0,
|
||||||
total_cannabinoids REAL DEFAULT 0,
|
total_cannabinoids REAL DEFAULT 0,
|
||||||
weight REAL DEFAULT 0,
|
weight REAL DEFAULT 0,
|
||||||
|
container_weight REAL,
|
||||||
last_audit_weight REAL,
|
last_audit_weight REAL,
|
||||||
count_original INTEGER DEFAULT 0,
|
count_original INTEGER DEFAULT 0,
|
||||||
count_last_audit INTEGER,
|
count_last_audit INTEGER,
|
||||||
@@ -72,6 +75,8 @@ CREATE TABLE IF NOT EXISTS inventory_items (
|
|||||||
status TEXT NOT NULL DEFAULT 'active',
|
status TEXT NOT NULL DEFAULT 'active',
|
||||||
consumed_date TEXT,
|
consumed_date TEXT,
|
||||||
gone_date TEXT,
|
gone_date TEXT,
|
||||||
|
checkout_date TEXT,
|
||||||
|
prev_bin_id TEXT REFERENCES bins(id),
|
||||||
rating INTEGER,
|
rating INTEGER,
|
||||||
notes TEXT
|
notes TEXT
|
||||||
);
|
);
|
||||||
|
|||||||
+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 |
+354
-21
@@ -1,13 +1,28 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { Routes, Route } from "react-router-dom";
|
import { Routes, Route } from "react-router-dom";
|
||||||
import { api } from "./api.js";
|
import { api } from "./api.js";
|
||||||
import type { Bin, Bootstrap, Brand, Item, Shop } from "./types.js";
|
import type { Bin, Bootstrap, Brand, Item, Product, Shop } from "./types.js";
|
||||||
import { enrichItems } from "./types.js";
|
import { enrichItems } from "./types.js";
|
||||||
|
|
||||||
|
type DrawerBack =
|
||||||
|
| { kind: "brand"; brand: Brand }
|
||||||
|
| { kind: "shop"; shop: Shop }
|
||||||
|
| { kind: "sku"; product: Product }
|
||||||
|
| null;
|
||||||
|
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 { BinsView } from "./views/BinsView.js";
|
import { BinsView } from "./views/BinsView.js";
|
||||||
import { BrandsView } from "./views/BrandsView.js";
|
import { BrandsView } from "./views/BrandsView.js";
|
||||||
import { ShopsView } from "./views/ShopsView.js";
|
import { ShopsView } from "./views/ShopsView.js";
|
||||||
@@ -15,11 +30,18 @@ import { ChartsView } from "./views/ChartsView.js";
|
|||||||
import { SettingsView } from "./views/SettingsView.js";
|
import { SettingsView } from "./views/SettingsView.js";
|
||||||
import type { ThemeKey } from "./views/SettingsView.js";
|
import type { ThemeKey } from "./views/SettingsView.js";
|
||||||
import { ProductDetail } from "./components/ProductDetail.js";
|
import { ProductDetail } from "./components/ProductDetail.js";
|
||||||
|
import { SkuDetail } from "./components/SkuDetail.js";
|
||||||
|
import { BrandDetail } from "./components/BrandDetail.js";
|
||||||
|
import { ShopDetail } from "./components/ShopDetail.js";
|
||||||
import { AddInventoryFlow } from "./components/modals/AddInventoryFlow.js";
|
import { AddInventoryFlow } from "./components/modals/AddInventoryFlow.js";
|
||||||
import { EditInventoryFlow } from "./components/modals/EditInventoryFlow.js";
|
import { EditInventoryFlow } from "./components/modals/EditInventoryFlow.js";
|
||||||
import { ConsumeFlow } from "./components/modals/ConsumeFlow.js";
|
import { ConsumeFlow } from "./components/modals/ConsumeFlow.js";
|
||||||
import { MarkGoneFlow } from "./components/modals/MarkGoneFlow.js";
|
import { MarkGoneFlow } from "./components/modals/MarkGoneFlow.js";
|
||||||
import { AuditFlow } from "./components/modals/AuditFlow.js";
|
import { WeighInFlow } from "./components/modals/WeighInFlow.js";
|
||||||
|
import { BinCheckFlow } from "./components/modals/BinCheckFlow.js";
|
||||||
|
import { CheckoutFlow } from "./components/modals/CheckoutFlow.js";
|
||||||
|
import { CheckinFlow } from "./components/modals/CheckinFlow.js";
|
||||||
|
import { CustodyView } from "./views/CustodyView.js";
|
||||||
import {
|
import {
|
||||||
AddBinModal,
|
AddBinModal,
|
||||||
AddBrandModal,
|
AddBrandModal,
|
||||||
@@ -28,38 +50,75 @@ import {
|
|||||||
EditBrandModal,
|
EditBrandModal,
|
||||||
EditShopModal,
|
EditShopModal,
|
||||||
} from "./components/modals/CatalogModals.js";
|
} from "./components/modals/CatalogModals.js";
|
||||||
|
import { AddSkuModal, EditSkuModal } from "./components/modals/SkuModals.js";
|
||||||
|
import { BulkEditModal } from "./components/modals/BulkEditModal.js";
|
||||||
|
import { BulkConsumeModal } from "./components/modals/BulkConsumeModal.js";
|
||||||
|
import { BulkCheckoutModal } from "./components/modals/BulkCheckoutModal.js";
|
||||||
|
import { BulkCheckinModal } from "./components/modals/BulkCheckinModal.js";
|
||||||
|
import { BulkGoneModal } from "./components/modals/BulkGoneModal.js";
|
||||||
|
|
||||||
type ModalKey =
|
type ModalKey =
|
||||||
| "add"
|
| "add"
|
||||||
| "edit"
|
| "edit"
|
||||||
| "consume"
|
| "consume"
|
||||||
| "gone"
|
| "gone"
|
||||||
| "audit"
|
| "weighIn"
|
||||||
|
| "binCheck"
|
||||||
|
| "checkout"
|
||||||
|
| "checkin"
|
||||||
| "addBrand"
|
| "addBrand"
|
||||||
| "addShop"
|
| "addShop"
|
||||||
| "addBin"
|
| "addBin"
|
||||||
| "editBin"
|
| "editBin"
|
||||||
| "editBrand"
|
| "editBrand"
|
||||||
| "editShop"
|
| "editShop"
|
||||||
|
| "bulkEdit"
|
||||||
|
| "bulkConsume"
|
||||||
|
| "bulkCheckout"
|
||||||
|
| "bulkCheckin"
|
||||||
|
| "bulkGone"
|
||||||
|
| "addSku"
|
||||||
|
| "editSku"
|
||||||
| null;
|
| null;
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
const [selected, setSelected] = useState<Item | null>(null);
|
const [selected, setSelected] = useState<Item | null>(null);
|
||||||
const [modal, setModal] = useState<ModalKey>(null);
|
const [modal, setModal] = useState<ModalKey>(null);
|
||||||
const [modalItem, setModalItem] = useState<Item | null>(null);
|
const [modalItem, setModalItem] = useState<Item | null>(null);
|
||||||
const [modalBin, setModalBin] = useState<Bin | null>(null);
|
const [modalBin, setModalBin] = useState<Bin | null>(null);
|
||||||
const [modalBrand, setModalBrand] = useState<Brand | null>(null);
|
const [modalBrand, setModalBrand] = useState<Brand | null>(null);
|
||||||
const [modalShop, setModalShop] = useState<Shop | null>(null);
|
const [modalShop, setModalShop] = useState<Shop | null>(null);
|
||||||
|
const [bulkItems, setBulkItems] = useState<Item[]>([]);
|
||||||
|
const [selectedSku, setSelectedSku] = useState<Product | null>(null);
|
||||||
|
const [selectedBrand, setSelectedBrand] = useState<Brand | null>(null);
|
||||||
|
const [selectedShop, setSelectedShop] = useState<Shop | null>(null);
|
||||||
|
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>(
|
const [theme, setTheme] = useState<ThemeKey>(
|
||||||
() => (localStorage.getItem("apothecary.theme") as ThemeKey | null) ?? "light",
|
() => (localStorage.getItem("apothecary.theme") as ThemeKey | null) ?? "light",
|
||||||
);
|
);
|
||||||
|
const [timezone, setTimezone] = useState<string>(getStoredTimezone);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.documentElement.dataset.theme = theme;
|
document.documentElement.dataset.theme = theme;
|
||||||
localStorage.setItem("apothecary.theme", theme);
|
localStorage.setItem("apothecary.theme", theme);
|
||||||
}, [theme]);
|
}, [theme]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
localStorage.setItem(TZ_STORAGE_KEY, timezone);
|
||||||
|
}, [timezone]);
|
||||||
|
|
||||||
const { data, isLoading, error } = useQuery<Bootstrap>({
|
const { data, isLoading, error } = useQuery<Bootstrap>({
|
||||||
queryKey: ["bootstrap"],
|
queryKey: ["bootstrap"],
|
||||||
queryFn: api.bootstrap,
|
queryFn: api.bootstrap,
|
||||||
@@ -78,6 +137,27 @@ export function App() {
|
|||||||
}
|
}
|
||||||
}, [data, items]); // eslint-disable-line react-hooks/exhaustive-deps
|
}, [data, items]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedSku && data) {
|
||||||
|
const fresh = data.products.find((p) => p.id === selectedSku.id);
|
||||||
|
if (fresh && fresh !== selectedSku) setSelectedSku(fresh);
|
||||||
|
}
|
||||||
|
}, [data]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedBrand && data) {
|
||||||
|
const fresh = data.brands.find((b) => b.id === selectedBrand.id);
|
||||||
|
if (fresh && fresh !== selectedBrand) setSelectedBrand(fresh);
|
||||||
|
}
|
||||||
|
}, [data]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedShop && data) {
|
||||||
|
const fresh = data.shops.find((s) => s.id === selectedShop.id);
|
||||||
|
if (fresh && fresh !== selectedShop) setSelectedShop(fresh);
|
||||||
|
}
|
||||||
|
}, [data]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
const openAdd = () => {
|
const openAdd = () => {
|
||||||
setModalItem(null);
|
setModalItem(null);
|
||||||
setModal("add");
|
setModal("add");
|
||||||
@@ -92,15 +172,58 @@ export function App() {
|
|||||||
setSelected(null);
|
setSelected(null);
|
||||||
setModal("gone");
|
setModal("gone");
|
||||||
};
|
};
|
||||||
const openAudit = (i?: Item) => {
|
const [weighInQueue, setWeighInQueue] = useState<Item[]>([]);
|
||||||
|
const openWeighIn = (i?: Item) => {
|
||||||
setModalItem(i ?? null);
|
setModalItem(i ?? null);
|
||||||
setModal("audit");
|
setWeighInQueue([]);
|
||||||
|
setModal("weighIn");
|
||||||
|
};
|
||||||
|
const openWeighInQueue = (queue: Item[]) => {
|
||||||
|
if (queue.length === 0) return;
|
||||||
|
setModalItem(queue[0]!);
|
||||||
|
setWeighInQueue(queue);
|
||||||
|
setModal("weighIn");
|
||||||
|
};
|
||||||
|
const [binCheckBin, setBinCheckBin] = useState<Bin | null>(null);
|
||||||
|
const openBinCheck = (bin?: Bin) => {
|
||||||
|
setBinCheckBin(bin ?? null);
|
||||||
|
setModal("binCheck");
|
||||||
|
};
|
||||||
|
const openCheckout = (i?: Item) => {
|
||||||
|
setModalItem(i ?? null);
|
||||||
|
setSelected(null);
|
||||||
|
setModal("checkout");
|
||||||
|
};
|
||||||
|
const openCheckin = (i?: Item) => {
|
||||||
|
setModalItem(i ?? null);
|
||||||
|
setSelected(null);
|
||||||
|
setModal("checkin");
|
||||||
};
|
};
|
||||||
const openEdit = (i: Item) => {
|
const openEdit = (i: Item) => {
|
||||||
setModalItem(i);
|
setModalItem(i);
|
||||||
setSelected(null);
|
setSelected(null);
|
||||||
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 openBulkConsume = (items: Item[]) => { setBulkItems(items); setModal("bulkConsume"); };
|
||||||
|
const openBulkCheckout = (items: Item[]) => { setBulkItems(items); setModal("bulkCheckout"); };
|
||||||
|
const openBulkCheckin = (items: Item[]) => { setBulkItems(items); setModal("bulkCheckin"); };
|
||||||
|
const openBulkGone = (items: Item[]) => { setBulkItems(items); setModal("bulkGone"); };
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -153,32 +276,69 @@ 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()}
|
||||||
/>
|
onWeighIn={() => openWeighIn()}
|
||||||
|
onBinCheck={() => openBinCheck()}
|
||||||
|
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} onSelectItem={setSelected} />
|
<Dashboard data={data} stats={stats} onWeighInItem={openWeighIn} onWeighInQueue={openWeighInQueue} onBinCheck={openBinCheck} onSelectItem={setSelected} />
|
||||||
} />
|
} />
|
||||||
<Route path="/inventory" element={
|
<Route path="/inventory" element={
|
||||||
<Inventory data={data} onSelectItem={setSelected} onAddInventory={openAdd} onAuditNew={() => openAudit()} />
|
<Inventory
|
||||||
|
data={data}
|
||||||
|
onSelectItem={setSelected}
|
||||||
|
onAddInventory={openAdd}
|
||||||
|
onWeighInNew={() => openWeighIn()}
|
||||||
|
onBulkEdit={openBulkEdit}
|
||||||
|
onBulkConsume={openBulkConsume}
|
||||||
|
onBulkCheckout={openBulkCheckout}
|
||||||
|
onBulkCheckin={openBulkCheckin}
|
||||||
|
onBulkGone={openBulkGone}
|
||||||
|
/>
|
||||||
|
} />
|
||||||
|
<Route path="/skus" element={
|
||||||
|
<SkusView
|
||||||
|
data={data}
|
||||||
|
onSelectSku={setSelectedSku}
|
||||||
|
onAddSku={() => setModal("addSku")}
|
||||||
|
/>
|
||||||
|
} />
|
||||||
|
<Route path="/custody" element={
|
||||||
|
<CustodyView data={data} onSelectItem={setSelected} onCheckin={openCheckin} onConsume={openConsume} onMarkGone={openMarkGone} />
|
||||||
} />
|
} />
|
||||||
<Route path="/bins" element={
|
<Route path="/bins" element={
|
||||||
<BinsView data={data} onSelectItem={setSelected} onAddBin={() => setModal("addBin")} onEditBin={(bin) => { setModalBin(bin); setModal("editBin"); }} />
|
<BinsView data={data} onSelectItem={setSelected} onAddBin={() => setModal("addBin")} onEditBin={(bin) => { setModalBin(bin); setModal("editBin"); }} onBinCheck={openBinCheck} />
|
||||||
} />
|
} />
|
||||||
<Route path="/shops" element={
|
<Route path="/shops" element={
|
||||||
<ShopsView data={data} onAddShop={() => setModal("addShop")} onEditShop={(shop) => { setModalShop(shop); setModal("editShop"); }} />
|
<ShopsView data={data} onSelectShop={setSelectedShop} onAddShop={() => setModal("addShop")} />
|
||||||
} />
|
} />
|
||||||
<Route path="/brands" element={
|
<Route path="/brands" element={
|
||||||
<BrandsView data={data} onAddBrand={() => setModal("addBrand")} onEditBrand={(brand) => { setModalBrand(brand); setModal("editBrand"); }} />
|
<BrandsView data={data} onSelectBrand={setSelectedBrand} onAddBrand={() => setModal("addBrand")} />
|
||||||
} />
|
} />
|
||||||
<Route path="/charts" element={<ChartsView data={data} stats={stats} />} />
|
<Route path="/charts" element={<ChartsView data={data} stats={stats} />} />
|
||||||
<Route path="/settings" element={
|
<Route path="/settings" element={
|
||||||
<SettingsView data={data} theme={theme} onThemeChange={setTheme} />
|
<SettingsView data={data} theme={theme} onThemeChange={setTheme} timezone={timezone} onTimezoneChange={setTimezone} />
|
||||||
} />
|
} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</main>
|
</main>
|
||||||
@@ -187,11 +347,116 @@ export function App() {
|
|||||||
<ProductDetail
|
<ProductDetail
|
||||||
item={selected}
|
item={selected}
|
||||||
data={data}
|
data={data}
|
||||||
onClose={() => setSelected(null)}
|
onClose={() => { setSelected(null); setDrawerBack(null); }}
|
||||||
onConsume={openConsume}
|
onConsume={openConsume}
|
||||||
onMarkGone={openMarkGone}
|
onMarkGone={openMarkGone}
|
||||||
onAudit={openAudit}
|
onWeighIn={openWeighIn}
|
||||||
onEdit={openEdit}
|
onEdit={openEdit}
|
||||||
|
onCheckout={openCheckout}
|
||||||
|
onCheckin={openCheckin}
|
||||||
|
backLabel={drawerBack?.kind === "brand" ? drawerBack.brand.name : drawerBack?.kind === "shop" ? drawerBack.shop.name : drawerBack?.kind === "sku" ? `SKU ${drawerBack.product.sku}` : undefined}
|
||||||
|
onBack={drawerBack ? () => {
|
||||||
|
setSelected(null);
|
||||||
|
if (drawerBack.kind === "brand") setSelectedBrand(drawerBack.brand);
|
||||||
|
else if (drawerBack.kind === "shop") setSelectedShop(drawerBack.shop);
|
||||||
|
else if (drawerBack.kind === "sku") setSelectedSku(drawerBack.product);
|
||||||
|
setDrawerBack(null);
|
||||||
|
} : undefined}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectedSku && (
|
||||||
|
<SkuDetail
|
||||||
|
product={selectedSku}
|
||||||
|
data={data}
|
||||||
|
onClose={() => { setSelectedSku(null); setDrawerBack(null); }}
|
||||||
|
onEdit={() => {
|
||||||
|
setModalProduct(selectedSku);
|
||||||
|
setModal("editSku");
|
||||||
|
}}
|
||||||
|
onDelete={() => {
|
||||||
|
api.deleteProduct(selectedSku.id).then(() => {
|
||||||
|
setSelectedSku(null);
|
||||||
|
setDrawerBack(null);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["bootstrap"] });
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
onSelectItem={(i) => {
|
||||||
|
setDrawerBack({ kind: "sku", product: selectedSku });
|
||||||
|
setSelectedSku(null);
|
||||||
|
setSelected(i);
|
||||||
|
}}
|
||||||
|
backLabel={drawerBack?.kind === "brand" ? drawerBack.brand.name : drawerBack?.kind === "shop" ? drawerBack.shop.name : undefined}
|
||||||
|
onBack={drawerBack ? () => {
|
||||||
|
setSelectedSku(null);
|
||||||
|
if (drawerBack.kind === "brand") setSelectedBrand(drawerBack.brand);
|
||||||
|
else if (drawerBack.kind === "shop") setSelectedShop(drawerBack.shop);
|
||||||
|
setDrawerBack(null);
|
||||||
|
} : undefined}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectedBrand && (
|
||||||
|
<BrandDetail
|
||||||
|
brand={selectedBrand}
|
||||||
|
data={data}
|
||||||
|
onClose={() => { setSelectedBrand(null); setDrawerBack(null); }}
|
||||||
|
onEdit={() => {
|
||||||
|
setModalBrand(selectedBrand);
|
||||||
|
setModal("editBrand");
|
||||||
|
}}
|
||||||
|
onDelete={() => {
|
||||||
|
api.deleteBrand(selectedBrand.id).then(() => {
|
||||||
|
setSelectedBrand(null);
|
||||||
|
setDrawerBack(null);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["bootstrap"] });
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
onSelectSku={(p) => {
|
||||||
|
setDrawerBack({ kind: "brand", brand: selectedBrand });
|
||||||
|
setSelectedBrand(null);
|
||||||
|
setSelectedSku(p);
|
||||||
|
}}
|
||||||
|
onSelectItem={(i) => {
|
||||||
|
setDrawerBack({ kind: "brand", brand: selectedBrand });
|
||||||
|
setSelectedBrand(null);
|
||||||
|
setSelected(i);
|
||||||
|
}}
|
||||||
|
backLabel={drawerBack?.kind === "shop" ? drawerBack.shop.name : undefined}
|
||||||
|
onBack={drawerBack ? () => {
|
||||||
|
setSelectedBrand(null);
|
||||||
|
if (drawerBack.kind === "shop") setSelectedShop(drawerBack.shop);
|
||||||
|
setDrawerBack(null);
|
||||||
|
} : undefined}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectedShop && (
|
||||||
|
<ShopDetail
|
||||||
|
shop={selectedShop}
|
||||||
|
data={data}
|
||||||
|
onClose={() => { setSelectedShop(null); setDrawerBack(null); }}
|
||||||
|
onEdit={() => {
|
||||||
|
setModalShop(selectedShop);
|
||||||
|
setModal("editShop");
|
||||||
|
}}
|
||||||
|
onDelete={() => {
|
||||||
|
api.deleteShop(selectedShop.id).then(() => {
|
||||||
|
setSelectedShop(null);
|
||||||
|
setDrawerBack(null);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["bootstrap"] });
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
onSelectBrand={(b) => {
|
||||||
|
setDrawerBack({ kind: "shop", shop: selectedShop });
|
||||||
|
setSelectedShop(null);
|
||||||
|
setSelectedBrand(b);
|
||||||
|
}}
|
||||||
|
onSelectItem={(i) => {
|
||||||
|
setDrawerBack({ kind: "shop", shop: selectedShop });
|
||||||
|
setSelectedShop(null);
|
||||||
|
setSelected(i);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -205,8 +470,17 @@ export function App() {
|
|||||||
{modal === "gone" && (
|
{modal === "gone" && (
|
||||||
<MarkGoneFlow data={data} onClose={() => setModal(null)} item={modalItem} />
|
<MarkGoneFlow data={data} onClose={() => setModal(null)} item={modalItem} />
|
||||||
)}
|
)}
|
||||||
{modal === "audit" && (
|
{modal === "weighIn" && (
|
||||||
<AuditFlow data={data} onClose={() => setModal(null)} item={modalItem} />
|
<WeighInFlow data={data} onClose={() => setModal(null)} item={modalItem} queue={weighInQueue.length > 0 ? weighInQueue : undefined} />
|
||||||
|
)}
|
||||||
|
{modal === "binCheck" && (
|
||||||
|
<BinCheckFlow data={data} onClose={() => setModal(null)} bin={binCheckBin} />
|
||||||
|
)}
|
||||||
|
{modal === "checkout" && (
|
||||||
|
<CheckoutFlow data={data} onClose={() => setModal(null)} item={modalItem} />
|
||||||
|
)}
|
||||||
|
{modal === "checkin" && (
|
||||||
|
<CheckinFlow data={data} onClose={() => setModal(null)} item={modalItem} />
|
||||||
)}
|
)}
|
||||||
{modal === "addBrand" && <AddBrandModal onClose={() => setModal(null)} />}
|
{modal === "addBrand" && <AddBrandModal onClose={() => setModal(null)} />}
|
||||||
{modal === "addShop" && <AddShopModal onClose={() => setModal(null)} />}
|
{modal === "addShop" && <AddShopModal onClose={() => setModal(null)} />}
|
||||||
@@ -220,6 +494,65 @@ export function App() {
|
|||||||
{modal === "editShop" && modalShop && (
|
{modal === "editShop" && modalShop && (
|
||||||
<EditShopModal shop={modalShop} onClose={() => setModal(null)} />
|
<EditShopModal shop={modalShop} onClose={() => setModal(null)} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{modal === "bulkEdit" && (
|
||||||
|
<BulkEditModal data={data} items={bulkItems} onClose={() => setModal(null)} />
|
||||||
|
)}
|
||||||
|
{modal === "bulkConsume" && (
|
||||||
|
<BulkConsumeModal data={data} items={bulkItems} onClose={() => setModal(null)} />
|
||||||
|
)}
|
||||||
|
{modal === "bulkCheckout" && (
|
||||||
|
<BulkCheckoutModal data={data} items={bulkItems} onClose={() => setModal(null)} />
|
||||||
|
)}
|
||||||
|
{modal === "bulkCheckin" && (
|
||||||
|
<BulkCheckinModal data={data} items={bulkItems} onClose={() => setModal(null)} />
|
||||||
|
)}
|
||||||
|
{modal === "bulkGone" && (
|
||||||
|
<BulkGoneModal data={data} items={bulkItems} onClose={() => setModal(null)} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{modal === "addSku" && <AddSkuModal data={data} onClose={() => setModal(null)} />}
|
||||||
|
{modal === "editSku" && modalProduct && (
|
||||||
|
<EditSkuModal data={data} product={modalProduct} onClose={() => setModal(null)} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isMobile && (
|
||||||
|
<MobileBottomNav
|
||||||
|
onScan={() => setScannerOpen(true)}
|
||||||
|
onAddProduct={openAdd}
|
||||||
|
onMarkFinished={() => openConsume()}
|
||||||
|
onWeighIn={() => openWeighIn()}
|
||||||
|
onBinCheck={() => openBinCheck()}
|
||||||
|
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)}
|
||||||
|
onWeighIn={(i) => openWeighIn(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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+56
-4
@@ -1,5 +1,12 @@
|
|||||||
import type { Bootstrap, AuditMode } from "./types.js";
|
import type { Bootstrap, AuditMode } from "./types.js";
|
||||||
|
|
||||||
|
export type BatchOp =
|
||||||
|
| { action: "update"; id: string; fields: Partial<{ shopId: string | null; binId: string | null; price: number; thc: number; cbd: number; totalCannabinoids: number; containerWeight: number | null; purchaseDate: string }> }
|
||||||
|
| { action: "checkout"; id: string; date: string }
|
||||||
|
| { action: "checkin"; id: string; date: string; binId: string }
|
||||||
|
| { action: "finish"; id: string; date: string; rating?: number; notes?: string }
|
||||||
|
| { action: "gone"; id: string; date: string; reason: string; notes?: string };
|
||||||
|
|
||||||
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
const res = await fetch(`/api${path}`, {
|
const res = await fetch(`/api${path}`, {
|
||||||
...init,
|
...init,
|
||||||
@@ -31,6 +38,7 @@ export const api = {
|
|||||||
updateProduct: (
|
updateProduct: (
|
||||||
id: string,
|
id: string,
|
||||||
body: Partial<{
|
body: Partial<{
|
||||||
|
sku: string;
|
||||||
type: string;
|
type: string;
|
||||||
kind: "bulk" | "discrete";
|
kind: "bulk" | "discrete";
|
||||||
strainId: string | null;
|
strainId: string | null;
|
||||||
@@ -72,6 +80,7 @@ export const api = {
|
|||||||
cbd?: number;
|
cbd?: number;
|
||||||
totalCannabinoids?: number;
|
totalCannabinoids?: number;
|
||||||
weight?: number;
|
weight?: number;
|
||||||
|
containerWeight?: number | null;
|
||||||
countOriginal?: number;
|
countOriginal?: number;
|
||||||
unitWeight?: number;
|
unitWeight?: number;
|
||||||
purchaseDate: string;
|
purchaseDate: string;
|
||||||
@@ -91,6 +100,7 @@ export const api = {
|
|||||||
cbd: number;
|
cbd: number;
|
||||||
totalCannabinoids: number;
|
totalCannabinoids: number;
|
||||||
weight: number;
|
weight: number;
|
||||||
|
containerWeight: number | null;
|
||||||
countOriginal: number;
|
countOriginal: number;
|
||||||
unitWeight: number;
|
unitWeight: number;
|
||||||
purchaseDate: string;
|
purchaseDate: string;
|
||||||
@@ -119,6 +129,33 @@ export const api = {
|
|||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
checkoutInventoryItem: (
|
||||||
|
id: string,
|
||||||
|
body: { date: string },
|
||||||
|
) =>
|
||||||
|
request<{ ok: true }>(`/inventory/${id}/checkout`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
}),
|
||||||
|
|
||||||
|
checkinInventoryItem: (
|
||||||
|
id: string,
|
||||||
|
body: { date: string; binId: string; remainingWeight?: number },
|
||||||
|
) =>
|
||||||
|
request<{ ok: true }>(`/inventory/${id}/checkin`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
}),
|
||||||
|
|
||||||
|
reactivateInventoryItem: (
|
||||||
|
id: string,
|
||||||
|
body: { binId: string },
|
||||||
|
) =>
|
||||||
|
request<{ ok: true }>(`/inventory/${id}/reactivate`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
}),
|
||||||
|
|
||||||
auditInventoryItem: (
|
auditInventoryItem: (
|
||||||
id: string,
|
id: string,
|
||||||
body: { date: string; mode: AuditMode; value: number; confirmedBy?: string },
|
body: { date: string; mode: AuditMode; value: number; confirmedBy?: string },
|
||||||
@@ -128,6 +165,12 @@ export const api = {
|
|||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
batchInventory: (ops: BatchOp[]) =>
|
||||||
|
request<{ ok: true; count: number }>("/inventory/batch", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ ops }),
|
||||||
|
}),
|
||||||
|
|
||||||
// Catalog tables (brand/shop/bin) — unchanged
|
// Catalog tables (brand/shop/bin) — unchanged
|
||||||
createBrand: (name: string) =>
|
createBrand: (name: string) =>
|
||||||
request<{ id: string; name: string }>("/brands", {
|
request<{ id: string; name: string }>("/brands", {
|
||||||
@@ -159,18 +202,27 @@ export const api = {
|
|||||||
deleteShop: (id: string) =>
|
deleteShop: (id: string) =>
|
||||||
request<{ ok: true }>(`/shops/${id}`, { method: "DELETE" }),
|
request<{ ok: true }>(`/shops/${id}`, { method: "DELETE" }),
|
||||||
|
|
||||||
createBin: (body: { name: string; capacity?: number }) =>
|
createBin: (body: { name: string; capacity?: number; cadenceDays?: number }) =>
|
||||||
request<{ id: string; name: string; capacity: number }>("/bins", {
|
request<{ id: string; name: string; capacity: number; cadenceDays: number; lastChecked: string | null }>("/bins", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
updateBin: (id: string, body: { name?: string; capacity?: number }) =>
|
updateBin: (id: string, body: { name?: string; capacity?: number; cadenceDays?: number }) =>
|
||||||
request<{ id: string; name: string; capacity: number }>(`/bins/${id}`, {
|
request<{ id: string; name: string; capacity: number; cadenceDays: number; lastChecked: string | null }>(`/bins/${id}`, {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
deleteBin: (id: string) =>
|
deleteBin: (id: string) =>
|
||||||
request<{ ok: true }>(`/bins/${id}`, { method: "DELETE" }),
|
request<{ ok: true }>(`/bins/${id}`, { method: "DELETE" }),
|
||||||
|
|
||||||
|
completeBinCheck: (
|
||||||
|
binId: string,
|
||||||
|
body: { date: string; verifiedItemIds: string[]; goneItemIds: string[] },
|
||||||
|
) =>
|
||||||
|
request<{ ok: true; verified: number; gone: number }>(`/bins/${binId}/check`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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,386 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
|
import type { Bootstrap, Brand, Product, Item } from "../types.js";
|
||||||
|
import { TYPES, helpers, enrichItems } from "../types.js";
|
||||||
|
import { getToday, getStoredTimezone } from "../tz.js";
|
||||||
|
import { fmt, TYPE_GLYPHS } from "../format.js";
|
||||||
|
import { Btn, Pill, Icon } from "./primitives/index.js";
|
||||||
|
import { remainingShort } from "../stats.js";
|
||||||
|
import { useExitAnimation } from "../hooks/useExitAnimation.js";
|
||||||
|
import { useFocusTrap } from "../hooks/useFocusTrap.js";
|
||||||
|
|
||||||
|
export function BrandDetail({
|
||||||
|
brand,
|
||||||
|
data,
|
||||||
|
onClose,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
onSelectSku,
|
||||||
|
onSelectItem,
|
||||||
|
backLabel,
|
||||||
|
onBack,
|
||||||
|
}: {
|
||||||
|
brand: Brand;
|
||||||
|
data: Bootstrap;
|
||||||
|
onClose: () => void;
|
||||||
|
onEdit: () => void;
|
||||||
|
onDelete: () => void;
|
||||||
|
onSelectSku: (p: Product) => void;
|
||||||
|
onSelectItem: (i: Item) => void;
|
||||||
|
backLabel?: string;
|
||||||
|
onBack?: () => void;
|
||||||
|
}) {
|
||||||
|
const products = data.products.filter((p) => p.brandId === brand.id);
|
||||||
|
const strainMap = new Map(data.strains.map((s) => [s.id, s]));
|
||||||
|
const allItems = enrichItems(data).filter((i) => i.brandId === brand.id);
|
||||||
|
const hasItems = allItems.length > 0;
|
||||||
|
|
||||||
|
const active = allItems.filter((i) => i.status === "active" || i.status === "checked-out");
|
||||||
|
const consumed = allItems.filter((i) => i.status === "consumed");
|
||||||
|
const gone = allItems.filter((i) => i.status === "gone");
|
||||||
|
|
||||||
|
const totalSpend = allItems.reduce((s, i) => s + i.price, 0);
|
||||||
|
const avgPrice = hasItems ? totalSpend / allItems.length : 0;
|
||||||
|
|
||||||
|
const rated = allItems.filter((i) => i.rating != null);
|
||||||
|
const avgRating =
|
||||||
|
rated.length > 0 ? rated.reduce((s, i) => s + i.rating!, 0) / rated.length : null;
|
||||||
|
|
||||||
|
const sortedItems = [...allItems].sort(
|
||||||
|
(a, b) => +new Date(b.purchaseDate) - +new Date(a.purchaseDate),
|
||||||
|
);
|
||||||
|
const recentItems = sortedItems.slice(0, 20);
|
||||||
|
|
||||||
|
const todayStr = getToday(getStoredTimezone());
|
||||||
|
const tz = getStoredTimezone();
|
||||||
|
|
||||||
|
const { closing, triggerClose } = useExitAnimation(220, onClose);
|
||||||
|
const trapRef = useFocusTrap<HTMLDivElement>();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape") triggerClose();
|
||||||
|
};
|
||||||
|
document.addEventListener("keydown", handleKeyDown);
|
||||||
|
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||||
|
}, [triggerClose]);
|
||||||
|
|
||||||
|
const statCards: [string, React.ReactNode][] = [
|
||||||
|
["SKUs", String(products.length)],
|
||||||
|
["Purchases", String(allItems.length)],
|
||||||
|
["Total spent", hasItems ? fmt.money(totalSpend) : "—"],
|
||||||
|
["Avg price", hasItems ? fmt.money(avgPrice) : "—"],
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={trapRef}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
style={{
|
||||||
|
position: "fixed",
|
||||||
|
inset: 0,
|
||||||
|
background: "oklch(20% 0.02 60 / 0.4)",
|
||||||
|
zIndex: 50,
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "flex-end",
|
||||||
|
animation: closing ? "backdrop-out 220ms ease-in forwards" : "backdrop-in 200ms ease-out",
|
||||||
|
}}
|
||||||
|
onClick={triggerClose}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
style={{
|
||||||
|
width: "min(720px, 100vw)",
|
||||||
|
height: "100%",
|
||||||
|
animation: closing ? "drawer-out 220ms ease-in forwards" : "drawer-in 250ms ease-out",
|
||||||
|
background: "var(--bg)",
|
||||||
|
borderLeft: "1px solid var(--line)",
|
||||||
|
overflow: "auto",
|
||||||
|
boxShadow: "var(--shadow-lg)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: "20px 32px",
|
||||||
|
borderBottom: "1px solid var(--line)",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: onBack ? 8 : 0,
|
||||||
|
position: "sticky",
|
||||||
|
top: 0,
|
||||||
|
background: "var(--bg)",
|
||||||
|
zIndex: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{onBack && backLabel && (
|
||||||
|
<button
|
||||||
|
onClick={onBack}
|
||||||
|
style={{
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 4,
|
||||||
|
background: "none",
|
||||||
|
border: "none",
|
||||||
|
padding: 0,
|
||||||
|
fontSize: 12,
|
||||||
|
color: "var(--sage)",
|
||||||
|
cursor: "pointer",
|
||||||
|
alignSelf: "flex-start",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ transform: "scaleX(-1)", display: "inline-flex" }}><Icon name="arrow" size={12} /></span> Back to {backLabel}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
|
||||||
|
<div className="smallcaps" style={{ color: "var(--ink-3)" }}>
|
||||||
|
Brand
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "flex", gap: 6 }}>
|
||||||
|
<Btn variant="ghost" icon="edit" onClick={onEdit} />
|
||||||
|
<Btn
|
||||||
|
variant="ghost"
|
||||||
|
icon="bin"
|
||||||
|
disabled={hasItems}
|
||||||
|
onClick={onDelete}
|
||||||
|
title={hasItems ? "Cannot delete — has inventory items" : undefined}
|
||||||
|
style={hasItems ? { opacity: 0.3, cursor: "not-allowed" } : undefined}
|
||||||
|
/>
|
||||||
|
<Btn variant="ghost" icon="close" onClick={triggerClose} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ padding: "32px 32px 60px" }}>
|
||||||
|
<h1
|
||||||
|
className="serif"
|
||||||
|
style={{
|
||||||
|
fontSize: 48,
|
||||||
|
margin: "0 0 4px",
|
||||||
|
fontWeight: 500,
|
||||||
|
letterSpacing: "-0.02em",
|
||||||
|
lineHeight: 1.1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{brand.name}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
{hasItems && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: `repeat(${statCards.length}, 1fr)`,
|
||||||
|
gap: 1,
|
||||||
|
marginTop: 32,
|
||||||
|
background: "var(--line)",
|
||||||
|
border: "1px solid var(--line)",
|
||||||
|
borderRadius: "var(--r-md)",
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{statCards.map(([l, v], i) => (
|
||||||
|
<div key={i} style={{ padding: "18px 16px", background: "var(--surface)" }}>
|
||||||
|
<div className="smallcaps" style={{ color: "var(--ink-3)" }}>{l}</div>
|
||||||
|
<div className="serif" style={{ fontSize: 26, marginTop: 4, fontWeight: 500 }}>
|
||||||
|
{v}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasItems && (
|
||||||
|
<div style={{ marginTop: 28 }}>
|
||||||
|
<div className="smallcaps" style={{ color: "var(--ink-3)", marginBottom: 12 }}>
|
||||||
|
Lifecycle
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "flex", gap: 12, flexWrap: "wrap" }}>
|
||||||
|
{active.length > 0 && <Pill tone="sage">{active.length} active</Pill>}
|
||||||
|
{consumed.length > 0 && <Pill tone="terra">{consumed.length} consumed</Pill>}
|
||||||
|
{gone.length > 0 && <Pill tone="amber">{gone.length} gone</Pill>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{avgRating != null && (
|
||||||
|
<div style={{ marginTop: 28 }}>
|
||||||
|
<div className="smallcaps" style={{ color: "var(--ink-3)", marginBottom: 12 }}>
|
||||||
|
Ratings
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
|
||||||
|
<div style={{ display: "flex", gap: 2 }}>
|
||||||
|
{[1, 2, 3, 4, 5].map((n) => (
|
||||||
|
<Icon
|
||||||
|
key={n}
|
||||||
|
name="star"
|
||||||
|
size={18}
|
||||||
|
color={n <= Math.round(avgRating) ? "var(--amber)" : "var(--ink-4)"}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<span className="serif" style={{ fontSize: 22, fontWeight: 500 }}>
|
||||||
|
{avgRating.toFixed(1)}
|
||||||
|
</span>
|
||||||
|
<span style={{ fontSize: 12, color: "var(--ink-3)" }}>
|
||||||
|
from {rated.length} review{rated.length === 1 ? "" : "s"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{products.length > 0 && (
|
||||||
|
<div style={{ marginTop: 28 }}>
|
||||||
|
<div className="smallcaps" style={{ color: "var(--ink-3)", marginBottom: 12 }}>
|
||||||
|
SKUs ({products.length})
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
border: "1px solid var(--line)",
|
||||||
|
borderRadius: "var(--r-md)",
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{products.map((p, idx) => {
|
||||||
|
const strain = strainMap.get(p.strainId);
|
||||||
|
const itemCount = allItems.filter((i) => i.productId === p.id).length;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={p.id}
|
||||||
|
onClick={() => onSelectSku(p)}
|
||||||
|
className="inv-row"
|
||||||
|
style={{
|
||||||
|
padding: "12px 16px",
|
||||||
|
borderBottom: idx < products.length - 1 ? "1px solid var(--line)" : "none",
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: "24px 1fr auto auto auto",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 12,
|
||||||
|
background: "var(--surface)",
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ fontFamily: "var(--serif)", fontSize: 16, color: "var(--ink-3)" }}>
|
||||||
|
{TYPE_GLYPHS[p.type]}
|
||||||
|
</span>
|
||||||
|
<div style={{ minWidth: 0 }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontWeight: 500,
|
||||||
|
fontSize: 13,
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{strain?.name ?? "(unknown)"}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 11, color: "var(--ink-3)" }}>
|
||||||
|
{p.type} · {p.kind}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="mono" style={{ fontSize: 12 }}>
|
||||||
|
{p.sku}
|
||||||
|
</span>
|
||||||
|
<span style={{ fontSize: 12, color: "var(--ink-3)" }}>
|
||||||
|
{itemCount} item{itemCount === 1 ? "" : "s"}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className="inv-row-chevron"
|
||||||
|
style={{ color: "var(--ink-3)", fontSize: 14 }}
|
||||||
|
>
|
||||||
|
›
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasItems && (
|
||||||
|
<div style={{ marginTop: 28 }}>
|
||||||
|
<div className="smallcaps" style={{ color: "var(--ink-3)", marginBottom: 12 }}>
|
||||||
|
Recent purchases ({allItems.length})
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
border: "1px solid var(--line)",
|
||||||
|
borderRadius: "var(--r-md)",
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{recentItems.map((item, idx) => {
|
||||||
|
const isInactive = item.status !== "active" && item.status !== "checked-out";
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={item.id}
|
||||||
|
onClick={() => onSelectItem(item)}
|
||||||
|
className="inv-row"
|
||||||
|
style={{
|
||||||
|
padding: "12px 16px",
|
||||||
|
borderBottom: idx < recentItems.length - 1 ? "1px solid var(--line)" : "none",
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: "auto 1fr auto auto auto",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 12,
|
||||||
|
background: "var(--surface)",
|
||||||
|
cursor: "pointer",
|
||||||
|
opacity: isInactive ? 0.55 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="mono" style={{ fontSize: 12 }}>{item.assetId}</span>
|
||||||
|
<span style={{ fontSize: 13 }}>
|
||||||
|
{item.status === "consumed" && <Pill tone="terra" style={{ fontSize: 10 }}>Consumed</Pill>}
|
||||||
|
{item.status === "gone" && <Pill tone="amber" style={{ fontSize: 10 }}>Gone</Pill>}
|
||||||
|
{item.status === "checked-out" && <Pill tone="outline" style={{ fontSize: 10 }}>Checked out</Pill>}
|
||||||
|
{item.status === "active" && helpers.auditOverdue(item, todayStr) && (
|
||||||
|
<Pill tone="amber" style={{ fontSize: 10 }}>Audit due</Pill>
|
||||||
|
)}
|
||||||
|
{item.status === "active" && !helpers.auditOverdue(item, todayStr) && (
|
||||||
|
<Pill tone="sage" style={{ fontSize: 10 }}>Active</Pill>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span className="mono" style={{ fontSize: 12 }}>{fmt.money(item.price)}</span>
|
||||||
|
<span style={{ fontSize: 12, color: "var(--ink-3)" }}>
|
||||||
|
{fmt.dateShort(item.purchaseDate, tz)}
|
||||||
|
</span>
|
||||||
|
<span style={{ fontSize: 12, color: "var(--ink-3)" }}>
|
||||||
|
{(item.status === "active" || item.status === "checked-out")
|
||||||
|
? remainingShort(item)
|
||||||
|
: ""}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!hasItems && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: 36,
|
||||||
|
padding: 40,
|
||||||
|
textAlign: "center",
|
||||||
|
color: "var(--ink-3)",
|
||||||
|
background: "var(--bg-2)",
|
||||||
|
borderRadius: "var(--r-md)",
|
||||||
|
border: "1px solid var(--line)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontSize: 14, marginBottom: 4 }}>No inventory items yet</div>
|
||||||
|
<div style={{ fontSize: 12 }}>
|
||||||
|
Products from this brand will appear here.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasItems && (
|
||||||
|
<div style={{ marginTop: 12, fontSize: 11, color: "var(--ink-3)", fontStyle: "italic" }}>
|
||||||
|
Cannot delete this brand while it has associated inventory items.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import type { Item } from "../types.js";
|
||||||
|
import { Btn, Icon } from "./primitives/index.js";
|
||||||
|
|
||||||
|
export function BulkToolbar({
|
||||||
|
count,
|
||||||
|
selectedItems,
|
||||||
|
onClear,
|
||||||
|
onBulkEdit,
|
||||||
|
onBulkConsume,
|
||||||
|
onBulkCheckout,
|
||||||
|
onBulkCheckin,
|
||||||
|
onBulkGone,
|
||||||
|
}: {
|
||||||
|
count: number;
|
||||||
|
selectedItems: Item[];
|
||||||
|
onClear: () => void;
|
||||||
|
onBulkEdit: () => void;
|
||||||
|
onBulkConsume: () => void;
|
||||||
|
onBulkCheckout: () => void;
|
||||||
|
onBulkCheckin: () => void;
|
||||||
|
onBulkGone: () => void;
|
||||||
|
}) {
|
||||||
|
const canCheckout = selectedItems.filter((i) => i.status === "active").length;
|
||||||
|
const canCheckin = selectedItems.filter((i) => i.status === "checked-out").length;
|
||||||
|
const canConsume = selectedItems.filter((i) => i.status === "active" || i.status === "checked-out").length;
|
||||||
|
const canGone = canConsume;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="bulk-toolbar"
|
||||||
|
style={{
|
||||||
|
position: "fixed",
|
||||||
|
bottom: 0,
|
||||||
|
left: 264,
|
||||||
|
right: 0,
|
||||||
|
background: "var(--surface)",
|
||||||
|
borderTop: "1px solid var(--line)",
|
||||||
|
boxShadow: "var(--shadow-lg)",
|
||||||
|
padding: "12px 24px",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
gap: 16,
|
||||||
|
zIndex: 40,
|
||||||
|
animation: "toolbar-slide-up 200ms ease-out",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
|
||||||
|
<span style={{ fontSize: 13, fontWeight: 600, color: "var(--ink)" }}>
|
||||||
|
{count} selected
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={onClear}
|
||||||
|
style={{
|
||||||
|
background: "none",
|
||||||
|
border: "none",
|
||||||
|
cursor: "pointer",
|
||||||
|
color: "var(--ink-3)",
|
||||||
|
fontSize: 12,
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon name="close" size={12} />
|
||||||
|
Deselect
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
|
||||||
|
<Btn variant="secondary" icon="edit" onClick={onBulkEdit}>
|
||||||
|
Edit
|
||||||
|
</Btn>
|
||||||
|
{canCheckout > 0 && (
|
||||||
|
<Btn variant="secondary" icon="pocket" onClick={onBulkCheckout}>
|
||||||
|
Checkout ({canCheckout})
|
||||||
|
</Btn>
|
||||||
|
)}
|
||||||
|
{canCheckin > 0 && (
|
||||||
|
<Btn variant="secondary" icon="check" onClick={onBulkCheckin}>
|
||||||
|
Check in ({canCheckin})
|
||||||
|
</Btn>
|
||||||
|
)}
|
||||||
|
{canConsume > 0 && (
|
||||||
|
<Btn variant="secondary" icon="check" onClick={onBulkConsume}>
|
||||||
|
Consume ({canConsume})
|
||||||
|
</Btn>
|
||||||
|
)}
|
||||||
|
{canGone > 0 && (
|
||||||
|
<Btn variant="danger" icon="bin" onClick={onBulkGone}>
|
||||||
|
Mark gone ({canGone})
|
||||||
|
</Btn>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,307 @@
|
|||||||
|
import { useCallback, 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 [started, setStarted] = useState(false);
|
||||||
|
const [torchOn, setTorchOn] = useState(false);
|
||||||
|
const [torchAvailable, setTorchAvailable] = useState(false);
|
||||||
|
const lastScan = useRef("");
|
||||||
|
const lastScanTime = useRef(0);
|
||||||
|
|
||||||
|
const startScanner = useCallback(async () => {
|
||||||
|
const id = "apothecary-scanner";
|
||||||
|
let scanner = scannerRef.current;
|
||||||
|
if (!scanner) {
|
||||||
|
scanner = new Html5Qrcode(id, { verbose: false });
|
||||||
|
scannerRef.current = scanner;
|
||||||
|
}
|
||||||
|
if (scanner.isScanning) return;
|
||||||
|
|
||||||
|
await 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);
|
||||||
|
},
|
||||||
|
() => {},
|
||||||
|
);
|
||||||
|
setStarted(true);
|
||||||
|
try {
|
||||||
|
const caps = scanner.getRunningTrackCameraCapabilities();
|
||||||
|
if (caps.torchFeature().isSupported()) {
|
||||||
|
setTorchAvailable(true);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// torch check can fail on some browsers
|
||||||
|
}
|
||||||
|
}, [onScan]);
|
||||||
|
|
||||||
|
const startCamera = useCallback(async () => {
|
||||||
|
if (!window.isSecureContext) {
|
||||||
|
setError(
|
||||||
|
"Camera requires a secure connection (HTTPS). " +
|
||||||
|
"Access this site over https:// or on localhost to use the scanner.",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!navigator.mediaDevices?.getUserMedia) {
|
||||||
|
setError("Camera API not available in this browser.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Request camera permission directly in the user gesture handler.
|
||||||
|
// html5-qrcode does async DOM work before calling getUserMedia internally,
|
||||||
|
// which breaks iOS Safari's transient user activation window.
|
||||||
|
// By calling getUserMedia here first, we trigger the permission prompt
|
||||||
|
// while the gesture is still active, then let html5-qrcode reuse the grant.
|
||||||
|
let stream: MediaStream;
|
||||||
|
try {
|
||||||
|
stream = await navigator.mediaDevices.getUserMedia({
|
||||||
|
video: { facingMode: "environment" },
|
||||||
|
});
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
if (msg.includes("denied") || msg.includes("NotAllowedError")) {
|
||||||
|
setError(
|
||||||
|
"Camera permission was denied. Open your browser settings to allow camera access for this site, then try again.",
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
setError(msg);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Permission granted — stop this stream so html5-qrcode can open its own
|
||||||
|
stream.getTracks().forEach((t) => t.stop());
|
||||||
|
|
||||||
|
try {
|
||||||
|
await startScanner();
|
||||||
|
} catch (err: unknown) {
|
||||||
|
setError(err instanceof Error ? err.message : "Scanner failed to start");
|
||||||
|
}
|
||||||
|
}, [startScanner]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Auto-start on desktop where getUserMedia works outside gestures
|
||||||
|
const isTouchDevice = "ontouchstart" in window || navigator.maxTouchPoints > 0;
|
||||||
|
if (!isTouchDevice && window.isSecureContext) {
|
||||||
|
startCamera();
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
if (scannerRef.current?.isScanning) {
|
||||||
|
scannerRef.current.stop().catch(() => {});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [startCamera]);
|
||||||
|
|
||||||
|
const toggleTorch = () => {
|
||||||
|
try {
|
||||||
|
const caps = scannerRef.current?.getRunningTrackCameraCapabilities();
|
||||||
|
if (caps?.torchFeature().isSupported()) {
|
||||||
|
const next = !torchOn;
|
||||||
|
caps.torchFeature().apply(next);
|
||||||
|
setTorchOn(next);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const insecure = !window.isSecureContext;
|
||||||
|
|
||||||
|
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%" }}
|
||||||
|
/>
|
||||||
|
{!started && !error && (
|
||||||
|
<div
|
||||||
|
onClick={insecure ? undefined : startCamera}
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
inset: 0,
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
gap: 16,
|
||||||
|
padding: 32,
|
||||||
|
color: "#fff",
|
||||||
|
textAlign: "center",
|
||||||
|
cursor: insecure ? "default" : "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{insecure ? (
|
||||||
|
<>
|
||||||
|
<Icon name="camera" size={48} color="rgba(255,255,255,0.5)" />
|
||||||
|
<div style={{ fontSize: 16, fontWeight: 500 }}>HTTPS required</div>
|
||||||
|
<div style={{ fontSize: 13, opacity: 0.7, maxWidth: 300 }}>
|
||||||
|
Camera access requires a secure connection. Access this site
|
||||||
|
over <strong>https://</strong> to use the barcode scanner.
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 80,
|
||||||
|
height: 80,
|
||||||
|
borderRadius: "50%",
|
||||||
|
background: "var(--sage)",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon name="camera" size={36} color="#fff" />
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 16, fontWeight: 500 }}>Tap to start camera</div>
|
||||||
|
<div style={{ fontSize: 13, opacity: 0.7, maxWidth: 300 }}>
|
||||||
|
Allow camera access when prompted
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{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>
|
||||||
|
{!insecure && (
|
||||||
|
<button
|
||||||
|
onClick={() => { setError(null); startCamera(); }}
|
||||||
|
style={{
|
||||||
|
marginTop: 8,
|
||||||
|
padding: "10px 24px",
|
||||||
|
borderRadius: 8,
|
||||||
|
background: "var(--sage)",
|
||||||
|
color: "#fff",
|
||||||
|
border: "none",
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: 500,
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Try again
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</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,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{started ? "Point at a barcode or QR code" : ""}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
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,
|
||||||
|
onWeighIn,
|
||||||
|
onBinCheck,
|
||||||
|
onCheckout,
|
||||||
|
}: {
|
||||||
|
onScan: () => void;
|
||||||
|
onAddProduct: () => void;
|
||||||
|
onMarkFinished: () => void;
|
||||||
|
onWeighIn: () => void;
|
||||||
|
onBinCheck: () => 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: "Weigh In", action: onWeighIn },
|
||||||
|
{ icon: "bin", label: "Bin Check", action: onBinCheck },
|
||||||
|
{ 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,8 +1,13 @@
|
|||||||
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, TODAY_STR } from "../types.js";
|
import { TYPES, helpers } from "../types.js";
|
||||||
|
import { getToday, getStoredTimezone } from "../tz.js";
|
||||||
import { fmt, TYPE_GLYPHS } from "../format.js";
|
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 { 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),
|
||||||
@@ -13,35 +18,48 @@ export function ProductDetail({
|
|||||||
onClose,
|
onClose,
|
||||||
onConsume,
|
onConsume,
|
||||||
onMarkGone,
|
onMarkGone,
|
||||||
onAudit,
|
onWeighIn,
|
||||||
onEdit,
|
onEdit,
|
||||||
|
onCheckout,
|
||||||
|
onCheckin,
|
||||||
|
backLabel,
|
||||||
|
onBack,
|
||||||
}: {
|
}: {
|
||||||
item: Item;
|
item: Item;
|
||||||
data: Bootstrap;
|
data: Bootstrap;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onConsume: (i: Item) => void;
|
onConsume: (i: Item) => void;
|
||||||
onMarkGone: (i: Item) => void;
|
onMarkGone: (i: Item) => void;
|
||||||
onAudit: (i: Item) => void;
|
onWeighIn: (i: Item) => void;
|
||||||
onEdit: (i: Item) => void;
|
onEdit: (i: Item) => void;
|
||||||
|
onCheckout: (i: Item) => void;
|
||||||
|
onCheckin: (i: Item) => void;
|
||||||
|
backLabel?: string;
|
||||||
|
onBack?: () => void;
|
||||||
}) {
|
}) {
|
||||||
const bin = data.bins.find((b) => b.id === item.binId);
|
const bin = data.bins.find((b) => b.id === item.binId);
|
||||||
const cfg = TYPES.find((t) => t.id === item.type);
|
const cfg = TYPES.find((t) => t.id === item.type);
|
||||||
const product = data.products.find((p) => p.id === item.productId);
|
const product = data.products.find((p) => p.id === item.productId);
|
||||||
const pctRemaining = helpers.pctRemaining(item, TODAY_STR);
|
const pctRemaining = helpers.pctRemaining(item);
|
||||||
const est = helpers.estimatedRemaining(item, TODAY_STR);
|
const rem = helpers.remaining(item);
|
||||||
const last = helpers.lastAudit(item);
|
const last = helpers.lastAudit(item);
|
||||||
const overdue = helpers.auditOverdue(item, TODAY_STR);
|
const overdue = helpers.auditOverdue(item, getToday(getStoredTimezone()));
|
||||||
const sinceCheck = helpers.daysSinceCheck(item, TODAY_STR);
|
const sinceCheck = helpers.daysSinceCheck(item, getToday(getStoredTimezone()));
|
||||||
|
|
||||||
const isActive = item.status === "active";
|
const isActive = item.status === "active";
|
||||||
|
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(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
if (e.key === "Escape") onClose();
|
if (e.key === "Escape") triggerClose();
|
||||||
};
|
};
|
||||||
document.addEventListener("keydown", handleKeyDown);
|
document.addEventListener("keydown", handleKeyDown);
|
||||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||||
}, [onClose]);
|
}, [triggerClose]);
|
||||||
|
|
||||||
// Sibling instances of the same product (excluding this one) — useful for
|
// Sibling instances of the same product (excluding this one) — useful for
|
||||||
// seeing previous purchases of the same SKU.
|
// seeing previous purchases of the same SKU.
|
||||||
@@ -56,22 +74,27 @@ export function ProductDetail({
|
|||||||
["Strain", item.name],
|
["Strain", item.name],
|
||||||
["Brand", helpers.brandName(data, item.brandId)],
|
["Brand", helpers.brandName(data, item.brandId)],
|
||||||
["Shop", helpers.shopName(data, item.shopId)],
|
["Shop", helpers.shopName(data, item.shopId)],
|
||||||
["Total cannabinoids", `${item.totalCannabinoids.toFixed(1)}%`],
|
...(cfg?.showCannabinoidPct !== false
|
||||||
["Purchase date", fmt.date(item.purchaseDate)],
|
? [["Total cannabinoids", `${item.totalCannabinoids.toFixed(1)}%`] as [string, React.ReactNode]]
|
||||||
["Bin", bin ? bin.name : <span style={{ color: "var(--ink-3)" }}>—</span>],
|
: []),
|
||||||
|
["Purchase date", fmt.date(item.purchaseDate, getStoredTimezone())],
|
||||||
|
["Bin", isCheckedOut ? "In your custody" : bin ? bin.name : <span style={{ color: "var(--ink-3)" }}>—</span>],
|
||||||
["Audit cadence", `Every ${cfg?.cadenceDays ?? "—"} days · ${cfg?.auditMode ?? "—"}`],
|
["Audit cadence", `Every ${cfg?.cadenceDays ?? "—"} days · ${cfg?.auditMode ?? "—"}`],
|
||||||
[
|
[
|
||||||
"Cost per gram",
|
item.kind === "discrete" ? `Cost per ${cfg?.unit ?? "ct"}` : "Cost per gram",
|
||||||
item.kind === "bulk" && item.weight > 0
|
item.kind === "bulk" && item.weight > 0
|
||||||
? fmt.money(item.price / item.weight)
|
? fmt.money(item.price / item.weight)
|
||||||
: item.kind === "discrete" && item.unitWeight > 0
|
: item.kind === "discrete" && item.countOriginal > 0
|
||||||
? `${fmt.money(item.price / (item.countOriginal * item.unitWeight))} (effective)`
|
? fmt.money(item.price / item.countOriginal)
|
||||||
: "—",
|
: "—",
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
if (item.status === "checked-out") {
|
||||||
|
detailRows.push(["Checked out", fmt.date(item.checkoutDate, getStoredTimezone())]);
|
||||||
|
}
|
||||||
if (item.status === "consumed") {
|
if (item.status === "consumed") {
|
||||||
detailRows.push(
|
detailRows.push(
|
||||||
["Date finished", fmt.date(item.consumedDate)],
|
["Date finished", fmt.date(item.consumedDate, getStoredTimezone())],
|
||||||
[
|
[
|
||||||
"Lasted",
|
"Lasted",
|
||||||
`${Math.round((+new Date(item.consumedDate!) - +new Date(item.purchaseDate)) / 86_400_000)} days`,
|
`${Math.round((+new Date(item.consumedDate!) - +new Date(item.purchaseDate)) / 86_400_000)} days`,
|
||||||
@@ -80,7 +103,7 @@ export function ProductDetail({
|
|||||||
}
|
}
|
||||||
if (item.status === "gone") {
|
if (item.status === "gone") {
|
||||||
detailRows.push(
|
detailRows.push(
|
||||||
["Date gone", fmt.date(item.goneDate)],
|
["Date gone", fmt.date(item.goneDate, getStoredTimezone())],
|
||||||
[
|
[
|
||||||
"After",
|
"After",
|
||||||
`${Math.round((+new Date(item.goneDate!) - +new Date(item.purchaseDate)) / 86_400_000)} days`,
|
`${Math.round((+new Date(item.goneDate!) - +new Date(item.purchaseDate)) / 86_400_000)} days`,
|
||||||
@@ -90,6 +113,9 @@ export function ProductDetail({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
ref={trapRef}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
style={{
|
style={{
|
||||||
position: "fixed",
|
position: "fixed",
|
||||||
inset: 0,
|
inset: 0,
|
||||||
@@ -97,16 +123,16 @@ export function ProductDetail({
|
|||||||
zIndex: 50,
|
zIndex: 50,
|
||||||
display: "flex",
|
display: "flex",
|
||||||
justifyContent: "flex-end",
|
justifyContent: "flex-end",
|
||||||
animation: "backdrop-in 200ms ease-out",
|
animation: closing ? "backdrop-out 220ms ease-in forwards" : "backdrop-in 200ms ease-out",
|
||||||
}}
|
}}
|
||||||
onClick={onClose}
|
onClick={triggerClose}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
style={{
|
style={{
|
||||||
width: "min(720px, 100vw)",
|
width: "min(720px, 100vw)",
|
||||||
height: "100%",
|
height: "100%",
|
||||||
animation: "drawer-in 250ms ease-out",
|
animation: closing ? "drawer-out 220ms ease-in forwards" : "drawer-in 250ms ease-out",
|
||||||
background: "var(--bg)",
|
background: "var(--bg)",
|
||||||
borderLeft: "1px solid var(--line)",
|
borderLeft: "1px solid var(--line)",
|
||||||
overflow: "auto",
|
overflow: "auto",
|
||||||
@@ -115,56 +141,102 @@ 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",
|
||||||
alignItems: "center",
|
flexDirection: "column",
|
||||||
justifyContent: "space-between",
|
gap: onBack ? 8 : 0,
|
||||||
position: "sticky",
|
position: "sticky",
|
||||||
top: 0,
|
top: 0,
|
||||||
background: "var(--bg)",
|
background: "var(--bg)",
|
||||||
zIndex: 1,
|
zIndex: 1,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
{onBack && backLabel && (
|
||||||
|
<button
|
||||||
|
onClick={onBack}
|
||||||
|
style={{
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 4,
|
||||||
|
background: "none",
|
||||||
|
border: "none",
|
||||||
|
padding: 0,
|
||||||
|
fontSize: 12,
|
||||||
|
color: "var(--sage)",
|
||||||
|
cursor: "pointer",
|
||||||
|
alignSelf: "flex-start",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ transform: "scaleX(-1)", display: "inline-flex" }}><Icon name="arrow" size={12} /></span> Back to {backLabel}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
|
||||||
<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, flexWrap: "wrap", justifyContent: "flex-end" }}>
|
{isMobile ? (
|
||||||
{isActive && (
|
<div style={{ display: "flex", gap: 6, alignItems: "center" }}>
|
||||||
<Btn variant="ghost" icon="check" 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 && item.kind === "bulk" && (
|
||||||
|
<Btn variant={overdue ? "sage" : "ghost"} icon="search" onClick={() => onWeighIn(item)}>
|
||||||
|
Weigh In
|
||||||
|
</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="secondary" icon="check" onClick={() => onConsume(item)}>
|
)}
|
||||||
Mark consumed
|
|
||||||
</Btn>
|
|
||||||
)}
|
|
||||||
{isActive && (
|
|
||||||
<Btn variant="ghost" icon="bin" onClick={() => onMarkGone(item)} />
|
|
||||||
)}
|
|
||||||
<Btn variant="ghost" icon="edit" onClick={() => onEdit(item)} />
|
|
||||||
<Btn variant="ghost" icon="close" onClick={onClose} />
|
|
||||||
</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>
|
||||||
{item.status === "consumed" && (
|
{item.status === "consumed" && (
|
||||||
<Pill tone="terra">Consumed · {fmt.daysAgo(item.consumedDate)}</Pill>
|
<Pill tone="terra">Consumed · {fmt.daysAgo(item.consumedDate, getStoredTimezone())}</Pill>
|
||||||
)}
|
)}
|
||||||
{item.status === "gone" && (
|
{item.status === "gone" && (
|
||||||
<Pill tone="amber">Gone · {fmt.daysAgo(item.goneDate)}</Pill>
|
<Pill tone="amber">Gone · {fmt.daysAgo(item.goneDate, getStoredTimezone())}</Pill>
|
||||||
)}
|
)}
|
||||||
{isActive && overdue && <Pill tone="amber">Audit overdue · {sinceCheck}d</Pill>}
|
{isCheckedOut && (
|
||||||
|
<Pill tone="outline">Checked out · {fmt.daysAgo(item.checkoutDate, getStoredTimezone())}</Pill>
|
||||||
|
)}
|
||||||
|
{isActive && overdue && <Pill tone="amber">Weigh-in overdue · {sinceCheck}d</Pill>}
|
||||||
</div>
|
</div>
|
||||||
<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",
|
||||||
@@ -182,39 +254,46 @@ export function ProductDetail({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div
|
{(() => {
|
||||||
style={{
|
const statCards: [string, React.ReactNode][] = [
|
||||||
display: "grid",
|
["Price", fmt.money(item.price)],
|
||||||
gridTemplateColumns: "repeat(4, 1fr)",
|
|
||||||
gap: 1,
|
|
||||||
marginTop: 32,
|
|
||||||
background: "var(--line)",
|
|
||||||
border: "1px solid var(--line)",
|
|
||||||
borderRadius: "var(--r-md)",
|
|
||||||
overflow: "hidden",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{(
|
|
||||||
[
|
[
|
||||||
["Price", fmt.money(item.price)],
|
item.kind === "discrete" ? "Unit weight" : "Size",
|
||||||
[
|
item.kind === "discrete"
|
||||||
item.kind === "discrete" ? "Unit weight" : "Size",
|
? `${item.unitWeight} ${cfg?.weightUnit ?? "g"}`
|
||||||
item.kind === "discrete"
|
: `${item.weight} ${cfg?.unit ?? "g"}`,
|
||||||
? `${item.unitWeight} g`
|
],
|
||||||
: `${item.weight} ${cfg?.unit ?? "g"}`,
|
...(cfg?.showCannabinoidPct !== false
|
||||||
],
|
? [
|
||||||
["THC", `${item.thc.toFixed(1)}%`],
|
["THC", `${item.thc.toFixed(1)}%`] as [string, React.ReactNode],
|
||||||
["CBD", `${item.cbd.toFixed(1)}%`],
|
["CBD", `${item.cbd.toFixed(1)}%`] as [string, React.ReactNode],
|
||||||
] as [string, React.ReactNode][]
|
]
|
||||||
).map(([l, v], i) => (
|
: []),
|
||||||
<div key={i} style={{ padding: "18px 16px", background: "var(--surface)" }}>
|
];
|
||||||
<div className="smallcaps" style={{ color: "var(--ink-3)" }}>{l}</div>
|
return (
|
||||||
<div className="serif" style={{ fontSize: 26, marginTop: 4, fontWeight: 500 }}>{v}</div>
|
<div
|
||||||
|
style={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: `repeat(${statCards.length}, 1fr)`,
|
||||||
|
gap: 1,
|
||||||
|
marginTop: 32,
|
||||||
|
background: "var(--line)",
|
||||||
|
border: "1px solid var(--line)",
|
||||||
|
borderRadius: "var(--r-md)",
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{statCards.map(([l, v], i) => (
|
||||||
|
<div key={i} style={{ padding: "18px 16px", background: "var(--surface)" }}>
|
||||||
|
<div className="smallcaps" style={{ color: "var(--ink-3)" }}>{l}</div>
|
||||||
|
<div className="serif" style={{ fontSize: 26, marginTop: 4, fontWeight: 500 }}>{v}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
))}
|
);
|
||||||
</div>
|
})()}
|
||||||
|
|
||||||
{isActive && (
|
{(isActive || isCheckedOut) && (
|
||||||
<div style={{ marginTop: 20 }}>
|
<div style={{ marginTop: 20 }}>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
@@ -225,12 +304,12 @@ export function ProductDetail({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="smallcaps" style={{ color: "var(--ink-3)" }}>
|
<div className="smallcaps" style={{ color: "var(--ink-3)" }}>
|
||||||
{item.kind === "discrete" ? "Units remaining" : "Estimated remaining"}
|
{item.kind === "discrete" ? "Units remaining" : "Remaining"}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontFamily: "var(--mono)", fontSize: 13 }}>
|
<div style={{ fontFamily: "var(--mono)", fontSize: 13 }}>
|
||||||
{item.kind === "discrete"
|
{item.kind === "discrete"
|
||||||
? `${item.countLastAudit ?? item.countOriginal} of ${item.countOriginal}`
|
? `${item.countLastAudit ?? item.countOriginal} of ${item.countOriginal}`
|
||||||
: `${est.toFixed(2)} of ${item.weight} ${cfg?.unit ?? "g"}`}
|
: `${rem.toFixed(2)} of ${item.weight} ${cfg?.unit ?? "g"}`}
|
||||||
<span style={{ color: "var(--ink-3)", marginLeft: 8 }}>
|
<span style={{ color: "var(--ink-3)", marginLeft: 8 }}>
|
||||||
{Math.round(pctRemaining * 100)}%
|
{Math.round(pctRemaining * 100)}%
|
||||||
</span>
|
</span>
|
||||||
@@ -259,8 +338,19 @@ export function ProductDetail({
|
|||||||
fontStyle: "italic",
|
fontStyle: "italic",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Estimated by linear decay since last {last.mode} on {fmt.dateShort(last.date)} ({last.value}
|
Last {last.mode} on {fmt.dateShort(last.date, getStoredTimezone())}
|
||||||
{cfg?.unit}). Re-audit to update.
|
</div>
|
||||||
|
)}
|
||||||
|
{item.containerWeight != null && last && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
color: "var(--ink-3)",
|
||||||
|
marginTop: 6,
|
||||||
|
fontStyle: "italic",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Expected container total: {((item.containerWeight - item.weight) + rem).toFixed(2)}g
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -275,10 +365,12 @@ export function ProductDetail({
|
|||||||
marginBottom: 12,
|
marginBottom: 12,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="smallcaps" style={{ color: "var(--ink-3)" }}>Audit history</div>
|
<div className="smallcaps" style={{ color: "var(--ink-3)" }}>
|
||||||
{isActive && (
|
{item.kind === "bulk" ? "Weigh-in history" : "Check history"}
|
||||||
|
</div>
|
||||||
|
{isActive && item.kind === "bulk" && (
|
||||||
<button
|
<button
|
||||||
onClick={() => onAudit(item)}
|
onClick={() => onWeighIn(item)}
|
||||||
style={{
|
style={{
|
||||||
background: "none",
|
background: "none",
|
||||||
border: "none",
|
border: "none",
|
||||||
@@ -288,7 +380,7 @@ export function ProductDetail({
|
|||||||
textDecoration: "underline",
|
textDecoration: "underline",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
+ New audit
|
+ New weigh-in
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -339,15 +431,15 @@ export function ProductDetail({
|
|||||||
{a.mode === "presence" && (a.confirmedBy === "lost" ? "Marked lost" : "Confirmed presence")}
|
{a.mode === "presence" && (a.confirmedBy === "lost" ? "Marked lost" : "Confirmed presence")}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: 11, color: "var(--ink-3)" }}>
|
<div style={{ fontSize: 11, color: "var(--ink-3)" }}>
|
||||||
{fmt.date(a.date)} · {fmt.daysAgo(a.date)}
|
{fmt.date(a.date, getStoredTimezone())} · {fmt.daysAgo(a.date, getStoredTimezone())}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mono" style={{ fontSize: 13, textAlign: "right" }}>
|
<div className="mono" style={{ fontSize: 13, textAlign: "right" }}>
|
||||||
<div>
|
<div>
|
||||||
{a.value} {cfg?.unit}
|
{item.kind === "discrete" ? a.value : a.value.toFixed(2)} {cfg?.unit}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: 10, color: "var(--ink-3)" }}>
|
<div style={{ fontSize: 10, color: "var(--ink-3)" }}>
|
||||||
was {a.prev} {cfg?.unit}
|
was {a.prev != null ? (item.kind === "discrete" ? a.prev : a.prev.toFixed(2)) : "—"} {cfg?.unit}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -358,7 +450,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}
|
||||||
@@ -419,7 +511,56 @@ export function ProductDetail({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{isMobile && (
|
||||||
|
<BottomSheet open={actionsOpen} onClose={() => setActionsOpen(false)}>
|
||||||
|
<div style={{ padding: "8px 16px 20px" }}>
|
||||||
|
{isActive && item.kind === "bulk" && (
|
||||||
|
<MobileAction icon="search" label="Weigh In" onClick={() => { setActionsOpen(false); onWeighIn(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,177 @@
|
|||||||
|
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,
|
||||||
|
onWeighIn,
|
||||||
|
onCheckout,
|
||||||
|
onCheckin,
|
||||||
|
onConsume,
|
||||||
|
onMarkGone,
|
||||||
|
onAddInventory,
|
||||||
|
onViewSku,
|
||||||
|
onCreateProduct,
|
||||||
|
}: {
|
||||||
|
result: ScanResult | null;
|
||||||
|
data: Bootstrap;
|
||||||
|
noMatchText: string | null;
|
||||||
|
onClose: () => void;
|
||||||
|
onViewItem: (item: Item) => void;
|
||||||
|
onWeighIn: (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 }}>Weigh-in due</Pill>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<ActionButton icon="arrow" label="View details" onClick={() => { onClose(); onViewItem(item); }} />
|
||||||
|
{item.kind === "bulk" && (
|
||||||
|
<ActionButton icon="search" label="Weigh In" onClick={() => { onClose(); onWeighIn(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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ export function ScanField({
|
|||||||
onMatch,
|
onMatch,
|
||||||
onScanNoMatch,
|
onScanNoMatch,
|
||||||
matchedLabel,
|
matchedLabel,
|
||||||
|
mode = "both",
|
||||||
}: {
|
}: {
|
||||||
items: Item[];
|
items: Item[];
|
||||||
products?: Product[];
|
products?: Product[];
|
||||||
@@ -25,6 +26,7 @@ export function ScanField({
|
|||||||
// open a "create new product" form prefilled with the scanned SKU).
|
// open a "create new product" form prefilled with the scanned SKU).
|
||||||
onScanNoMatch?: (raw: string) => void;
|
onScanNoMatch?: (raw: string) => void;
|
||||||
matchedLabel: string | null;
|
matchedLabel: string | null;
|
||||||
|
mode?: "both" | "assetId" | "sku";
|
||||||
}) {
|
}) {
|
||||||
const [scan, setScan] = useState("");
|
const [scan, setScan] = useState("");
|
||||||
const [feedback, setFeedback] = useState<{ type: "matched" | "miss"; text: string } | null>(null);
|
const [feedback, setFeedback] = useState<{ type: "matched" | "miss"; text: string } | null>(null);
|
||||||
@@ -35,7 +37,7 @@ export function ScanField({
|
|||||||
setFeedback(null);
|
setFeedback(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const hit = lookup(trimmed, items, products);
|
const hit = lookup(trimmed, items, products, mode);
|
||||||
if (hit) {
|
if (hit) {
|
||||||
const label = hit.kind === "item" ? hit.item.name : hit.product.sku;
|
const label = hit.kind === "item" ? hit.item.name : hit.product.sku;
|
||||||
onMatch(hit);
|
onMatch(hit);
|
||||||
@@ -50,13 +52,16 @@ export function ScanField({
|
|||||||
if (!scan.trim() || feedback?.type === "matched") return;
|
if (!scan.trim() || feedback?.type === "matched") return;
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
const raw = scan.trim();
|
const raw = scan.trim();
|
||||||
if (!lookup(raw.toLowerCase(), items, products)) {
|
if (!lookup(raw.toLowerCase(), items, products, mode)) {
|
||||||
if (onScanNoMatch) {
|
if (onScanNoMatch) {
|
||||||
onScanNoMatch(raw);
|
onScanNoMatch(raw);
|
||||||
setScan("");
|
setScan("");
|
||||||
setFeedback(null);
|
setFeedback(null);
|
||||||
} else {
|
} else {
|
||||||
setFeedback({ type: "miss", text: "No asset id or SKU matches that." });
|
const missText = mode === "assetId" ? "No item matches that asset ID."
|
||||||
|
: mode === "sku" ? "No product matches that SKU."
|
||||||
|
: "No asset id or SKU matches that.";
|
||||||
|
setFeedback({ type: "miss", text: missText });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, 400);
|
}, 400);
|
||||||
@@ -64,7 +69,7 @@ export function ScanField({
|
|||||||
}, [scan, items, products]); // eslint-disable-line react-hooks/exhaustive-deps
|
}, [scan, items, products]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Field label="Scan asset id or SKU" hint="Or pick from the list below.">
|
<Field label={mode === "assetId" ? "Scan asset ID" : mode === "sku" ? "Scan SKU" : "Scan asset id or SKU"}>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
@@ -80,7 +85,7 @@ export function ScanField({
|
|||||||
value={scan}
|
value={scan}
|
||||||
onChange={(e) => setScan(e.target.value)}
|
onChange={(e) => setScan(e.target.value)}
|
||||||
onFocus={(e) => e.currentTarget.select()}
|
onFocus={(e) => e.currentTarget.select()}
|
||||||
placeholder="K3F9X2 or SKU-XXXXXX"
|
placeholder={mode === "assetId" ? "123456" : mode === "sku" ? "SKU-XXXXXX" : "123456 or SKU-XXXXXX"}
|
||||||
style={{
|
style={{
|
||||||
border: "none",
|
border: "none",
|
||||||
outline: "none",
|
outline: "none",
|
||||||
@@ -120,18 +125,23 @@ export function ScanField({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function lookup(
|
export function lookup(
|
||||||
trimmed: string,
|
trimmed: string,
|
||||||
items: Item[],
|
items: Item[],
|
||||||
products?: Product[],
|
products?: Product[],
|
||||||
|
mode: "both" | "assetId" | "sku" = "both",
|
||||||
): ScanResult | null {
|
): ScanResult | null {
|
||||||
const itemHit = items.find((i) => i.assetId.toLowerCase() === trimmed);
|
if (mode !== "sku") {
|
||||||
if (itemHit) return { kind: "item", item: itemHit };
|
const itemHit = items.find((i) => i.assetId.toLowerCase() === trimmed);
|
||||||
const skuHitItem = items.find((i) => i.sku.toLowerCase() === trimmed);
|
if (itemHit) return { kind: "item", item: itemHit };
|
||||||
if (skuHitItem) return { kind: "item", item: skuHitItem };
|
}
|
||||||
if (products) {
|
if (mode !== "assetId") {
|
||||||
const productHit = products.find((p) => p.sku.toLowerCase() === trimmed);
|
const skuHitItem = items.find((i) => i.sku.toLowerCase() === trimmed);
|
||||||
if (productHit) return { kind: "product", product: productHit };
|
if (skuHitItem) return { kind: "item", item: skuHitItem };
|
||||||
|
if (products) {
|
||||||
|
const productHit = products.find((p) => p.sku.toLowerCase() === trimmed);
|
||||||
|
if (productHit) return { kind: "product", product: productHit };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,346 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
|
import type { Bootstrap, Shop, Brand, Product, Item } from "../types.js";
|
||||||
|
import { helpers, enrichItems } from "../types.js";
|
||||||
|
import { getToday, getStoredTimezone } from "../tz.js";
|
||||||
|
import { fmt } from "../format.js";
|
||||||
|
import { Btn, Pill, Icon } from "./primitives/index.js";
|
||||||
|
import { remainingShort } from "../stats.js";
|
||||||
|
import { useExitAnimation } from "../hooks/useExitAnimation.js";
|
||||||
|
import { useFocusTrap } from "../hooks/useFocusTrap.js";
|
||||||
|
|
||||||
|
export function ShopDetail({
|
||||||
|
shop,
|
||||||
|
data,
|
||||||
|
onClose,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
onSelectBrand,
|
||||||
|
onSelectItem,
|
||||||
|
}: {
|
||||||
|
shop: Shop;
|
||||||
|
data: Bootstrap;
|
||||||
|
onClose: () => void;
|
||||||
|
onEdit: () => void;
|
||||||
|
onDelete: () => void;
|
||||||
|
onSelectBrand: (b: Brand) => void;
|
||||||
|
onSelectItem: (i: Item) => void;
|
||||||
|
}) {
|
||||||
|
const allItems = enrichItems(data).filter((i) => i.shopId === shop.id);
|
||||||
|
const hasItems = allItems.length > 0;
|
||||||
|
|
||||||
|
const active = allItems.filter((i) => i.status === "active" || i.status === "checked-out");
|
||||||
|
const consumed = allItems.filter((i) => i.status === "consumed");
|
||||||
|
const gone = allItems.filter((i) => i.status === "gone");
|
||||||
|
|
||||||
|
const totalSpend = allItems.reduce((s, i) => s + i.price, 0);
|
||||||
|
const avgPrice = hasItems ? totalSpend / allItems.length : 0;
|
||||||
|
|
||||||
|
const rated = allItems.filter((i) => i.rating != null);
|
||||||
|
const avgRating =
|
||||||
|
rated.length > 0 ? rated.reduce((s, i) => s + i.rating!, 0) / rated.length : null;
|
||||||
|
|
||||||
|
const brandIds = [...new Set(allItems.map((i) => i.brandId).filter(Boolean))] as string[];
|
||||||
|
const brands = brandIds
|
||||||
|
.map((id) => data.brands.find((b) => b.id === id))
|
||||||
|
.filter(Boolean) as Brand[];
|
||||||
|
|
||||||
|
const sortedItems = [...allItems].sort(
|
||||||
|
(a, b) => +new Date(b.purchaseDate) - +new Date(a.purchaseDate),
|
||||||
|
);
|
||||||
|
const recentItems = sortedItems.slice(0, 20);
|
||||||
|
|
||||||
|
const todayStr = getToday(getStoredTimezone());
|
||||||
|
const tz = getStoredTimezone();
|
||||||
|
|
||||||
|
const { closing, triggerClose } = useExitAnimation(220, onClose);
|
||||||
|
const trapRef = useFocusTrap<HTMLDivElement>();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape") triggerClose();
|
||||||
|
};
|
||||||
|
document.addEventListener("keydown", handleKeyDown);
|
||||||
|
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||||
|
}, [triggerClose]);
|
||||||
|
|
||||||
|
const statCards: [string, React.ReactNode][] = [
|
||||||
|
["Purchases", String(allItems.length)],
|
||||||
|
["Total spent", hasItems ? fmt.money(totalSpend) : "—"],
|
||||||
|
["Avg price", hasItems ? fmt.money(avgPrice) : "—"],
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={trapRef}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
style={{
|
||||||
|
position: "fixed",
|
||||||
|
inset: 0,
|
||||||
|
background: "oklch(20% 0.02 60 / 0.4)",
|
||||||
|
zIndex: 50,
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "flex-end",
|
||||||
|
animation: closing ? "backdrop-out 220ms ease-in forwards" : "backdrop-in 200ms ease-out",
|
||||||
|
}}
|
||||||
|
onClick={triggerClose}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
style={{
|
||||||
|
width: "min(720px, 100vw)",
|
||||||
|
height: "100%",
|
||||||
|
animation: closing ? "drawer-out 220ms ease-in forwards" : "drawer-in 250ms ease-out",
|
||||||
|
background: "var(--bg)",
|
||||||
|
borderLeft: "1px solid var(--line)",
|
||||||
|
overflow: "auto",
|
||||||
|
boxShadow: "var(--shadow-lg)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: "20px 32px",
|
||||||
|
borderBottom: "1px solid var(--line)",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
position: "sticky",
|
||||||
|
top: 0,
|
||||||
|
background: "var(--bg)",
|
||||||
|
zIndex: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="smallcaps" style={{ color: "var(--ink-3)" }}>
|
||||||
|
Shop
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "flex", gap: 6 }}>
|
||||||
|
<Btn variant="ghost" icon="edit" onClick={onEdit} />
|
||||||
|
<Btn
|
||||||
|
variant="ghost"
|
||||||
|
icon="bin"
|
||||||
|
disabled={hasItems}
|
||||||
|
onClick={onDelete}
|
||||||
|
title={hasItems ? "Cannot delete — has inventory items" : undefined}
|
||||||
|
style={hasItems ? { opacity: 0.3, cursor: "not-allowed" } : undefined}
|
||||||
|
/>
|
||||||
|
<Btn variant="ghost" icon="close" onClick={triggerClose} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ padding: "32px 32px 60px" }}>
|
||||||
|
<h1
|
||||||
|
className="serif"
|
||||||
|
style={{
|
||||||
|
fontSize: 48,
|
||||||
|
margin: "0 0 4px",
|
||||||
|
fontWeight: 500,
|
||||||
|
letterSpacing: "-0.02em",
|
||||||
|
lineHeight: 1.1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{shop.name}
|
||||||
|
</h1>
|
||||||
|
{shop.location && (
|
||||||
|
<div style={{ fontSize: 16, color: "var(--ink-2)", marginTop: 4 }}>
|
||||||
|
{shop.location}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasItems && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: `repeat(${statCards.length}, 1fr)`,
|
||||||
|
gap: 1,
|
||||||
|
marginTop: 32,
|
||||||
|
background: "var(--line)",
|
||||||
|
border: "1px solid var(--line)",
|
||||||
|
borderRadius: "var(--r-md)",
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{statCards.map(([l, v], i) => (
|
||||||
|
<div key={i} style={{ padding: "18px 16px", background: "var(--surface)" }}>
|
||||||
|
<div className="smallcaps" style={{ color: "var(--ink-3)" }}>{l}</div>
|
||||||
|
<div className="serif" style={{ fontSize: 26, marginTop: 4, fontWeight: 500 }}>
|
||||||
|
{v}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasItems && (
|
||||||
|
<div style={{ marginTop: 28 }}>
|
||||||
|
<div className="smallcaps" style={{ color: "var(--ink-3)", marginBottom: 12 }}>
|
||||||
|
Lifecycle
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "flex", gap: 12, flexWrap: "wrap" }}>
|
||||||
|
{active.length > 0 && <Pill tone="sage">{active.length} active</Pill>}
|
||||||
|
{consumed.length > 0 && <Pill tone="terra">{consumed.length} consumed</Pill>}
|
||||||
|
{gone.length > 0 && <Pill tone="amber">{gone.length} gone</Pill>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{avgRating != null && (
|
||||||
|
<div style={{ marginTop: 28 }}>
|
||||||
|
<div className="smallcaps" style={{ color: "var(--ink-3)", marginBottom: 12 }}>
|
||||||
|
Ratings
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
|
||||||
|
<div style={{ display: "flex", gap: 2 }}>
|
||||||
|
{[1, 2, 3, 4, 5].map((n) => (
|
||||||
|
<Icon
|
||||||
|
key={n}
|
||||||
|
name="star"
|
||||||
|
size={18}
|
||||||
|
color={n <= Math.round(avgRating) ? "var(--amber)" : "var(--ink-4)"}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<span className="serif" style={{ fontSize: 22, fontWeight: 500 }}>
|
||||||
|
{avgRating.toFixed(1)}
|
||||||
|
</span>
|
||||||
|
<span style={{ fontSize: 12, color: "var(--ink-3)" }}>
|
||||||
|
from {rated.length} review{rated.length === 1 ? "" : "s"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{brands.length > 0 && (
|
||||||
|
<div style={{ marginTop: 28 }}>
|
||||||
|
<div className="smallcaps" style={{ color: "var(--ink-3)", marginBottom: 12 }}>
|
||||||
|
Brands ({brands.length})
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
border: "1px solid var(--line)",
|
||||||
|
borderRadius: "var(--r-md)",
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{brands.map((b, idx) => {
|
||||||
|
const brandItemCount = allItems.filter((i) => i.brandId === b.id).length;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={b.id}
|
||||||
|
onClick={() => onSelectBrand(b)}
|
||||||
|
className="inv-row"
|
||||||
|
style={{
|
||||||
|
padding: "12px 16px",
|
||||||
|
borderBottom: idx < brands.length - 1 ? "1px solid var(--line)" : "none",
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: "1fr auto auto",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 12,
|
||||||
|
background: "var(--surface)",
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontWeight: 500, fontSize: 13 }}>{b.name}</div>
|
||||||
|
<span style={{ fontSize: 12, color: "var(--ink-3)" }}>
|
||||||
|
{brandItemCount} item{brandItemCount === 1 ? "" : "s"}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className="inv-row-chevron"
|
||||||
|
style={{ color: "var(--ink-3)", fontSize: 14 }}
|
||||||
|
>
|
||||||
|
›
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasItems && (
|
||||||
|
<div style={{ marginTop: 28 }}>
|
||||||
|
<div className="smallcaps" style={{ color: "var(--ink-3)", marginBottom: 12 }}>
|
||||||
|
Recent purchases ({allItems.length})
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
border: "1px solid var(--line)",
|
||||||
|
borderRadius: "var(--r-md)",
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{recentItems.map((item, idx) => {
|
||||||
|
const isInactive = item.status !== "active" && item.status !== "checked-out";
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={item.id}
|
||||||
|
onClick={() => onSelectItem(item)}
|
||||||
|
className="inv-row"
|
||||||
|
style={{
|
||||||
|
padding: "12px 16px",
|
||||||
|
borderBottom: idx < recentItems.length - 1 ? "1px solid var(--line)" : "none",
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: "auto 1fr auto auto auto",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 12,
|
||||||
|
background: "var(--surface)",
|
||||||
|
cursor: "pointer",
|
||||||
|
opacity: isInactive ? 0.55 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="mono" style={{ fontSize: 12 }}>{item.assetId}</span>
|
||||||
|
<span style={{ fontSize: 13 }}>
|
||||||
|
{item.status === "consumed" && <Pill tone="terra" style={{ fontSize: 10 }}>Consumed</Pill>}
|
||||||
|
{item.status === "gone" && <Pill tone="amber" style={{ fontSize: 10 }}>Gone</Pill>}
|
||||||
|
{item.status === "checked-out" && <Pill tone="outline" style={{ fontSize: 10 }}>Checked out</Pill>}
|
||||||
|
{item.status === "active" && helpers.auditOverdue(item, todayStr) && (
|
||||||
|
<Pill tone="amber" style={{ fontSize: 10 }}>Audit due</Pill>
|
||||||
|
)}
|
||||||
|
{item.status === "active" && !helpers.auditOverdue(item, todayStr) && (
|
||||||
|
<Pill tone="sage" style={{ fontSize: 10 }}>Active</Pill>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span className="mono" style={{ fontSize: 12 }}>{fmt.money(item.price)}</span>
|
||||||
|
<span style={{ fontSize: 12, color: "var(--ink-3)" }}>
|
||||||
|
{fmt.dateShort(item.purchaseDate, tz)}
|
||||||
|
</span>
|
||||||
|
<span style={{ fontSize: 12, color: "var(--ink-3)" }}>
|
||||||
|
{(item.status === "active" || item.status === "checked-out")
|
||||||
|
? remainingShort(item)
|
||||||
|
: ""}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!hasItems && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: 36,
|
||||||
|
padding: 40,
|
||||||
|
textAlign: "center",
|
||||||
|
color: "var(--ink-3)",
|
||||||
|
background: "var(--bg-2)",
|
||||||
|
borderRadius: "var(--r-md)",
|
||||||
|
border: "1px solid var(--line)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontSize: 14, marginBottom: 4 }}>No inventory items yet</div>
|
||||||
|
<div style={{ fontSize: 12 }}>
|
||||||
|
Purchases from this shop will appear here.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasItems && (
|
||||||
|
<div style={{ marginTop: 12, fontSize: 11, color: "var(--ink-3)", fontStyle: "italic" }}>
|
||||||
|
Cannot delete this shop while it has associated inventory items.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,15 +4,19 @@ import { Icon } from "./primitives/index.js";
|
|||||||
export type ViewKey =
|
export type ViewKey =
|
||||||
| "dashboard"
|
| "dashboard"
|
||||||
| "inventory"
|
| "inventory"
|
||||||
|
| "skus"
|
||||||
|
| "custody"
|
||||||
| "bins"
|
| "bins"
|
||||||
| "shops"
|
| "shops"
|
||||||
| "brands"
|
| "brands"
|
||||||
| "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: "/custody", label: "My Custody", icon: "pocket" },
|
||||||
{ path: "/bins", label: "Bins", icon: "bin" },
|
{ path: "/bins", label: "Bins", icon: "bin" },
|
||||||
{ path: "/shops", label: "Shops", icon: "shop" },
|
{ path: "/shops", label: "Shops", icon: "shop" },
|
||||||
{ path: "/brands", label: "Brands", icon: "tag" },
|
{ path: "/brands", label: "Brands", icon: "tag" },
|
||||||
@@ -41,11 +45,15 @@ const TAGLINE = TAGLINES[Math.floor(Math.random() * TAGLINES.length)]!;
|
|||||||
export function Sidebar({
|
export function Sidebar({
|
||||||
onAddProduct,
|
onAddProduct,
|
||||||
onMarkFinished,
|
onMarkFinished,
|
||||||
onAudit,
|
onWeighIn,
|
||||||
|
onBinCheck,
|
||||||
|
onCheckout,
|
||||||
}: {
|
}: {
|
||||||
onAddProduct: () => void;
|
onAddProduct: () => void;
|
||||||
onMarkFinished: () => void;
|
onMarkFinished: () => void;
|
||||||
onAudit: () => void;
|
onWeighIn: () => void;
|
||||||
|
onBinCheck: () => void;
|
||||||
|
onCheckout: () => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<aside className="sidebar">
|
<aside className="sidebar">
|
||||||
@@ -82,13 +90,19 @@ export function Sidebar({
|
|||||||
))}
|
))}
|
||||||
<div className="nav-section">Quick</div>
|
<div className="nav-section">Quick</div>
|
||||||
<div className="nav-divider" />
|
<div className="nav-divider" />
|
||||||
<button className="nav-link" onClick={onAddProduct} title="Add product">
|
<button className="nav-link nav-action" onClick={onAddProduct} title="Add inventory">
|
||||||
<Icon name="plus" size={16} /> <span className="nav-label">Add product</span>
|
<Icon name="plus" size={16} /> <span className="nav-label">Add inventory</span>
|
||||||
</button>
|
</button>
|
||||||
<button className="nav-link" onClick={onAudit} title="Audit">
|
<button className="nav-link nav-action" onClick={onWeighIn} title="Weigh in">
|
||||||
<Icon name="search" size={16} /> <span className="nav-label">Audit</span>
|
<Icon name="search" size={16} /> <span className="nav-label">Weigh In</span>
|
||||||
</button>
|
</button>
|
||||||
<button className="nav-link" onClick={onMarkFinished} title="Mark consumed">
|
<button className="nav-link nav-action" onClick={onBinCheck} title="Bin check">
|
||||||
|
<Icon name="bin" size={16} /> <span className="nav-label">Bin Check</span>
|
||||||
|
</button>
|
||||||
|
<button className="nav-link nav-action" onClick={onCheckout} title="Check out">
|
||||||
|
<Icon name="pocket" size={16} /> <span className="nav-label">Check out</span>
|
||||||
|
</button>
|
||||||
|
<button className="nav-link nav-action" onClick={onMarkFinished} title="Mark consumed">
|
||||||
<Icon name="check" size={16} /> <span className="nav-label">Mark consumed</span>
|
<Icon name="check" size={16} /> <span className="nav-label">Mark consumed</span>
|
||||||
</button>
|
</button>
|
||||||
</aside>
|
</aside>
|
||||||
|
|||||||
@@ -0,0 +1,440 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
|
import type { Bootstrap, Product, Item } from "../types.js";
|
||||||
|
import { TYPES, helpers, enrichItems } from "../types.js";
|
||||||
|
import { getToday, getStoredTimezone } from "../tz.js";
|
||||||
|
import { fmt, TYPE_GLYPHS } from "../format.js";
|
||||||
|
import { Btn, Pill, Icon } from "./primitives/index.js";
|
||||||
|
import { remainingShort } from "../stats.js";
|
||||||
|
import { useExitAnimation } from "../hooks/useExitAnimation.js";
|
||||||
|
import { useFocusTrap } from "../hooks/useFocusTrap.js";
|
||||||
|
|
||||||
|
export function SkuDetail({
|
||||||
|
product,
|
||||||
|
data,
|
||||||
|
onClose,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
onSelectItem,
|
||||||
|
backLabel,
|
||||||
|
onBack,
|
||||||
|
}: {
|
||||||
|
product: Product;
|
||||||
|
data: Bootstrap;
|
||||||
|
onClose: () => void;
|
||||||
|
onEdit: () => void;
|
||||||
|
onDelete: () => void;
|
||||||
|
onSelectItem: (i: Item) => void;
|
||||||
|
backLabel?: string;
|
||||||
|
onBack?: () => void;
|
||||||
|
}) {
|
||||||
|
const strain = data.strains.find((s) => s.id === product.strainId);
|
||||||
|
const cfg = TYPES.find((t) => t.id === product.type);
|
||||||
|
const items = enrichItems(data).filter((i) => i.productId === product.id);
|
||||||
|
const active = items.filter((i) => i.status === "active" || i.status === "checked-out");
|
||||||
|
const consumed = items.filter((i) => i.status === "consumed");
|
||||||
|
const gone = items.filter((i) => i.status === "gone");
|
||||||
|
const hasItems = items.length > 0;
|
||||||
|
|
||||||
|
const totalSpend = items.reduce((s, i) => s + i.price, 0);
|
||||||
|
const avgPrice = hasItems ? totalSpend / items.length : 0;
|
||||||
|
|
||||||
|
let avgCostPerGram: number | null = null;
|
||||||
|
if (product.kind === "bulk") {
|
||||||
|
const totalGrams = items.reduce((s, i) => s + i.weight, 0);
|
||||||
|
if (totalGrams > 0) avgCostPerGram = totalSpend / totalGrams;
|
||||||
|
} else {
|
||||||
|
const totalCount = items.reduce((s, i) => s + i.countOriginal, 0);
|
||||||
|
if (totalCount > 0) avgCostPerGram = totalSpend / totalCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rated = items.filter((i) => i.rating != null);
|
||||||
|
const avgRating = rated.length > 0
|
||||||
|
? rated.reduce((s, i) => s + i.rating!, 0) / rated.length
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const lifespans = consumed
|
||||||
|
.filter((i) => i.consumedDate)
|
||||||
|
.map((i) => Math.max(1, Math.round((+new Date(i.consumedDate!) - +new Date(i.purchaseDate)) / 86_400_000)));
|
||||||
|
const avgLifespan = lifespans.length > 0 ? lifespans.reduce((a, b) => a + b, 0) / lifespans.length : null;
|
||||||
|
|
||||||
|
const todayStr = getToday(getStoredTimezone());
|
||||||
|
const tz = getStoredTimezone();
|
||||||
|
|
||||||
|
const totalGramsConsumed = consumed.reduce((s, i) => {
|
||||||
|
if (i.kind === "bulk") return s + i.weight;
|
||||||
|
return s + i.countOriginal;
|
||||||
|
}, 0);
|
||||||
|
const totalDaysConsumed = lifespans.reduce((a, b) => a + b, 0);
|
||||||
|
const consumptionRate = totalDaysConsumed > 0 ? totalGramsConsumed / totalDaysConsumed : null;
|
||||||
|
|
||||||
|
const sortedItems = [...items].sort(
|
||||||
|
(a, b) => +new Date(b.purchaseDate) - +new Date(a.purchaseDate),
|
||||||
|
);
|
||||||
|
|
||||||
|
const { closing, triggerClose } = useExitAnimation(220, onClose);
|
||||||
|
const trapRef = useFocusTrap<HTMLDivElement>();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape") triggerClose();
|
||||||
|
};
|
||||||
|
document.addEventListener("keydown", handleKeyDown);
|
||||||
|
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||||
|
}, [triggerClose]);
|
||||||
|
|
||||||
|
const statCards: [string, React.ReactNode][] = [
|
||||||
|
["Purchases", String(items.length)],
|
||||||
|
["Total spent", hasItems ? fmt.money(totalSpend) : "—"],
|
||||||
|
["Avg price", hasItems ? fmt.money(avgPrice) : "—"],
|
||||||
|
[
|
||||||
|
`Avg /${cfg?.unit ?? "g"}`,
|
||||||
|
avgCostPerGram != null ? fmt.money(avgCostPerGram) : "—",
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={trapRef}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
style={{
|
||||||
|
position: "fixed",
|
||||||
|
inset: 0,
|
||||||
|
background: "oklch(20% 0.02 60 / 0.4)",
|
||||||
|
zIndex: 50,
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "flex-end",
|
||||||
|
animation: closing ? "backdrop-out 220ms ease-in forwards" : "backdrop-in 200ms ease-out",
|
||||||
|
}}
|
||||||
|
onClick={triggerClose}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
style={{
|
||||||
|
width: "min(720px, 100vw)",
|
||||||
|
height: "100%",
|
||||||
|
animation: closing ? "drawer-out 220ms ease-in forwards" : "drawer-in 250ms ease-out",
|
||||||
|
background: "var(--bg)",
|
||||||
|
borderLeft: "1px solid var(--line)",
|
||||||
|
overflow: "auto",
|
||||||
|
boxShadow: "var(--shadow-lg)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: "20px 32px",
|
||||||
|
borderBottom: "1px solid var(--line)",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: onBack ? 8 : 0,
|
||||||
|
position: "sticky",
|
||||||
|
top: 0,
|
||||||
|
background: "var(--bg)",
|
||||||
|
zIndex: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{onBack && backLabel && (
|
||||||
|
<button
|
||||||
|
onClick={onBack}
|
||||||
|
style={{
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 4,
|
||||||
|
background: "none",
|
||||||
|
border: "none",
|
||||||
|
padding: 0,
|
||||||
|
fontSize: 12,
|
||||||
|
color: "var(--sage)",
|
||||||
|
cursor: "pointer",
|
||||||
|
alignSelf: "flex-start",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ transform: "scaleX(-1)", display: "inline-flex" }}><Icon name="arrow" size={12} /></span> Back to {backLabel}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
|
||||||
|
<div className="smallcaps" style={{ color: "var(--ink-3)" }}>
|
||||||
|
SKU · <span className="mono">{product.sku}</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "flex", gap: 6 }}>
|
||||||
|
<Btn variant="ghost" icon="edit" onClick={onEdit} />
|
||||||
|
<Btn
|
||||||
|
variant="ghost"
|
||||||
|
icon="bin"
|
||||||
|
disabled={hasItems}
|
||||||
|
onClick={onDelete}
|
||||||
|
title={hasItems ? "Cannot delete — has inventory items" : undefined}
|
||||||
|
style={hasItems ? { opacity: 0.3, cursor: "not-allowed" } : undefined}
|
||||||
|
/>
|
||||||
|
<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 className="serif" style={{ fontSize: 18, color: "var(--ink-3)" }}>
|
||||||
|
{TYPE_GLYPHS[product.type]} {product.type} · {product.kind}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h1
|
||||||
|
className="serif"
|
||||||
|
style={{
|
||||||
|
fontSize: 48,
|
||||||
|
margin: "0 0 4px",
|
||||||
|
fontWeight: 500,
|
||||||
|
letterSpacing: "-0.02em",
|
||||||
|
lineHeight: 1.1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{strain?.name ?? "(unknown)"}
|
||||||
|
</h1>
|
||||||
|
<div style={{ fontSize: 16, color: "var(--ink-2)" }}>
|
||||||
|
{helpers.brandName(data, product.brandId)}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 12, color: "var(--ink-3)", marginTop: 6 }}>
|
||||||
|
Created {fmt.date(product.createdAt, tz)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{hasItems && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: `repeat(${statCards.length}, 1fr)`,
|
||||||
|
gap: 1,
|
||||||
|
marginTop: 32,
|
||||||
|
background: "var(--line)",
|
||||||
|
border: "1px solid var(--line)",
|
||||||
|
borderRadius: "var(--r-md)",
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{statCards.map(([l, v], i) => (
|
||||||
|
<div key={i} style={{ padding: "18px 16px", background: "var(--surface)" }}>
|
||||||
|
<div className="smallcaps" style={{ color: "var(--ink-3)" }}>{l}</div>
|
||||||
|
<div className="serif" style={{ fontSize: 26, marginTop: 4, fontWeight: 500 }}>
|
||||||
|
{v}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasItems && (
|
||||||
|
<div style={{ marginTop: 28 }}>
|
||||||
|
<div className="smallcaps" style={{ color: "var(--ink-3)", marginBottom: 12 }}>
|
||||||
|
Lifecycle
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "flex", gap: 12, flexWrap: "wrap", marginBottom: 16 }}>
|
||||||
|
{active.length > 0 && (
|
||||||
|
<Pill tone="sage">{active.length} active</Pill>
|
||||||
|
)}
|
||||||
|
{consumed.length > 0 && (
|
||||||
|
<Pill tone="terra">{consumed.length} consumed</Pill>
|
||||||
|
)}
|
||||||
|
{gone.length > 0 && (
|
||||||
|
<Pill tone="amber">{gone.length} gone</Pill>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: "1fr 1fr",
|
||||||
|
gap: "14px 32px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DetailRow label="Avg lifespan" value={avgLifespan != null ? `${Math.round(avgLifespan)} days` : "—"} />
|
||||||
|
<DetailRow
|
||||||
|
label="Consumption rate"
|
||||||
|
value={
|
||||||
|
consumptionRate != null
|
||||||
|
? `${consumptionRate.toFixed(2)} ${cfg?.unit ?? "g"}/day`
|
||||||
|
: "—"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{avgRating != null && (
|
||||||
|
<div style={{ marginTop: 28 }}>
|
||||||
|
<div className="smallcaps" style={{ color: "var(--ink-3)", marginBottom: 12 }}>
|
||||||
|
Ratings
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 12, marginBottom: 12 }}>
|
||||||
|
<div style={{ display: "flex", gap: 2 }}>
|
||||||
|
{[1, 2, 3, 4, 5].map((n) => (
|
||||||
|
<Icon
|
||||||
|
key={n}
|
||||||
|
name="star"
|
||||||
|
size={18}
|
||||||
|
color={n <= Math.round(avgRating) ? "var(--amber)" : "var(--ink-4)"}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<span className="serif" style={{ fontSize: 22, fontWeight: 500 }}>
|
||||||
|
{avgRating.toFixed(1)}
|
||||||
|
</span>
|
||||||
|
<span style={{ fontSize: 12, color: "var(--ink-3)" }}>
|
||||||
|
from {rated.length} review{rated.length === 1 ? "" : "s"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
|
||||||
|
{rated
|
||||||
|
.sort((a, b) => +new Date(b.consumedDate ?? 0) - +new Date(a.consumedDate ?? 0))
|
||||||
|
.map((item) => (
|
||||||
|
<div
|
||||||
|
key={item.id}
|
||||||
|
style={{
|
||||||
|
padding: "8px 14px",
|
||||||
|
background: "var(--bg-2)",
|
||||||
|
border: "1px solid var(--line)",
|
||||||
|
borderRadius: "var(--r-md)",
|
||||||
|
fontSize: 12,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="mono">{item.assetId}</span>
|
||||||
|
<span style={{ display: "flex", gap: 1 }}>
|
||||||
|
{[1, 2, 3, 4, 5].map((n) => (
|
||||||
|
<Icon
|
||||||
|
key={n}
|
||||||
|
name="star"
|
||||||
|
size={10}
|
||||||
|
color={n <= item.rating! ? "var(--amber)" : "var(--ink-4)"}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ marginTop: 28 }}>
|
||||||
|
<div className="smallcaps" style={{ color: "var(--ink-3)", marginBottom: 12 }}>
|
||||||
|
Strain defaults
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: "1fr 1fr",
|
||||||
|
gap: "14px 32px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DetailRow label="Default THC" value={strain?.defaultThc != null ? `${strain.defaultThc.toFixed(1)}%` : "—"} />
|
||||||
|
<DetailRow label="Default CBD" value={strain?.defaultCbd != null ? `${strain.defaultCbd.toFixed(1)}%` : "—"} />
|
||||||
|
<DetailRow
|
||||||
|
label="Default total cannabinoids"
|
||||||
|
value={strain?.defaultTotalCannabinoids != null ? `${strain.defaultTotalCannabinoids.toFixed(1)}%` : "—"}
|
||||||
|
/>
|
||||||
|
{strain?.notes && <DetailRow label="Notes" value={strain.notes} />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{hasItems && (
|
||||||
|
<div style={{ marginTop: 36 }}>
|
||||||
|
<div className="smallcaps" style={{ color: "var(--ink-3)", marginBottom: 12 }}>
|
||||||
|
Inventory ({items.length})
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
border: "1px solid var(--line)",
|
||||||
|
borderRadius: "var(--r-md)",
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{sortedItems.map((item, idx) => {
|
||||||
|
const isInactive = item.status !== "active" && item.status !== "checked-out";
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={item.id}
|
||||||
|
onClick={() => onSelectItem(item)}
|
||||||
|
className="inv-row"
|
||||||
|
style={{
|
||||||
|
padding: "12px 16px",
|
||||||
|
borderBottom: idx < sortedItems.length - 1 ? "1px solid var(--line)" : "none",
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: "auto 1fr auto auto auto",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 12,
|
||||||
|
background: "var(--surface)",
|
||||||
|
cursor: "pointer",
|
||||||
|
opacity: isInactive ? 0.55 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="mono" style={{ fontSize: 12 }}>{item.assetId}</span>
|
||||||
|
<span style={{ fontSize: 13 }}>
|
||||||
|
{item.status === "consumed" && <Pill tone="terra" style={{ fontSize: 10 }}>Consumed</Pill>}
|
||||||
|
{item.status === "gone" && <Pill tone="amber" style={{ fontSize: 10 }}>Gone</Pill>}
|
||||||
|
{item.status === "checked-out" && <Pill tone="outline" style={{ fontSize: 10 }}>Checked out</Pill>}
|
||||||
|
{item.status === "active" && helpers.auditOverdue(item, todayStr) && (
|
||||||
|
<Pill tone="amber" style={{ fontSize: 10 }}>Audit due</Pill>
|
||||||
|
)}
|
||||||
|
{item.status === "active" && !helpers.auditOverdue(item, todayStr) && (
|
||||||
|
<Pill tone="sage" style={{ fontSize: 10 }}>Active</Pill>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span className="mono" style={{ fontSize: 12 }}>{fmt.money(item.price)}</span>
|
||||||
|
<span style={{ fontSize: 12, color: "var(--ink-3)" }}>
|
||||||
|
{fmt.dateShort(item.purchaseDate, tz)}
|
||||||
|
</span>
|
||||||
|
<span style={{ fontSize: 12, color: "var(--ink-3)" }}>
|
||||||
|
{(item.status === "active" || item.status === "checked-out")
|
||||||
|
? remainingShort(item)
|
||||||
|
: ""}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!hasItems && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: 36,
|
||||||
|
padding: 40,
|
||||||
|
textAlign: "center",
|
||||||
|
color: "var(--ink-3)",
|
||||||
|
background: "var(--bg-2)",
|
||||||
|
borderRadius: "var(--r-md)",
|
||||||
|
border: "1px solid var(--line)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontSize: 14, marginBottom: 4 }}>No inventory items yet</div>
|
||||||
|
<div style={{ fontSize: 12 }}>
|
||||||
|
Add inventory through the sidebar to start tracking this SKU.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasItems && (
|
||||||
|
<div style={{ marginTop: 12, fontSize: 11, color: "var(--ink-3)", fontStyle: "italic" }}>
|
||||||
|
Cannot delete this SKU while it has inventory items.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DetailRow({ label, value }: { label: string; value: string }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
paddingBottom: 12,
|
||||||
|
borderBottom: "1px solid var(--line)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ color: "var(--ink-3)", fontSize: 12 }}>{label}</span>
|
||||||
|
<span style={{ fontSize: 13, fontWeight: 500, textAlign: "right" }}>{value}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,16 +1,23 @@
|
|||||||
import { createContext, useCallback, useContext, 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";
|
||||||
|
|
||||||
|
interface ToastAction {
|
||||||
|
label: string;
|
||||||
|
onClick: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
interface Toast {
|
interface Toast {
|
||||||
id: number;
|
id: number;
|
||||||
message: string;
|
message: string;
|
||||||
type: ToastType;
|
type: ToastType;
|
||||||
|
action?: ToastAction;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ToastContext = createContext<{
|
const ToastContext = createContext<{
|
||||||
toast: (message: string, type?: ToastType) => void;
|
toast: (message: string, type?: ToastType, action?: ToastAction) => void;
|
||||||
}>({ toast: () => {} });
|
}>({ toast: () => {} });
|
||||||
|
|
||||||
export const useToast = () => useContext(ToastContext);
|
export const useToast = () => useContext(ToastContext);
|
||||||
@@ -19,32 +26,62 @@ let nextId = 0;
|
|||||||
|
|
||||||
export function ToastProvider({ children }: { children: React.ReactNode }) {
|
export function ToastProvider({ children }: { children: React.ReactNode }) {
|
||||||
const [toasts, setToasts] = useState<Toast[]>([]);
|
const [toasts, setToasts] = useState<Toast[]>([]);
|
||||||
|
const timers = useRef<Map<number, ReturnType<typeof setTimeout>>>(new Map());
|
||||||
|
|
||||||
const toast = useCallback((message: string, type: ToastType = "success") => {
|
const dismiss = useCallback((id: number) => {
|
||||||
const id = nextId++;
|
const timer = timers.current.get(id);
|
||||||
setToasts((prev) => [...prev, { id, message, type }]);
|
if (timer) clearTimeout(timer);
|
||||||
setTimeout(() => setToasts((prev) => prev.filter((t) => t.id !== id)), 4000);
|
timers.current.delete(id);
|
||||||
|
setToasts((prev) => prev.filter((t) => t.id !== id));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const startTimer = useCallback((id: number) => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
timers.current.delete(id);
|
||||||
|
setToasts((prev) => prev.filter((t) => t.id !== id));
|
||||||
|
}, 4000);
|
||||||
|
timers.current.set(id, timer);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const pauseTimer = useCallback((id: number) => {
|
||||||
|
const timer = timers.current.get(id);
|
||||||
|
if (timer) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
timers.current.delete(id);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const toast = useCallback((message: string, type: ToastType = "success", action?: ToastAction) => {
|
||||||
|
const id = nextId++;
|
||||||
|
setToasts((prev) => [...prev, { id, message, type, action }]);
|
||||||
|
startTimer(id);
|
||||||
|
}, [startTimer]);
|
||||||
|
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ToastContext.Provider value={{ toast }}>
|
<ToastContext.Provider value={{ toast }}>
|
||||||
{children}
|
{children}
|
||||||
{toasts.length > 0 && (
|
{toasts.length > 0 && (
|
||||||
<div
|
<div
|
||||||
|
role="status"
|
||||||
|
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",
|
||||||
gap: 8,
|
gap: 8,
|
||||||
pointerEvents: "none",
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{toasts.map((t) => (
|
{toasts.map((t) => (
|
||||||
<div
|
<div
|
||||||
key={t.id}
|
key={t.id}
|
||||||
|
onMouseEnter={() => pauseTimer(t.id)}
|
||||||
|
onMouseLeave={() => startTimer(t.id)}
|
||||||
style={{
|
style={{
|
||||||
pointerEvents: "auto",
|
pointerEvents: "auto",
|
||||||
padding: "12px 18px",
|
padding: "12px 18px",
|
||||||
@@ -66,7 +103,44 @@ export function ToastProvider({ children }: { children: React.ReactNode }) {
|
|||||||
size={16}
|
size={16}
|
||||||
color={t.type === "error" ? "var(--terracotta)" : "var(--sage)"}
|
color={t.type === "error" ? "var(--terracotta)" : "var(--sage)"}
|
||||||
/>
|
/>
|
||||||
{t.message}
|
<span style={{ flex: 1 }}>{t.message}</span>
|
||||||
|
{t.action && (
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
t.action!.onClick();
|
||||||
|
dismiss(t.id);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
background: "none",
|
||||||
|
border: "none",
|
||||||
|
cursor: "pointer",
|
||||||
|
padding: "2px 6px",
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: "var(--sage)",
|
||||||
|
flexShrink: 0,
|
||||||
|
textDecoration: "underline",
|
||||||
|
textUnderlineOffset: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t.action.label}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => dismiss(t.id)}
|
||||||
|
aria-label="Dismiss notification"
|
||||||
|
style={{
|
||||||
|
background: "transparent",
|
||||||
|
border: "none",
|
||||||
|
cursor: "pointer",
|
||||||
|
padding: 2,
|
||||||
|
display: "inline-flex",
|
||||||
|
color: "var(--ink-3)",
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon name="close" size={12} />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,10 +4,10 @@ import type { Bootstrap, InventoryItem, Item, Product, Strain } from "../../type
|
|||||||
import {
|
import {
|
||||||
ASSET_ID_RE,
|
ASSET_ID_RE,
|
||||||
TYPES,
|
TYPES,
|
||||||
TODAY_STR,
|
|
||||||
enrichItems,
|
enrichItems,
|
||||||
getLastInstance,
|
getLastInstance,
|
||||||
} from "../../types.js";
|
} from "../../types.js";
|
||||||
|
import { getToday, getStoredTimezone } from "../../tz.js";
|
||||||
import { fmt } from "../../format.js";
|
import { fmt } from "../../format.js";
|
||||||
import { api } from "../../api.js";
|
import { api } from "../../api.js";
|
||||||
import { Btn, Field, Input, Select } from "../primitives/index.js";
|
import { Btn, Field, Input, Select } from "../primitives/index.js";
|
||||||
@@ -139,6 +139,8 @@ function SelectProductStep({
|
|||||||
const [newBrandName, setNewBrandName] = useState("");
|
const [newBrandName, setNewBrandName] = useState("");
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => { setError(null); }, [newSku, newName, newBrandId, newBrandName]);
|
||||||
|
|
||||||
const handleScan = (result: ScanResult) => {
|
const handleScan = (result: ScanResult) => {
|
||||||
if (result.kind === "product") {
|
if (result.kind === "product") {
|
||||||
onPickProduct(result.product.id);
|
onPickProduct(result.product.id);
|
||||||
@@ -208,6 +210,7 @@ function SelectProductStep({
|
|||||||
onMatch={handleScan}
|
onMatch={handleScan}
|
||||||
onScanNoMatch={creating ? undefined : handleNoMatch}
|
onScanNoMatch={creating ? undefined : handleNoMatch}
|
||||||
matchedLabel={null}
|
matchedLabel={null}
|
||||||
|
mode="sku"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{!creating && (
|
{!creating && (
|
||||||
@@ -357,26 +360,46 @@ function InstanceDetailsStep({
|
|||||||
return last.price;
|
return last.price;
|
||||||
})();
|
})();
|
||||||
|
|
||||||
const [assetId, setAssetId] = useState("");
|
const nextAssetId = useMemo(() => {
|
||||||
|
if (data.inventoryItems.length === 0) return "";
|
||||||
|
const latest = data.inventoryItems.reduce((best, i) =>
|
||||||
|
i.id > best.id ? i : best,
|
||||||
|
);
|
||||||
|
const num = parseInt(latest.assetId, 10);
|
||||||
|
return isNaN(num) ? "" : String(num + 1).padStart(6, "0");
|
||||||
|
}, [data.inventoryItems]);
|
||||||
|
|
||||||
|
const [assetId, setAssetId] = useState(nextAssetId);
|
||||||
const [form, setForm] = useState({
|
const [form, setForm] = useState({
|
||||||
shopId: last?.shopId ?? data.shops[0]?.id ?? NEW_SHOP,
|
shopId: last?.shopId ?? data.shops[0]?.id ?? NEW_SHOP,
|
||||||
binId: data.bins[0]?.id ?? NEW_BIN,
|
binId: last?.binId ?? data.bins[0]?.id ?? NEW_BIN,
|
||||||
weight: last?.weight ?? (isDiscrete ? 0 : 3.5),
|
weight: last?.weight ?? (isDiscrete ? 0 : 3.5),
|
||||||
unitWeight: last?.unitWeight ?? (isDiscrete ? 0.7 : 0),
|
unitWeight: last?.unitWeight ?? (isDiscrete ? 0.7 : 0),
|
||||||
price: initialPrice,
|
price: initialPrice,
|
||||||
thc: last?.thc ?? 22,
|
thc: last?.thc ?? (cfg?.showCannabinoidPct !== false ? 22 : 0),
|
||||||
cbd: last?.cbd ?? 0.4,
|
cbd: last?.cbd ?? (cfg?.showCannabinoidPct !== false ? 0.4 : 0),
|
||||||
totalCannabinoids: last?.totalCannabinoids ?? 26,
|
totalCannabinoids: last?.totalCannabinoids ?? (cfg?.showCannabinoidPct !== false ? 22.4 : 0),
|
||||||
purchaseDate: TODAY_STR,
|
purchaseDate: getToday(getStoredTimezone()),
|
||||||
});
|
});
|
||||||
const [newShopName, setNewShopName] = useState("");
|
const [newShopName, setNewShopName] = useState("");
|
||||||
const [newShopLocation, setNewShopLocation] = useState("");
|
const [newShopLocation, setNewShopLocation] = useState("");
|
||||||
const [newBinName, setNewBinName] = useState("");
|
const [newBinName, setNewBinName] = useState("");
|
||||||
const [newBinCapacity, setNewBinCapacity] = useState(10);
|
const [newBinCapacity, setNewBinCapacity] = useState(10);
|
||||||
|
const [countOriginal, setCountOriginal] = useState(last?.countOriginal ?? 1);
|
||||||
|
const [containerWeight, setContainerWeight] = useState("");
|
||||||
|
const [totalCannaManual, setTotalCannaManual] = useState(!!last);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => { setError(null); }, [assetId]);
|
||||||
|
|
||||||
const update = <K extends keyof typeof form>(k: K, v: (typeof form)[K]) =>
|
const update = <K extends keyof typeof form>(k: K, v: (typeof form)[K]) =>
|
||||||
setForm((f) => ({ ...f, [k]: v }));
|
setForm((f) => {
|
||||||
|
const next = { ...f, [k]: v };
|
||||||
|
if ((k === "thc" || k === "cbd") && !totalCannaManual) {
|
||||||
|
next.totalCannabinoids = +(next.thc + next.cbd).toFixed(1);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
const cpg = !isDiscrete && form.weight > 0 ? form.price / form.weight : 0;
|
const cpg = !isDiscrete && form.weight > 0 ? form.price / form.weight : 0;
|
||||||
const assetIdValid = ASSET_ID_RE.test(assetId);
|
const assetIdValid = ASSET_ID_RE.test(assetId);
|
||||||
@@ -410,9 +433,10 @@ function InstanceDetailsStep({
|
|||||||
shopId,
|
shopId,
|
||||||
binId,
|
binId,
|
||||||
weight: isDiscrete ? undefined : form.weight,
|
weight: isDiscrete ? undefined : form.weight,
|
||||||
countOriginal: isDiscrete ? 1 : undefined,
|
containerWeight: !isDiscrete && containerWeight !== "" ? parseFloat(containerWeight) : undefined,
|
||||||
|
countOriginal: isDiscrete ? countOriginal : undefined,
|
||||||
unitWeight: isDiscrete ? form.unitWeight : undefined,
|
unitWeight: isDiscrete ? form.unitWeight : undefined,
|
||||||
price: form.price,
|
price: isDiscrete ? form.price * countOriginal : form.price,
|
||||||
thc: form.thc,
|
thc: form.thc,
|
||||||
cbd: form.cbd,
|
cbd: form.cbd,
|
||||||
totalCannabinoids: form.totalCannabinoids,
|
totalCannabinoids: form.totalCannabinoids,
|
||||||
@@ -571,7 +595,17 @@ function InstanceDetailsStep({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{isDiscrete ? (
|
{isDiscrete ? (
|
||||||
<Field label="Unit weight (g)" span={2} hint="Weight of one unit — for grams stats">
|
<>
|
||||||
|
<Field label="Count" hint="How many units in this purchase">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
step="1"
|
||||||
|
min="1"
|
||||||
|
value={countOriginal}
|
||||||
|
onChange={(e) => setCountOriginal(Math.max(1, Math.floor(+e.target.value || 1)))}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label={`Unit weight (${cfg?.weightUnit ?? "g"})`} hint="Weight of one unit — for grams stats">
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
step="0.1"
|
step="0.1"
|
||||||
@@ -579,6 +613,7 @@ function InstanceDetailsStep({
|
|||||||
onChange={(e) => update("unitWeight", +e.target.value)}
|
onChange={(e) => update("unitWeight", +e.target.value)}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<Field label={`Size (${cfg?.unit ?? "g"})`} span={2}>
|
<Field label={`Size (${cfg?.unit ?? "g"})`} span={2}>
|
||||||
<Input
|
<Input
|
||||||
@@ -589,7 +624,10 @@ function InstanceDetailsStep({
|
|||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
)}
|
)}
|
||||||
<Field label="Price ($)">
|
<Field
|
||||||
|
label={isDiscrete ? "Price per unit ($)" : "Total price ($)"}
|
||||||
|
hint={isDiscrete && form.price > 0 && countOriginal > 1 ? `${countOriginal} × ${fmt.money(form.price)} = ${fmt.money(form.price * countOriginal)} total` : undefined}
|
||||||
|
>
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
step="0.01"
|
step="0.01"
|
||||||
@@ -615,38 +653,66 @@ function InstanceDetailsStep({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div
|
{!isDiscrete && cfg?.weighable && (
|
||||||
className="smallcaps"
|
<div style={{ marginTop: 16 }}>
|
||||||
style={{ color: "var(--ink-3)", margin: "28px 0 16px" }}
|
<Field label="Container weight (g)" hint="Total weight of jar + lid + product on a scale. Optional — enables weigh-based audits.">
|
||||||
>
|
<Input
|
||||||
Cannabinoid profile
|
type="number"
|
||||||
</div>
|
step="0.01"
|
||||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(3, 1fr)", gap: 16 }}>
|
placeholder="—"
|
||||||
<Field label="THC %">
|
value={containerWeight}
|
||||||
<Input
|
onChange={(e) => setContainerWeight(e.target.value)}
|
||||||
type="number"
|
/>
|
||||||
step="0.1"
|
</Field>
|
||||||
value={form.thc}
|
{containerWeight !== "" && form.weight > 0 && (
|
||||||
onChange={(e) => update("thc", +e.target.value)}
|
<div style={{ marginTop: 6, fontSize: 12, color: parseFloat(containerWeight) <= form.weight ? "var(--terracotta)" : "var(--ink-3)" }}>
|
||||||
/>
|
{parseFloat(containerWeight) > form.weight
|
||||||
</Field>
|
? `Tare (empty jar): ${(parseFloat(containerWeight) - form.weight).toFixed(2)}g`
|
||||||
<Field label="CBD %">
|
: "Container weight must be greater than product weight"}
|
||||||
<Input
|
</div>
|
||||||
type="number"
|
)}
|
||||||
step="0.1"
|
</div>
|
||||||
value={form.cbd}
|
)}
|
||||||
onChange={(e) => update("cbd", +e.target.value)}
|
|
||||||
/>
|
{cfg?.showCannabinoidPct !== false && (
|
||||||
</Field>
|
<>
|
||||||
<Field label="Total cannabinoids %">
|
<div
|
||||||
<Input
|
className="smallcaps"
|
||||||
type="number"
|
style={{ color: "var(--ink-3)", margin: "28px 0 16px" }}
|
||||||
step="0.1"
|
>
|
||||||
value={form.totalCannabinoids}
|
Cannabinoid profile
|
||||||
onChange={(e) => update("totalCannabinoids", +e.target.value)}
|
</div>
|
||||||
/>
|
<div style={{ display: "grid", gridTemplateColumns: "repeat(3, 1fr)", gap: 16 }}>
|
||||||
</Field>
|
<Field label="THC %">
|
||||||
</div>
|
<Input
|
||||||
|
type="number"
|
||||||
|
step="0.1"
|
||||||
|
value={form.thc}
|
||||||
|
onChange={(e) => update("thc", +e.target.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="CBD %">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
step="0.1"
|
||||||
|
value={form.cbd}
|
||||||
|
onChange={(e) => update("cbd", +e.target.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Total cannabinoids %">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
step="0.1"
|
||||||
|
value={form.totalCannabinoids}
|
||||||
|
onChange={(e) => {
|
||||||
|
setTotalCannaManual(true);
|
||||||
|
update("totalCannabinoids", +e.target.value);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div style={{ marginTop: 14, fontSize: 12, color: "var(--terracotta)" }}>{error}</div>
|
<div style={{ marginTop: 14, fontSize: 12, color: "var(--terracotta)" }}>{error}</div>
|
||||||
|
|||||||
@@ -1,277 +0,0 @@
|
|||||||
import { useEffect, useState } from "react";
|
|
||||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
|
||||||
import type { Bootstrap, Item } from "../../types.js";
|
|
||||||
import { TYPES, helpers, TODAY_STR, enrichItems } from "../../types.js";
|
|
||||||
import { api } from "../../api.js";
|
|
||||||
import { Btn, Field, Input, Select } from "../primitives/index.js";
|
|
||||||
import { ScanField, type ScanResult } from "../ScanField.js";
|
|
||||||
import { ModalBackdrop, ModalHeader, ModalFooter } from "./ModalChrome.js";
|
|
||||||
import { useToast } from "../Toast.js";
|
|
||||||
|
|
||||||
const AUDIT_MODE_LABELS: Record<string, { title: string; desc: string }> = {
|
|
||||||
weigh: {
|
|
||||||
title: "Reweigh on a scale",
|
|
||||||
desc: "Place the jar (minus tare) and record the new weight.",
|
|
||||||
},
|
|
||||||
estimate: {
|
|
||||||
title: "Visual estimate",
|
|
||||||
desc: "Eyeball the remaining amount — quick and approximate.",
|
|
||||||
},
|
|
||||||
presence: {
|
|
||||||
title: "Confirm presence",
|
|
||||||
desc: "Verify the item is still where you left it. Count units if applicable.",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export function AuditFlow({
|
|
||||||
data,
|
|
||||||
onClose,
|
|
||||||
item: initialItem,
|
|
||||||
}: {
|
|
||||||
data: Bootstrap;
|
|
||||||
onClose: () => void;
|
|
||||||
item: Item | null;
|
|
||||||
}) {
|
|
||||||
const qc = useQueryClient();
|
|
||||||
const { toast } = useToast();
|
|
||||||
const allItems = enrichItems(data);
|
|
||||||
const overdueFirst = [...allItems]
|
|
||||||
.filter((i) => i.status === "active")
|
|
||||||
.sort((a, b) => helpers.daysSinceCheck(b) - helpers.daysSinceCheck(a));
|
|
||||||
|
|
||||||
const [itemId, setItemId] = useState(initialItem?.id ?? overdueFirst[0]?.id ?? "");
|
|
||||||
const [date, setDate] = useState(TODAY_STR);
|
|
||||||
const [confirmedBy, setConfirmedBy] = useState<"asset" | "SKU" | "visual">("asset");
|
|
||||||
|
|
||||||
const item = allItems.find((i) => i.id === itemId);
|
|
||||||
const cfg = item ? TYPES.find((t) => t.id === item.type) : undefined;
|
|
||||||
|
|
||||||
const initialValueFor = (i: Item | undefined): string => {
|
|
||||||
if (!i) return "0";
|
|
||||||
if (i.kind === "discrete") {
|
|
||||||
return String(i.countLastAudit ?? i.countOriginal);
|
|
||||||
}
|
|
||||||
return helpers.estimatedRemaining(i, TODAY_STR).toFixed(2);
|
|
||||||
};
|
|
||||||
const [value, setValue] = useState<string>(initialValueFor(item));
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setValue(initialValueFor(item));
|
|
||||||
}, [itemId]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
||||||
|
|
||||||
const audit = useMutation({
|
|
||||||
mutationFn: () =>
|
|
||||||
api.auditInventoryItem(itemId, {
|
|
||||||
date,
|
|
||||||
mode: cfg?.auditMode ?? "weigh",
|
|
||||||
value: Number(value),
|
|
||||||
confirmedBy: cfg?.auditMode === "presence" ? confirmedBy : undefined,
|
|
||||||
}),
|
|
||||||
onSuccess: () => {
|
|
||||||
qc.invalidateQueries({ queryKey: ["bootstrap"] });
|
|
||||||
toast(`Audit saved — next due in ${cfg?.cadenceDays ?? "?"}d`);
|
|
||||||
onClose();
|
|
||||||
},
|
|
||||||
onError: (e: Error) => setError(e.message),
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleScan = (result: ScanResult) => {
|
|
||||||
if (result.kind === "item") {
|
|
||||||
setItemId(result.item.id);
|
|
||||||
} else {
|
|
||||||
// SKU scan — pick the most recent active instance of that product.
|
|
||||||
const candidate = overdueFirst
|
|
||||||
.filter((i) => i.productId === result.product.id)
|
|
||||||
.sort((a, b) => +new Date(b.purchaseDate) - +new Date(a.purchaseDate))[0];
|
|
||||||
if (candidate) setItemId(candidate.id);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!item) return null;
|
|
||||||
const auditMode = cfg?.auditMode ?? "weigh";
|
|
||||||
const ml = AUDIT_MODE_LABELS[auditMode] ?? AUDIT_MODE_LABELS.weigh!;
|
|
||||||
|
|
||||||
const last = helpers.lastAudit(item);
|
|
||||||
const prevValue =
|
|
||||||
item.kind === "discrete"
|
|
||||||
? item.countLastAudit ?? item.countOriginal
|
|
||||||
: last
|
|
||||||
? last.value
|
|
||||||
: item.weight;
|
|
||||||
|
|
||||||
const delta = Number(value) - prevValue;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ModalBackdrop onClose={onClose}>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: "min(720px, 96vw)",
|
|
||||||
margin: "40px 20px",
|
|
||||||
background: "var(--bg)",
|
|
||||||
border: "1px solid var(--line)",
|
|
||||||
borderRadius: "var(--r-lg)",
|
|
||||||
boxShadow: "var(--shadow-lg)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ModalHeader title={ml.title} eyebrow="Audit" onClose={onClose} />
|
|
||||||
|
|
||||||
<div style={{ padding: 32 }}>
|
|
||||||
<ScanField
|
|
||||||
items={overdueFirst}
|
|
||||||
products={data.products}
|
|
||||||
matchedLabel={item ? `${item.assetId} · ${item.name}` : null}
|
|
||||||
onMatch={handleScan}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div style={{ marginTop: 16 }}>
|
|
||||||
<Field label="Or pick from list">
|
|
||||||
<Select value={itemId} onChange={(e) => setItemId(e.target.value)}>
|
|
||||||
{overdueFirst.map((i) => {
|
|
||||||
const od = helpers.auditOverdue(i);
|
|
||||||
const sc = helpers.daysSinceCheck(i);
|
|
||||||
return (
|
|
||||||
<option key={i.id} value={i.id}>
|
|
||||||
{od ? "⚠ " : ""}
|
|
||||||
{i.assetId} · {i.name} — {helpers.brandName(data, i.brandId)} · {sc}d since check
|
|
||||||
</option>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</Select>
|
|
||||||
</Field>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
marginTop: 16,
|
|
||||||
padding: 16,
|
|
||||||
background: "var(--bg-2)",
|
|
||||||
border: "1px solid var(--line)",
|
|
||||||
borderRadius: "var(--r-md)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
|
||||||
<div>
|
|
||||||
<div className="serif" style={{ fontSize: 20, fontWeight: 500 }}>
|
|
||||||
{item.name}
|
|
||||||
</div>
|
|
||||||
<div style={{ fontSize: 12, color: "var(--ink-3)" }}>
|
|
||||||
<span className="mono">{item.assetId}</span> · {item.type} · {item.kind} · cadence every {cfg?.cadenceDays}d
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div style={{ textAlign: "right" }}>
|
|
||||||
<div className="mono" style={{ fontSize: 11, color: "var(--ink-3)" }}>LAST CHECKED</div>
|
|
||||||
<div className="serif" style={{ fontSize: 18 }}>
|
|
||||||
{last ? `${helpers.daysSinceCheck(item)}d ago` : "Never"}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div style={{ fontSize: 12, color: "var(--ink-2)", marginTop: 10, fontStyle: "italic" }}>
|
|
||||||
{ml.desc}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "grid",
|
|
||||||
gridTemplateColumns: auditMode === "presence" ? "1fr 1fr 1fr" : "1fr 1fr",
|
|
||||||
gap: 16,
|
|
||||||
marginTop: 24,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Field
|
|
||||||
label={
|
|
||||||
item.kind === "discrete"
|
|
||||||
? `Count now (${cfg?.unit})`
|
|
||||||
: auditMode === "weigh"
|
|
||||||
? `Weight now (${cfg?.unit})`
|
|
||||||
: `Estimate now (${cfg?.unit})`
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
step={item.kind === "discrete" ? "1" : "0.1"}
|
|
||||||
value={value}
|
|
||||||
onChange={(e) => setValue(e.target.value)}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
<Field label="Date">
|
|
||||||
<Input type="date" value={date} onChange={(e) => setDate(e.target.value)} />
|
|
||||||
</Field>
|
|
||||||
{auditMode === "presence" && (
|
|
||||||
<Field label="Confirmed by">
|
|
||||||
<Select
|
|
||||||
value={confirmedBy}
|
|
||||||
onChange={(e) => setConfirmedBy(e.target.value as typeof confirmedBy)}
|
|
||||||
>
|
|
||||||
<option value="asset">Asset id</option>
|
|
||||||
<option value="SKU">SKU label</option>
|
|
||||||
<option value="visual">Visual ID</option>
|
|
||||||
</Select>
|
|
||||||
</Field>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
marginTop: 20,
|
|
||||||
padding: 14,
|
|
||||||
background: "var(--surface)",
|
|
||||||
border: "1px solid var(--line)",
|
|
||||||
borderRadius: "var(--r-md)",
|
|
||||||
display: "grid",
|
|
||||||
gridTemplateColumns: "1fr 1fr 1fr",
|
|
||||||
gap: 16,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<div className="smallcaps" style={{ color: "var(--ink-3)" }}>Was</div>
|
|
||||||
<div className="serif" style={{ fontSize: 22 }}>
|
|
||||||
{prevValue} {cfg?.unit}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="smallcaps" style={{ color: "var(--ink-3)" }}>Now</div>
|
|
||||||
<div className="serif" style={{ fontSize: 22, color: "var(--sage)" }}>
|
|
||||||
{value} {cfg?.unit}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="smallcaps" style={{ color: "var(--ink-3)" }}>Δ since last</div>
|
|
||||||
<div
|
|
||||||
className="serif"
|
|
||||||
style={{
|
|
||||||
fontSize: 22,
|
|
||||||
color: delta < 0 ? "var(--terracotta)" : "var(--ink)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{delta.toFixed(item.kind === "discrete" ? 0 : 2)} {cfg?.unit}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<div style={{ marginTop: 14, fontSize: 12, color: "var(--terracotta)" }}>{error}</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ModalFooter>
|
|
||||||
<div style={{ fontSize: 12, color: "var(--ink-3)" }}>
|
|
||||||
Next audit due in {cfg?.cadenceDays}d
|
|
||||||
</div>
|
|
||||||
<div style={{ display: "flex", gap: 8 }}>
|
|
||||||
<Btn variant="ghost" onClick={onClose}>Cancel</Btn>
|
|
||||||
<Btn
|
|
||||||
variant="primary"
|
|
||||||
icon="check"
|
|
||||||
disabled={audit.isPending}
|
|
||||||
onClick={() => audit.mutate()}
|
|
||||||
>
|
|
||||||
{audit.isPending ? "Saving…" : "Save audit"}
|
|
||||||
</Btn>
|
|
||||||
</div>
|
|
||||||
</ModalFooter>
|
|
||||||
</div>
|
|
||||||
</ModalBackdrop>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,442 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import type { Bin, Bootstrap, Item } from "../../types.js";
|
||||||
|
import { helpers, enrichItems } from "../../types.js";
|
||||||
|
import { getToday, getStoredTimezone } from "../../tz.js";
|
||||||
|
import { api } from "../../api.js";
|
||||||
|
import { Btn, Icon } from "../primitives/index.js";
|
||||||
|
import { ScanField, type ScanResult } from "../ScanField.js";
|
||||||
|
import { ModalBackdrop, ModalHeader, ModalFooter } from "./ModalChrome.js";
|
||||||
|
import { useToast } from "../Toast.js";
|
||||||
|
|
||||||
|
type Phase = "select" | "scan" | "review";
|
||||||
|
|
||||||
|
export function BinCheckFlow({
|
||||||
|
data,
|
||||||
|
onClose,
|
||||||
|
bin: initialBin,
|
||||||
|
}: {
|
||||||
|
data: Bootstrap;
|
||||||
|
onClose: () => void;
|
||||||
|
bin: Bin | null;
|
||||||
|
}) {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const allItems = enrichItems(data);
|
||||||
|
const todayStr = getToday(getStoredTimezone());
|
||||||
|
|
||||||
|
const [phase, setPhase] = useState<Phase>(initialBin ? "scan" : "select");
|
||||||
|
const [selectedBin, setSelectedBin] = useState<Bin | null>(initialBin);
|
||||||
|
const [verified, setVerified] = useState<Set<string>>(new Set());
|
||||||
|
const [gone, setGone] = useState<Set<string>>(new Set());
|
||||||
|
const [scanMessage, setScanMessage] = useState<{ text: string; color: string } | null>(null);
|
||||||
|
|
||||||
|
const expectedItems = selectedBin
|
||||||
|
? allItems.filter((i) => i.binId === selectedBin.id && i.status === "active")
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const missingItems = expectedItems.filter(
|
||||||
|
(i) => !verified.has(i.id) && !gone.has(i.id),
|
||||||
|
);
|
||||||
|
|
||||||
|
const sortedBins = [...data.bins].sort((a, b) => {
|
||||||
|
const aOver = helpers.binCheckOverdue(a, todayStr) ? 0 : 1;
|
||||||
|
const bOver = helpers.binCheckOverdue(b, todayStr) ? 0 : 1;
|
||||||
|
if (aOver !== bOver) return aOver - bOver;
|
||||||
|
return helpers.daysSinceBinCheck(b, todayStr) - helpers.daysSinceBinCheck(a, todayStr);
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSelectBin = (bin: Bin) => {
|
||||||
|
setSelectedBin(bin);
|
||||||
|
setVerified(new Set());
|
||||||
|
setGone(new Set());
|
||||||
|
setScanMessage(null);
|
||||||
|
setPhase("scan");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleScan = (result: ScanResult) => {
|
||||||
|
if (result.kind !== "item") return;
|
||||||
|
const item = result.item;
|
||||||
|
|
||||||
|
if (verified.has(item.id)) {
|
||||||
|
setScanMessage({ text: `Already scanned — ${item.name}`, color: "var(--ink-3)" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.binId === selectedBin?.id && item.status === "active") {
|
||||||
|
setVerified((prev) => new Set(prev).add(item.id));
|
||||||
|
setScanMessage({ text: `Verified — ${item.name}`, color: "var(--sage)" });
|
||||||
|
} else if (item.binId && item.binId !== selectedBin?.id) {
|
||||||
|
const correctBin = data.bins.find((b) => b.id === item.binId);
|
||||||
|
setScanMessage({
|
||||||
|
text: `Wrong bin — move ${item.name} to ${correctBin?.name ?? item.binId}`,
|
||||||
|
color: "var(--amber)",
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setScanMessage({
|
||||||
|
text: `${item.name} is not assigned to this bin`,
|
||||||
|
color: "var(--amber)",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleScanNoMatch = (raw: string) => {
|
||||||
|
setScanMessage({
|
||||||
|
text: `"${raw}" not in system — ingest this item first`,
|
||||||
|
color: "var(--terracotta)",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMarkGone = (itemId: string) => {
|
||||||
|
setGone((prev) => new Set(prev).add(itemId));
|
||||||
|
};
|
||||||
|
|
||||||
|
const complete = useMutation({
|
||||||
|
mutationFn: () =>
|
||||||
|
api.completeBinCheck(selectedBin!.id, {
|
||||||
|
date: todayStr,
|
||||||
|
verifiedItemIds: [...verified],
|
||||||
|
goneItemIds: [...gone],
|
||||||
|
}),
|
||||||
|
onSuccess: (result) => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["bootstrap"] });
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (result.verified > 0) parts.push(`${result.verified} verified`);
|
||||||
|
if (result.gone > 0) parts.push(`${result.gone} marked gone`);
|
||||||
|
toast(`Bin check complete — ${parts.join(", ")}`);
|
||||||
|
onClose();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const binItemCount = (bin: Bin) =>
|
||||||
|
allItems.filter((i) => i.binId === bin.id && i.status === "active").length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ModalBackdrop onClose={onClose}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: "min(720px, 96vw)",
|
||||||
|
margin: "40px 20px",
|
||||||
|
background: "var(--bg)",
|
||||||
|
border: "1px solid var(--line)",
|
||||||
|
borderRadius: "var(--r-lg)",
|
||||||
|
boxShadow: "var(--shadow-lg)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* ── Phase: Select bin ─────────────────────────── */}
|
||||||
|
{phase === "select" && (
|
||||||
|
<>
|
||||||
|
<ModalHeader title="Bin Check" eyebrow="Select a bin to check" onClose={onClose} />
|
||||||
|
<div style={{ padding: 32, maxHeight: "60vh", overflowY: "auto" }}>
|
||||||
|
{sortedBins.length === 0 ? (
|
||||||
|
<div style={{ textAlign: "center", color: "var(--ink-3)", padding: "24px 0", fontStyle: "italic" }}>
|
||||||
|
No bins created yet.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||||
|
{sortedBins.map((bin) => {
|
||||||
|
const overdue = helpers.binCheckOverdue(bin, todayStr);
|
||||||
|
const days = helpers.daysSinceBinCheck(bin, todayStr);
|
||||||
|
const count = binItemCount(bin);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={bin.id}
|
||||||
|
onClick={() => handleSelectBin(bin)}
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
padding: "14px 18px",
|
||||||
|
background: overdue ? "var(--amber-soft)" : "var(--bg-2)",
|
||||||
|
border: `1px solid ${overdue ? "var(--amber)" : "var(--line)"}`,
|
||||||
|
borderRadius: "var(--r-md)",
|
||||||
|
cursor: "pointer",
|
||||||
|
textAlign: "left",
|
||||||
|
width: "100%",
|
||||||
|
transition: "background 120ms",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div className="serif" style={{ fontSize: 18, fontWeight: 500 }}>
|
||||||
|
{bin.name}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 12, color: "var(--ink-3)", marginTop: 2 }}>
|
||||||
|
{count} item{count !== 1 ? "s" : ""} · cadence {bin.cadenceDays}d
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ textAlign: "right" }}>
|
||||||
|
<div style={{ fontSize: 13, color: overdue ? "var(--terracotta)" : "var(--ink-2)" }}>
|
||||||
|
{days === Infinity ? "Never checked" : `${days}d ago`}
|
||||||
|
</div>
|
||||||
|
{overdue && (
|
||||||
|
<div style={{ fontSize: 11, color: "var(--terracotta)", fontWeight: 600, marginTop: 2 }}>
|
||||||
|
OVERDUE
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<ModalFooter>
|
||||||
|
<div />
|
||||||
|
<Btn variant="ghost" onClick={onClose}>Cancel</Btn>
|
||||||
|
</ModalFooter>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Phase: Scan items ─────────────────────────── */}
|
||||||
|
{phase === "scan" && selectedBin && (
|
||||||
|
<>
|
||||||
|
<ModalHeader
|
||||||
|
title={selectedBin.name}
|
||||||
|
eyebrow={`${verified.size} of ${expectedItems.length} verified`}
|
||||||
|
onClose={onClose}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{expectedItems.length > 0 && (
|
||||||
|
<div style={{ height: 3, background: "var(--bg-3)" }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: "100%",
|
||||||
|
width: `${(verified.size / expectedItems.length) * 100}%`,
|
||||||
|
background: "var(--sage)",
|
||||||
|
borderRadius: "0 2px 2px 0",
|
||||||
|
transition: "width 300ms ease",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ padding: 32 }}>
|
||||||
|
<ScanField
|
||||||
|
items={allItems}
|
||||||
|
products={[]}
|
||||||
|
matchedLabel={null}
|
||||||
|
onMatch={handleScan}
|
||||||
|
onScanNoMatch={handleScanNoMatch}
|
||||||
|
mode="assetId"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{scanMessage && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: 12,
|
||||||
|
padding: "10px 14px",
|
||||||
|
borderRadius: "var(--r-md)",
|
||||||
|
background: "var(--bg-2)",
|
||||||
|
border: "1px solid var(--line)",
|
||||||
|
fontSize: 13,
|
||||||
|
color: scanMessage.color,
|
||||||
|
fontWeight: 500,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{scanMessage.text}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ marginTop: 20, maxHeight: 320, overflowY: "auto" }}>
|
||||||
|
{expectedItems.length === 0 ? (
|
||||||
|
<div style={{ textAlign: "center", color: "var(--ink-3)", padding: "24px 0", fontStyle: "italic" }}>
|
||||||
|
This bin has no active items.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
expectedItems.map((item) => {
|
||||||
|
const isVerified = verified.has(item.id);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={item.id}
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 10,
|
||||||
|
padding: "8px 0",
|
||||||
|
borderBottom: "1px solid var(--line)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 22,
|
||||||
|
height: 22,
|
||||||
|
borderRadius: "50%",
|
||||||
|
border: `2px solid ${isVerified ? "var(--sage)" : "var(--line)"}`,
|
||||||
|
background: isVerified ? "var(--sage)" : "transparent",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isVerified && <Icon name="check" size={14} color="white" />}
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{ fontSize: 14, fontWeight: 500, color: isVerified ? "var(--sage)" : "var(--ink)" }}>
|
||||||
|
{item.name}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 11, color: "var(--ink-3)" }}>
|
||||||
|
<span className="mono">{item.assetId}</span> · {item.type}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ModalFooter>
|
||||||
|
<Btn variant="ghost" onClick={() => { setPhase("select"); setSelectedBin(null); }}>
|
||||||
|
Back
|
||||||
|
</Btn>
|
||||||
|
<Btn
|
||||||
|
variant="primary"
|
||||||
|
onClick={() => setPhase("review")}
|
||||||
|
>
|
||||||
|
Done scanning
|
||||||
|
</Btn>
|
||||||
|
</ModalFooter>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Phase: Review & complete ──────────────────── */}
|
||||||
|
{phase === "review" && selectedBin && (
|
||||||
|
<>
|
||||||
|
<ModalHeader
|
||||||
|
title="Review"
|
||||||
|
eyebrow={selectedBin.name}
|
||||||
|
onClose={onClose}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div style={{ padding: 32 }}>
|
||||||
|
{/* Summary */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: "1fr 1fr 1fr",
|
||||||
|
gap: 16,
|
||||||
|
marginBottom: 24,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ padding: 14, background: "var(--bg-2)", borderRadius: "var(--r-md)", textAlign: "center" }}>
|
||||||
|
<div className="smallcaps" style={{ color: "var(--ink-3)" }}>Verified</div>
|
||||||
|
<div className="serif" style={{ fontSize: 24, color: "var(--sage)" }}>{verified.size}</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ padding: 14, background: "var(--bg-2)", borderRadius: "var(--r-md)", textAlign: "center" }}>
|
||||||
|
<div className="smallcaps" style={{ color: "var(--ink-3)" }}>Missing</div>
|
||||||
|
<div className="serif" style={{ fontSize: 24, color: missingItems.length > 0 ? "var(--terracotta)" : "var(--ink)" }}>
|
||||||
|
{missingItems.length}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ padding: 14, background: "var(--bg-2)", borderRadius: "var(--r-md)", textAlign: "center" }}>
|
||||||
|
<div className="smallcaps" style={{ color: "var(--ink-3)" }}>Gone</div>
|
||||||
|
<div className="serif" style={{ fontSize: 24, color: gone.size > 0 ? "var(--terracotta)" : "var(--ink)" }}>
|
||||||
|
{gone.size}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Missing items list */}
|
||||||
|
{missingItems.length > 0 && (
|
||||||
|
<div style={{ marginBottom: 16 }}>
|
||||||
|
<div className="smallcaps" style={{ color: "var(--terracotta)", marginBottom: 8 }}>
|
||||||
|
Missing items
|
||||||
|
</div>
|
||||||
|
{missingItems.map((item) => (
|
||||||
|
<div
|
||||||
|
key={item.id}
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
padding: "10px 0",
|
||||||
|
borderBottom: "1px solid var(--line)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 14, fontWeight: 500 }}>{item.name}</div>
|
||||||
|
<div style={{ fontSize: 11, color: "var(--ink-3)" }}>
|
||||||
|
<span className="mono">{item.assetId}</span> · {item.type}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "flex", gap: 6 }}>
|
||||||
|
<Btn variant="ghost" onClick={() => handleMarkGone(item.id)}>
|
||||||
|
Mark gone
|
||||||
|
</Btn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div style={{ marginTop: 12 }}>
|
||||||
|
<Btn variant="ghost" onClick={() => setPhase("scan")}>
|
||||||
|
Re-scan
|
||||||
|
</Btn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Gone items */}
|
||||||
|
{gone.size > 0 && (
|
||||||
|
<div>
|
||||||
|
<div className="smallcaps" style={{ color: "var(--ink-3)", marginBottom: 8 }}>
|
||||||
|
Marked as gone
|
||||||
|
</div>
|
||||||
|
{[...gone].map((id) => {
|
||||||
|
const item = allItems.find((i) => i.id === id);
|
||||||
|
if (!item) return null;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={id}
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
padding: "8px 0",
|
||||||
|
borderBottom: "1px solid var(--line)",
|
||||||
|
color: "var(--ink-3)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 14, textDecoration: "line-through" }}>{item.name}</div>
|
||||||
|
<div style={{ fontSize: 11 }}>
|
||||||
|
<span className="mono">{item.assetId}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Btn
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => setGone((prev) => { const next = new Set(prev); next.delete(id); return next; })}
|
||||||
|
>
|
||||||
|
Undo
|
||||||
|
</Btn>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{missingItems.length === 0 && gone.size === 0 && (
|
||||||
|
<div style={{ textAlign: "center", padding: "16px 0", color: "var(--sage)", fontSize: 15, fontWeight: 500 }}>
|
||||||
|
All items verified — ready to complete.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ModalFooter>
|
||||||
|
<Btn variant="ghost" onClick={() => setPhase("scan")}>
|
||||||
|
Back
|
||||||
|
</Btn>
|
||||||
|
<Btn
|
||||||
|
variant="primary"
|
||||||
|
icon="check"
|
||||||
|
disabled={complete.isPending}
|
||||||
|
onClick={() => complete.mutate()}
|
||||||
|
>
|
||||||
|
{complete.isPending ? "Saving…" : "Complete bin check"}
|
||||||
|
</Btn>
|
||||||
|
</ModalFooter>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ModalBackdrop>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import type { Bootstrap, Item } from "../../types.js";
|
||||||
|
import { getToday, getStoredTimezone } from "../../tz.js";
|
||||||
|
import { api } from "../../api.js";
|
||||||
|
import type { BatchOp } from "../../api.js";
|
||||||
|
import { Btn, Field, Input, Select } from "../primitives/index.js";
|
||||||
|
import { ModalBackdrop, ModalHeader, ModalFooter } from "./ModalChrome.js";
|
||||||
|
import { useToast } from "../Toast.js";
|
||||||
|
|
||||||
|
export function BulkCheckinModal({
|
||||||
|
data,
|
||||||
|
items,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
data: Bootstrap;
|
||||||
|
items: Item[];
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const eligible = items.filter((i) => i.status === "checked-out");
|
||||||
|
const excluded = items.length - eligible.length;
|
||||||
|
|
||||||
|
const [date, setDate] = useState(getToday(getStoredTimezone()));
|
||||||
|
const [binId, setBinId] = useState(data.bins[0]?.id ?? "");
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const checkin = useMutation({
|
||||||
|
mutationFn: () => {
|
||||||
|
const ops: BatchOp[] = eligible.map((i) => ({
|
||||||
|
action: "checkin" as const,
|
||||||
|
id: i.id,
|
||||||
|
date,
|
||||||
|
binId,
|
||||||
|
}));
|
||||||
|
return api.batchInventory(ops);
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["bootstrap"] });
|
||||||
|
const binName = data.bins.find((b) => b.id === binId)?.name ?? "bin";
|
||||||
|
toast(`Checked ${eligible.length} item${eligible.length === 1 ? "" : "s"} into ${binName}`);
|
||||||
|
onClose();
|
||||||
|
},
|
||||||
|
onError: (e: Error) => setError(e.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ModalBackdrop onClose={onClose}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: "min(640px, 96vw)",
|
||||||
|
margin: "40px 20px",
|
||||||
|
background: "var(--bg)",
|
||||||
|
border: "1px solid var(--line)",
|
||||||
|
borderRadius: "var(--r-lg)",
|
||||||
|
boxShadow: "var(--shadow-lg)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ModalHeader title="Bulk check in" eyebrow={`${eligible.length} eligible item${eligible.length === 1 ? "" : "s"}`} onClose={onClose} />
|
||||||
|
|
||||||
|
<div style={{ padding: 32 }}>
|
||||||
|
{excluded > 0 && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 13,
|
||||||
|
color: "var(--ink-2)",
|
||||||
|
marginBottom: 20,
|
||||||
|
padding: 14,
|
||||||
|
background: "var(--amber-soft)",
|
||||||
|
borderRadius: "var(--r-md)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{excluded} item{excluded === 1 ? " is" : "s are"} not checked out and will be skipped.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{eligible.length === 0 ? (
|
||||||
|
<div style={{ textAlign: "center", color: "var(--ink-3)", padding: "24px 0", fontStyle: "italic" }}>
|
||||||
|
No checked-out items to return.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 16 }}>
|
||||||
|
<Field label="Return to bin">
|
||||||
|
<Select value={binId} onChange={(e) => setBinId(e.target.value)}>
|
||||||
|
{data.bins.map((b) => (
|
||||||
|
<option key={b.id} value={b.id}>{b.name}</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</Field>
|
||||||
|
<Field label="Date">
|
||||||
|
<Input type="date" value={date} onChange={(e) => setDate(e.target.value)} />
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: 16, fontSize: 12, color: "var(--ink-3)" }}>
|
||||||
|
Items: {eligible.map((i) => i.name).join(", ")}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div style={{ marginTop: 14, fontSize: 12, color: "var(--terracotta)" }}>{error}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ModalFooter>
|
||||||
|
<div />
|
||||||
|
<div style={{ display: "flex", gap: 8 }}>
|
||||||
|
<Btn variant="ghost" onClick={onClose}>Cancel</Btn>
|
||||||
|
<Btn
|
||||||
|
variant="primary"
|
||||||
|
icon="check"
|
||||||
|
disabled={checkin.isPending || eligible.length === 0 || !binId}
|
||||||
|
onClick={() => checkin.mutate()}
|
||||||
|
>
|
||||||
|
{checkin.isPending ? "Saving…" : `Check in ${eligible.length}`}
|
||||||
|
</Btn>
|
||||||
|
</div>
|
||||||
|
</ModalFooter>
|
||||||
|
</div>
|
||||||
|
</ModalBackdrop>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import type { Bootstrap, Item } from "../../types.js";
|
||||||
|
import { getToday, getStoredTimezone } from "../../tz.js";
|
||||||
|
import { api } from "../../api.js";
|
||||||
|
import type { BatchOp } from "../../api.js";
|
||||||
|
import { Btn, Field, Input } from "../primitives/index.js";
|
||||||
|
import { ModalBackdrop, ModalHeader, ModalFooter } from "./ModalChrome.js";
|
||||||
|
import { useToast } from "../Toast.js";
|
||||||
|
|
||||||
|
export function BulkCheckoutModal({
|
||||||
|
data,
|
||||||
|
items,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
data: Bootstrap;
|
||||||
|
items: Item[];
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const eligible = items.filter((i) => i.status === "active");
|
||||||
|
const excluded = items.length - eligible.length;
|
||||||
|
|
||||||
|
const [date, setDate] = useState(getToday(getStoredTimezone()));
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const checkout = useMutation({
|
||||||
|
mutationFn: () => {
|
||||||
|
const ops: BatchOp[] = eligible.map((i) => ({
|
||||||
|
action: "checkout" as const,
|
||||||
|
id: i.id,
|
||||||
|
date,
|
||||||
|
}));
|
||||||
|
return api.batchInventory(ops);
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["bootstrap"] });
|
||||||
|
toast(`Checked out ${eligible.length} item${eligible.length === 1 ? "" : "s"}`);
|
||||||
|
onClose();
|
||||||
|
},
|
||||||
|
onError: (e: Error) => setError(e.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ModalBackdrop onClose={onClose}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: "min(640px, 96vw)",
|
||||||
|
margin: "40px 20px",
|
||||||
|
background: "var(--bg)",
|
||||||
|
border: "1px solid var(--line)",
|
||||||
|
borderRadius: "var(--r-lg)",
|
||||||
|
boxShadow: "var(--shadow-lg)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ModalHeader title="Bulk checkout" eyebrow={`${eligible.length} eligible item${eligible.length === 1 ? "" : "s"}`} onClose={onClose} />
|
||||||
|
|
||||||
|
<div style={{ padding: 32 }}>
|
||||||
|
{excluded > 0 && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 13,
|
||||||
|
color: "var(--ink-2)",
|
||||||
|
marginBottom: 20,
|
||||||
|
padding: 14,
|
||||||
|
background: "var(--amber-soft)",
|
||||||
|
borderRadius: "var(--r-md)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{excluded} item{excluded === 1 ? " is" : "s are"} not active and will be skipped.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{eligible.length === 0 ? (
|
||||||
|
<div style={{ textAlign: "center", color: "var(--ink-3)", padding: "24px 0", fontStyle: "italic" }}>
|
||||||
|
No active items to check out.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div style={{ maxWidth: 240 }}>
|
||||||
|
<Field label="Date">
|
||||||
|
<Input type="date" value={date} onChange={(e) => setDate(e.target.value)} />
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: 16, fontSize: 12, color: "var(--ink-3)" }}>
|
||||||
|
Items: {eligible.map((i) => i.name).join(", ")}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div style={{ marginTop: 14, fontSize: 12, color: "var(--terracotta)" }}>{error}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ModalFooter>
|
||||||
|
<div />
|
||||||
|
<div style={{ display: "flex", gap: 8 }}>
|
||||||
|
<Btn variant="ghost" onClick={onClose}>Cancel</Btn>
|
||||||
|
<Btn
|
||||||
|
variant="primary"
|
||||||
|
icon="pocket"
|
||||||
|
disabled={checkout.isPending || eligible.length === 0}
|
||||||
|
onClick={() => checkout.mutate()}
|
||||||
|
>
|
||||||
|
{checkout.isPending ? "Saving…" : `Check out ${eligible.length}`}
|
||||||
|
</Btn>
|
||||||
|
</div>
|
||||||
|
</ModalFooter>
|
||||||
|
</div>
|
||||||
|
</ModalBackdrop>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import type { Bootstrap, Item } from "../../types.js";
|
||||||
|
import { getToday, getStoredTimezone } from "../../tz.js";
|
||||||
|
import { api } from "../../api.js";
|
||||||
|
import type { BatchOp } from "../../api.js";
|
||||||
|
import { Btn, Field, Icon, Input, Textarea } from "../primitives/index.js";
|
||||||
|
import { ModalBackdrop, ModalHeader, ModalFooter } from "./ModalChrome.js";
|
||||||
|
import { useToast } from "../Toast.js";
|
||||||
|
|
||||||
|
export function BulkConsumeModal({
|
||||||
|
data,
|
||||||
|
items,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
data: Bootstrap;
|
||||||
|
items: Item[];
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const eligible = items.filter((i) => i.status === "active" || i.status === "checked-out");
|
||||||
|
const excluded = items.length - eligible.length;
|
||||||
|
|
||||||
|
const [rating, setRating] = useState(4);
|
||||||
|
const [notes, setNotes] = useState("");
|
||||||
|
const [date, setDate] = useState(getToday(getStoredTimezone()));
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const finish = useMutation({
|
||||||
|
mutationFn: () => {
|
||||||
|
const ops: BatchOp[] = eligible.map((i) => ({
|
||||||
|
action: "finish" as const,
|
||||||
|
id: i.id,
|
||||||
|
date,
|
||||||
|
rating,
|
||||||
|
notes: notes || undefined,
|
||||||
|
}));
|
||||||
|
return api.batchInventory(ops);
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["bootstrap"] });
|
||||||
|
toast(`Marked ${eligible.length} item${eligible.length === 1 ? "" : "s"} as consumed`);
|
||||||
|
onClose();
|
||||||
|
},
|
||||||
|
onError: (e: Error) => setError(e.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ModalBackdrop onClose={onClose}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: "min(720px, 96vw)",
|
||||||
|
margin: "40px 20px",
|
||||||
|
background: "var(--bg)",
|
||||||
|
border: "1px solid var(--line)",
|
||||||
|
borderRadius: "var(--r-lg)",
|
||||||
|
boxShadow: "var(--shadow-lg)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ModalHeader title="Bulk consume" eyebrow={`${eligible.length} eligible item${eligible.length === 1 ? "" : "s"}`} onClose={onClose} />
|
||||||
|
|
||||||
|
<div style={{ padding: 32 }}>
|
||||||
|
{excluded > 0 && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 13,
|
||||||
|
color: "var(--ink-2)",
|
||||||
|
marginBottom: 20,
|
||||||
|
padding: 14,
|
||||||
|
background: "var(--amber-soft)",
|
||||||
|
borderRadius: "var(--r-md)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{excluded} item{excluded === 1 ? "" : "s"} already consumed or gone — {excluded === 1 ? "it" : "they"} will be skipped.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{eligible.length === 0 ? (
|
||||||
|
<div style={{ textAlign: "center", color: "var(--ink-3)", padding: "24px 0", fontStyle: "italic" }}>
|
||||||
|
No eligible items to consume.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 16 }}>
|
||||||
|
<Field label="Date finished">
|
||||||
|
<Input type="date" value={date} onChange={(e) => setDate(e.target.value)} />
|
||||||
|
</Field>
|
||||||
|
<Field label="Rating">
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
gap: 4,
|
||||||
|
alignItems: "center",
|
||||||
|
padding: "10px 12px",
|
||||||
|
background: "var(--bg)",
|
||||||
|
border: "1px solid var(--line)",
|
||||||
|
borderRadius: "var(--r-md)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{[1, 2, 3, 4, 5].map((n) => (
|
||||||
|
<button
|
||||||
|
key={n}
|
||||||
|
onClick={() => setRating(n)}
|
||||||
|
style={{ border: "none", background: "transparent", cursor: "pointer", padding: 2 }}
|
||||||
|
>
|
||||||
|
<Icon name="star" size={20} color={n <= rating ? "var(--amber)" : "var(--ink-4)"} />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<span style={{ marginLeft: "auto", fontSize: 12, color: "var(--ink-3)", fontFamily: "var(--mono)" }}>
|
||||||
|
{rating}/5
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: 16 }}>
|
||||||
|
<Field label="Notes (optional)">
|
||||||
|
<Textarea
|
||||||
|
value={notes}
|
||||||
|
onChange={(e) => setNotes(e.target.value)}
|
||||||
|
placeholder="Shared notes for all items"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: 20,
|
||||||
|
padding: 14,
|
||||||
|
background: "var(--bg-2)",
|
||||||
|
border: "1px solid var(--line)",
|
||||||
|
borderRadius: "var(--r-md)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="smallcaps" style={{ color: "var(--ink-3)", marginBottom: 8 }}>
|
||||||
|
Items to consume
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
||||||
|
{eligible.map((i) => (
|
||||||
|
<div key={i.id} style={{ fontSize: 13, display: "flex", alignItems: "center", gap: 8 }}>
|
||||||
|
<span style={{ fontWeight: 500 }}>{i.name}</span>
|
||||||
|
<span className="mono" style={{ fontSize: 11, color: "var(--ink-3)" }}>{i.assetId}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: 10, fontSize: 11, color: "var(--terracotta)", fontWeight: 500 }}>
|
||||||
|
This cannot be undone.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div style={{ marginTop: 14, fontSize: 12, color: "var(--terracotta)" }}>{error}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ModalFooter>
|
||||||
|
<div style={{ fontSize: 12, color: "var(--ink-3)" }}>
|
||||||
|
{eligible.length} item{eligible.length === 1 ? "" : "s"} will be permanently archived.
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "flex", gap: 8 }}>
|
||||||
|
<Btn variant="ghost" onClick={onClose}>Cancel</Btn>
|
||||||
|
<Btn
|
||||||
|
variant="primary"
|
||||||
|
icon="check"
|
||||||
|
disabled={finish.isPending || eligible.length === 0}
|
||||||
|
onClick={() => finish.mutate()}
|
||||||
|
>
|
||||||
|
{finish.isPending ? "Saving…" : `Mark ${eligible.length} consumed`}
|
||||||
|
</Btn>
|
||||||
|
</div>
|
||||||
|
</ModalFooter>
|
||||||
|
</div>
|
||||||
|
</ModalBackdrop>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import type { Bootstrap, Item } from "../../types.js";
|
||||||
|
import { TYPES } from "../../types.js";
|
||||||
|
import { api } from "../../api.js";
|
||||||
|
import type { BatchOp } from "../../api.js";
|
||||||
|
import { Btn, Field, Input, Select } from "../primitives/index.js";
|
||||||
|
import { ModalBackdrop, ModalHeader, ModalFooter } from "./ModalChrome.js";
|
||||||
|
import { useToast } from "../Toast.js";
|
||||||
|
|
||||||
|
export function BulkEditModal({
|
||||||
|
data,
|
||||||
|
items,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
data: Bootstrap;
|
||||||
|
items: Item[];
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const [shopId, setShopId] = useState("");
|
||||||
|
const [binId, setBinId] = useState("");
|
||||||
|
const [price, setPrice] = useState("");
|
||||||
|
const [thc, setThc] = useState("");
|
||||||
|
const [cbd, setCbd] = useState("");
|
||||||
|
const [totalCannabinoids, setTotalCannabinoids] = useState("");
|
||||||
|
const [purchaseDate, setPurchaseDate] = useState("");
|
||||||
|
const [containerWeight, setContainerWeight] = useState("");
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const hasBulkWeighable = items.some((i) => {
|
||||||
|
const cfg = TYPES.find((t) => t.id === i.type);
|
||||||
|
return i.kind === "bulk" && cfg?.weighable;
|
||||||
|
});
|
||||||
|
|
||||||
|
const save = useMutation({
|
||||||
|
mutationFn: () => {
|
||||||
|
const fields: Record<string, string | number | null> = {};
|
||||||
|
if (shopId) fields.shopId = shopId;
|
||||||
|
if (binId) fields.binId = binId;
|
||||||
|
if (price !== "") fields.price = parseFloat(price);
|
||||||
|
if (thc !== "") fields.thc = parseFloat(thc);
|
||||||
|
if (cbd !== "") fields.cbd = parseFloat(cbd);
|
||||||
|
if (totalCannabinoids !== "") fields.totalCannabinoids = parseFloat(totalCannabinoids);
|
||||||
|
if (purchaseDate) fields.purchaseDate = purchaseDate;
|
||||||
|
if (containerWeight !== "") fields.containerWeight = parseFloat(containerWeight);
|
||||||
|
|
||||||
|
if (Object.keys(fields).length === 0) {
|
||||||
|
return Promise.reject(new Error("No fields to update — fill in at least one field."));
|
||||||
|
}
|
||||||
|
|
||||||
|
const ops: BatchOp[] = items.map((i) => ({
|
||||||
|
action: "update" as const,
|
||||||
|
id: i.id,
|
||||||
|
fields,
|
||||||
|
}));
|
||||||
|
return api.batchInventory(ops);
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["bootstrap"] });
|
||||||
|
toast(`Updated ${items.length} item${items.length === 1 ? "" : "s"}`);
|
||||||
|
onClose();
|
||||||
|
},
|
||||||
|
onError: (e: Error) => setError(e.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ModalBackdrop onClose={onClose}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: "min(840px, 96vw)",
|
||||||
|
margin: "40px 20px",
|
||||||
|
background: "var(--bg)",
|
||||||
|
border: "1px solid var(--line)",
|
||||||
|
borderRadius: "var(--r-lg)",
|
||||||
|
boxShadow: "var(--shadow-lg)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ModalHeader
|
||||||
|
title="Bulk edit"
|
||||||
|
eyebrow={`${items.length} item${items.length === 1 ? "" : "s"} selected`}
|
||||||
|
onClose={onClose}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div style={{ padding: 32 }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 13,
|
||||||
|
color: "var(--ink-2)",
|
||||||
|
marginBottom: 20,
|
||||||
|
padding: 14,
|
||||||
|
background: "var(--bg-2)",
|
||||||
|
borderRadius: "var(--r-md)",
|
||||||
|
border: "1px solid var(--line)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Only fields you fill in will be updated. Leave blank to keep current values.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="smallcaps" style={{ color: "var(--ink-3)", marginBottom: 16 }}>
|
||||||
|
Source
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: "repeat(2, 1fr)", gap: 16, marginBottom: 28 }}>
|
||||||
|
<Field label="Shop">
|
||||||
|
<Select value={shopId} onChange={(e) => setShopId(e.target.value)}>
|
||||||
|
<option value="">No change</option>
|
||||||
|
{data.shops.map((s) => (
|
||||||
|
<option key={s.id} value={s.id}>{s.name}</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</Field>
|
||||||
|
<Field label="Bin">
|
||||||
|
<Select value={binId} onChange={(e) => setBinId(e.target.value)}>
|
||||||
|
<option value="">No change</option>
|
||||||
|
{data.bins.map((b) => (
|
||||||
|
<option key={b.id} value={b.id}>{b.name}</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="smallcaps" style={{ color: "var(--ink-3)", marginBottom: 16 }}>
|
||||||
|
Values
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: "repeat(5, 1fr)", gap: 16 }}>
|
||||||
|
<Field label="Price ($)">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
min="0"
|
||||||
|
placeholder="—"
|
||||||
|
value={price}
|
||||||
|
onChange={(e) => setPrice(e.target.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Purchase date">
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
value={purchaseDate}
|
||||||
|
onChange={(e) => setPurchaseDate(e.target.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="THC %">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
step="0.1"
|
||||||
|
placeholder="—"
|
||||||
|
value={thc}
|
||||||
|
onChange={(e) => setThc(e.target.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="CBD %">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
step="0.1"
|
||||||
|
placeholder="—"
|
||||||
|
value={cbd}
|
||||||
|
onChange={(e) => setCbd(e.target.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Total cannabinoids %">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
step="0.1"
|
||||||
|
placeholder="—"
|
||||||
|
value={totalCannabinoids}
|
||||||
|
onChange={(e) => setTotalCannabinoids(e.target.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{hasBulkWeighable && (
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: "repeat(5, 1fr)", gap: 16, marginTop: 16 }}>
|
||||||
|
<Field label="Container weight (g)">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
placeholder="—"
|
||||||
|
value={containerWeight}
|
||||||
|
onChange={(e) => setContainerWeight(e.target.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div style={{ marginTop: 14, fontSize: 12, color: "var(--terracotta)" }}>{error}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ModalFooter>
|
||||||
|
<div />
|
||||||
|
<div style={{ display: "flex", gap: 8 }}>
|
||||||
|
<Btn variant="ghost" onClick={onClose}>Cancel</Btn>
|
||||||
|
<Btn
|
||||||
|
variant="primary"
|
||||||
|
icon="check"
|
||||||
|
disabled={save.isPending}
|
||||||
|
onClick={() => save.mutate()}
|
||||||
|
>
|
||||||
|
{save.isPending ? "Saving…" : `Update ${items.length} item${items.length === 1 ? "" : "s"}`}
|
||||||
|
</Btn>
|
||||||
|
</div>
|
||||||
|
</ModalFooter>
|
||||||
|
</div>
|
||||||
|
</ModalBackdrop>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import type { Bootstrap, Item } from "../../types.js";
|
||||||
|
import { getToday, getStoredTimezone } from "../../tz.js";
|
||||||
|
import { api } from "../../api.js";
|
||||||
|
import type { BatchOp } from "../../api.js";
|
||||||
|
import { Btn, Field, Input, Select, Textarea } from "../primitives/index.js";
|
||||||
|
import { ModalBackdrop, ModalHeader, ModalFooter } from "./ModalChrome.js";
|
||||||
|
import { useToast } from "../Toast.js";
|
||||||
|
|
||||||
|
const REASONS: [string, string][] = [
|
||||||
|
["lost", "Lost / misplaced"],
|
||||||
|
["damaged", "Damaged"],
|
||||||
|
["expired", "Expired"],
|
||||||
|
["gifted", "Gifted away"],
|
||||||
|
["other", "Other"],
|
||||||
|
];
|
||||||
|
|
||||||
|
export function BulkGoneModal({
|
||||||
|
data,
|
||||||
|
items,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
data: Bootstrap;
|
||||||
|
items: Item[];
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const eligible = items.filter((i) => i.status === "active" || i.status === "checked-out");
|
||||||
|
const excluded = items.length - eligible.length;
|
||||||
|
|
||||||
|
const [reason, setReason] = useState("lost");
|
||||||
|
const [notes, setNotes] = useState("");
|
||||||
|
const [date, setDate] = useState(getToday(getStoredTimezone()));
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const mark = useMutation({
|
||||||
|
mutationFn: () => {
|
||||||
|
const ops: BatchOp[] = eligible.map((i) => ({
|
||||||
|
action: "gone" as const,
|
||||||
|
id: i.id,
|
||||||
|
date,
|
||||||
|
reason,
|
||||||
|
notes: notes || undefined,
|
||||||
|
}));
|
||||||
|
return api.batchInventory(ops);
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["bootstrap"] });
|
||||||
|
toast(`Marked ${eligible.length} item${eligible.length === 1 ? "" : "s"} as gone`);
|
||||||
|
onClose();
|
||||||
|
},
|
||||||
|
onError: (e: Error) => setError(e.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ModalBackdrop onClose={onClose}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: "min(640px, 96vw)",
|
||||||
|
margin: "40px 20px",
|
||||||
|
background: "var(--bg)",
|
||||||
|
border: "1px solid var(--line)",
|
||||||
|
borderRadius: "var(--r-lg)",
|
||||||
|
boxShadow: "var(--shadow-lg)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ModalHeader
|
||||||
|
title="Bulk mark as gone"
|
||||||
|
eyebrow="Archive · not consumed"
|
||||||
|
eyebrowColor="var(--terracotta)"
|
||||||
|
onClose={onClose}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div style={{ padding: 32 }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 13,
|
||||||
|
color: "var(--ink-2)",
|
||||||
|
marginBottom: 20,
|
||||||
|
padding: 14,
|
||||||
|
background: "var(--amber-soft)",
|
||||||
|
borderRadius: "var(--r-md)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
This marks items as lost, damaged, expired, or gifted. Counts as{" "}
|
||||||
|
<strong>spend</strong> but not <strong>consumption</strong>.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{excluded > 0 && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 13,
|
||||||
|
color: "var(--ink-2)",
|
||||||
|
marginBottom: 20,
|
||||||
|
padding: 14,
|
||||||
|
background: "var(--bg-2)",
|
||||||
|
borderRadius: "var(--r-md)",
|
||||||
|
border: "1px solid var(--line)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{excluded} item{excluded === 1 ? " is" : "s are"} already consumed or gone and will be skipped.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{eligible.length === 0 ? (
|
||||||
|
<div style={{ textAlign: "center", color: "var(--ink-3)", padding: "24px 0", fontStyle: "italic" }}>
|
||||||
|
No eligible items to mark gone.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 16 }}>
|
||||||
|
<Field label="Reason">
|
||||||
|
<Select value={reason} onChange={(e) => setReason(e.target.value)}>
|
||||||
|
{REASONS.map(([k, l]) => (
|
||||||
|
<option key={k} value={k}>{l}</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</Field>
|
||||||
|
<Field label="Date">
|
||||||
|
<Input type="date" value={date} onChange={(e) => setDate(e.target.value)} />
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: 16 }}>
|
||||||
|
<Field label="Notes (optional)">
|
||||||
|
<Textarea
|
||||||
|
value={notes}
|
||||||
|
onChange={(e) => setNotes(e.target.value)}
|
||||||
|
placeholder="What happened"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: 20,
|
||||||
|
padding: 14,
|
||||||
|
background: "var(--bg-2)",
|
||||||
|
border: "1px solid var(--line)",
|
||||||
|
borderRadius: "var(--r-md)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="smallcaps" style={{ color: "var(--ink-3)", marginBottom: 8 }}>
|
||||||
|
Items to mark gone
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
||||||
|
{eligible.map((i) => (
|
||||||
|
<div key={i.id} style={{ fontSize: 13, display: "flex", alignItems: "center", gap: 8 }}>
|
||||||
|
<span style={{ fontWeight: 500 }}>{i.name}</span>
|
||||||
|
<span className="mono" style={{ fontSize: 11, color: "var(--ink-3)" }}>{i.assetId}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: 10, fontSize: 11, color: "var(--terracotta)", fontWeight: 500 }}>
|
||||||
|
This cannot be undone.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div style={{ marginTop: 14, fontSize: 12, color: "var(--terracotta)" }}>{error}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ModalFooter>
|
||||||
|
<div style={{ fontSize: 12, color: "var(--ink-3)" }}>
|
||||||
|
{eligible.length} item{eligible.length === 1 ? "" : "s"} will be permanently archived.
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "flex", gap: 8 }}>
|
||||||
|
<Btn variant="ghost" onClick={onClose}>Cancel</Btn>
|
||||||
|
<Btn
|
||||||
|
variant="danger"
|
||||||
|
icon="bin"
|
||||||
|
disabled={mark.isPending || eligible.length === 0}
|
||||||
|
onClick={() => mark.mutate()}
|
||||||
|
>
|
||||||
|
{mark.isPending ? "Saving…" : `Mark ${eligible.length} gone`}
|
||||||
|
</Btn>
|
||||||
|
</div>
|
||||||
|
</ModalFooter>
|
||||||
|
</div>
|
||||||
|
</ModalBackdrop>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -125,14 +125,15 @@ export function EditBinModal({
|
|||||||
bin,
|
bin,
|
||||||
onClose,
|
onClose,
|
||||||
}: {
|
}: {
|
||||||
bin: { id: string; name: string; capacity: number };
|
bin: { id: string; name: string; capacity: number; cadenceDays: number };
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}) {
|
}) {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const [name, setName] = useState(bin.name);
|
const [name, setName] = useState(bin.name);
|
||||||
const [capacity, setCapacity] = useState(bin.capacity);
|
const [capacity, setCapacity] = useState(bin.capacity);
|
||||||
|
const [cadenceDays, setCadenceDays] = useState(bin.cadenceDays);
|
||||||
const update = useMutation({
|
const update = useMutation({
|
||||||
mutationFn: () => api.updateBin(bin.id, { name: name.trim(), capacity }),
|
mutationFn: () => api.updateBin(bin.id, { name: name.trim(), capacity, cadenceDays }),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
qc.invalidateQueries({ queryKey: ["bootstrap"] });
|
qc.invalidateQueries({ queryKey: ["bootstrap"] });
|
||||||
onClose();
|
onClose();
|
||||||
@@ -152,7 +153,7 @@ export function EditBinModal({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ModalHeader title="Edit bin" eyebrow="Storage" onClose={onClose} />
|
<ModalHeader title="Edit bin" eyebrow="Storage" onClose={onClose} />
|
||||||
<div style={{ padding: 32, display: "grid", gridTemplateColumns: "2fr 1fr", gap: 16 }}>
|
<div style={{ padding: 32, display: "grid", gridTemplateColumns: "2fr 1fr 1fr", gap: 16 }}>
|
||||||
<Field label="Bin name">
|
<Field label="Bin name">
|
||||||
<Input
|
<Input
|
||||||
autoFocus
|
autoFocus
|
||||||
@@ -170,6 +171,15 @@ export function EditBinModal({
|
|||||||
onChange={(e) => setCapacity(Math.max(1, Math.floor(+e.target.value || 1)))}
|
onChange={(e) => setCapacity(Math.max(1, Math.floor(+e.target.value || 1)))}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
<Field label="Check cadence">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
step={1}
|
||||||
|
value={cadenceDays}
|
||||||
|
onChange={(e) => setCadenceDays(Math.max(1, Math.floor(+e.target.value || 30)))}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
</div>
|
</div>
|
||||||
<ModalFooter>
|
<ModalFooter>
|
||||||
<div />
|
<div />
|
||||||
@@ -194,8 +204,9 @@ export function AddBinModal({ onClose }: { onClose: () => void }) {
|
|||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
const [capacity, setCapacity] = useState(10);
|
const [capacity, setCapacity] = useState(10);
|
||||||
|
const [cadenceDays, setCadenceDays] = useState(30);
|
||||||
const create = useMutation({
|
const create = useMutation({
|
||||||
mutationFn: () => api.createBin({ name: name.trim(), capacity }),
|
mutationFn: () => api.createBin({ name: name.trim(), capacity, cadenceDays }),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
qc.invalidateQueries({ queryKey: ["bootstrap"] });
|
qc.invalidateQueries({ queryKey: ["bootstrap"] });
|
||||||
onClose();
|
onClose();
|
||||||
@@ -215,7 +226,7 @@ export function AddBinModal({ onClose }: { onClose: () => void }) {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ModalHeader title="Add a bin" eyebrow="Storage" onClose={onClose} />
|
<ModalHeader title="Add a bin" eyebrow="Storage" onClose={onClose} />
|
||||||
<div style={{ padding: 32, display: "grid", gridTemplateColumns: "2fr 1fr", gap: 16 }}>
|
<div style={{ padding: 32, display: "grid", gridTemplateColumns: "2fr 1fr 1fr", gap: 16 }}>
|
||||||
<Field label="Bin name">
|
<Field label="Bin name">
|
||||||
<Input
|
<Input
|
||||||
autoFocus
|
autoFocus
|
||||||
@@ -233,6 +244,15 @@ export function AddBinModal({ onClose }: { onClose: () => void }) {
|
|||||||
onChange={(e) => setCapacity(Math.max(1, Math.floor(+e.target.value || 1)))}
|
onChange={(e) => setCapacity(Math.max(1, Math.floor(+e.target.value || 1)))}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
<Field label="Check cadence">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
step={1}
|
||||||
|
value={cadenceDays}
|
||||||
|
onChange={(e) => setCadenceDays(Math.max(1, Math.floor(+e.target.value || 30)))}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
</div>
|
</div>
|
||||||
<ModalFooter>
|
<ModalFooter>
|
||||||
<div />
|
<div />
|
||||||
|
|||||||
@@ -0,0 +1,203 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import type { Bootstrap, Item } from "../../types.js";
|
||||||
|
import { TYPES, helpers, enrichItems } from "../../types.js";
|
||||||
|
import { getToday, getStoredTimezone } from "../../tz.js";
|
||||||
|
import { fmt } from "../../format.js";
|
||||||
|
import { api } from "../../api.js";
|
||||||
|
import { Btn, Field, Input, Select } from "../primitives/index.js";
|
||||||
|
import { ScanField, type ScanResult } from "../ScanField.js";
|
||||||
|
import { ModalBackdrop, ModalHeader, ModalFooter } from "./ModalChrome.js";
|
||||||
|
import { useToast } from "../Toast.js";
|
||||||
|
|
||||||
|
export function CheckinFlow({
|
||||||
|
data,
|
||||||
|
onClose,
|
||||||
|
item: initialItem,
|
||||||
|
}: {
|
||||||
|
data: Bootstrap;
|
||||||
|
onClose: () => void;
|
||||||
|
item: Item | null;
|
||||||
|
}) {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const allItems = enrichItems(data);
|
||||||
|
const checkedOut = allItems.filter((i) => i.status === "checked-out");
|
||||||
|
const [itemId, setItemId] = useState(initialItem?.id ?? "");
|
||||||
|
const [binId, setBinId] = useState(initialItem?.prevBinId ?? data.bins[0]?.id ?? "");
|
||||||
|
const [date, setDate] = useState(getToday(getStoredTimezone()));
|
||||||
|
const [remaining, setRemaining] = useState("");
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const item = allItems.find((i) => i.id === itemId);
|
||||||
|
|
||||||
|
useEffect(() => { setError(null); }, [itemId]);
|
||||||
|
|
||||||
|
const isBulk = item?.kind === "bulk";
|
||||||
|
const cfg = item ? TYPES.find((t) => t.id === item.type) : undefined;
|
||||||
|
const lastAudit = item ? helpers.lastAudit(item) : null;
|
||||||
|
const rem = item ? (lastAudit ? lastAudit.value : item.weight) : 0;
|
||||||
|
|
||||||
|
const checkin = useMutation({
|
||||||
|
mutationFn: () => {
|
||||||
|
const body: { date: string; binId: string; remainingWeight?: number } = {
|
||||||
|
date,
|
||||||
|
binId,
|
||||||
|
};
|
||||||
|
if (isBulk && remaining !== "") {
|
||||||
|
body.remainingWeight = parseFloat(remaining);
|
||||||
|
}
|
||||||
|
return api.checkinInventoryItem(itemId, body);
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["bootstrap"] });
|
||||||
|
const binName = data.bins.find((b) => b.id === binId)?.name ?? "bin";
|
||||||
|
toast(`Checked ${item?.name ?? "item"} back into ${binName}`);
|
||||||
|
onClose();
|
||||||
|
},
|
||||||
|
onError: (e: Error) => setError(e.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleScan = (result: ScanResult) => {
|
||||||
|
if (result.kind === "item") {
|
||||||
|
setItemId(result.item.id);
|
||||||
|
const scanned = allItems.find((i) => i.id === result.item.id);
|
||||||
|
if (scanned?.prevBinId) setBinId(scanned.prevBinId);
|
||||||
|
if (scanned?.kind === "bulk") {
|
||||||
|
const scanLast = helpers.lastAudit(scanned);
|
||||||
|
setRemaining((scanLast ? scanLast.value : scanned.weight).toFixed(2));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ModalBackdrop onClose={onClose}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: "min(720px, 96vw)",
|
||||||
|
margin: "40px 20px",
|
||||||
|
background: "var(--bg)",
|
||||||
|
border: "1px solid var(--line)",
|
||||||
|
borderRadius: "var(--r-lg)",
|
||||||
|
boxShadow: "var(--shadow-lg)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ModalHeader title="Check in" eyebrow="Return to bin" onClose={onClose} />
|
||||||
|
|
||||||
|
<div style={{ padding: 32 }}>
|
||||||
|
<ScanField
|
||||||
|
items={checkedOut}
|
||||||
|
products={[]}
|
||||||
|
matchedLabel={item ? `${item.assetId} · ${item.name}` : null}
|
||||||
|
onMatch={handleScan}
|
||||||
|
mode="assetId"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{!item ? (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: 24,
|
||||||
|
textAlign: "center",
|
||||||
|
color: "var(--ink-3)",
|
||||||
|
fontSize: 13,
|
||||||
|
fontStyle: "italic",
|
||||||
|
padding: "24px 0",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Scan a checked-out asset ID to continue.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: 16,
|
||||||
|
padding: 16,
|
||||||
|
background: "var(--bg-2)",
|
||||||
|
border: "1px solid var(--line)",
|
||||||
|
borderRadius: "var(--r-md)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="serif" style={{ fontSize: 22, fontWeight: 500 }}>
|
||||||
|
{item.name}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 12, color: "var(--ink-3)" }}>
|
||||||
|
<span className="mono">{item.assetId}</span> ·{" "}
|
||||||
|
{helpers.brandName(data, item.brandId)} · checked out{" "}
|
||||||
|
{fmt.dateShort(item.checkoutDate, getStoredTimezone())}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: isBulk ? "1fr 1fr 1fr" : "1fr 1fr",
|
||||||
|
gap: 16,
|
||||||
|
marginTop: 24,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Field
|
||||||
|
label="Return to bin"
|
||||||
|
hint={item.prevBinId ? `Was in ${data.bins.find((b) => b.id === item.prevBinId)?.name ?? "unknown"} before checkout` : undefined}
|
||||||
|
>
|
||||||
|
<Select value={binId} onChange={(e) => setBinId(e.target.value)}>
|
||||||
|
{data.bins.map((b) => (
|
||||||
|
<option key={b.id} value={b.id}>
|
||||||
|
{b.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label="Date">
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
value={date}
|
||||||
|
onChange={(e) => setDate(e.target.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
{isBulk && (
|
||||||
|
<Field
|
||||||
|
label={`Weight now (${cfg?.unit ?? "g"})`}
|
||||||
|
hint={`Last audit: ${rem.toFixed(2)} ${cfg?.unit ?? "g"}`}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
min="0"
|
||||||
|
value={remaining}
|
||||||
|
onChange={(e) => setRemaining(e.target.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div style={{ marginTop: 14, fontSize: 12, color: "var(--terracotta)" }}>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ModalFooter>
|
||||||
|
<div />
|
||||||
|
<div style={{ display: "flex", gap: 8 }}>
|
||||||
|
<Btn variant="ghost" onClick={onClose}>
|
||||||
|
Cancel
|
||||||
|
</Btn>
|
||||||
|
<Btn
|
||||||
|
variant="primary"
|
||||||
|
icon="check"
|
||||||
|
disabled={checkin.isPending || !item || !binId}
|
||||||
|
onClick={() => checkin.mutate()}
|
||||||
|
>
|
||||||
|
{checkin.isPending ? "Saving…" : "Check in"}
|
||||||
|
</Btn>
|
||||||
|
</div>
|
||||||
|
</ModalFooter>
|
||||||
|
</div>
|
||||||
|
</ModalBackdrop>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import type { Bootstrap, Item } from "../../types.js";
|
||||||
|
import { helpers, enrichItems } from "../../types.js";
|
||||||
|
import { getToday, getStoredTimezone } from "../../tz.js";
|
||||||
|
import { fmt } from "../../format.js";
|
||||||
|
import { api } from "../../api.js";
|
||||||
|
import { Btn, Field, Icon, Input } from "../primitives/index.js";
|
||||||
|
import { ScanField, type ScanResult } from "../ScanField.js";
|
||||||
|
import { ModalBackdrop, ModalHeader, ModalFooter } from "./ModalChrome.js";
|
||||||
|
import { useToast } from "../Toast.js";
|
||||||
|
|
||||||
|
export function CheckoutFlow({
|
||||||
|
data,
|
||||||
|
onClose,
|
||||||
|
item: initialItem,
|
||||||
|
}: {
|
||||||
|
data: Bootstrap;
|
||||||
|
onClose: () => void;
|
||||||
|
item: Item | null;
|
||||||
|
}) {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const allItems = enrichItems(data);
|
||||||
|
const active = allItems.filter((i) => i.status === "active");
|
||||||
|
const [itemId, setItemId] = useState(initialItem?.id ?? "");
|
||||||
|
const [date, setDate] = useState(getToday(getStoredTimezone()));
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const item = allItems.find((i) => i.id === itemId);
|
||||||
|
|
||||||
|
useEffect(() => { setError(null); }, [itemId]);
|
||||||
|
|
||||||
|
const checkout = useMutation({
|
||||||
|
mutationFn: () => api.checkoutInventoryItem(itemId, { date }),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["bootstrap"] });
|
||||||
|
const undoItemId = itemId;
|
||||||
|
const undoBinId = item?.binId ?? data.bins[0]?.id ?? "";
|
||||||
|
toast(`Checked out ${item?.name ?? "item"}`, "success", {
|
||||||
|
label: "Undo",
|
||||||
|
onClick: () => {
|
||||||
|
api.reactivateInventoryItem(undoItemId, { binId: undoBinId }).then(() => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["bootstrap"] });
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
onClose();
|
||||||
|
},
|
||||||
|
onError: (e: Error) => setError(e.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleScan = (result: ScanResult) => {
|
||||||
|
if (result.kind === "item") {
|
||||||
|
setItemId(result.item.id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const bin = item ? data.bins.find((b) => b.id === item.binId) : undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ModalBackdrop onClose={onClose}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: "min(720px, 96vw)",
|
||||||
|
margin: "40px 20px",
|
||||||
|
background: "var(--bg)",
|
||||||
|
border: "1px solid var(--line)",
|
||||||
|
borderRadius: "var(--r-lg)",
|
||||||
|
boxShadow: "var(--shadow-lg)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ModalHeader title="Check out" eyebrow="" onClose={onClose} />
|
||||||
|
|
||||||
|
<div style={{ padding: 32 }}>
|
||||||
|
<ScanField
|
||||||
|
items={active}
|
||||||
|
products={[]}
|
||||||
|
matchedLabel={item ? `${item.assetId} · ${item.name}` : null}
|
||||||
|
onMatch={handleScan}
|
||||||
|
mode="assetId"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{!item ? (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: 24,
|
||||||
|
textAlign: "center",
|
||||||
|
color: "var(--ink-3)",
|
||||||
|
fontSize: 13,
|
||||||
|
fontStyle: "italic",
|
||||||
|
padding: "24px 0",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Scan an asset ID to continue.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: 16,
|
||||||
|
padding: 16,
|
||||||
|
background: "var(--bg-2)",
|
||||||
|
border: "1px solid var(--line)",
|
||||||
|
borderRadius: "var(--r-md)",
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div className="serif" style={{ fontSize: 22, fontWeight: 500 }}>
|
||||||
|
{item.name}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 12, color: "var(--ink-3)" }}>
|
||||||
|
<span className="mono">{item.assetId}</span> ·{" "}
|
||||||
|
{helpers.brandName(data, item.brandId)} · {bin?.name ?? "no bin"} ·
|
||||||
|
purchased {fmt.dateShort(item.purchaseDate, getStoredTimezone())}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ textAlign: "right" }}>
|
||||||
|
<Icon name="pocket" size={28} color="var(--ink-3)" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginTop: 24, maxWidth: 240 }}>
|
||||||
|
<Field label="Date">
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
value={date}
|
||||||
|
onChange={(e) => setDate(e.target.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div style={{ marginTop: 14, fontSize: 12, color: "var(--terracotta)" }}>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ModalFooter>
|
||||||
|
<div />
|
||||||
|
<div style={{ display: "flex", gap: 8 }}>
|
||||||
|
<Btn variant="ghost" onClick={onClose}>
|
||||||
|
Cancel
|
||||||
|
</Btn>
|
||||||
|
<Btn
|
||||||
|
variant="primary"
|
||||||
|
icon="pocket"
|
||||||
|
disabled={checkout.isPending || !item}
|
||||||
|
onClick={() => checkout.mutate()}
|
||||||
|
>
|
||||||
|
{checkout.isPending ? "Saving…" : "Check out"}
|
||||||
|
</Btn>
|
||||||
|
</div>
|
||||||
|
</ModalFooter>
|
||||||
|
</div>
|
||||||
|
</ModalBackdrop>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
import { useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import type { Bootstrap, Item } from "../../types.js";
|
import type { Bootstrap, Item } from "../../types.js";
|
||||||
import { helpers, TODAY_STR, enrichItems } from "../../types.js";
|
import { helpers, enrichItems } from "../../types.js";
|
||||||
import { remainingShort } from "../../stats.js";
|
import { getToday, getStoredTimezone } from "../../tz.js";
|
||||||
import { fmt } from "../../format.js";
|
import { fmt } from "../../format.js";
|
||||||
import { api } from "../../api.js";
|
import { api } from "../../api.js";
|
||||||
import { Btn, Field, Icon, Input, Select, Textarea } from "../primitives/index.js";
|
import { Btn, Field, Icon, Input, Textarea } from "../primitives/index.js";
|
||||||
import { ScanField, type ScanResult } from "../ScanField.js";
|
import { ScanField, type ScanResult } from "../ScanField.js";
|
||||||
import { ModalBackdrop, ModalHeader, ModalFooter } from "./ModalChrome.js";
|
import { ModalBackdrop, ModalHeader, ModalFooter } from "./ModalChrome.js";
|
||||||
import { useToast } from "../Toast.js";
|
import { useToast } from "../Toast.js";
|
||||||
@@ -22,20 +22,33 @@ export function ConsumeFlow({
|
|||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const allItems = enrichItems(data);
|
const allItems = enrichItems(data);
|
||||||
const active = allItems.filter((i) => i.status === "active");
|
const active = allItems.filter((i) => i.status === "active" || i.status === "checked-out");
|
||||||
const [itemId, setItemId] = useState(initialItem?.id ?? active[0]?.id ?? "");
|
const [itemId, setItemId] = useState(initialItem?.id ?? "");
|
||||||
const [rating, setRating] = useState(4);
|
const [rating, setRating] = useState<number | null>(null);
|
||||||
const [notes, setNotes] = useState("");
|
const [notes, setNotes] = useState("");
|
||||||
const [date, setDate] = useState(TODAY_STR);
|
const [date, setDate] = useState(getToday(getStoredTimezone()));
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [confirming, setConfirming] = useState(false);
|
||||||
|
|
||||||
const item = allItems.find((i) => i.id === itemId);
|
const item = allItems.find((i) => i.id === itemId);
|
||||||
|
|
||||||
|
useEffect(() => { setError(null); setConfirming(false); }, [itemId]);
|
||||||
|
|
||||||
const finish = useMutation({
|
const finish = useMutation({
|
||||||
mutationFn: () => api.finishInventoryItem(itemId, { date, rating, notes }),
|
mutationFn: () => api.finishInventoryItem(itemId, { date, rating: rating ?? undefined, notes }),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
qc.invalidateQueries({ queryKey: ["bootstrap"] });
|
qc.invalidateQueries({ queryKey: ["bootstrap"] });
|
||||||
toast(`Marked ${item?.name ?? "item"} as consumed — ${rating}/5 stars`);
|
const ratingStr = rating != null ? ` — ${rating}/5 stars` : "";
|
||||||
|
const undoItemId = itemId;
|
||||||
|
const undoBinId = item?.binId ?? data.bins[0]?.id ?? "";
|
||||||
|
toast(`Marked ${item?.name ?? "item"} as consumed${ratingStr}`, "success", {
|
||||||
|
label: "Undo",
|
||||||
|
onClick: () => {
|
||||||
|
api.reactivateInventoryItem(undoItemId, { binId: undoBinId }).then(() => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["bootstrap"] });
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
onClose();
|
onClose();
|
||||||
},
|
},
|
||||||
onError: (e: Error) => setError(e.message),
|
onError: (e: Error) => setError(e.message),
|
||||||
@@ -44,17 +57,11 @@ export function ConsumeFlow({
|
|||||||
const handleScan = (result: ScanResult) => {
|
const handleScan = (result: ScanResult) => {
|
||||||
if (result.kind === "item") {
|
if (result.kind === "item") {
|
||||||
setItemId(result.item.id);
|
setItemId(result.item.id);
|
||||||
} else {
|
|
||||||
const candidate = active
|
|
||||||
.filter((i) => i.productId === result.product.id)
|
|
||||||
.sort((a, b) => +new Date(b.purchaseDate) - +new Date(a.purchaseDate))[0];
|
|
||||||
if (candidate) setItemId(candidate.id);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!item) return null;
|
const bin = item ? data.bins.find((b) => b.id === item.binId) : undefined;
|
||||||
const bin = data.bins.find((b) => b.id === item.binId);
|
const lifespan = item ? Math.round((+new Date(date) - +new Date(item.purchaseDate)) / 86_400_000) : 0;
|
||||||
const lifespan = Math.round((+new Date(date) - +new Date(item.purchaseDate)) / 86_400_000);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ModalBackdrop onClose={onClose}>
|
<ModalBackdrop onClose={onClose}>
|
||||||
@@ -68,106 +75,118 @@ export function ConsumeFlow({
|
|||||||
boxShadow: "var(--shadow-lg)",
|
boxShadow: "var(--shadow-lg)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ModalHeader title="Mark as consumed" eyebrow="Archive · used up" onClose={onClose} />
|
<ModalHeader title="Mark as consumed" eyebrow="" onClose={onClose} />
|
||||||
|
|
||||||
<div style={{ padding: 32 }}>
|
<div style={{ padding: 32 }}>
|
||||||
<ScanField
|
<ScanField
|
||||||
items={active}
|
items={active}
|
||||||
products={data.products}
|
products={[]}
|
||||||
matchedLabel={item ? `${item.assetId} · ${item.name}` : null}
|
matchedLabel={item ? `${item.assetId} · ${item.name}` : null}
|
||||||
onMatch={handleScan}
|
onMatch={handleScan}
|
||||||
|
mode="assetId"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div style={{ marginTop: 16 }}>
|
{!item ? (
|
||||||
<Field label="Or pick from list">
|
<div style={{ marginTop: 24, textAlign: "center", color: "var(--ink-3)", fontSize: 13, fontStyle: "italic", padding: "24px 0" }}>
|
||||||
<Select value={itemId} onChange={(e) => setItemId(e.target.value)}>
|
Scan an asset ID to continue.
|
||||||
{active.map((i) => (
|
|
||||||
<option key={i.id} value={i.id}>
|
|
||||||
{i.assetId} · {i.name} — {helpers.brandName(data, i.brandId)} ({remainingShort(i)} left)
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
</Field>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
marginTop: 16,
|
|
||||||
padding: 16,
|
|
||||||
background: "var(--bg-2)",
|
|
||||||
border: "1px solid var(--line)",
|
|
||||||
borderRadius: "var(--r-md)",
|
|
||||||
display: "flex",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
alignItems: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<div className="serif" style={{ fontSize: 22, fontWeight: 500 }}>
|
|
||||||
{item.name}
|
|
||||||
</div>
|
|
||||||
<div style={{ fontSize: 12, color: "var(--ink-3)" }}>
|
|
||||||
<span className="mono">{item.assetId}</span> · {helpers.brandName(data, item.brandId)} · {bin?.name} · purchased{" "}
|
|
||||||
{fmt.dateShort(item.purchaseDate)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div style={{ textAlign: "right" }}>
|
) : (
|
||||||
<div className="mono" style={{ fontSize: 11, color: "var(--ink-3)" }}>LASTED</div>
|
<>
|
||||||
<div className="serif" style={{ fontSize: 24 }}>{lifespan} days</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 16, marginTop: 24 }}>
|
|
||||||
<Field label="Date finished">
|
|
||||||
<Input type="date" value={date} onChange={(e) => setDate(e.target.value)} />
|
|
||||||
</Field>
|
|
||||||
<Field label="Rating">
|
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: "flex",
|
marginTop: 16,
|
||||||
gap: 4,
|
padding: 16,
|
||||||
alignItems: "center",
|
background: "var(--bg-2)",
|
||||||
padding: "10px 12px",
|
|
||||||
background: "var(--bg)",
|
|
||||||
border: "1px solid var(--line)",
|
border: "1px solid var(--line)",
|
||||||
borderRadius: "var(--r-md)",
|
borderRadius: "var(--r-md)",
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{[1, 2, 3, 4, 5].map((n) => (
|
<div>
|
||||||
<button
|
<div className="serif" style={{ fontSize: 22, fontWeight: 500 }}>
|
||||||
key={n}
|
{item.name}
|
||||||
onClick={() => setRating(n)}
|
</div>
|
||||||
style={{ border: "none", background: "transparent", cursor: "pointer", padding: 2 }}
|
<div style={{ fontSize: 12, color: "var(--ink-3)" }}>
|
||||||
>
|
<span className="mono">{item.assetId}</span> · {helpers.brandName(data, item.brandId)} · {bin?.name} · purchased{" "}
|
||||||
<Icon
|
{fmt.dateShort(item.purchaseDate, getStoredTimezone())}
|
||||||
name="star"
|
</div>
|
||||||
size={20}
|
</div>
|
||||||
color={n <= rating ? "var(--amber)" : "var(--ink-4)"}
|
<div style={{ textAlign: "right" }}>
|
||||||
/>
|
<div className="mono" style={{ fontSize: 11, color: "var(--ink-3)" }}>LASTED</div>
|
||||||
</button>
|
<div className="serif" style={{ fontSize: 24 }}>{lifespan} days</div>
|
||||||
))}
|
</div>
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
marginLeft: "auto",
|
|
||||||
fontSize: 12,
|
|
||||||
color: "var(--ink-3)",
|
|
||||||
fontFamily: "var(--mono)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{rating}/5
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</Field>
|
|
||||||
</div>
|
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 16, marginTop: 24 }}>
|
||||||
<div style={{ marginTop: 16 }}>
|
<Field label="Date finished">
|
||||||
<Field label="Final notes" hint="Flavor, effects, would you rebuy">
|
<Input type="date" value={date} onChange={(e) => setDate(e.target.value)} />
|
||||||
<Textarea
|
</Field>
|
||||||
value={notes}
|
<Field label="Rating" hint="Optional — click to rate, click again to clear">
|
||||||
onChange={(e) => setNotes(e.target.value)}
|
<div
|
||||||
placeholder="What stood out?"
|
style={{
|
||||||
/>
|
display: "flex",
|
||||||
</Field>
|
gap: 4,
|
||||||
</div>
|
alignItems: "center",
|
||||||
|
padding: "10px 12px",
|
||||||
|
background: "var(--bg)",
|
||||||
|
border: "1px solid var(--line)",
|
||||||
|
borderRadius: "var(--r-md)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{[1, 2, 3, 4, 5].map((n) => (
|
||||||
|
<button
|
||||||
|
key={n}
|
||||||
|
onClick={() => setRating(rating === n ? null : n)}
|
||||||
|
style={{ border: "none", background: "transparent", cursor: "pointer", padding: 2 }}
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
name="star"
|
||||||
|
size={20}
|
||||||
|
color={rating != null && n <= rating ? "var(--amber)" : "var(--ink-4)"}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
marginLeft: "auto",
|
||||||
|
fontSize: 12,
|
||||||
|
color: "var(--ink-3)",
|
||||||
|
fontFamily: "var(--mono)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{rating != null ? `${rating}/5` : "—"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: 16 }}>
|
||||||
|
<Field label="Final notes" hint="Flavor, effects, would you rebuy">
|
||||||
|
<Textarea
|
||||||
|
value={notes}
|
||||||
|
onChange={(e) => setNotes(e.target.value)}
|
||||||
|
placeholder="What stood out?"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{confirming && item && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: 20,
|
||||||
|
padding: 14,
|
||||||
|
background: "var(--amber-soft)",
|
||||||
|
border: "1px solid var(--amber)",
|
||||||
|
borderRadius: "var(--r-md)",
|
||||||
|
fontSize: 13,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Mark <strong>{item.name}</strong> (<span className="mono">{item.assetId}</span>) as consumed? This cannot be undone.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div style={{ marginTop: 14, fontSize: 12, color: "var(--terracotta)" }}>{error}</div>
|
<div style={{ marginTop: 14, fontSize: 12, color: "var(--terracotta)" }}>{error}</div>
|
||||||
@@ -175,17 +194,33 @@ export function ConsumeFlow({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ModalFooter>
|
<ModalFooter>
|
||||||
<div />
|
<div style={{ fontSize: 12, color: "var(--ink-3)" }}>
|
||||||
|
{item ? `Lasted ${lifespan} day${lifespan === 1 ? "" : "s"} from purchase.` : ""}
|
||||||
|
</div>
|
||||||
<div style={{ display: "flex", gap: 8 }}>
|
<div style={{ display: "flex", gap: 8 }}>
|
||||||
<Btn variant="ghost" onClick={onClose}>Cancel</Btn>
|
<Btn variant="ghost" onClick={onClose}>Cancel</Btn>
|
||||||
<Btn
|
{confirming ? (
|
||||||
variant="primary"
|
<>
|
||||||
icon="check"
|
<Btn variant="ghost" onClick={() => setConfirming(false)}>Back</Btn>
|
||||||
disabled={finish.isPending}
|
<Btn
|
||||||
onClick={() => finish.mutate()}
|
variant="danger"
|
||||||
>
|
icon="check"
|
||||||
{finish.isPending ? "Saving…" : "Mark consumed"}
|
disabled={finish.isPending}
|
||||||
</Btn>
|
onClick={() => finish.mutate()}
|
||||||
|
>
|
||||||
|
{finish.isPending ? "Saving…" : "Confirm"}
|
||||||
|
</Btn>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Btn
|
||||||
|
variant="primary"
|
||||||
|
icon="check"
|
||||||
|
disabled={!item}
|
||||||
|
onClick={() => setConfirming(true)}
|
||||||
|
>
|
||||||
|
{error ? "Try again" : "Mark consumed"}
|
||||||
|
</Btn>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</ModalFooter>
|
</ModalFooter>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useState } from "react";
|
|||||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import type { Bootstrap, Item } from "../../types.js";
|
import type { Bootstrap, Item } from "../../types.js";
|
||||||
import { TYPES } from "../../types.js";
|
import { TYPES } from "../../types.js";
|
||||||
import { fmt, TYPE_GLYPHS } from "../../format.js";
|
import { fmt } from "../../format.js";
|
||||||
import { api } from "../../api.js";
|
import { api } from "../../api.js";
|
||||||
import { Btn, Field, Input, Select } from "../primitives/index.js";
|
import { Btn, Field, Input, Select } from "../primitives/index.js";
|
||||||
import { ModalBackdrop, ModalHeader, ModalFooter } from "./ModalChrome.js";
|
import { ModalBackdrop, ModalHeader, ModalFooter } from "./ModalChrome.js";
|
||||||
@@ -38,6 +38,9 @@ export function EditInventoryFlow({
|
|||||||
const [newShopLocation, setNewShopLocation] = useState("");
|
const [newShopLocation, setNewShopLocation] = useState("");
|
||||||
const [newBinName, setNewBinName] = useState("");
|
const [newBinName, setNewBinName] = useState("");
|
||||||
const [newBinCapacity, setNewBinCapacity] = useState(10);
|
const [newBinCapacity, setNewBinCapacity] = useState(10);
|
||||||
|
const [containerWeight, setContainerWeight] = useState(
|
||||||
|
item.containerWeight != null ? String(item.containerWeight) : "",
|
||||||
|
);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const update = <K extends keyof typeof form>(k: K, v: (typeof form)[K]) =>
|
const update = <K extends keyof typeof form>(k: K, v: (typeof form)[K]) =>
|
||||||
@@ -69,6 +72,7 @@ export function EditInventoryFlow({
|
|||||||
shopId,
|
shopId,
|
||||||
binId,
|
binId,
|
||||||
weight: isDiscrete ? undefined : form.weight,
|
weight: isDiscrete ? undefined : form.weight,
|
||||||
|
containerWeight: !isDiscrete ? (containerWeight !== "" ? parseFloat(containerWeight) : null) : undefined,
|
||||||
unitWeight: isDiscrete ? form.unitWeight : undefined,
|
unitWeight: isDiscrete ? form.unitWeight : undefined,
|
||||||
price: form.price,
|
price: form.price,
|
||||||
thc: form.thc,
|
thc: form.thc,
|
||||||
@@ -106,30 +110,6 @@ export function EditInventoryFlow({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div style={{ padding: 32 }}>
|
<div style={{ padding: 32 }}>
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
marginBottom: 24,
|
|
||||||
padding: "10px 14px",
|
|
||||||
background: "var(--bg-2)",
|
|
||||||
border: "1px solid var(--line)",
|
|
||||||
borderRadius: "var(--r-sm)",
|
|
||||||
fontSize: 12,
|
|
||||||
color: "var(--ink-3)",
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: 8,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span style={{ fontFamily: "var(--serif)", fontSize: 16 }}>
|
|
||||||
{TYPE_GLYPHS[item.type]}
|
|
||||||
</span>
|
|
||||||
<span>
|
|
||||||
Editing this physical instance of{" "}
|
|
||||||
<strong style={{ color: "var(--ink-2)" }}>{item.name}</strong> ({item.type} ·{" "}
|
|
||||||
{item.kind}). To change the product (SKU, name, type), edit the catalog entry.
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="smallcaps" style={{ color: "var(--ink-3)", marginBottom: 16 }}>
|
<div className="smallcaps" style={{ color: "var(--ink-3)", marginBottom: 16 }}>
|
||||||
Source
|
Source
|
||||||
</div>
|
</div>
|
||||||
@@ -215,7 +195,7 @@ export function EditInventoryFlow({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{isDiscrete ? (
|
{isDiscrete ? (
|
||||||
<Field label="Unit weight (g)" span={2} hint="Weight of one unit — for grams stats">
|
<Field label={`Unit weight (${cfg?.weightUnit ?? "g"})`} span={2} hint="Weight of one unit — for grams stats">
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
step="0.1"
|
step="0.1"
|
||||||
@@ -259,38 +239,63 @@ export function EditInventoryFlow({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div
|
{!isDiscrete && cfg?.weighable && (
|
||||||
className="smallcaps"
|
<div style={{ marginTop: 16 }}>
|
||||||
style={{ color: "var(--ink-3)", margin: "28px 0 16px" }}
|
<Field label="Container weight (g)" hint="Total weight of jar + lid + product on a scale. Leave blank to clear.">
|
||||||
>
|
<Input
|
||||||
Cannabinoid profile
|
type="number"
|
||||||
</div>
|
step="0.01"
|
||||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(3, 1fr)", gap: 16 }}>
|
placeholder="—"
|
||||||
<Field label="THC %">
|
value={containerWeight}
|
||||||
<Input
|
onChange={(e) => setContainerWeight(e.target.value)}
|
||||||
type="number"
|
/>
|
||||||
step="0.1"
|
</Field>
|
||||||
value={form.thc}
|
{containerWeight !== "" && form.weight > 0 && (
|
||||||
onChange={(e) => update("thc", +e.target.value)}
|
<div style={{ marginTop: 6, fontSize: 12, color: parseFloat(containerWeight) <= form.weight ? "var(--terracotta)" : "var(--ink-3)" }}>
|
||||||
/>
|
{parseFloat(containerWeight) > form.weight
|
||||||
</Field>
|
? `Tare (empty jar): ${(parseFloat(containerWeight) - form.weight).toFixed(2)}g`
|
||||||
<Field label="CBD %">
|
: "Container weight must be greater than product weight"}
|
||||||
<Input
|
</div>
|
||||||
type="number"
|
)}
|
||||||
step="0.1"
|
</div>
|
||||||
value={form.cbd}
|
)}
|
||||||
onChange={(e) => update("cbd", +e.target.value)}
|
|
||||||
/>
|
{cfg?.showCannabinoidPct !== false && (
|
||||||
</Field>
|
<>
|
||||||
<Field label="Total cannabinoids %">
|
<div
|
||||||
<Input
|
className="smallcaps"
|
||||||
type="number"
|
style={{ color: "var(--ink-3)", margin: "28px 0 16px" }}
|
||||||
step="0.1"
|
>
|
||||||
value={form.totalCannabinoids}
|
Cannabinoid profile
|
||||||
onChange={(e) => update("totalCannabinoids", +e.target.value)}
|
</div>
|
||||||
/>
|
<div style={{ display: "grid", gridTemplateColumns: "repeat(3, 1fr)", gap: 16 }}>
|
||||||
</Field>
|
<Field label="THC %">
|
||||||
</div>
|
<Input
|
||||||
|
type="number"
|
||||||
|
step="0.1"
|
||||||
|
value={form.thc}
|
||||||
|
onChange={(e) => update("thc", +e.target.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="CBD %">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
step="0.1"
|
||||||
|
value={form.cbd}
|
||||||
|
onChange={(e) => update("cbd", +e.target.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Total cannabinoids %">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
step="0.1"
|
||||||
|
value={form.totalCannabinoids}
|
||||||
|
onChange={(e) => update("totalCannabinoids", +e.target.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{item.audits.length > 0 && (
|
{item.audits.length > 0 && (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import { useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import type { Bootstrap, Item } from "../../types.js";
|
import type { Bootstrap, Item } from "../../types.js";
|
||||||
import { helpers, TODAY_STR, enrichItems } from "../../types.js";
|
import { helpers, enrichItems } from "../../types.js";
|
||||||
|
import { getToday, getStoredTimezone } from "../../tz.js";
|
||||||
import { remainingShort } from "../../stats.js";
|
import { remainingShort } from "../../stats.js";
|
||||||
|
import { fmt } from "../../format.js";
|
||||||
import { api } from "../../api.js";
|
import { api } from "../../api.js";
|
||||||
import { Btn, Field, Input, Select, Textarea } from "../primitives/index.js";
|
import { Btn, Field, Input, Select, Textarea } from "../primitives/index.js";
|
||||||
|
import { ScanField, type ScanResult } from "../ScanField.js";
|
||||||
import { ModalBackdrop, ModalHeader, ModalFooter } from "./ModalChrome.js";
|
import { ModalBackdrop, ModalHeader, ModalFooter } from "./ModalChrome.js";
|
||||||
import { useToast } from "../Toast.js";
|
import { useToast } from "../Toast.js";
|
||||||
|
|
||||||
@@ -28,25 +31,43 @@ export function MarkGoneFlow({
|
|||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const allItems = enrichItems(data);
|
const allItems = enrichItems(data);
|
||||||
const active = allItems.filter((i) => i.status === "active");
|
const active = allItems.filter((i) => i.status === "active" || i.status === "checked-out");
|
||||||
const [itemId, setItemId] = useState(initialItem?.id ?? active[0]?.id ?? "");
|
const [itemId, setItemId] = useState(initialItem?.id ?? "");
|
||||||
const [reason, setReason] = useState("lost");
|
const [reason, setReason] = useState("lost");
|
||||||
const [notes, setNotes] = useState("");
|
const [notes, setNotes] = useState("");
|
||||||
const [date, setDate] = useState(TODAY_STR);
|
const [date, setDate] = useState(getToday(getStoredTimezone()));
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [confirming, setConfirming] = useState(false);
|
||||||
const item = allItems.find((i) => i.id === itemId);
|
const item = allItems.find((i) => i.id === itemId);
|
||||||
|
|
||||||
|
useEffect(() => { setError(null); setConfirming(false); }, [itemId]);
|
||||||
|
|
||||||
const mark = useMutation({
|
const mark = useMutation({
|
||||||
mutationFn: () => api.markInventoryItemGone(itemId, { date, reason, notes }),
|
mutationFn: () => api.markInventoryItemGone(itemId, { date, reason, notes }),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
qc.invalidateQueries({ queryKey: ["bootstrap"] });
|
qc.invalidateQueries({ queryKey: ["bootstrap"] });
|
||||||
toast(`Marked ${item?.name ?? "item"} as gone`);
|
const undoItemId = itemId;
|
||||||
|
const undoBinId = item?.binId ?? data.bins[0]?.id ?? "";
|
||||||
|
toast(`Marked ${item?.name ?? "item"} as gone`, "success", {
|
||||||
|
label: "Undo",
|
||||||
|
onClick: () => {
|
||||||
|
api.reactivateInventoryItem(undoItemId, { binId: undoBinId }).then(() => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["bootstrap"] });
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
onClose();
|
onClose();
|
||||||
},
|
},
|
||||||
onError: (e: Error) => setError(e.message),
|
onError: (e: Error) => setError(e.message),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!item) return null;
|
const handleScan = (result: ScanResult) => {
|
||||||
|
if (result.kind === "item") {
|
||||||
|
setItemId(result.item.id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const bin = item ? data.bins.find((b) => b.id === item.binId) : undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ModalBackdrop onClose={onClose}>
|
<ModalBackdrop onClose={onClose}>
|
||||||
@@ -82,38 +103,91 @@ export function MarkGoneFlow({
|
|||||||
<strong>spend</strong> but not as <strong>consumption</strong>, so daily averages stay accurate.
|
<strong>spend</strong> but not as <strong>consumption</strong>, so daily averages stay accurate.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Field label="Inventory item">
|
<ScanField
|
||||||
<Select value={itemId} onChange={(e) => setItemId(e.target.value)}>
|
items={active}
|
||||||
{active.map((i) => (
|
products={[]}
|
||||||
<option key={i.id} value={i.id}>
|
matchedLabel={item ? `${item.assetId} · ${item.name}` : null}
|
||||||
{i.assetId} · {i.name} — {helpers.brandName(data, i.brandId)} ({remainingShort(i)} left)
|
onMatch={handleScan}
|
||||||
</option>
|
mode="assetId"
|
||||||
))}
|
/>
|
||||||
</Select>
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 16, marginTop: 16 }}>
|
{active.length === 0 ? (
|
||||||
<Field label="Reason">
|
<div style={{ marginTop: 24, textAlign: "center", color: "var(--ink-3)", fontSize: 13, fontStyle: "italic", padding: "24px 0" }}>
|
||||||
<Select value={reason} onChange={(e) => setReason(e.target.value)}>
|
No active items to mark as gone.
|
||||||
{REASONS.map(([k, l]) => (
|
</div>
|
||||||
<option key={k} value={k}>{l}</option>
|
) : !item ? (
|
||||||
))}
|
<div style={{ marginTop: 24, textAlign: "center", color: "var(--ink-3)", fontSize: 13, fontStyle: "italic", padding: "24px 0" }}>
|
||||||
</Select>
|
Scan or type an asset ID to continue.
|
||||||
</Field>
|
</div>
|
||||||
<Field label="Date">
|
) : (
|
||||||
<Input type="date" value={date} onChange={(e) => setDate(e.target.value)} />
|
<>
|
||||||
</Field>
|
<div
|
||||||
</div>
|
style={{
|
||||||
|
marginTop: 16,
|
||||||
|
padding: 16,
|
||||||
|
background: "var(--bg-2)",
|
||||||
|
border: "1px solid var(--line)",
|
||||||
|
borderRadius: "var(--r-md)",
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div className="serif" style={{ fontSize: 22, fontWeight: 500 }}>
|
||||||
|
{item.name}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 12, color: "var(--ink-3)" }}>
|
||||||
|
<span className="mono">{item.assetId}</span> · {helpers.brandName(data, item.brandId)} · {bin?.name ?? "no bin"} · {remainingShort(item)} left
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ textAlign: "right" }}>
|
||||||
|
<div className="mono" style={{ fontSize: 11, color: "var(--ink-3)" }}>PURCHASED</div>
|
||||||
|
<div className="serif" style={{ fontSize: 18 }}>
|
||||||
|
{fmt.dateShort(item.purchaseDate, getStoredTimezone())}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div style={{ marginTop: 16 }}>
|
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 16, marginTop: 24 }}>
|
||||||
<Field label="Notes (optional)" hint="What happened">
|
<Field label="Reason">
|
||||||
<Textarea
|
<Select value={reason} onChange={(e) => setReason(e.target.value)}>
|
||||||
value={notes}
|
{REASONS.map(([k, l]) => (
|
||||||
onChange={(e) => setNotes(e.target.value)}
|
<option key={k} value={k}>{l}</option>
|
||||||
placeholder="e.g. Pack went through the wash"
|
))}
|
||||||
/>
|
</Select>
|
||||||
</Field>
|
</Field>
|
||||||
</div>
|
<Field label="Date">
|
||||||
|
<Input type="date" value={date} onChange={(e) => setDate(e.target.value)} />
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginTop: 16 }}>
|
||||||
|
<Field label="Notes (optional)" hint="What happened">
|
||||||
|
<Textarea
|
||||||
|
value={notes}
|
||||||
|
onChange={(e) => setNotes(e.target.value)}
|
||||||
|
placeholder="e.g. Pack went through the wash"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{confirming && item && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: 20,
|
||||||
|
padding: 14,
|
||||||
|
background: "var(--amber-soft)",
|
||||||
|
border: "1px solid var(--amber)",
|
||||||
|
borderRadius: "var(--r-md)",
|
||||||
|
fontSize: 13,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Mark <strong>{item.name}</strong> (<span className="mono">{item.assetId}</span>) as gone? This cannot be undone.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div style={{ marginTop: 14, fontSize: 12, color: "var(--terracotta)" }}>{error}</div>
|
<div style={{ marginTop: 14, fontSize: 12, color: "var(--terracotta)" }}>{error}</div>
|
||||||
@@ -124,9 +198,18 @@ export function MarkGoneFlow({
|
|||||||
<div />
|
<div />
|
||||||
<div style={{ display: "flex", gap: 8 }}>
|
<div style={{ display: "flex", gap: 8 }}>
|
||||||
<Btn variant="ghost" onClick={onClose}>Cancel</Btn>
|
<Btn variant="ghost" onClick={onClose}>Cancel</Btn>
|
||||||
<Btn variant="danger" icon="bin" disabled={mark.isPending} onClick={() => mark.mutate()}>
|
{confirming ? (
|
||||||
{mark.isPending ? "Saving…" : "Mark gone"}
|
<>
|
||||||
</Btn>
|
<Btn variant="ghost" onClick={() => setConfirming(false)}>Back</Btn>
|
||||||
|
<Btn variant="danger" icon="bin" disabled={mark.isPending} onClick={() => mark.mutate()}>
|
||||||
|
{mark.isPending ? "Saving…" : "Confirm"}
|
||||||
|
</Btn>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Btn variant="danger" icon="bin" disabled={!item} onClick={() => setConfirming(true)}>
|
||||||
|
Mark gone
|
||||||
|
</Btn>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</ModalFooter>
|
</ModalFooter>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import { useEffect, useRef } from "react";
|
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";
|
import { Btn } from "../primitives/index.js";
|
||||||
|
|
||||||
|
const ModalCloseCtx = createContext<(() => void) | null>(null);
|
||||||
|
|
||||||
export function ModalBackdrop({
|
export function ModalBackdrop({
|
||||||
children,
|
children,
|
||||||
onClose,
|
onClose,
|
||||||
@@ -10,6 +14,8 @@ 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 isMobile = useIsMobile();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
previousFocus.current = document.activeElement;
|
previousFocus.current = document.activeElement;
|
||||||
@@ -17,7 +23,7 @@ export function ModalBackdrop({
|
|||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
if (e.key === "Escape") {
|
if (e.key === "Escape") {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onClose();
|
triggerClose();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (e.key === "Tab" && backdropRef.current) {
|
if (e.key === "Tab" && backdropRef.current) {
|
||||||
@@ -50,7 +56,7 @@ export function ModalBackdrop({
|
|||||||
previousFocus.current.focus();
|
previousFocus.current.focus();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [onClose]);
|
}, [triggerClose]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -66,16 +72,33 @@ export function ModalBackdrop({
|
|||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
alignItems: "flex-start",
|
alignItems: "flex-start",
|
||||||
overflow: "auto",
|
overflow: "auto",
|
||||||
animation: "backdrop-in 200ms ease-out",
|
animation: closing ? "backdrop-out 180ms ease-in forwards" : "backdrop-in 200ms ease-out",
|
||||||
}}
|
}}
|
||||||
onClick={onClose}
|
onClick={triggerClose}
|
||||||
>
|
>
|
||||||
<div
|
<ModalCloseCtx.Provider value={triggerClose}>
|
||||||
onClick={(e) => e.stopPropagation()}
|
<div
|
||||||
style={{ width: "100%", display: "flex", justifyContent: "center", animation: "modal-in 200ms ease-out" }}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
style={isMobile ? {
|
||||||
{children}
|
position: "absolute",
|
||||||
</div>
|
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",
|
||||||
|
animation: closing ? "modal-out 180ms ease-in forwards" : "modal-in 200ms ease-out",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</ModalCloseCtx.Provider>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -91,10 +114,12 @@ export function ModalHeader({
|
|||||||
eyebrowColor?: string;
|
eyebrowColor?: string;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}) {
|
}) {
|
||||||
|
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",
|
||||||
@@ -107,26 +132,28 @@ 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>
|
||||||
<Btn variant="ghost" icon="close" onClick={onClose} />
|
<Btn variant="ghost" icon="close" onClick={animatedClose ?? onClose} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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}
|
||||||
|
|||||||
@@ -0,0 +1,338 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { api } from "../../api.js";
|
||||||
|
import type { Bootstrap, Product } from "../../types.js";
|
||||||
|
import { TYPES } from "../../types.js";
|
||||||
|
import { Btn, Field, Input, Select, Textarea } from "../primitives/index.js";
|
||||||
|
import { ModalBackdrop, ModalHeader, ModalFooter } from "./ModalChrome.js";
|
||||||
|
|
||||||
|
export function AddSkuModal({
|
||||||
|
data,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
data: Bootstrap;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const [sku, setSku] = useState("");
|
||||||
|
const [strainName, setStrainName] = useState("");
|
||||||
|
const [brandId, setBrandId] = useState<string>("");
|
||||||
|
const [typeId, setTypeId] = useState("Flower");
|
||||||
|
const [defaultThc, setDefaultThc] = useState("");
|
||||||
|
const [defaultCbd, setDefaultCbd] = useState("");
|
||||||
|
|
||||||
|
const selectedType = TYPES.find((t) => t.id === typeId) ?? TYPES[0]!;
|
||||||
|
|
||||||
|
const create = useMutation({
|
||||||
|
mutationFn: () =>
|
||||||
|
api.createProduct({
|
||||||
|
sku: sku.trim(),
|
||||||
|
strainName: strainName.trim(),
|
||||||
|
brandId: brandId || null,
|
||||||
|
type: typeId,
|
||||||
|
kind: selectedType.kind,
|
||||||
|
defaultThc: defaultThc ? parseFloat(defaultThc) : undefined,
|
||||||
|
defaultCbd: defaultCbd ? parseFloat(defaultCbd) : undefined,
|
||||||
|
defaultTotalCannabinoids:
|
||||||
|
defaultThc || defaultCbd
|
||||||
|
? (parseFloat(defaultThc) || 0) + (parseFloat(defaultCbd) || 0)
|
||||||
|
: undefined,
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["bootstrap"] });
|
||||||
|
onClose();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const matchingStrains = strainName.trim()
|
||||||
|
? data.strains.filter((s) =>
|
||||||
|
s.name.toLowerCase().includes(strainName.trim().toLowerCase()),
|
||||||
|
).slice(0, 5)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ModalBackdrop onClose={onClose}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: "min(560px, 96vw)",
|
||||||
|
margin: "40px 20px",
|
||||||
|
background: "var(--bg)",
|
||||||
|
border: "1px solid var(--line)",
|
||||||
|
borderRadius: "var(--r-lg)",
|
||||||
|
boxShadow: "var(--shadow-lg)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ModalHeader title="Add a SKU" eyebrow="Catalog" onClose={onClose} />
|
||||||
|
<div style={{ padding: 32, display: "grid", gap: 16 }}>
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 16 }}>
|
||||||
|
<Field label="SKU code">
|
||||||
|
<Input
|
||||||
|
autoFocus
|
||||||
|
value={sku}
|
||||||
|
onChange={(e) => setSku(e.target.value)}
|
||||||
|
placeholder="e.g. SKU-0042"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Type">
|
||||||
|
<Select value={typeId} onChange={(e) => setTypeId(e.target.value)}>
|
||||||
|
{TYPES.map((t) => (
|
||||||
|
<option key={t.id} value={t.id}>
|
||||||
|
{t.id} ({t.kind})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Field label="Strain name" hint={matchingStrains.length > 0 ? `Matching: ${matchingStrains.map((s) => s.name).join(", ")}` : undefined}>
|
||||||
|
<Input
|
||||||
|
value={strainName}
|
||||||
|
onChange={(e) => setStrainName(e.target.value)}
|
||||||
|
placeholder="e.g. Blue Dream"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label="Brand">
|
||||||
|
<Select value={brandId} onChange={(e) => setBrandId(e.target.value)}>
|
||||||
|
<option value="">None</option>
|
||||||
|
{data.brands.map((b) => (
|
||||||
|
<option key={b.id} value={b.id}>
|
||||||
|
{b.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
{selectedType.showCannabinoidPct && (
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 16 }}>
|
||||||
|
<Field label="Default THC %" hint="Optional">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
step="0.1"
|
||||||
|
min="0"
|
||||||
|
max="100"
|
||||||
|
value={defaultThc}
|
||||||
|
onChange={(e) => setDefaultThc(e.target.value)}
|
||||||
|
placeholder="e.g. 22.5"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Default CBD %" hint="Optional">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
step="0.1"
|
||||||
|
min="0"
|
||||||
|
max="100"
|
||||||
|
value={defaultCbd}
|
||||||
|
onChange={(e) => setDefaultCbd(e.target.value)}
|
||||||
|
placeholder="e.g. 0.5"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{create.isError && (
|
||||||
|
<div style={{ fontSize: 12, color: "var(--terracotta)" }}>
|
||||||
|
{String(create.error instanceof Error ? create.error.message : create.error)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<ModalFooter>
|
||||||
|
<div />
|
||||||
|
<div style={{ display: "flex", gap: 8 }}>
|
||||||
|
<Btn variant="ghost" onClick={onClose}>
|
||||||
|
Cancel
|
||||||
|
</Btn>
|
||||||
|
<Btn
|
||||||
|
variant="primary"
|
||||||
|
icon="check"
|
||||||
|
disabled={!sku.trim() || !strainName.trim() || create.isPending}
|
||||||
|
onClick={() => create.mutate()}
|
||||||
|
>
|
||||||
|
{create.isPending ? "Saving..." : "Add SKU"}
|
||||||
|
</Btn>
|
||||||
|
</div>
|
||||||
|
</ModalFooter>
|
||||||
|
</div>
|
||||||
|
</ModalBackdrop>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EditSkuModal({
|
||||||
|
data,
|
||||||
|
product,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
data: Bootstrap;
|
||||||
|
product: Product;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const strain = data.strains.find((s) => s.id === product.strainId);
|
||||||
|
|
||||||
|
const [skuValue, setSkuValue] = useState(product.sku);
|
||||||
|
const [strainName, setStrainName] = useState(strain?.name ?? "");
|
||||||
|
const [brandId, setBrandId] = useState(product.brandId ?? "");
|
||||||
|
const [typeId, setTypeId] = useState(product.type);
|
||||||
|
const [defaultThc, setDefaultThc] = useState(
|
||||||
|
strain?.defaultThc != null ? String(strain.defaultThc) : "",
|
||||||
|
);
|
||||||
|
const [defaultCbd, setDefaultCbd] = useState(
|
||||||
|
strain?.defaultCbd != null ? String(strain.defaultCbd) : "",
|
||||||
|
);
|
||||||
|
const [defaultTotal, setDefaultTotal] = useState(
|
||||||
|
strain?.defaultTotalCannabinoids != null ? String(strain.defaultTotalCannabinoids) : "",
|
||||||
|
);
|
||||||
|
const [notes, setNotes] = useState(strain?.notes ?? "");
|
||||||
|
|
||||||
|
const selectedType = TYPES.find((t) => t.id === typeId) ?? TYPES[0]!;
|
||||||
|
|
||||||
|
const updateProduct = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
await api.updateProduct(product.id, {
|
||||||
|
sku: skuValue.trim(),
|
||||||
|
type: typeId,
|
||||||
|
kind: selectedType.kind,
|
||||||
|
strainName: strainName.trim(),
|
||||||
|
brandId: brandId || null,
|
||||||
|
});
|
||||||
|
if (strain) {
|
||||||
|
await api.updateStrain(strain.id, {
|
||||||
|
name: strainName.trim(),
|
||||||
|
defaultThc: defaultThc ? parseFloat(defaultThc) : null,
|
||||||
|
defaultCbd: defaultCbd ? parseFloat(defaultCbd) : null,
|
||||||
|
defaultTotalCannabinoids: defaultTotal ? parseFloat(defaultTotal) : null,
|
||||||
|
notes: notes.trim() || null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["bootstrap"] });
|
||||||
|
onClose();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ModalBackdrop onClose={onClose}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: "min(560px, 96vw)",
|
||||||
|
margin: "40px 20px",
|
||||||
|
background: "var(--bg)",
|
||||||
|
border: "1px solid var(--line)",
|
||||||
|
borderRadius: "var(--r-lg)",
|
||||||
|
boxShadow: "var(--shadow-lg)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ModalHeader title="Edit SKU" eyebrow="SKU" onClose={onClose} />
|
||||||
|
<div style={{ padding: 32, display: "grid", gap: 16 }}>
|
||||||
|
<Field label="SKU code">
|
||||||
|
<Input
|
||||||
|
autoFocus
|
||||||
|
value={skuValue}
|
||||||
|
onChange={(e) => setSkuValue(e.target.value)}
|
||||||
|
placeholder="e.g. SKU-0042"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Strain name">
|
||||||
|
<Input
|
||||||
|
value={strainName}
|
||||||
|
onChange={(e) => setStrainName(e.target.value)}
|
||||||
|
placeholder="e.g. Blue Dream"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 16 }}>
|
||||||
|
<Field label="Brand">
|
||||||
|
<Select value={brandId} onChange={(e) => setBrandId(e.target.value)}>
|
||||||
|
<option value="">None</option>
|
||||||
|
{data.brands.map((b) => (
|
||||||
|
<option key={b.id} value={b.id}>
|
||||||
|
{b.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</Field>
|
||||||
|
<Field label="Type">
|
||||||
|
<Select value={typeId} onChange={(e) => setTypeId(e.target.value)}>
|
||||||
|
{TYPES.map((t) => (
|
||||||
|
<option key={t.id} value={t.id}>
|
||||||
|
{t.id} ({t.kind})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedType.showCannabinoidPct && (
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: 16 }}>
|
||||||
|
<Field label="Default THC %">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
step="0.1"
|
||||||
|
min="0"
|
||||||
|
max="100"
|
||||||
|
value={defaultThc}
|
||||||
|
onChange={(e) => setDefaultThc(e.target.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Default CBD %">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
step="0.1"
|
||||||
|
min="0"
|
||||||
|
max="100"
|
||||||
|
value={defaultCbd}
|
||||||
|
onChange={(e) => setDefaultCbd(e.target.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Default total %">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
step="0.1"
|
||||||
|
min="0"
|
||||||
|
max="100"
|
||||||
|
value={defaultTotal}
|
||||||
|
onChange={(e) => setDefaultTotal(e.target.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Field label="Strain notes">
|
||||||
|
<Textarea
|
||||||
|
value={notes}
|
||||||
|
onChange={(e) => setNotes(e.target.value)}
|
||||||
|
placeholder="Optional notes about this strain..."
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
{updateProduct.isError && (
|
||||||
|
<div style={{ fontSize: 12, color: "var(--terracotta)" }}>
|
||||||
|
{String(
|
||||||
|
updateProduct.error instanceof Error
|
||||||
|
? updateProduct.error.message
|
||||||
|
: updateProduct.error,
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<ModalFooter>
|
||||||
|
<div />
|
||||||
|
<div style={{ display: "flex", gap: 8 }}>
|
||||||
|
<Btn variant="ghost" onClick={onClose}>
|
||||||
|
Cancel
|
||||||
|
</Btn>
|
||||||
|
<Btn
|
||||||
|
variant="primary"
|
||||||
|
icon="check"
|
||||||
|
disabled={!skuValue.trim() || !strainName.trim() || updateProduct.isPending}
|
||||||
|
onClick={() => updateProduct.mutate()}
|
||||||
|
>
|
||||||
|
{updateProduct.isPending ? "Saving..." : "Save changes"}
|
||||||
|
</Btn>
|
||||||
|
</div>
|
||||||
|
</ModalFooter>
|
||||||
|
</div>
|
||||||
|
</ModalBackdrop>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,335 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import type { Bootstrap, Item } from "../../types.js";
|
||||||
|
import { TYPES, helpers, enrichItems } from "../../types.js";
|
||||||
|
import { getToday, getStoredTimezone } from "../../tz.js";
|
||||||
|
import { api } from "../../api.js";
|
||||||
|
import { Btn, Field, Input } from "../primitives/index.js";
|
||||||
|
import { ScanField, type ScanResult } from "../ScanField.js";
|
||||||
|
import { ModalBackdrop, ModalHeader, ModalFooter } from "./ModalChrome.js";
|
||||||
|
import { useToast } from "../Toast.js";
|
||||||
|
|
||||||
|
const MODE_LABELS: Record<string, { title: string; desc: string }> = {
|
||||||
|
weigh: {
|
||||||
|
title: "Reweigh on a scale",
|
||||||
|
desc: "Place the jar (minus tare) and record the new weight.",
|
||||||
|
},
|
||||||
|
estimate: {
|
||||||
|
title: "Visual estimate",
|
||||||
|
desc: "Eyeball the remaining amount — quick and approximate.",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export function WeighInFlow({
|
||||||
|
data,
|
||||||
|
onClose,
|
||||||
|
item: initialItem,
|
||||||
|
queue,
|
||||||
|
}: {
|
||||||
|
data: Bootstrap;
|
||||||
|
onClose: () => void;
|
||||||
|
item: Item | null;
|
||||||
|
queue?: Item[];
|
||||||
|
}) {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const allItems = enrichItems(data);
|
||||||
|
const bulkItems = [...allItems]
|
||||||
|
.filter((i) => i.status === "active" && i.kind === "bulk")
|
||||||
|
.sort((a, b) => helpers.daysSinceCheck(b) - helpers.daysSinceCheck(a));
|
||||||
|
|
||||||
|
const [queueIdx, setQueueIdx] = useState(0);
|
||||||
|
const [itemId, setItemId] = useState(initialItem?.id ?? "");
|
||||||
|
const [date, setDate] = useState(getToday(getStoredTimezone()));
|
||||||
|
|
||||||
|
const item = allItems.find((i) => i.id === itemId);
|
||||||
|
const cfg = item ? TYPES.find((t) => t.id === item.type) : undefined;
|
||||||
|
|
||||||
|
const initialValueFor = (i: Item | undefined): string => {
|
||||||
|
if (!i) return "0";
|
||||||
|
const last = helpers.lastAudit(i);
|
||||||
|
return (last ? last.value : i.weight).toFixed(2);
|
||||||
|
};
|
||||||
|
const [value, setValue] = useState<string>(initialValueFor(item));
|
||||||
|
const [inputMode, setInputMode] = useState<"direct" | "container">(
|
||||||
|
item?.containerWeight != null ? "container" : "direct",
|
||||||
|
);
|
||||||
|
const [containerTotal, setContainerTotal] = useState("");
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setValue(initialValueFor(item));
|
||||||
|
setInputMode(item?.containerWeight != null ? "container" : "direct");
|
||||||
|
setContainerTotal("");
|
||||||
|
setError(null);
|
||||||
|
}, [itemId]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
const tare = item ? helpers.tare(item) : null;
|
||||||
|
const derivedRemaining =
|
||||||
|
tare != null && containerTotal !== ""
|
||||||
|
? parseFloat(containerTotal) - tare
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const effectiveValue =
|
||||||
|
inputMode === "container" && derivedRemaining != null
|
||||||
|
? derivedRemaining
|
||||||
|
: Number(value);
|
||||||
|
const effectiveMode =
|
||||||
|
inputMode === "container" ? "weigh" : (cfg?.auditMode ?? "weigh");
|
||||||
|
|
||||||
|
const weighIn = useMutation({
|
||||||
|
mutationFn: () =>
|
||||||
|
api.auditInventoryItem(itemId, {
|
||||||
|
date,
|
||||||
|
mode: effectiveMode,
|
||||||
|
value: effectiveValue,
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["bootstrap"] });
|
||||||
|
toast(`Weigh-in saved — next due in ${cfg?.cadenceDays ?? "?"}d`);
|
||||||
|
if (queue && queueIdx + 1 < queue.length) {
|
||||||
|
const nextItem = queue[queueIdx + 1]!;
|
||||||
|
setQueueIdx((i) => i + 1);
|
||||||
|
setItemId(nextItem.id);
|
||||||
|
setError(null);
|
||||||
|
} else {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: (e: Error) => setError(e.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleScan = (result: ScanResult) => {
|
||||||
|
if (result.kind === "item") {
|
||||||
|
setItemId(result.item.id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const auditMode = cfg?.auditMode ?? "weigh";
|
||||||
|
const ml = inputMode === "container"
|
||||||
|
? { title: "Weigh container", desc: "Place the sealed jar on a scale and enter the total weight. Product remaining is calculated from the tare." }
|
||||||
|
: MODE_LABELS[auditMode] ?? MODE_LABELS.weigh!;
|
||||||
|
|
||||||
|
const last = item ? helpers.lastAudit(item) : null;
|
||||||
|
const prevValue = item
|
||||||
|
? last
|
||||||
|
? last.value
|
||||||
|
: item.weight
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
const delta = effectiveValue - prevValue;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ModalBackdrop onClose={onClose}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: "min(720px, 96vw)",
|
||||||
|
margin: "40px 20px",
|
||||||
|
background: "var(--bg)",
|
||||||
|
border: "1px solid var(--line)",
|
||||||
|
borderRadius: "var(--r-lg)",
|
||||||
|
boxShadow: "var(--shadow-lg)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ModalHeader
|
||||||
|
title={item ? ml.title : "Weigh In"}
|
||||||
|
eyebrow={queue && queue.length > 1 ? `${queueIdx + 1} of ${queue.length} overdue weigh-ins` : ""}
|
||||||
|
onClose={onClose}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{queue && queue.length > 1 && (
|
||||||
|
<div style={{ height: 3, background: "var(--bg-3)" }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: "100%",
|
||||||
|
width: `${((queueIdx + 1) / queue.length) * 100}%`,
|
||||||
|
background: "var(--sage)",
|
||||||
|
borderRadius: "0 2px 2px 0",
|
||||||
|
transition: "width 300ms ease",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ padding: 32 }}>
|
||||||
|
<ScanField
|
||||||
|
items={bulkItems}
|
||||||
|
products={[]}
|
||||||
|
matchedLabel={item ? `${item.assetId} · ${item.name}` : null}
|
||||||
|
onMatch={handleScan}
|
||||||
|
mode="assetId"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{!item ? (
|
||||||
|
<div style={{ marginTop: 24, textAlign: "center", color: "var(--ink-3)", fontSize: 13, fontStyle: "italic", padding: "24px 0" }}>
|
||||||
|
Scan an asset ID to continue.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: 16,
|
||||||
|
padding: 16,
|
||||||
|
background: "var(--bg-2)",
|
||||||
|
border: "1px solid var(--line)",
|
||||||
|
borderRadius: "var(--r-md)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
||||||
|
<div>
|
||||||
|
<div className="serif" style={{ fontSize: 20, fontWeight: 500 }}>
|
||||||
|
{item.name}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 12, color: "var(--ink-3)" }}>
|
||||||
|
<span className="mono">{item.assetId}</span> · {item.type} · {item.kind} · cadence every {cfg?.cadenceDays}d
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ textAlign: "right" }}>
|
||||||
|
<div className="mono" style={{ fontSize: 11, color: "var(--ink-3)" }}>LAST CHECKED</div>
|
||||||
|
<div className="serif" style={{ fontSize: 18 }}>
|
||||||
|
{last ? `${helpers.daysSinceCheck(item)}d ago` : "Never"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 12, color: "var(--ink-2)", marginTop: 10, fontStyle: "italic" }}>
|
||||||
|
{ml.desc}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{tare != null && (
|
||||||
|
<div style={{ display: "flex", gap: 8, marginTop: 16 }}>
|
||||||
|
<Btn
|
||||||
|
variant={inputMode === "container" ? "primary" : "ghost"}
|
||||||
|
onClick={() => setInputMode("container")}
|
||||||
|
>
|
||||||
|
Weigh container
|
||||||
|
</Btn>
|
||||||
|
<Btn
|
||||||
|
variant={inputMode === "direct" ? "primary" : "ghost"}
|
||||||
|
onClick={() => setInputMode("direct")}
|
||||||
|
>
|
||||||
|
Direct entry
|
||||||
|
</Btn>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: "1fr 1fr",
|
||||||
|
gap: 16,
|
||||||
|
marginTop: 24,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{inputMode === "container" && tare != null ? (
|
||||||
|
<Field label="Container weight now (g)">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
value={containerTotal}
|
||||||
|
onChange={(e) => setContainerTotal(e.target.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
) : (
|
||||||
|
<Field
|
||||||
|
label={
|
||||||
|
auditMode === "weigh"
|
||||||
|
? `Weight now (${cfg?.unit})`
|
||||||
|
: `Estimate now (${cfg?.unit})`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
step="0.1"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => setValue(e.target.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
<Field label="Date">
|
||||||
|
<Input type="date" value={date} onChange={(e) => setDate(e.target.value)} />
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{inputMode === "container" && tare != null && (
|
||||||
|
<div style={{ marginTop: 8, fontSize: 12, color: "var(--ink-3)" }}>
|
||||||
|
Tare (empty jar): {tare.toFixed(2)}g
|
||||||
|
{derivedRemaining != null && (
|
||||||
|
<span style={{ color: derivedRemaining >= 0 ? "var(--sage)" : "var(--terracotta)" }}>
|
||||||
|
{" · "}Product remaining: {derivedRemaining.toFixed(2)}g
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: 20,
|
||||||
|
padding: 14,
|
||||||
|
background: "var(--surface)",
|
||||||
|
border: "1px solid var(--line)",
|
||||||
|
borderRadius: "var(--r-md)",
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: "1fr 1fr 1fr",
|
||||||
|
gap: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div className="smallcaps" style={{ color: "var(--ink-3)" }}>Was</div>
|
||||||
|
<div className="serif" style={{ fontSize: 22 }}>
|
||||||
|
{prevValue.toFixed(2)} {cfg?.unit}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="smallcaps" style={{ color: "var(--ink-3)" }}>Now</div>
|
||||||
|
<div className="serif" style={{ fontSize: 22, color: "var(--sage)" }}>
|
||||||
|
{effectiveValue.toFixed(2)} {cfg?.unit}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="smallcaps" style={{ color: "var(--ink-3)" }}>Δ since last</div>
|
||||||
|
<div
|
||||||
|
className="serif"
|
||||||
|
style={{
|
||||||
|
fontSize: 22,
|
||||||
|
color: delta < 0 ? "var(--terracotta)" : "var(--ink)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{delta.toFixed(2)} {cfg?.unit}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div style={{ marginTop: 14, fontSize: 12, color: "var(--terracotta)" }}>{error}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ModalFooter>
|
||||||
|
<div style={{ fontSize: 12, color: "var(--ink-3)" }}>
|
||||||
|
{item ? `Next weigh-in due in ${cfg?.cadenceDays}d` : ""}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "flex", gap: 8 }}>
|
||||||
|
<Btn variant="ghost" onClick={onClose}>Cancel</Btn>
|
||||||
|
<Btn
|
||||||
|
variant="primary"
|
||||||
|
icon="check"
|
||||||
|
disabled={weighIn.isPending || !item}
|
||||||
|
onClick={() => weighIn.mutate()}
|
||||||
|
>
|
||||||
|
{weighIn.isPending
|
||||||
|
? "Saving…"
|
||||||
|
: error
|
||||||
|
? "Try again"
|
||||||
|
: queue && queueIdx + 1 < queue.length
|
||||||
|
? "Save & next"
|
||||||
|
: "Save weigh-in"}
|
||||||
|
</Btn>
|
||||||
|
</div>
|
||||||
|
</ModalFooter>
|
||||||
|
</div>
|
||||||
|
</ModalBackdrop>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useRef, useEffect } from "react";
|
||||||
import type { CSSProperties, ReactNode, ButtonHTMLAttributes, InputHTMLAttributes, SelectHTMLAttributes, TextareaHTMLAttributes } from "react";
|
import type { CSSProperties, ReactNode, ButtonHTMLAttributes, InputHTMLAttributes, SelectHTMLAttributes, TextareaHTMLAttributes } from "react";
|
||||||
|
|
||||||
// ─── Icons ─────────────────────────────────────────────────────────
|
// ─── Icons ─────────────────────────────────────────────────────────
|
||||||
@@ -22,7 +23,13 @@ const ICON_PATHS: Record<string, string> = {
|
|||||||
star: "M12 3l3 6 7 1-5 5 1 7-6-3-6 3 1-7-5-5 7-1z",
|
star: "M12 3l3 6 7 1-5 5 1 7-6-3-6 3 1-7-5-5 7-1z",
|
||||||
calendar: "M5 5h14v15H5zM3 10h18M9 3v4M15 3v4",
|
calendar: "M5 5h14v15H5zM3 10h18M9 3v4M15 3v4",
|
||||||
tag: "M3 12V3h9l9 9-9 9-9-9zM7 7h.01",
|
tag: "M3 12V3h9l9 9-9 9-9-9zM7 7h.01",
|
||||||
|
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",
|
||||||
|
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({
|
||||||
@@ -312,6 +319,7 @@ export function Btn({
|
|||||||
style,
|
style,
|
||||||
type,
|
type,
|
||||||
disabled,
|
disabled,
|
||||||
|
title,
|
||||||
}: {
|
}: {
|
||||||
children?: ReactNode;
|
children?: ReactNode;
|
||||||
variant?: BtnVariant;
|
variant?: BtnVariant;
|
||||||
@@ -320,6 +328,7 @@ export function Btn({
|
|||||||
style?: CSSProperties;
|
style?: CSSProperties;
|
||||||
type?: "button" | "submit" | "reset";
|
type?: "button" | "submit" | "reset";
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
title?: string;
|
||||||
}) {
|
}) {
|
||||||
const variants: Record<BtnVariant, CSSProperties> = {
|
const variants: Record<BtnVariant, CSSProperties> = {
|
||||||
primary: disabled
|
primary: disabled
|
||||||
@@ -336,6 +345,7 @@ export function Btn({
|
|||||||
type={type}
|
type={type}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
title={title}
|
||||||
style={{
|
style={{
|
||||||
display: "inline-flex",
|
display: "inline-flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
@@ -406,3 +416,41 @@ export function Textarea(props: TextareaHTMLAttributes<HTMLTextAreaElement>) {
|
|||||||
const { style, ...rest } = props;
|
const { style, ...rest } = props;
|
||||||
return <textarea style={{ ...inputStyle, minHeight: 80, resize: "vertical", ...style }} {...rest} />;
|
return <textarea style={{ ...inputStyle, minHeight: 80, resize: "vertical", ...style }} {...rest} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Checkbox ─────────────────────────────────────────────────────
|
||||||
|
export function Checkbox({
|
||||||
|
checked,
|
||||||
|
indeterminate = false,
|
||||||
|
onChange,
|
||||||
|
style,
|
||||||
|
}: {
|
||||||
|
checked: boolean;
|
||||||
|
indeterminate?: boolean;
|
||||||
|
onChange: (checked: boolean) => void;
|
||||||
|
style?: CSSProperties;
|
||||||
|
}) {
|
||||||
|
const ref = useRef<HTMLInputElement>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
if (ref.current) ref.current.indeterminate = indeterminate;
|
||||||
|
}, [indeterminate]);
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
ref={ref}
|
||||||
|
type="checkbox"
|
||||||
|
checked={checked}
|
||||||
|
onChange={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onChange(e.target.checked);
|
||||||
|
}}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
style={{
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
accentColor: "var(--sage)",
|
||||||
|
cursor: "pointer",
|
||||||
|
margin: 0,
|
||||||
|
...style,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
+19
-10
@@ -1,4 +1,9 @@
|
|||||||
// fmt.* — verbatim port from primitives.jsx
|
import { getToday } from "./tz.js";
|
||||||
|
|
||||||
|
function parseDate(s: string): Date {
|
||||||
|
return new Date(s.length === 10 ? s + "T12:00:00" : s);
|
||||||
|
}
|
||||||
|
|
||||||
export const fmt = {
|
export const fmt = {
|
||||||
g(n: number | null | undefined): string {
|
g(n: number | null | undefined): string {
|
||||||
if (n == null) return "—";
|
if (n == null) return "—";
|
||||||
@@ -17,22 +22,26 @@ export const fmt = {
|
|||||||
if (n == null) return "—";
|
if (n == null) return "—";
|
||||||
return `${(+n).toFixed(1)}%`;
|
return `${(+n).toFixed(1)}%`;
|
||||||
},
|
},
|
||||||
date(s: string | null | undefined): string {
|
date(s: string | null | undefined, tz?: string): string {
|
||||||
if (!s) return "—";
|
if (!s) return "—";
|
||||||
const d = new Date(s);
|
const opts: Intl.DateTimeFormatOptions = { month: "short", day: "numeric", year: "numeric" };
|
||||||
return d.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
|
if (tz) opts.timeZone = tz;
|
||||||
|
return parseDate(s).toLocaleDateString("en-US", opts);
|
||||||
},
|
},
|
||||||
dateShort(s: string | null | undefined): string {
|
dateShort(s: string | null | undefined, tz?: string): string {
|
||||||
if (!s) return "—";
|
if (!s) return "—";
|
||||||
const d = new Date(s);
|
const opts: Intl.DateTimeFormatOptions = { month: "short", day: "numeric" };
|
||||||
return d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
if (tz) opts.timeZone = tz;
|
||||||
|
return parseDate(s).toLocaleDateString("en-US", opts);
|
||||||
},
|
},
|
||||||
daysAgo(s: string | null | undefined): string {
|
daysAgo(s: string | null | undefined, tz?: string): string {
|
||||||
if (!s) return "—";
|
if (!s) return "—";
|
||||||
const ms = Date.now() - new Date(s).getTime();
|
const todayMs = +parseDate(tz ? getToday(tz) : new Date().toISOString().slice(0, 10));
|
||||||
const d = Math.floor(ms / 86_400_000);
|
const thenMs = +parseDate(s);
|
||||||
|
const d = Math.floor((todayMs - thenMs) / 86_400_000);
|
||||||
if (d === 0) return "today";
|
if (d === 0) return "today";
|
||||||
if (d === 1) return "yesterday";
|
if (d === 1) return "yesterday";
|
||||||
|
if (d < 0) return "in the future";
|
||||||
if (d < 30) return `${d}d ago`;
|
if (d < 30) return `${d}d ago`;
|
||||||
if (d < 365) return `${Math.floor(d / 30)}mo ago`;
|
if (d < 365) return `${Math.floor(d / 30)}mo ago`;
|
||||||
return `${Math.floor(d / 365)}y ago`;
|
return `${Math.floor(d / 365)}y ago`;
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { useCallback, useState } from "react";
|
||||||
|
|
||||||
|
export function useExitAnimation(durationMs: number, onDone: () => void) {
|
||||||
|
const [closing, setClosing] = useState(false);
|
||||||
|
|
||||||
|
const triggerClose = useCallback(() => {
|
||||||
|
setClosing(true);
|
||||||
|
setTimeout(onDone, durationMs);
|
||||||
|
}, [durationMs, onDone]);
|
||||||
|
|
||||||
|
return { closing, triggerClose };
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
|
||||||
|
const FOCUSABLE = 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
|
||||||
|
|
||||||
|
export function useFocusTrap<T extends HTMLElement>() {
|
||||||
|
const ref = useRef<T>(null);
|
||||||
|
const previousFocus = useRef<Element | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
previousFocus.current = document.activeElement;
|
||||||
|
|
||||||
|
const el = ref.current;
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
|
const firstFocusable = el.querySelector<HTMLElement>(FOCUSABLE);
|
||||||
|
firstFocusable?.focus();
|
||||||
|
|
||||||
|
const handleTab = (e: KeyboardEvent) => {
|
||||||
|
if (e.key !== "Tab" || !el) return;
|
||||||
|
const focusable = el.querySelectorAll<HTMLElement>(FOCUSABLE);
|
||||||
|
if (focusable.length === 0) return;
|
||||||
|
const first = focusable[0]!;
|
||||||
|
const last = focusable[focusable.length - 1]!;
|
||||||
|
if (e.shiftKey && document.activeElement === first) {
|
||||||
|
e.preventDefault();
|
||||||
|
last.focus();
|
||||||
|
} else if (!e.shiftKey && document.activeElement === last) {
|
||||||
|
e.preventDefault();
|
||||||
|
first.focus();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("keydown", handleTab);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("keydown", handleTab);
|
||||||
|
if (previousFocus.current instanceof HTMLElement) {
|
||||||
|
previousFocus.current.focus();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return ref;
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { useState, useCallback, useRef, useEffect } from "react";
|
||||||
|
|
||||||
|
export function useSelection(visibleIds: string[]) {
|
||||||
|
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||||
|
const lastClicked = useRef<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSelected((prev) => {
|
||||||
|
const vis = new Set(visibleIds);
|
||||||
|
const next = new Set<string>();
|
||||||
|
for (const id of prev) {
|
||||||
|
if (vis.has(id)) next.add(id);
|
||||||
|
}
|
||||||
|
return next.size === prev.size ? prev : next;
|
||||||
|
});
|
||||||
|
}, [visibleIds]);
|
||||||
|
|
||||||
|
const toggle = useCallback(
|
||||||
|
(id: string, shiftKey: boolean) => {
|
||||||
|
setSelected((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (shiftKey && lastClicked.current !== null) {
|
||||||
|
const fromIdx = visibleIds.indexOf(lastClicked.current);
|
||||||
|
const toIdx = visibleIds.indexOf(id);
|
||||||
|
if (fromIdx !== -1 && toIdx !== -1) {
|
||||||
|
const lo = Math.min(fromIdx, toIdx);
|
||||||
|
const hi = Math.max(fromIdx, toIdx);
|
||||||
|
const adding = !prev.has(id);
|
||||||
|
for (let i = lo; i <= hi; i++) {
|
||||||
|
if (adding) next.add(visibleIds[i]);
|
||||||
|
else next.delete(visibleIds[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (next.has(id)) next.delete(id);
|
||||||
|
else next.add(id);
|
||||||
|
}
|
||||||
|
lastClicked.current = id;
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[visibleIds],
|
||||||
|
);
|
||||||
|
|
||||||
|
const toggleAll = useCallback(() => {
|
||||||
|
setSelected((prev) => {
|
||||||
|
if (prev.size === visibleIds.length && visibleIds.every((id) => prev.has(id))) {
|
||||||
|
return new Set();
|
||||||
|
}
|
||||||
|
return new Set(visibleIds);
|
||||||
|
});
|
||||||
|
}, [visibleIds]);
|
||||||
|
|
||||||
|
const toggleGroup = useCallback((groupIds: string[]) => {
|
||||||
|
setSelected((prev) => {
|
||||||
|
const allIn = groupIds.every((id) => prev.has(id));
|
||||||
|
const next = new Set(prev);
|
||||||
|
for (const id of groupIds) {
|
||||||
|
if (allIn) next.delete(id);
|
||||||
|
else next.add(id);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const clear = useCallback(() => {
|
||||||
|
setSelected(new Set());
|
||||||
|
lastClicked.current = null;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const isAllSelected = visibleIds.length > 0 && visibleIds.every((id) => selected.has(id));
|
||||||
|
const isIndeterminate = !isAllSelected && visibleIds.some((id) => selected.has(id));
|
||||||
|
|
||||||
|
return { selected, toggle, toggleAll, toggleGroup, clear, isAllSelected, isIndeterminate };
|
||||||
|
}
|
||||||
+34
-24
@@ -1,10 +1,11 @@
|
|||||||
// computeStats — derives daily/weekly/monthly grams from purchase + audit
|
// computeStats — derives daily/weekly/monthly grams from purchase + audit
|
||||||
// history, using estimated remaining for active items and full weight for
|
// history, using last audit values for active items and full weight for
|
||||||
// consumed. Gone items contribute spend but NOT grams (so daily averages
|
// consumed. Gone items contribute spend but NOT grams (so daily averages
|
||||||
// stay clean). Operates on the enriched Item[] view, not raw products.
|
// stay clean). Operates on the enriched Item[] view, not raw products.
|
||||||
|
|
||||||
import type { Bootstrap, Item } from "./types.js";
|
import type { Bin, Bootstrap, Item } from "./types.js";
|
||||||
import { TYPES, TODAY_STR, helpers, enrichItems } from "./types.js";
|
import { TYPES, helpers, enrichItems } from "./types.js";
|
||||||
|
import { getToday, getStoredTimezone } from "./tz.js";
|
||||||
|
|
||||||
export interface Stats {
|
export interface Stats {
|
||||||
dailyAvg: number;
|
dailyAvg: number;
|
||||||
@@ -31,11 +32,13 @@ export interface Stats {
|
|||||||
series30: { date: string; grams: number }[];
|
series30: { date: string; grams: number }[];
|
||||||
series90: { date: string; grams: number }[];
|
series90: { date: string; grams: number }[];
|
||||||
activeCount: number;
|
activeCount: number;
|
||||||
|
checkedOutCount: number;
|
||||||
consumedCount: number;
|
consumedCount: number;
|
||||||
goneCount: number;
|
goneCount: number;
|
||||||
archivedCount: number;
|
archivedCount: number;
|
||||||
purchaseCount: number;
|
purchaseCount: number;
|
||||||
overdueAudits: Item[];
|
overdueWeighIns: Item[];
|
||||||
|
overdueBinChecks: Bin[];
|
||||||
lowStockBulk: Item[];
|
lowStockBulk: Item[];
|
||||||
lowStockDiscreteGroups: {
|
lowStockDiscreteGroups: {
|
||||||
key: string;
|
key: string;
|
||||||
@@ -48,12 +51,19 @@ export interface Stats {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function computeStats(data: Bootstrap): Stats {
|
export function computeStats(data: Bootstrap): Stats {
|
||||||
const today = new Date(data.today || TODAY_STR);
|
const tz = getStoredTimezone();
|
||||||
const todayStr = today.toISOString().slice(0, 10);
|
const todayStr = getToday(tz);
|
||||||
|
const today = new Date(todayStr + "T12:00:00");
|
||||||
const items = enrichItems(data);
|
const items = enrichItems(data);
|
||||||
const dayKey = (d: Date) => d.toISOString().slice(0, 10);
|
const dayKeyFmt = new Intl.DateTimeFormat("en-CA", {
|
||||||
|
timeZone: tz,
|
||||||
|
year: "numeric",
|
||||||
|
month: "2-digit",
|
||||||
|
day: "2-digit",
|
||||||
|
});
|
||||||
|
const dayKey = (d: Date) => dayKeyFmt.format(d);
|
||||||
|
|
||||||
const active = items.filter((p) => p.status === "active");
|
const active = items.filter((p) => p.status === "active" || p.status === "checked-out");
|
||||||
const consumed = items.filter((p) => p.status === "consumed" && p.consumedDate);
|
const consumed = items.filter((p) => p.status === "consumed" && p.consumedDate);
|
||||||
const gone = items.filter((p) => p.status === "gone");
|
const gone = items.filter((p) => p.status === "gone");
|
||||||
|
|
||||||
@@ -79,8 +89,8 @@ export function computeStats(data: Bootstrap): Stats {
|
|||||||
const bulkGramsUsedSoFar = (p: Item): number => {
|
const bulkGramsUsedSoFar = (p: Item): number => {
|
||||||
if (p.type === "Tincture" || p.type === "Edible") return 0;
|
if (p.type === "Tincture" || p.type === "Edible") return 0;
|
||||||
if (p.kind === "bulk") {
|
if (p.kind === "bulk") {
|
||||||
const est = helpers.estimatedRemaining(p, todayStr);
|
const rem = helpers.remaining(p);
|
||||||
return Math.max(0, p.weight - est);
|
return Math.max(0, p.weight - rem);
|
||||||
}
|
}
|
||||||
const cur = p.countLastAudit ?? p.countOriginal;
|
const cur = p.countLastAudit ?? p.countOriginal;
|
||||||
return Math.max(0, p.countOriginal - cur) * (p.unitWeight || 0);
|
return Math.max(0, p.countOriginal - cur) * (p.unitWeight || 0);
|
||||||
@@ -143,16 +153,13 @@ export function computeStats(data: Bootstrap): Stats {
|
|||||||
const spend90 = last90p.reduce((s, p) => s + p.price, 0);
|
const spend90 = last90p.reduce((s, p) => s + p.price, 0);
|
||||||
|
|
||||||
const inventoryValue = active.reduce(
|
const inventoryValue = active.reduce(
|
||||||
(s, p) => s + p.price * helpers.pctRemaining(p, todayStr),
|
(s, p) => s + p.price * helpers.pctRemaining(p),
|
||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Grams currently on hand: bulk uses estimated remaining; discrete uses
|
|
||||||
// (units × per-unit weight). Tincture (ml) and edibles (count) are excluded
|
|
||||||
// to match the existing `bulkGrams` convention used for $/g and totals.
|
|
||||||
const inventoryGrams = active.reduce((s, p) => {
|
const inventoryGrams = active.reduce((s, p) => {
|
||||||
if (p.type === "Tincture" || p.type === "Edible") return s;
|
if (p.type === "Tincture" || p.type === "Edible") return s;
|
||||||
if (p.kind === "bulk") return s + helpers.estimatedRemaining(p, todayStr);
|
if (p.kind === "bulk") return s + helpers.remaining(p);
|
||||||
const cur = p.countLastAudit ?? p.countOriginal;
|
const cur = p.countLastAudit ?? p.countOriginal;
|
||||||
return s + cur * (p.unitWeight || 0);
|
return s + cur * (p.unitWeight || 0);
|
||||||
}, 0);
|
}, 0);
|
||||||
@@ -189,10 +196,10 @@ export function computeStats(data: Bootstrap): Stats {
|
|||||||
const typeBreakdown: Record<string, number> = {};
|
const typeBreakdown: Record<string, number> = {};
|
||||||
active.forEach((p) => {
|
active.forEach((p) => {
|
||||||
let g: number;
|
let g: number;
|
||||||
if (p.type === "Tincture") g = helpers.estimatedRemaining(p, todayStr) * 0.5;
|
if (p.type === "Tincture") g = helpers.remaining(p) * 0.5;
|
||||||
else if (p.type === "Edible")
|
else if (p.type === "Edible")
|
||||||
g = (p.countLastAudit ?? p.countOriginal) * 0.3;
|
g = (p.countLastAudit ?? p.countOriginal) * 0.3;
|
||||||
else if (p.kind === "bulk") g = helpers.estimatedRemaining(p, todayStr);
|
else if (p.kind === "bulk") g = helpers.remaining(p);
|
||||||
else g = (p.countLastAudit ?? p.countOriginal) * (p.unitWeight || 0);
|
else g = (p.countLastAudit ?? p.countOriginal) * (p.unitWeight || 0);
|
||||||
if (g > 0) typeBreakdown[p.type] = (typeBreakdown[p.type] || 0) + g;
|
if (g > 0) typeBreakdown[p.type] = (typeBreakdown[p.type] || 0) + g;
|
||||||
});
|
});
|
||||||
@@ -200,7 +207,7 @@ export function computeStats(data: Bootstrap): Stats {
|
|||||||
const flowerEquivalent = active
|
const flowerEquivalent = active
|
||||||
.filter((p) => p.type === "Flower" || p.type === "Pre-roll")
|
.filter((p) => p.type === "Flower" || p.type === "Pre-roll")
|
||||||
.reduce((s, p) => {
|
.reduce((s, p) => {
|
||||||
if (p.kind === "bulk") return s + helpers.estimatedRemaining(p, todayStr);
|
if (p.kind === "bulk") return s + helpers.remaining(p);
|
||||||
return s + (p.countLastAudit ?? p.countOriginal) * (p.unitWeight || 0);
|
return s + (p.countLastAudit ?? p.countOriginal) * (p.unitWeight || 0);
|
||||||
}, 0);
|
}, 0);
|
||||||
const daysOfSupply = dailyAvg > 0 ? flowerEquivalent / dailyAvg : 0;
|
const daysOfSupply = dailyAvg > 0 ? flowerEquivalent / dailyAvg : 0;
|
||||||
@@ -214,10 +221,11 @@ export function computeStats(data: Bootstrap): Stats {
|
|||||||
}
|
}
|
||||||
const avgGap = gaps.length > 0 ? gaps.reduce((a, b) => a + b, 0) / gaps.length : 0;
|
const avgGap = gaps.length > 0 ? gaps.reduce((a, b) => a + b, 0) / gaps.length : 0;
|
||||||
|
|
||||||
const overdueAudits = active.filter((p) => helpers.auditOverdue(p, todayStr));
|
const overdueWeighIns = active.filter((p) => helpers.auditOverdue(p, todayStr));
|
||||||
|
const overdueBinChecks = data.bins.filter((b) => helpers.binCheckOverdue(b, todayStr));
|
||||||
|
|
||||||
const lowStockBulk = active.filter(
|
const lowStockBulk = active.filter(
|
||||||
(p) => p.kind === "bulk" && helpers.pctRemaining(p, todayStr) < 0.25,
|
(p) => p.kind === "bulk" && helpers.pctRemaining(p) < 0.25,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Group discrete instances by product so multiple jars of the same
|
// Group discrete instances by product so multiple jars of the same
|
||||||
@@ -271,12 +279,14 @@ export function computeStats(data: Bootstrap): Stats {
|
|||||||
series7,
|
series7,
|
||||||
series30,
|
series30,
|
||||||
series90,
|
series90,
|
||||||
activeCount: active.length,
|
activeCount: items.filter((p) => p.status === "active").length,
|
||||||
|
checkedOutCount: items.filter((p) => p.status === "checked-out").length,
|
||||||
consumedCount: consumed.length,
|
consumedCount: consumed.length,
|
||||||
goneCount: gone.length,
|
goneCount: gone.length,
|
||||||
archivedCount: consumed.length + gone.length,
|
archivedCount: consumed.length + gone.length,
|
||||||
purchaseCount: items.length,
|
purchaseCount: items.length,
|
||||||
overdueAudits,
|
overdueWeighIns,
|
||||||
|
overdueBinChecks,
|
||||||
lowStockBulk,
|
lowStockBulk,
|
||||||
lowStockDiscreteGroups,
|
lowStockDiscreteGroups,
|
||||||
};
|
};
|
||||||
@@ -289,7 +299,7 @@ export function remainingShort(p: Item): string {
|
|||||||
const cur = p.countLastAudit ?? p.countOriginal;
|
const cur = p.countLastAudit ?? p.countOriginal;
|
||||||
return `${cur} ct`;
|
return `${cur} ct`;
|
||||||
}
|
}
|
||||||
const est = helpers.estimatedRemaining(p);
|
const rem = helpers.remaining(p);
|
||||||
const trimmed = est.toFixed(2).replace(/\.?0+$/, "") || "0";
|
const trimmed = rem.toFixed(2).replace(/\.?0+$/, "") || "0";
|
||||||
return `${trimmed} ${cfg?.unit ?? "g"}`;
|
return `${trimmed} ${cfg?.unit ?? "g"}`;
|
||||||
}
|
}
|
||||||
|
|||||||
+111
-38
@@ -69,12 +69,29 @@
|
|||||||
padding: 16px 12px 6px;
|
padding: 16px 12px 6px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
.nav-action {
|
||||||
|
border: 1px dashed var(--line-strong);
|
||||||
|
color: var(--ink-2);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.nav-action:hover {
|
||||||
|
border-style: solid;
|
||||||
|
background: var(--sage-soft);
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
.nav-divider {
|
.nav-divider {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
.inv-row:hover {
|
.inv-row:hover {
|
||||||
background: var(--bg-2);
|
background: var(--bg-2);
|
||||||
}
|
}
|
||||||
|
.sortable-col .sort-hint {
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 100ms;
|
||||||
|
}
|
||||||
|
.sortable-col:hover .sort-hint {
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
.inv-row .inv-row-chevron {
|
.inv-row .inv-row-chevron {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transition: opacity 100ms;
|
transition: opacity 100ms;
|
||||||
@@ -105,12 +122,36 @@
|
|||||||
from { transform: translateX(100%); }
|
from { transform: translateX(100%); }
|
||||||
to { transform: translateX(0); }
|
to { transform: translateX(0); }
|
||||||
}
|
}
|
||||||
|
@keyframes toolbar-slide-up {
|
||||||
|
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; }
|
||||||
|
}
|
||||||
|
@keyframes modal-out {
|
||||||
|
from { opacity: 1; transform: scale(1) translateY(0); }
|
||||||
|
to { opacity: 0; transform: scale(0.97) translateY(8px); }
|
||||||
|
}
|
||||||
|
@keyframes drawer-out {
|
||||||
|
from { transform: translateX(0); }
|
||||||
|
to { transform: translateX(100%); }
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 1200px) {
|
@media (max-width: 1200px) {
|
||||||
.inv-row > :nth-child(4),
|
.inv-row > :nth-child(5),
|
||||||
.inv-header > :nth-child(4) { display: none; } /* Shop */
|
.inv-header > :nth-child(5) { display: none; } /* Shop (shifted +1 by checkbox col) */
|
||||||
.inv-row > :nth-child(8),
|
.inv-row > :nth-child(9),
|
||||||
.inv-header > :nth-child(8) { display: none; } /* Last checked */
|
.inv-header > :nth-child(9) { display: none; } /* Last checked */
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 880px) {
|
@media (max-width: 880px) {
|
||||||
@@ -118,42 +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 {
|
|
||||||
width: 1px;
|
|
||||||
height: 24px;
|
|
||||||
background: var(--line);
|
|
||||||
flex-shrink: 0;
|
|
||||||
align-self: center;
|
|
||||||
}
|
|
||||||
.main {
|
.main {
|
||||||
padding-bottom: 60px;
|
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;
|
||||||
|
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);
|
||||||
|
|||||||
+36
-33
@@ -1,4 +1,4 @@
|
|||||||
export type ProductStatus = "active" | "consumed" | "gone";
|
export type ProductStatus = "active" | "consumed" | "gone" | "checked-out";
|
||||||
export type ProductKind = "bulk" | "discrete";
|
export type ProductKind = "bulk" | "discrete";
|
||||||
export type AuditMode = "weigh" | "estimate" | "presence";
|
export type AuditMode = "weigh" | "estimate" | "presence";
|
||||||
|
|
||||||
@@ -38,6 +38,7 @@ export interface InventoryItem {
|
|||||||
cbd: number;
|
cbd: number;
|
||||||
totalCannabinoids: number;
|
totalCannabinoids: number;
|
||||||
weight: number;
|
weight: number;
|
||||||
|
containerWeight: number | null;
|
||||||
lastAuditWeight: number | null;
|
lastAuditWeight: number | null;
|
||||||
countOriginal: number;
|
countOriginal: number;
|
||||||
countLastAudit: number | null;
|
countLastAudit: number | null;
|
||||||
@@ -46,6 +47,8 @@ export interface InventoryItem {
|
|||||||
status: ProductStatus;
|
status: ProductStatus;
|
||||||
consumedDate: string | null;
|
consumedDate: string | null;
|
||||||
goneDate: string | null;
|
goneDate: string | null;
|
||||||
|
checkoutDate: string | null;
|
||||||
|
prevBinId: string | null;
|
||||||
rating: number | null;
|
rating: number | null;
|
||||||
notes: string | null;
|
notes: string | null;
|
||||||
audits: Audit[];
|
audits: Audit[];
|
||||||
@@ -88,6 +91,8 @@ export interface Bin {
|
|||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
capacity: number;
|
capacity: number;
|
||||||
|
cadenceDays: number;
|
||||||
|
lastChecked: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TypeConfig {
|
export interface TypeConfig {
|
||||||
@@ -97,6 +102,8 @@ export interface TypeConfig {
|
|||||||
cadenceDays: number;
|
cadenceDays: number;
|
||||||
unit: string;
|
unit: string;
|
||||||
weighable: boolean;
|
weighable: boolean;
|
||||||
|
weightUnit: string;
|
||||||
|
showCannabinoidPct: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Bootstrap {
|
export interface Bootstrap {
|
||||||
@@ -111,26 +118,21 @@ export interface Bootstrap {
|
|||||||
|
|
||||||
// Type config lives client-side — static, not user data.
|
// Type config lives client-side — static, not user data.
|
||||||
export const TYPES: TypeConfig[] = [
|
export const TYPES: TypeConfig[] = [
|
||||||
{ id: "Flower", kind: "bulk", auditMode: "weigh", cadenceDays: 14, unit: "g", weighable: true },
|
{ id: "Flower", kind: "bulk", auditMode: "weigh", cadenceDays: 14, unit: "g", weighable: true, weightUnit: "g", showCannabinoidPct: true },
|
||||||
{ id: "Concentrate", kind: "bulk", auditMode: "estimate", cadenceDays: 21, unit: "g", weighable: true },
|
{ id: "Concentrate", kind: "bulk", auditMode: "estimate", cadenceDays: 21, unit: "g", weighable: true, weightUnit: "g", showCannabinoidPct: true },
|
||||||
{ id: "Tincture", kind: "bulk", auditMode: "estimate", cadenceDays: 30, unit: "ml", weighable: false },
|
{ id: "Tincture", kind: "bulk", auditMode: "estimate", cadenceDays: 30, unit: "ml", weighable: false, weightUnit: "ml", showCannabinoidPct: true },
|
||||||
{ id: "Pre-roll", kind: "discrete", auditMode: "presence", cadenceDays: 30, unit: "ct", weighable: false },
|
{ id: "Pre-roll", kind: "discrete", auditMode: "presence", cadenceDays: 30, unit: "ct", weighable: false, weightUnit: "g", showCannabinoidPct: true },
|
||||||
{ id: "Edible", kind: "discrete", auditMode: "presence", cadenceDays: 60, unit: "ct", weighable: false },
|
{ id: "Edible", kind: "discrete", auditMode: "presence", cadenceDays: 60, unit: "ct", weighable: false, weightUnit: "mg", showCannabinoidPct: false },
|
||||||
{ id: "Vaporizer", kind: "discrete", auditMode: "presence", cadenceDays: 30, unit: "ct", weighable: false },
|
{ id: "Vaporizer", kind: "discrete", auditMode: "presence", cadenceDays: 30, unit: "ct", weighable: false, weightUnit: "g", showCannabinoidPct: true },
|
||||||
];
|
];
|
||||||
|
|
||||||
// User-supplied 6-digit asset ids are printed on a roll of physical tags.
|
// User-supplied 6-digit asset ids are printed on a roll of physical tags.
|
||||||
export const ASSET_ID_RE = /^\d{6}$/;
|
export const ASSET_ID_RE = /^\d{6}$/;
|
||||||
|
|
||||||
// Local-time YYYY-MM-DD captured once at module load. Used as the default
|
import { getToday, getBrowserTimezone } from "./tz.js";
|
||||||
// value for date inputs and as the "today" anchor for days-since math.
|
export { getToday } from "./tz.js";
|
||||||
export const TODAY_STR = (() => {
|
|
||||||
const d = new Date();
|
export const TODAY_STR = getToday(getBrowserTimezone());
|
||||||
const y = d.getFullYear();
|
|
||||||
const m = String(d.getMonth() + 1).padStart(2, "0");
|
|
||||||
const day = String(d.getDate()).padStart(2, "0");
|
|
||||||
return `${y}-${m}-${day}`;
|
|
||||||
})();
|
|
||||||
|
|
||||||
// Build the joined Item[] view from bootstrap. Inventory items are dropped
|
// Build the joined Item[] view from bootstrap. Inventory items are dropped
|
||||||
// silently if they reference a missing product — that shouldn't happen in
|
// silently if they reference a missing product — that shouldn't happen in
|
||||||
@@ -185,6 +187,10 @@ export const helpers = {
|
|||||||
typeConfig(id: string): TypeConfig {
|
typeConfig(id: string): TypeConfig {
|
||||||
return TYPES.find((t) => t.id === id) ?? TYPES[0]!;
|
return TYPES.find((t) => t.id === id) ?? TYPES[0]!;
|
||||||
},
|
},
|
||||||
|
tare(item: { containerWeight: number | null; weight: number }): number | null {
|
||||||
|
if (item.containerWeight == null) return null;
|
||||||
|
return item.containerWeight - item.weight;
|
||||||
|
},
|
||||||
daysSince(iso: string | null, today = TODAY_STR): number {
|
daysSince(iso: string | null, today = TODAY_STR): number {
|
||||||
if (!iso) return Infinity;
|
if (!iso) return Infinity;
|
||||||
return Math.floor((+new Date(today) - +new Date(iso)) / 86_400_000);
|
return Math.floor((+new Date(today) - +new Date(iso)) / 86_400_000);
|
||||||
@@ -197,34 +203,31 @@ export const helpers = {
|
|||||||
return Math.floor((+new Date(today) - +new Date(last)) / 86_400_000);
|
return Math.floor((+new Date(today) - +new Date(last)) / 86_400_000);
|
||||||
},
|
},
|
||||||
auditOverdue(p: Item, today = TODAY_STR): boolean {
|
auditOverdue(p: Item, today = TODAY_STR): boolean {
|
||||||
if (p.status !== "active") return false;
|
if (p.status !== "active" && p.status !== "checked-out") return false;
|
||||||
const cfg = TYPES.find((t) => t.id === p.type);
|
const cfg = TYPES.find((t) => t.id === p.type);
|
||||||
if (!cfg) return false;
|
if (!cfg || cfg.kind === "discrete") return false;
|
||||||
return this.daysSinceCheck(p, today) >= cfg.cadenceDays;
|
return this.daysSinceCheck(p, today) >= cfg.cadenceDays;
|
||||||
},
|
},
|
||||||
estimatedRemaining(p: Item, today = TODAY_STR): number {
|
binCheckOverdue(bin: Bin, today = TODAY_STR): boolean {
|
||||||
if (p.status !== "active") return 0;
|
return this.daysSinceBinCheck(bin, today) >= bin.cadenceDays;
|
||||||
|
},
|
||||||
|
daysSinceBinCheck(bin: Bin, today = TODAY_STR): number {
|
||||||
|
return this.daysSince(bin.lastChecked, today);
|
||||||
|
},
|
||||||
|
remaining(p: Item): number {
|
||||||
|
if (p.status !== "active" && p.status !== "checked-out") return 0;
|
||||||
if (p.kind === "discrete") {
|
if (p.kind === "discrete") {
|
||||||
return p.countLastAudit ?? p.countOriginal;
|
return p.countLastAudit ?? p.countOriginal;
|
||||||
}
|
}
|
||||||
const last = this.lastAudit(p);
|
const last = this.lastAudit(p);
|
||||||
const baseDate = last ? last.date : p.purchaseDate;
|
return last ? last.value : p.weight;
|
||||||
const baseValue = last ? last.value : p.weight;
|
|
||||||
const daysSinceBase = Math.max(
|
|
||||||
0,
|
|
||||||
Math.floor((+new Date(today) - +new Date(baseDate)) / 86_400_000),
|
|
||||||
);
|
|
||||||
const expectedLifespan =
|
|
||||||
p.type === "Flower" ? 35 : p.type === "Concentrate" ? 40 : 90;
|
|
||||||
const dailyBurn = p.weight / expectedLifespan;
|
|
||||||
return Math.max(0, baseValue - dailyBurn * daysSinceBase);
|
|
||||||
},
|
},
|
||||||
pctRemaining(p: Item, today = TODAY_STR): number {
|
pctRemaining(p: Item): number {
|
||||||
if (p.kind === "discrete") {
|
if (p.kind === "discrete") {
|
||||||
const cur = p.countLastAudit ?? p.countOriginal;
|
const cur = p.countLastAudit ?? p.countOriginal;
|
||||||
return p.countOriginal > 0 ? cur / p.countOriginal : 0;
|
return p.countOriginal > 0 ? cur / p.countOriginal : 0;
|
||||||
}
|
}
|
||||||
const est = this.estimatedRemaining(p, today);
|
const rem = this.remaining(p);
|
||||||
return p.weight > 0 ? est / p.weight : 0;
|
return p.weight > 0 ? rem / p.weight : 0;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
export const TZ_STORAGE_KEY = "apothecary.timezone";
|
||||||
|
|
||||||
|
const enCA = (tz: string) =>
|
||||||
|
new Intl.DateTimeFormat("en-CA", {
|
||||||
|
timeZone: tz,
|
||||||
|
year: "numeric",
|
||||||
|
month: "2-digit",
|
||||||
|
day: "2-digit",
|
||||||
|
});
|
||||||
|
|
||||||
|
export function getToday(tz: string): string {
|
||||||
|
return enCA(tz).format(new Date());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getBrowserTimezone(): string {
|
||||||
|
return Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getStoredTimezone(): string {
|
||||||
|
return localStorage.getItem(TZ_STORAGE_KEY) || getBrowserTimezone();
|
||||||
|
}
|
||||||
@@ -1,12 +1,15 @@
|
|||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import type { Bootstrap, Bin, Item } from "../types.js";
|
import type { Bootstrap, Bin, Item } from "../types.js";
|
||||||
import { helpers, TODAY_STR, enrichItems } from "../types.js";
|
import { helpers, enrichItems } from "../types.js";
|
||||||
|
import { getToday, getStoredTimezone } from "../tz.js";
|
||||||
import { remainingShort } from "../stats.js";
|
import { remainingShort } from "../stats.js";
|
||||||
import { fmt, TYPE_GLYPHS } from "../format.js";
|
import { fmt, TYPE_GLYPHS } from "../format.js";
|
||||||
import { api } from "../api.js";
|
import { api } from "../api.js";
|
||||||
import { Btn, Card, Pill, Icon } from "../components/primitives/index.js";
|
import { Btn, Card, Pill, Icon } from "../components/primitives/index.js";
|
||||||
import { ConfirmDialog } from "../components/modals/ConfirmDialog.js";
|
import { ConfirmDialog } from "../components/modals/ConfirmDialog.js";
|
||||||
|
import { useToast } from "../components/Toast.js";
|
||||||
|
import { useIsMobile } from "../hooks/useIsMobile.js";
|
||||||
|
|
||||||
// Bins follow a "letter + number" naming convention (A1, A2, B1, …).
|
// Bins follow a "letter + number" naming convention (A1, A2, B1, …).
|
||||||
// Group by the letter prefix so each letter starts a new visual row,
|
// Group by the letter prefix so each letter starts a new visual row,
|
||||||
@@ -43,13 +46,17 @@ export function BinsView({
|
|||||||
onSelectItem,
|
onSelectItem,
|
||||||
onAddBin,
|
onAddBin,
|
||||||
onEditBin,
|
onEditBin,
|
||||||
|
onBinCheck,
|
||||||
}: {
|
}: {
|
||||||
data: Bootstrap;
|
data: Bootstrap;
|
||||||
onSelectItem: (i: Item) => void;
|
onSelectItem: (i: Item) => void;
|
||||||
onAddBin: () => void;
|
onAddBin: () => void;
|
||||||
onEditBin: (bin: Bin) => void;
|
onEditBin: (bin: Bin) => void;
|
||||||
|
onBinCheck: (bin: Bin) => void;
|
||||||
}) {
|
}) {
|
||||||
|
const isMobile = useIsMobile();
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
|
const { toast } = useToast();
|
||||||
const items = useMemo(() => enrichItems(data), [data]);
|
const items = useMemo(() => enrichItems(data), [data]);
|
||||||
|
|
||||||
const [confirmDelete, setConfirmDelete] = useState<{ id: string; name: string; count: number } | null>(null);
|
const [confirmDelete, setConfirmDelete] = useState<{ id: string; name: string; count: number } | null>(null);
|
||||||
@@ -57,6 +64,7 @@ export function BinsView({
|
|||||||
const remove = useMutation({
|
const remove = useMutation({
|
||||||
mutationFn: (id: string) => api.deleteBin(id),
|
mutationFn: (id: string) => api.deleteBin(id),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
|
toast(`Deleted bin "${confirmDelete?.name ?? ""}"`);
|
||||||
qc.invalidateQueries({ queryKey: ["bootstrap"] });
|
qc.invalidateQueries({ queryKey: ["bootstrap"] });
|
||||||
setConfirmDelete(null);
|
setConfirmDelete(null);
|
||||||
},
|
},
|
||||||
@@ -67,17 +75,24 @@ export function BinsView({
|
|||||||
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={{ display: "flex", alignItems: "baseline", justifyContent: "space-between", marginBottom: 24 }}>
|
<div style={{ display: "flex", alignItems: "baseline", justifyContent: "space-between", marginBottom: isMobile ? 16 : 24 }}>
|
||||||
<div>
|
<div>
|
||||||
<div className="smallcaps" style={{ color: "var(--ink-3)" }}>{data.bins.length} bins</div>
|
<div className="smallcaps" style={{ color: "var(--ink-3)" }}>{data.bins.length} bins</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",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
Bins & storage
|
Bins & storage
|
||||||
</h1>
|
</h1>
|
||||||
@@ -120,7 +135,7 @@ export function BinsView({
|
|||||||
);
|
);
|
||||||
const fillPct = slotsUsed / bin.capacity;
|
const fillPct = slotsUsed / bin.capacity;
|
||||||
const totalValue = binItems.reduce(
|
const totalValue = binItems.reduce(
|
||||||
(s, i) => s + i.price * helpers.pctRemaining(i, TODAY_STR),
|
(s, i) => s + i.price * helpers.pctRemaining(i),
|
||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
@@ -207,6 +222,43 @@ export function BinsView({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
{(() => {
|
||||||
|
const todayStr = getToday(getStoredTimezone());
|
||||||
|
const overdue = helpers.binCheckOverdue(bin, todayStr);
|
||||||
|
const days = helpers.daysSinceBinCheck(bin, todayStr);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
marginTop: 10,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontSize: 11, color: overdue ? "var(--terracotta)" : "var(--ink-3)" }}>
|
||||||
|
{days === Infinity ? "Never checked" : `Checked ${days}d ago`}
|
||||||
|
{overdue && " · overdue"}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); onBinCheck(bin); }}
|
||||||
|
title="Check bin"
|
||||||
|
style={{
|
||||||
|
background: "transparent",
|
||||||
|
border: "none",
|
||||||
|
padding: "2px 6px",
|
||||||
|
borderRadius: "var(--r-sm)",
|
||||||
|
cursor: "pointer",
|
||||||
|
color: overdue ? "var(--terracotta)" : "var(--ink-3)",
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 600,
|
||||||
|
textDecoration: "underline",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Check
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ padding: 8, flex: 1 }}>
|
<div style={{ padding: 8, flex: 1 }}>
|
||||||
{binItems.length === 0 && (
|
{binItems.length === 0 && (
|
||||||
|
|||||||
+425
-98
@@ -1,54 +1,139 @@
|
|||||||
import { useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
|
||||||
import type { Bootstrap, Brand } from "../types.js";
|
import type { Bootstrap, Brand } from "../types.js";
|
||||||
import { api } from "../api.js";
|
import { fmt } from "../format.js";
|
||||||
import { Btn, Card, Pill, Icon } from "../components/primitives/index.js";
|
import { getStoredTimezone } from "../tz.js";
|
||||||
import { ConfirmDialog } from "../components/modals/ConfirmDialog.js";
|
import { Btn, Card, Icon, Select, inputStyle } from "../components/primitives/index.js";
|
||||||
|
import { useIsMobile } from "../hooks/useIsMobile.js";
|
||||||
|
|
||||||
|
interface BrandRow {
|
||||||
|
brand: Brand;
|
||||||
|
skuCount: number;
|
||||||
|
itemCount: number;
|
||||||
|
totalSpend: number;
|
||||||
|
avgRating: number | null;
|
||||||
|
ratingCount: number;
|
||||||
|
lastPurchase: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
type SortKey = "name" | "skus" | "items" | "spent" | "rating" | "recent";
|
||||||
|
|
||||||
|
const GRID_COLS = "2fr 0.7fr 0.7fr 0.8fr 0.8fr 1fr";
|
||||||
|
|
||||||
|
function buildBrandRows(data: Bootstrap): BrandRow[] {
|
||||||
|
const productsByBrand = new Map<string, typeof data.products>();
|
||||||
|
for (const p of data.products) {
|
||||||
|
if (!p.brandId) continue;
|
||||||
|
const arr = productsByBrand.get(p.brandId);
|
||||||
|
if (arr) arr.push(p);
|
||||||
|
else productsByBrand.set(p.brandId, [p]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const itemsByProduct = new Map<string, typeof data.inventoryItems>();
|
||||||
|
for (const i of data.inventoryItems) {
|
||||||
|
const arr = itemsByProduct.get(i.productId);
|
||||||
|
if (arr) arr.push(i);
|
||||||
|
else itemsByProduct.set(i.productId, [i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return data.brands.map((brand) => {
|
||||||
|
const products = productsByBrand.get(brand.id) ?? [];
|
||||||
|
const items = products.flatMap((p) => itemsByProduct.get(p.id) ?? []);
|
||||||
|
|
||||||
|
const totalSpend = items.reduce((s, i) => s + i.price, 0);
|
||||||
|
const rated = items.filter((i) => i.rating != null);
|
||||||
|
const avgRating =
|
||||||
|
rated.length > 0 ? rated.reduce((s, i) => s + i.rating!, 0) / rated.length : null;
|
||||||
|
|
||||||
|
const dates = items.map((i) => i.purchaseDate).sort();
|
||||||
|
const lastPurchase = dates.length > 0 ? dates[dates.length - 1]! : null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
brand,
|
||||||
|
skuCount: products.length,
|
||||||
|
itemCount: items.length,
|
||||||
|
totalSpend,
|
||||||
|
avgRating,
|
||||||
|
ratingCount: rated.length,
|
||||||
|
lastPurchase,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function BrandsView({
|
export function BrandsView({
|
||||||
data,
|
data,
|
||||||
|
onSelectBrand,
|
||||||
onAddBrand,
|
onAddBrand,
|
||||||
onEditBrand,
|
|
||||||
}: {
|
}: {
|
||||||
data: Bootstrap;
|
data: Bootstrap;
|
||||||
|
onSelectBrand: (brand: Brand) => void;
|
||||||
onAddBrand: () => void;
|
onAddBrand: () => void;
|
||||||
onEditBrand: (brand: Brand) => void;
|
|
||||||
}) {
|
}) {
|
||||||
const qc = useQueryClient();
|
const isMobile = useIsMobile();
|
||||||
const [confirmDelete, setConfirmDelete] = useState<{ id: string; name: string; count: number } | null>(null);
|
const [search, setSearch] = useState("");
|
||||||
|
const [sortBy, setSortBy] = useState<SortKey>("name");
|
||||||
|
|
||||||
const remove = useMutation({
|
const rows = useMemo(() => buildBrandRows(data), [data]);
|
||||||
mutationFn: (id: string) => api.deleteBrand(id),
|
|
||||||
onSuccess: () => {
|
const filtered = useMemo(() => {
|
||||||
qc.invalidateQueries({ queryKey: ["bootstrap"] });
|
if (!search) return rows;
|
||||||
setConfirmDelete(null);
|
const q = search.toLowerCase();
|
||||||
},
|
return rows.filter((r) => r.brand.name.toLowerCase().includes(q));
|
||||||
});
|
}, [rows, search]);
|
||||||
|
|
||||||
|
const sorted = useMemo(() => {
|
||||||
|
const copy = [...filtered];
|
||||||
|
if (sortBy === "name") copy.sort((a, b) => a.brand.name.localeCompare(b.brand.name));
|
||||||
|
else if (sortBy === "skus") copy.sort((a, b) => b.skuCount - a.skuCount);
|
||||||
|
else if (sortBy === "items") copy.sort((a, b) => b.itemCount - a.itemCount);
|
||||||
|
else if (sortBy === "spent") copy.sort((a, b) => b.totalSpend - a.totalSpend);
|
||||||
|
else if (sortBy === "recent")
|
||||||
|
copy.sort(
|
||||||
|
(a, b) =>
|
||||||
|
+(b.lastPurchase ? new Date(b.lastPurchase) : 0) -
|
||||||
|
+(a.lastPurchase ? new Date(a.lastPurchase) : 0),
|
||||||
|
);
|
||||||
|
else if (sortBy === "rating")
|
||||||
|
copy.sort((a, b) => (b.avgRating ?? -1) - (a.avgRating ?? -1));
|
||||||
|
return copy;
|
||||||
|
}, [filtered, sortBy]);
|
||||||
|
|
||||||
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={{ display: "flex", alignItems: "baseline", justifyContent: "space-between", marginBottom: 24 }}>
|
<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)" }}>
|
||||||
{data.brands.length} brand{data.brands.length === 1 ? "" : "s"}
|
{sorted.length} brand{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",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
Brands
|
Brands
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
<Btn variant="primary" icon="plus" onClick={onAddBrand}>New brand</Btn>
|
<Btn variant="primary" icon="plus" onClick={onAddBrand}>
|
||||||
</div>
|
New brand
|
||||||
<div style={{ fontSize: 14, color: "var(--ink-2)", marginBottom: 24, maxWidth: 600 }}>
|
</Btn>
|
||||||
Brands you've purchased from. Used in the brand dropdown when adding a new product.
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{data.brands.length === 0 ? (
|
{data.brands.length === 0 ? (
|
||||||
@@ -57,87 +142,329 @@ export function BrandsView({
|
|||||||
<div style={{ fontSize: 13, color: "var(--ink-3)", marginBottom: 18 }}>
|
<div style={{ fontSize: 13, color: "var(--ink-3)", marginBottom: 18 }}>
|
||||||
Add a brand to start tagging your purchases.
|
Add a brand to start tagging your purchases.
|
||||||
</div>
|
</div>
|
||||||
<Btn variant="primary" icon="plus" onClick={onAddBrand}>Add your first brand</Btn>
|
<Btn variant="primary" icon="plus" onClick={onAddBrand}>
|
||||||
|
Add your first brand
|
||||||
|
</Btn>
|
||||||
</Card>
|
</Card>
|
||||||
|
) : isMobile ? (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 8,
|
||||||
|
background: "var(--surface)",
|
||||||
|
border: "1px solid var(--line)",
|
||||||
|
borderRadius: "var(--r-md)",
|
||||||
|
padding: "0 10px",
|
||||||
|
marginBottom: 10,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon name="search" size={14} color="var(--ink-3)" />
|
||||||
|
<input
|
||||||
|
placeholder="Search by brand name..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
style={{
|
||||||
|
border: "none",
|
||||||
|
outline: "none",
|
||||||
|
background: "transparent",
|
||||||
|
padding: "10px 0",
|
||||||
|
fontSize: 14,
|
||||||
|
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={sortBy}
|
||||||
|
onChange={(e) => setSortBy(e.target.value as SortKey)}
|
||||||
|
style={{ ...inputStyle, width: "100%", padding: "8px 10px", marginBottom: 14 }}
|
||||||
|
>
|
||||||
|
<option value="name">Name (A-Z)</option>
|
||||||
|
<option value="skus">Most SKUs</option>
|
||||||
|
<option value="items">Most items</option>
|
||||||
|
<option value="spent">Most spent</option>
|
||||||
|
<option value="recent">Recent</option>
|
||||||
|
<option value="rating">Top rated</option>
|
||||||
|
</Select>
|
||||||
|
{sorted.length === 0 && (
|
||||||
|
<div style={{ padding: 40, textAlign: "center", color: "var(--ink-3)", fontSize: 13 }}>
|
||||||
|
No brands match these filters.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{sorted.map((r) => (
|
||||||
|
<MobileBrandCard key={r.brand.id} row={r} onClick={() => onSelectBrand(r.brand)} />
|
||||||
|
))}
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div
|
<>
|
||||||
style={{
|
<Card style={{ marginBottom: 14, padding: 14 }}>
|
||||||
display: "grid",
|
<div style={{ display: "flex", gap: 12, alignItems: "center", flexWrap: "wrap" }}>
|
||||||
gridTemplateColumns: "repeat(auto-fill, minmax(280px, 1fr))",
|
<div
|
||||||
gap: 14,
|
style={{
|
||||||
}}
|
flex: 1,
|
||||||
>
|
minWidth: 220,
|
||||||
{data.brands.map((b) => {
|
display: "flex",
|
||||||
// Count instances whose product points at this brand.
|
alignItems: "center",
|
||||||
const productIds = new Set(
|
gap: 8,
|
||||||
data.products.filter((p) => p.brandId === b.id).map((p) => p.id),
|
background: "var(--bg-2)",
|
||||||
);
|
border: "1px solid var(--line)",
|
||||||
const itemCount = data.inventoryItems.filter((i) =>
|
borderRadius: "var(--r-md)",
|
||||||
productIds.has(i.productId),
|
padding: "0 10px",
|
||||||
).length;
|
}}
|
||||||
return (
|
>
|
||||||
<Card key={b.id} style={{ display: "flex", alignItems: "center", gap: 12 }}>
|
<Icon name="search" size={14} color="var(--ink-3)" />
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
<input
|
||||||
<div className="serif" style={{ fontSize: 22, fontWeight: 500, lineHeight: 1.1 }}>
|
placeholder="Search by brand name..."
|
||||||
{b.name}
|
value={search}
|
||||||
</div>
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
</div>
|
|
||||||
<Pill tone="outline">
|
|
||||||
{itemCount} purchase{itemCount === 1 ? "" : "s"}
|
|
||||||
</Pill>
|
|
||||||
<button
|
|
||||||
onClick={() => onEditBrand(b)}
|
|
||||||
title="Edit brand"
|
|
||||||
aria-label={`Edit brand ${b.name}`}
|
|
||||||
style={{
|
style={{
|
||||||
background: "transparent",
|
|
||||||
border: "none",
|
border: "none",
|
||||||
padding: 4,
|
outline: "none",
|
||||||
borderRadius: "var(--r-sm)",
|
|
||||||
cursor: "pointer",
|
|
||||||
color: "var(--ink-3)",
|
|
||||||
display: "inline-flex",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Icon name="edit" size={14} />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setConfirmDelete({ id: b.id, name: b.name, count: itemCount })}
|
|
||||||
title="Remove brand"
|
|
||||||
aria-label={`Remove brand ${b.name}`}
|
|
||||||
disabled={remove.isPending}
|
|
||||||
style={{
|
|
||||||
background: "transparent",
|
background: "transparent",
|
||||||
border: "none",
|
padding: "8px 0",
|
||||||
padding: 4,
|
fontSize: 13,
|
||||||
borderRadius: "var(--r-sm)",
|
flex: 1,
|
||||||
cursor: remove.isPending ? "wait" : "pointer",
|
color: "var(--ink)",
|
||||||
color: "var(--ink-3)",
|
|
||||||
display: "inline-flex",
|
|
||||||
}}
|
}}
|
||||||
>
|
/>
|
||||||
<Icon name="bin" size={14} />
|
{search && (
|
||||||
</button>
|
<button
|
||||||
</Card>
|
onClick={() => setSearch("")}
|
||||||
);
|
style={{
|
||||||
})}
|
background: "transparent",
|
||||||
</div>
|
border: "none",
|
||||||
)}
|
cursor: "pointer",
|
||||||
|
padding: 2,
|
||||||
{confirmDelete && (
|
display: "inline-flex",
|
||||||
<ConfirmDialog
|
color: "var(--ink-3)",
|
||||||
title={`Delete "${confirmDelete.name}"?`}
|
}}
|
||||||
message={
|
>
|
||||||
confirmDelete.count > 0
|
<Icon name="close" size={12} />
|
||||||
? `${confirmDelete.count} inventory item${confirmDelete.count === 1 ? "" : "s"} will be unbranded.`
|
</button>
|
||||||
: "This brand will be permanently removed."
|
)}
|
||||||
}
|
</div>
|
||||||
confirmLabel="Delete brand"
|
<Select
|
||||||
onConfirm={() => remove.mutate(confirmDelete.id)}
|
value={sortBy}
|
||||||
onCancel={() => setConfirmDelete(null)}
|
onChange={(e) => setSortBy(e.target.value as SortKey)}
|
||||||
isPending={remove.isPending}
|
style={{ ...inputStyle, width: "auto", padding: "8px 10px" }}
|
||||||
/>
|
>
|
||||||
|
<option value="name">Name (A-Z)</option>
|
||||||
|
<option value="skus">Most SKUs</option>
|
||||||
|
<option value="items">Most items</option>
|
||||||
|
<option value="spent">Most spent</option>
|
||||||
|
<option value="recent">Recent purchase</option>
|
||||||
|
<option value="rating">Highest rated</option>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
<Card padded={false}>
|
||||||
|
<BrandHeaderRow sortBy={sortBy} onSort={setSortBy} />
|
||||||
|
{sorted.length === 0 && (
|
||||||
|
<div style={{ padding: 60, textAlign: "center", color: "var(--ink-3)" }}>
|
||||||
|
No brands match these filters.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{sorted.map((r) => (
|
||||||
|
<BrandItemRow key={r.brand.id} row={r} onClick={() => onSelectBrand(r.brand)} />
|
||||||
|
))}
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const COL_SORT: (SortKey | null)[] = ["name", "skus", "items", "spent", "rating", "recent"];
|
||||||
|
const COL_LABELS = ["Name", "SKUs", "Items", "Spent", "Rating", "Last purchase"];
|
||||||
|
|
||||||
|
function BrandHeaderRow({
|
||||||
|
sortBy,
|
||||||
|
onSort,
|
||||||
|
}: {
|
||||||
|
sortBy: SortKey;
|
||||||
|
onSort: (k: SortKey) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: GRID_COLS,
|
||||||
|
columnGap: 16,
|
||||||
|
padding: "12px 20px",
|
||||||
|
borderBottom: "1px solid var(--line)",
|
||||||
|
background: "var(--bg-2)",
|
||||||
|
fontSize: 11,
|
||||||
|
color: "var(--ink-3)",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
letterSpacing: "0.08em",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{COL_LABELS.map((label, i) => {
|
||||||
|
const sk = COL_SORT[i];
|
||||||
|
if (!sk) return <div key={i}>{label}</div>;
|
||||||
|
const active = sortBy === sk;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
onClick={() => onSort(sk)}
|
||||||
|
style={{
|
||||||
|
background: "none",
|
||||||
|
border: "none",
|
||||||
|
padding: 0,
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: "inherit",
|
||||||
|
textTransform: "inherit",
|
||||||
|
letterSpacing: "inherit",
|
||||||
|
fontWeight: active ? 600 : "inherit",
|
||||||
|
color: active ? "var(--ink)" : "var(--ink-3)",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
{active && <span style={{ fontSize: 9 }}>▼</span>}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MobileBrandCard({ row, onClick }: { row: BrandRow; onClick: () => void }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onClick={onClick}
|
||||||
|
style={{
|
||||||
|
padding: "14px 16px",
|
||||||
|
background: "var(--surface)",
|
||||||
|
borderRadius: "var(--r-md)",
|
||||||
|
marginBottom: 8,
|
||||||
|
cursor: "pointer",
|
||||||
|
border: "1px solid var(--line)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontWeight: 500,
|
||||||
|
fontSize: 14,
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{row.brand.name}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span style={{ color: "var(--ink-3)", fontSize: 16 }}>›</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 12,
|
||||||
|
marginTop: 8,
|
||||||
|
paddingTop: 8,
|
||||||
|
borderTop: "1px solid var(--line)",
|
||||||
|
fontSize: 12,
|
||||||
|
color: "var(--ink-2)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="mono">{row.skuCount} SKU{row.skuCount !== 1 ? "s" : ""}</span>
|
||||||
|
<span className="mono">{row.itemCount} item{row.itemCount !== 1 ? "s" : ""}</span>
|
||||||
|
{row.itemCount > 0 && <span className="mono">{fmt.money(row.totalSpend)}</span>}
|
||||||
|
{row.avgRating != null && (
|
||||||
|
<span style={{ display: "flex", alignItems: "center", gap: 3 }}>
|
||||||
|
<Icon name="star" size={11} color="var(--amber)" />
|
||||||
|
<span className="mono">{row.avgRating.toFixed(1)}</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{row.lastPurchase && (
|
||||||
|
<span style={{ marginLeft: "auto", color: "var(--ink-3)" }}>
|
||||||
|
{fmt.dateShort(row.lastPurchase, getStoredTimezone())}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BrandItemRow({ row, onClick }: { row: BrandRow; onClick: () => void }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onClick={onClick}
|
||||||
|
className="inv-row"
|
||||||
|
style={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: GRID_COLS,
|
||||||
|
columnGap: 16,
|
||||||
|
padding: "14px 20px",
|
||||||
|
borderBottom: "1px solid var(--line)",
|
||||||
|
alignItems: "center",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: 13,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontWeight: 500,
|
||||||
|
color: "var(--ink)",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{row.brand.name}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontFamily: "var(--mono)" }}>{row.skuCount}</div>
|
||||||
|
<div style={{ fontFamily: "var(--mono)" }}>{row.itemCount}</div>
|
||||||
|
<div style={{ fontFamily: "var(--mono)" }}>
|
||||||
|
{row.itemCount > 0 ? fmt.money(row.totalSpend) : "—"}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 4 }}>
|
||||||
|
{row.avgRating != null ? (
|
||||||
|
<>
|
||||||
|
<Icon name="star" size={12} color="var(--amber)" />
|
||||||
|
<span style={{ fontFamily: "var(--mono)", fontSize: 12 }}>
|
||||||
|
{row.avgRating.toFixed(1)}
|
||||||
|
</span>
|
||||||
|
<span style={{ fontSize: 10, color: "var(--ink-3)" }}>({row.ratingCount})</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span style={{ color: "var(--ink-3)", fontSize: 12, fontStyle: "italic" }}>—</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
|
||||||
|
<span style={{ fontSize: 12, color: "var(--ink-3)" }}>
|
||||||
|
{row.lastPurchase ? fmt.dateShort(row.lastPurchase, getStoredTimezone()) : "—"}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className="inv-row-chevron"
|
||||||
|
style={{ color: "var(--ink-3)", marginLeft: "auto", fontSize: 14 }}
|
||||||
|
>
|
||||||
|
›
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
+592
-156
@@ -1,169 +1,469 @@
|
|||||||
|
import { useMemo } from "react";
|
||||||
import type { Bootstrap } from "../types.js";
|
import type { Bootstrap } from "../types.js";
|
||||||
import { helpers } from "../types.js";
|
import { helpers, enrichItems } from "../types.js";
|
||||||
import type { Stats } from "../stats.js";
|
import type { Stats } from "../stats.js";
|
||||||
import { fmt } from "../format.js";
|
import { fmt } from "../format.js";
|
||||||
import { BarChart, Card } from "../components/primitives/index.js";
|
import { BarChart, Card, Stat, Icon } from "../components/primitives/index.js";
|
||||||
|
import { getStoredTimezone } from "../tz.js";
|
||||||
|
import { useIsMobile } from "../hooks/useIsMobile.js";
|
||||||
|
|
||||||
|
const DOW_NAMES = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
||||||
|
const DOW_FULL = ["Sundays", "Mondays", "Tuesdays", "Wednesdays", "Thursdays", "Fridays", "Saturdays"];
|
||||||
|
const DOW_ORDER = [1, 2, 3, 4, 5, 6, 0]; // Mon→Sun
|
||||||
|
|
||||||
export function ChartsView({ data, stats }: { data: Bootstrap; stats: Stats }) {
|
export function ChartsView({ data, stats }: { data: Bootstrap; stats: Stats }) {
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
const tz = getStoredTimezone();
|
||||||
const series = stats.series90.map((s) => ({ date: s.date, grams: s.grams }));
|
const series = stats.series90.map((s) => ({ date: s.date, grams: s.grams }));
|
||||||
|
|
||||||
const spendByMonth: Record<string, number> = {};
|
const items = useMemo(() => enrichItems(data), [data]);
|
||||||
data.inventoryItems.forEach((i) => {
|
|
||||||
const k = i.purchaseDate.slice(0, 7);
|
|
||||||
spendByMonth[k] = (spendByMonth[k] ?? 0) + i.price;
|
|
||||||
});
|
|
||||||
const months = Object.entries(spendByMonth).sort();
|
|
||||||
|
|
||||||
const spendByShop: Record<string, number> = {};
|
// ── Peak day ──────────────────────────────────────────────────────
|
||||||
data.inventoryItems.forEach((i) => {
|
const peakDay = useMemo(() => {
|
||||||
const name = helpers.shopName(data, i.shopId);
|
if (!series.length) return null;
|
||||||
spendByShop[name] = (spendByShop[name] ?? 0) + i.price;
|
return series.reduce((best, d) => (d.grams > best.grams ? d : best), series[0]!);
|
||||||
});
|
}, [series]);
|
||||||
const shopRanked = Object.entries(spendByShop).sort((a, b) => b[1] - a[1]);
|
|
||||||
const shopMax = shopRanked[0]?.[1] ?? 1;
|
// ── Day-of-week averages ──────────────────────────────────────────
|
||||||
const monthMax = Math.max(...months.map((x) => x[1]), 1);
|
const dowAvgs = useMemo(() => {
|
||||||
|
const totals = [0, 0, 0, 0, 0, 0, 0];
|
||||||
|
const counts = [0, 0, 0, 0, 0, 0, 0];
|
||||||
|
series.forEach((s) => {
|
||||||
|
const dow = new Date(s.date + "T12:00:00").getDay();
|
||||||
|
totals[dow] += s.grams;
|
||||||
|
counts[dow]++;
|
||||||
|
});
|
||||||
|
return totals.map((t, i) => t / (counts[i] || 1));
|
||||||
|
}, [series]);
|
||||||
|
|
||||||
|
const busiestDow = useMemo(() => {
|
||||||
|
let maxIdx = 0;
|
||||||
|
dowAvgs.forEach((v, i) => {
|
||||||
|
if (v > dowAvgs[maxIdx]!) maxIdx = i;
|
||||||
|
});
|
||||||
|
return maxIdx;
|
||||||
|
}, [dowAvgs]);
|
||||||
|
|
||||||
|
// ── 30-day trend ──────────────────────────────────────────────────
|
||||||
|
const trend = useMemo(() => {
|
||||||
|
const recent = series.slice(-30);
|
||||||
|
const prev = series.slice(-60, -30);
|
||||||
|
const recentSum = recent.reduce((s, d) => s + d.grams, 0);
|
||||||
|
const prevSum = prev.reduce((s, d) => s + d.grams, 0);
|
||||||
|
const pct = prevSum > 0 ? ((recentSum - prevSum) / prevSum) * 100 : 0;
|
||||||
|
return { pct, up: pct >= 0 };
|
||||||
|
}, [series]);
|
||||||
|
|
||||||
|
// ── Average rating ────────────────────────────────────────────────
|
||||||
|
const ratingInfo = useMemo(() => {
|
||||||
|
const rated = items.filter((i) => i.rating != null);
|
||||||
|
if (!rated.length) return null;
|
||||||
|
const avg = rated.reduce((s, i) => s + i.rating!, 0) / rated.length;
|
||||||
|
return { avg, count: rated.length };
|
||||||
|
}, [items]);
|
||||||
|
|
||||||
|
// ── Spending ──────────────────────────────────────────────────────
|
||||||
|
const { months, monthMax, shopRanked, shopMax, shopItemCount } = useMemo(() => {
|
||||||
|
const spendByMonth: Record<string, number> = {};
|
||||||
|
const spendByShop: Record<string, number> = {};
|
||||||
|
const shopItems: Record<string, number> = {};
|
||||||
|
|
||||||
|
data.inventoryItems.forEach((i) => {
|
||||||
|
const mk = i.purchaseDate.slice(0, 7);
|
||||||
|
spendByMonth[mk] = (spendByMonth[mk] ?? 0) + i.price;
|
||||||
|
|
||||||
|
const name = helpers.shopName(data, i.shopId);
|
||||||
|
spendByShop[name] = (spendByShop[name] ?? 0) + i.price;
|
||||||
|
shopItems[name] = (shopItems[name] ?? 0) + 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
const m = Object.entries(spendByMonth).sort();
|
||||||
|
const sr = Object.entries(spendByShop).sort((a, b) => b[1] - a[1]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
months: m,
|
||||||
|
monthMax: Math.max(...m.map((x) => x[1]), 1),
|
||||||
|
shopRanked: sr,
|
||||||
|
shopMax: sr[0]?.[1] ?? 1,
|
||||||
|
shopItemCount: shopItems,
|
||||||
|
};
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
// ── Strain leaderboard ────────────────────────────────────────────
|
||||||
|
const topStrains = useMemo(() => {
|
||||||
|
const strainMap = new Map(data.strains.map((s) => [s.id, s.name]));
|
||||||
|
const acc: Record<string, { name: string; count: number; totalRating: number; ratedCount: number }> = {};
|
||||||
|
|
||||||
|
items.forEach((i) => {
|
||||||
|
const name = strainMap.get(i.strainId);
|
||||||
|
if (!name) return;
|
||||||
|
if (!acc[i.strainId]) acc[i.strainId] = { name, count: 0, totalRating: 0, ratedCount: 0 };
|
||||||
|
acc[i.strainId].count++;
|
||||||
|
if (i.rating != null) {
|
||||||
|
acc[i.strainId].totalRating += i.rating;
|
||||||
|
acc[i.strainId].ratedCount++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return Object.values(acc)
|
||||||
|
.map((d) => ({
|
||||||
|
name: d.name,
|
||||||
|
count: d.count,
|
||||||
|
avgRating: d.ratedCount > 0 ? d.totalRating / d.ratedCount : null,
|
||||||
|
}))
|
||||||
|
.sort((a, b) => b.count - a.count)
|
||||||
|
.slice(0, 5);
|
||||||
|
}, [items, data.strains]);
|
||||||
|
|
||||||
|
// ── Rolling 7-day average ─────────────────────────────────────────
|
||||||
|
const rolling7 = useMemo(() => {
|
||||||
|
return series.map((_, i) => {
|
||||||
|
const window = series.slice(Math.max(0, i - 6), i + 1);
|
||||||
|
return window.reduce((s, d) => s + d.grams, 0) / window.length;
|
||||||
|
});
|
||||||
|
}, [series]);
|
||||||
|
|
||||||
|
// ── Summary stats ─────────────────────────────────────────────────
|
||||||
|
const totalGrams90 = series.reduce((s, d) => s + d.grams, 0);
|
||||||
|
const dailyAvg90 = totalGrams90 / 90;
|
||||||
|
|
||||||
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 }}>
|
{/* ── 1. Header ──────────────────────────────────────────────── */}
|
||||||
<div className="smallcaps" style={{ color: "var(--ink-3)" }}>Last 90 days</div>
|
<div style={{ marginBottom: isMobile ? 16 : 24 }}>
|
||||||
|
<div className="smallcaps" style={{ color: "var(--ink-3)" }}>
|
||||||
|
Last 90 days
|
||||||
|
</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",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
Patterns & spend
|
Patterns
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card style={{ marginBottom: 14 }}>
|
{stats.purchaseCount === 0 ? (
|
||||||
<div
|
<Card style={{ textAlign: "center", padding: "60px 32px" }}>
|
||||||
style={{
|
<div className="serif" style={{ fontSize: 28, fontWeight: 500, marginBottom: 8 }}>
|
||||||
display: "flex",
|
No data yet
|
||||||
justifyContent: "space-between",
|
|
||||||
alignItems: "baseline",
|
|
||||||
marginBottom: 18,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="serif" style={{ fontSize: 22 }}>Daily grams · 90 days</div>
|
|
||||||
<div style={{ display: "flex", gap: 24, fontSize: 12, color: "var(--ink-3)" }}>
|
|
||||||
<div>
|
|
||||||
Total{" "}
|
|
||||||
<span className="serif" style={{ fontSize: 18, color: "var(--ink)" }}>
|
|
||||||
{series.reduce((s, e) => s + e.grams, 0).toFixed(1)} g
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
Avg{" "}
|
|
||||||
<span className="serif" style={{ fontSize: 18, color: "var(--ink)" }}>
|
|
||||||
{(series.reduce((s, e) => s + e.grams, 0) / 90).toFixed(2)} g/day
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
Items finished{" "}
|
|
||||||
<span className="serif" style={{ fontSize: 18, color: "var(--ink)" }}>{stats.consumedCount}</span>
|
|
||||||
</div>
|
|
||||||
{stats.goneCount > 0 && (
|
|
||||||
<div>
|
|
||||||
Items gone{" "}
|
|
||||||
<span className="serif" style={{ fontSize: 18, color: "var(--ink)" }}>{stats.goneCount}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div
|
||||||
<BarChart data={series.map((s) => ({ value: s.grams }))} height={180} color="var(--sage)" />
|
style={{
|
||||||
</Card>
|
fontSize: 14,
|
||||||
|
color: "var(--ink-3)",
|
||||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 14, marginBottom: 14 }}>
|
maxWidth: 400,
|
||||||
<Card>
|
margin: "0 auto",
|
||||||
<div className="serif" style={{ fontSize: 22, marginBottom: 18 }}>Spend by month</div>
|
}}
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: 14 }}>
|
>
|
||||||
{months.map(([m, v]) => {
|
Add inventory items to start seeing consumption patterns and spending insights.
|
||||||
const d = new Date(m + "-01");
|
|
||||||
return (
|
|
||||||
<div key={m} style={{ display: "flex", alignItems: "center", gap: 12 }}>
|
|
||||||
<div className="smallcaps" style={{ color: "var(--ink-3)", width: 60 }}>
|
|
||||||
{d.toLocaleDateString("en-US", { month: "short", year: "2-digit" })}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
height: 24,
|
|
||||||
background: "var(--bg-2)",
|
|
||||||
borderRadius: 4,
|
|
||||||
position: "relative",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: `${(v / monthMax) * 100}%`,
|
|
||||||
height: "100%",
|
|
||||||
background: "var(--terracotta)",
|
|
||||||
borderRadius: 4,
|
|
||||||
opacity: 0.85,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="mono" style={{ width: 70, textAlign: "right", fontSize: 13 }}>
|
|
||||||
{fmt.moneyShort(v)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* ── 2. Insight strip ──────────────────────────────────── */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: isMobile
|
||||||
|
? "1fr 1fr"
|
||||||
|
: "repeat(auto-fit, minmax(220px, 1fr))",
|
||||||
|
gap: 14,
|
||||||
|
marginBottom: 14,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stat
|
||||||
|
label="Peak day"
|
||||||
|
value={peakDay ? peakDay.grams.toFixed(1) : "—"}
|
||||||
|
unit="g"
|
||||||
|
sub={peakDay ? fmt.dateShort(peakDay.date, tz) : undefined}
|
||||||
|
/>
|
||||||
|
<Stat
|
||||||
|
label="Busiest day"
|
||||||
|
value={DOW_FULL[busiestDow]}
|
||||||
|
sub={`${dowAvgs[busiestDow]!.toFixed(2)}g average`}
|
||||||
|
/>
|
||||||
|
<Stat
|
||||||
|
label="30-day trend"
|
||||||
|
value={`${trend.up ? "+" : ""}${trend.pct.toFixed(0)}%`}
|
||||||
|
accent={trend.up ? "var(--sage)" : "var(--terracotta)"}
|
||||||
|
sub="vs previous 30 days"
|
||||||
|
/>
|
||||||
|
<Stat
|
||||||
|
label="Avg rating"
|
||||||
|
value={ratingInfo ? ratingInfo.avg.toFixed(1) : "—"}
|
||||||
|
sub={
|
||||||
|
ratingInfo ? (
|
||||||
|
<span style={{ display: "inline-flex", alignItems: "center", gap: 4 }}>
|
||||||
|
<Icon name="star" size={11} color="var(--amber)" />
|
||||||
|
{ratingInfo.count} rated item{ratingInfo.count === 1 ? "" : "s"}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
"No ratings yet"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Card>
|
{/* ── 3. Heatmap ───────────────────────────────────────── */}
|
||||||
<div className="serif" style={{ fontSize: 22, marginBottom: 18 }}>Spend by shop</div>
|
<Card style={{ marginBottom: 14 }}>
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: 14 }}>
|
<div style={{ marginBottom: 18 }}>
|
||||||
{shopRanked.map(([s, v]) => (
|
<div className="serif" style={{ fontSize: 22 }}>Consumption heatmap</div>
|
||||||
<div key={s} style={{ display: "flex", alignItems: "center", gap: 12 }}>
|
<div style={{ fontSize: 12, color: "var(--ink-3)", marginTop: 4 }}>
|
||||||
<div style={{ flex: 1.5, fontSize: 13, color: "var(--ink-2)" }}>{s}</div>
|
13 weeks — darker cells indicate higher daily use
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Heatmap series={series} tz={tz} />
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* ── 4. Day-of-week pattern ───────────────────────────── */}
|
||||||
|
<Card style={{ marginBottom: 14 }}>
|
||||||
|
<div style={{ marginBottom: 18 }}>
|
||||||
|
<div className="serif" style={{ fontSize: 22 }}>Day of week</div>
|
||||||
|
<div style={{ fontSize: 12, color: "var(--ink-3)", marginTop: 4 }}>
|
||||||
|
Average daily consumption by weekday
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DayOfWeekBars dowAvgs={dowAvgs} busiestDow={busiestDow} />
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* ── 5. 90-day trend ───────────────────────────────────── */}
|
||||||
|
<Card style={{ marginBottom: 14 }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "baseline",
|
||||||
|
marginBottom: 18,
|
||||||
|
flexWrap: "wrap",
|
||||||
|
gap: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div className="serif" style={{ fontSize: 22 }}>Daily consumption</div>
|
||||||
|
<div className="smallcaps" style={{ color: "var(--ink-3)", marginTop: 4 }}>
|
||||||
|
90 days
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "flex", gap: 24, fontSize: 12, color: "var(--ink-3)" }}>
|
||||||
|
<div>
|
||||||
|
Total{" "}
|
||||||
|
<span className="serif" style={{ fontSize: 18, color: "var(--ink)" }}>
|
||||||
|
{totalGrams90.toFixed(1)} g
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
Avg{" "}
|
||||||
|
<span className="serif" style={{ fontSize: 18, color: "var(--ink)" }}>
|
||||||
|
{dailyAvg90.toFixed(2)} g/day
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
Finished{" "}
|
||||||
|
<span className="serif" style={{ fontSize: 18, color: "var(--ink)" }}>
|
||||||
|
{stats.consumedCount}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ position: "relative" }}>
|
||||||
|
<BarChart data={series.map((s) => ({ value: s.grams }))} height={180} color="var(--sage)" />
|
||||||
|
<RollingAvgLine values={rolling7} max={Math.max(...series.map((s) => s.grams), 0.001)} height={180} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
marginTop: 8,
|
||||||
|
fontSize: 10,
|
||||||
|
color: "var(--ink-3)",
|
||||||
|
fontFamily: "var(--mono)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>{fmt.dateShort(series[0]?.date, tz)}</span>
|
||||||
|
<span>{fmt.dateShort(series[Math.floor(series.length / 2)]?.date, tz)}</span>
|
||||||
|
<span>today</span>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* ── 6. Spending ──────────────────────────────────────── */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: isMobile
|
||||||
|
? "1fr"
|
||||||
|
: "repeat(auto-fit, minmax(340px, 1fr))",
|
||||||
|
gap: 14,
|
||||||
|
marginBottom: 14,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Card>
|
||||||
|
<div className="serif" style={{ fontSize: 22, marginBottom: 18 }}>Spend by month</div>
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
||||||
|
{months.map(([m, v]) => {
|
||||||
|
const d = new Date(m + "-01");
|
||||||
|
return (
|
||||||
|
<div key={m} style={{ display: "flex", alignItems: "center", gap: 12 }}>
|
||||||
|
<div className="smallcaps" style={{ color: "var(--ink-3)", width: 60 }}>
|
||||||
|
{d.toLocaleDateString("en-US", { month: "short", year: "2-digit", timeZone: tz })}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
height: 20,
|
||||||
|
background: "var(--bg-2)",
|
||||||
|
borderRadius: 4,
|
||||||
|
position: "relative",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: `${(v / monthMax) * 100}%`,
|
||||||
|
height: "100%",
|
||||||
|
background: "var(--terracotta)",
|
||||||
|
borderRadius: 4,
|
||||||
|
opacity: 0.85,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="mono" style={{ width: 70, textAlign: "right", fontSize: 13 }}>
|
||||||
|
{fmt.moneyShort(v)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<div className="serif" style={{ fontSize: 22, marginBottom: 18 }}>Spend by shop</div>
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
||||||
|
{shopRanked.map(([s, v]) => (
|
||||||
|
<div key={s} style={{ display: "flex", alignItems: "center", gap: 12 }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
flex: 1.5,
|
||||||
|
fontSize: 13,
|
||||||
|
color: "var(--ink-2)",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
minWidth: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{s}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
flex: 2,
|
||||||
|
height: 8,
|
||||||
|
background: "var(--bg-2)",
|
||||||
|
borderRadius: 4,
|
||||||
|
position: "relative",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: `${(v / shopMax) * 100}%`,
|
||||||
|
height: "100%",
|
||||||
|
background: "var(--sage)",
|
||||||
|
borderRadius: 4,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "flex", alignItems: "baseline", gap: 4, flexShrink: 0 }}>
|
||||||
|
<span className="mono" style={{ fontSize: 13 }}>
|
||||||
|
{fmt.moneyShort(v)}
|
||||||
|
</span>
|
||||||
|
<span style={{ fontSize: 11, color: "var(--ink-3)" }}>
|
||||||
|
({shopItemCount[s]} items)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── 7. Strain leaderboard ────────────────────────────── */}
|
||||||
|
<Card padded={false}>
|
||||||
|
<div style={{ padding: "24px 24px 14px" }}>
|
||||||
|
<div className="serif" style={{ fontSize: 22 }}>Top strains</div>
|
||||||
|
</div>
|
||||||
|
{topStrains.length === 0 ? (
|
||||||
|
<div style={{ padding: "0 24px 24px", fontSize: 13, color: "var(--ink-3)" }}>
|
||||||
|
No strain data yet.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
topStrains.map((s, i) => (
|
||||||
<div
|
<div
|
||||||
|
key={s.name}
|
||||||
style={{
|
style={{
|
||||||
flex: 2,
|
padding: "12px 24px",
|
||||||
height: 8,
|
borderTop: "1px solid var(--line)",
|
||||||
background: "var(--bg-2)",
|
display: "flex",
|
||||||
borderRadius: 4,
|
alignItems: "center",
|
||||||
position: "relative",
|
gap: 12,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
style={{
|
className="mono"
|
||||||
width: `${(v / shopMax) * 100}%`,
|
style={{ width: 24, fontSize: 14, color: "var(--ink-3)", textAlign: "center" }}
|
||||||
height: "100%",
|
>
|
||||||
background: "var(--sage)",
|
{i + 1}
|
||||||
borderRadius: 4,
|
</div>
|
||||||
}}
|
<div style={{ flex: 1, fontWeight: 500, fontSize: 13, minWidth: 0 }}>
|
||||||
/>
|
<div
|
||||||
|
style={{
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{s.name}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mono" style={{ fontSize: 12, color: "var(--ink-2)", flexShrink: 0 }}>
|
||||||
|
{s.count} purchase{s.count === 1 ? "" : "s"}
|
||||||
|
</div>
|
||||||
|
{s.avgRating != null && (
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 4, flexShrink: 0 }}>
|
||||||
|
<Icon name="star" size={12} color="var(--amber)" />
|
||||||
|
<span className="mono" style={{ fontSize: 12 }}>
|
||||||
|
{s.avgRating.toFixed(1)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="mono" style={{ width: 70, textAlign: "right", fontSize: 13 }}>
|
))
|
||||||
{fmt.moneyShort(v)}
|
)}
|
||||||
</div>
|
</Card>
|
||||||
</div>
|
</>
|
||||||
))}
|
)}
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<div className="serif" style={{ fontSize: 22, marginBottom: 6 }}>Inferred consumption heatmap</div>
|
|
||||||
<div style={{ fontSize: 12, color: "var(--ink-3)", marginBottom: 18 }}>
|
|
||||||
13 weeks · darker = higher inferred daily use, prorated across each item's lifespan
|
|
||||||
</div>
|
|
||||||
<Heatmap series={series} />
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Heatmap({ series }: { series: { date: string; grams: number }[] }) {
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
const first = new Date(series[0]!.date);
|
// Sub-components
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
function Heatmap({ series, tz }: { series: { date: string; grams: number }[]; tz: string }) {
|
||||||
|
const first = new Date(series[0]!.date + "T12:00:00");
|
||||||
const offset = first.getDay();
|
const offset = first.getDay();
|
||||||
const cells: ({ date: string; grams: number } | null)[] = [];
|
const cells: ({ date: string; grams: number } | null)[] = [];
|
||||||
for (let i = 0; i < offset; i++) cells.push(null);
|
for (let i = 0; i < offset; i++) cells.push(null);
|
||||||
@@ -173,29 +473,51 @@ function Heatmap({ series }: { series: { date: string; grams: number }[] }) {
|
|||||||
const max = Math.max(...series.map((s) => s.grams), 0.001);
|
const max = Math.max(...series.map((s) => s.grams), 0.001);
|
||||||
const colorFor = (g: number) => {
|
const colorFor = (g: number) => {
|
||||||
if (g === 0) return "var(--bg-3)";
|
if (g === 0) return "var(--bg-3)";
|
||||||
const t = g / max;
|
const t = Math.min(g / max, 1);
|
||||||
return `oklch(${72 - t * 30}% ${0.04 + t * 0.06} 145)`;
|
return `oklch(${85 - t * 43}% ${0.02 + t * 0.06} 145)`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const tipFor = (c: { date: string; grams: number }) => {
|
||||||
|
const d = new Date(c.date + "T12:00:00");
|
||||||
|
const label = d.toLocaleDateString("en-US", {
|
||||||
|
weekday: "short",
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
timeZone: tz,
|
||||||
|
});
|
||||||
|
return `${label} — ${c.grams.toFixed(2)}g`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const days = ["S", "M", "T", "W", "T", "F", "S"];
|
const days = ["S", "M", "T", "W", "T", "F", "S"];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: "flex", gap: 8, alignItems: "flex-start" }}>
|
<div style={{ display: "flex", gap: 10, alignItems: "flex-start" }}>
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: 3, paddingTop: 18 }}>
|
{/* Day-of-week labels */}
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 4, paddingTop: 22 }}>
|
||||||
{days.map((d, i) => (
|
{days.map((d, i) => (
|
||||||
<div
|
<div
|
||||||
key={i}
|
key={i}
|
||||||
style={{ height: 14, fontSize: 9, color: "var(--ink-3)", fontFamily: "var(--mono)" }}
|
style={{
|
||||||
|
height: 20,
|
||||||
|
lineHeight: "20px",
|
||||||
|
fontSize: 10,
|
||||||
|
color: "var(--ink-3)",
|
||||||
|
fontFamily: "var(--mono)",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{d}
|
{d}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ flex: 1 }}>
|
|
||||||
|
{/* Grid */}
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
{/* Month labels */}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: "grid",
|
display: "grid",
|
||||||
gridTemplateColumns: "repeat(13, 1fr)",
|
gridTemplateColumns: "repeat(13, 1fr)",
|
||||||
gap: 3,
|
gap: 4,
|
||||||
marginBottom: 4,
|
marginBottom: 4,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -205,46 +527,55 @@ function Heatmap({ series }: { series: { date: string; grams: number }[] }) {
|
|||||||
<div
|
<div
|
||||||
key={w}
|
key={w}
|
||||||
style={{
|
style={{
|
||||||
fontSize: 9,
|
fontSize: 10,
|
||||||
color: "var(--ink-3)",
|
color: "var(--ink-3)",
|
||||||
fontFamily: "var(--mono)",
|
fontFamily: "var(--mono)",
|
||||||
textAlign: "center",
|
textAlign: "center",
|
||||||
|
height: 14,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{firstDay && new Date(firstDay.date).getDate() <= 7
|
{firstDay && new Date(firstDay.date + "T12:00:00").getDate() <= 7
|
||||||
? new Date(firstDay.date).toLocaleDateString("en-US", { month: "short" })
|
? new Date(firstDay.date + "T12:00:00").toLocaleDateString("en-US", {
|
||||||
|
month: "short",
|
||||||
|
timeZone: tz,
|
||||||
|
})
|
||||||
: ""}
|
: ""}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Cells */}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: "grid",
|
display: "grid",
|
||||||
gridTemplateRows: "repeat(7, 1fr)",
|
gridTemplateRows: "repeat(7, 1fr)",
|
||||||
gridAutoFlow: "column",
|
gridAutoFlow: "column",
|
||||||
gap: 3,
|
gap: 4,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{cells.map((c, i) => (
|
{cells.map((c, i) => (
|
||||||
<div
|
<div
|
||||||
key={i}
|
key={i}
|
||||||
title={c ? `${c.date}: ${c.grams.toFixed(2)}g` : ""}
|
title={c ? tipFor(c) : ""}
|
||||||
style={{
|
style={{
|
||||||
aspectRatio: "1",
|
aspectRatio: "1",
|
||||||
minHeight: 14,
|
minHeight: 20,
|
||||||
background: c ? colorFor(c.grams) : "transparent",
|
background: c ? colorFor(c.grams) : "transparent",
|
||||||
borderRadius: 2,
|
borderRadius: 3,
|
||||||
|
transition: "opacity 120ms",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Legend */}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
justifyContent: "flex-end",
|
justifyContent: "flex-end",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
gap: 6,
|
gap: 8,
|
||||||
marginTop: 14,
|
marginTop: 14,
|
||||||
fontSize: 10,
|
fontSize: 10,
|
||||||
color: "var(--ink-3)",
|
color: "var(--ink-3)",
|
||||||
@@ -252,20 +583,125 @@ function Heatmap({ series }: { series: { date: string; grams: number }[] }) {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span>Less</span>
|
<span>Less</span>
|
||||||
{[0, 0.25, 0.5, 0.75, 1].map((t) => (
|
<div
|
||||||
<div
|
style={{
|
||||||
key={t}
|
width: 120,
|
||||||
style={{
|
height: 12,
|
||||||
width: 14,
|
borderRadius: 3,
|
||||||
height: 14,
|
background: "linear-gradient(to right, oklch(85% 0.02 145), oklch(42% 0.08 145))",
|
||||||
background: t === 0 ? "var(--bg-3)" : `oklch(${72 - t * 30}% ${0.04 + t * 0.06} 145)`,
|
}}
|
||||||
borderRadius: 2,
|
/>
|
||||||
}}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
<span>More</span>
|
<span>More</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function DayOfWeekBars({ dowAvgs, busiestDow }: { dowAvgs: number[]; busiestDow: number }) {
|
||||||
|
const maxAvg = Math.max(...dowAvgs, 0.001);
|
||||||
|
const labels = ["MON", "TUE", "WED", "THU", "FRI", "SAT", "SUN"];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
|
||||||
|
{DOW_ORDER.map((dowIdx, i) => {
|
||||||
|
const avg = dowAvgs[dowIdx]!;
|
||||||
|
const isBusiest = dowIdx === busiestDow;
|
||||||
|
return (
|
||||||
|
<div key={dowIdx} style={{ display: "flex", alignItems: "center", gap: 12 }}>
|
||||||
|
<div
|
||||||
|
className="smallcaps"
|
||||||
|
style={{
|
||||||
|
width: 40,
|
||||||
|
color: isBusiest ? "var(--ink)" : "var(--ink-3)",
|
||||||
|
fontWeight: isBusiest ? 600 : 500,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{labels[i]}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
height: 20,
|
||||||
|
background: "var(--bg-2)",
|
||||||
|
borderRadius: 4,
|
||||||
|
position: "relative",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: `${(avg / maxAvg) * 100}%`,
|
||||||
|
height: "100%",
|
||||||
|
background: "var(--sage)",
|
||||||
|
borderRadius: 4,
|
||||||
|
opacity: isBusiest ? 1 : 0.7,
|
||||||
|
minWidth: avg > 0 ? 4 : 0,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="mono"
|
||||||
|
style={{
|
||||||
|
width: 50,
|
||||||
|
textAlign: "right",
|
||||||
|
fontSize: 13,
|
||||||
|
color: isBusiest ? "var(--ink)" : "var(--ink-2)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{avg.toFixed(2)}g
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RollingAvgLine({
|
||||||
|
values,
|
||||||
|
max,
|
||||||
|
height,
|
||||||
|
}: {
|
||||||
|
values: number[];
|
||||||
|
max: number;
|
||||||
|
height: number;
|
||||||
|
}) {
|
||||||
|
if (values.length < 2) return null;
|
||||||
|
|
||||||
|
const w = 1000;
|
||||||
|
const pad = 4;
|
||||||
|
const step = w / (values.length - 1 || 1);
|
||||||
|
const pts = values
|
||||||
|
.map((v, i) => {
|
||||||
|
const x = i * step;
|
||||||
|
const y = height - (v / max) * (height - pad) - pad / 2;
|
||||||
|
return `${x.toFixed(1)},${y.toFixed(1)}`;
|
||||||
|
})
|
||||||
|
.join(" ");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width="100%"
|
||||||
|
height={height}
|
||||||
|
viewBox={`0 0 ${w} ${height}`}
|
||||||
|
preserveAspectRatio="none"
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
pointerEvents: "none",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<polyline
|
||||||
|
points={pts}
|
||||||
|
fill="none"
|
||||||
|
stroke="var(--terracotta)"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
vectorEffect="non-scaling-stroke"
|
||||||
|
opacity="0.8"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,254 @@
|
|||||||
|
import { useMemo } from "react";
|
||||||
|
import type { Bootstrap, Item } from "../types.js";
|
||||||
|
import { helpers, enrichItems } from "../types.js";
|
||||||
|
import { getStoredTimezone } from "../tz.js";
|
||||||
|
import { remainingShort } from "../stats.js";
|
||||||
|
import { fmt, TYPE_GLYPHS } from "../format.js";
|
||||||
|
import { Btn, Card, Icon } from "../components/primitives/index.js";
|
||||||
|
import { useIsMobile } from "../hooks/useIsMobile.js";
|
||||||
|
|
||||||
|
const GRID_COLS = "32px 2fr 1fr 1fr 1fr 280px";
|
||||||
|
|
||||||
|
export function CustodyView({
|
||||||
|
data,
|
||||||
|
onSelectItem,
|
||||||
|
onCheckin,
|
||||||
|
onConsume,
|
||||||
|
onMarkGone,
|
||||||
|
}: {
|
||||||
|
data: Bootstrap;
|
||||||
|
onSelectItem: (i: Item) => void;
|
||||||
|
onCheckin: (i: Item) => void;
|
||||||
|
onConsume: (i: Item) => void;
|
||||||
|
onMarkGone: (i: Item) => void;
|
||||||
|
}) {
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
const items = useMemo(() => enrichItems(data), [data]);
|
||||||
|
const checkedOut = useMemo(
|
||||||
|
() =>
|
||||||
|
items
|
||||||
|
.filter((i) => i.status === "checked-out")
|
||||||
|
.sort(
|
||||||
|
(a, b) =>
|
||||||
|
+new Date(b.checkoutDate ?? b.purchaseDate) -
|
||||||
|
+new Date(a.checkoutDate ?? a.purchaseDate),
|
||||||
|
),
|
||||||
|
[items],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: isMobile
|
||||||
|
? "20px 16px 80px"
|
||||||
|
: "clamp(32px, 3vw, 64px) clamp(40px, 3vw, 80px) 80px",
|
||||||
|
maxWidth: 2400,
|
||||||
|
margin: "0 auto",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ marginBottom: isMobile ? 16 : 24 }}>
|
||||||
|
<div className="smallcaps" style={{ color: "var(--ink-3)" }}>
|
||||||
|
{checkedOut.length} item{checkedOut.length === 1 ? "" : "s"} checked out
|
||||||
|
</div>
|
||||||
|
<h1
|
||||||
|
className="serif"
|
||||||
|
style={{
|
||||||
|
fontSize: isMobile ? 28 : 44,
|
||||||
|
margin: "6px 0 0",
|
||||||
|
fontWeight: 500,
|
||||||
|
letterSpacing: "-0.02em",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
My Custody
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{checkedOut.length === 0 ? (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
textAlign: "center",
|
||||||
|
padding: "80px 20px",
|
||||||
|
color: "var(--ink-3)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon name="pocket" size={40} color="var(--ink-4)" />
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 14,
|
||||||
|
fontStyle: "italic",
|
||||||
|
marginTop: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Nothing checked out right now.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : isMobile ? (
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||||
|
{checkedOut.map((item) => (
|
||||||
|
<MobileCustodyCard
|
||||||
|
key={item.id}
|
||||||
|
item={item}
|
||||||
|
data={data}
|
||||||
|
onSelect={() => onSelectItem(item)}
|
||||||
|
onCheckin={() => onCheckin(item)}
|
||||||
|
onConsume={() => onConsume(item)}
|
||||||
|
onMarkGone={() => onMarkGone(item)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Card padded={false}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: GRID_COLS,
|
||||||
|
padding: "10px 16px",
|
||||||
|
fontSize: 11,
|
||||||
|
color: "var(--ink-3)",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
letterSpacing: "0.06em",
|
||||||
|
borderBottom: "1px solid var(--line)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div />
|
||||||
|
<div>Item</div>
|
||||||
|
<div>Brand</div>
|
||||||
|
<div>Remaining</div>
|
||||||
|
<div>Checked out</div>
|
||||||
|
<div />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{checkedOut.map((item) => (
|
||||||
|
<CustodyRow
|
||||||
|
key={item.id}
|
||||||
|
item={item}
|
||||||
|
data={data}
|
||||||
|
onSelect={() => onSelectItem(item)}
|
||||||
|
onCheckin={() => onCheckin(item)}
|
||||||
|
onConsume={() => onConsume(item)}
|
||||||
|
onMarkGone={() => onMarkGone(item)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MobileCustodyCard({
|
||||||
|
item,
|
||||||
|
data,
|
||||||
|
onSelect,
|
||||||
|
onCheckin,
|
||||||
|
onConsume,
|
||||||
|
onMarkGone,
|
||||||
|
}: {
|
||||||
|
item: Item;
|
||||||
|
data: Bootstrap;
|
||||||
|
onSelect: () => void;
|
||||||
|
onCheckin: () => void;
|
||||||
|
onConsume: () => void;
|
||||||
|
onMarkGone: () => void;
|
||||||
|
}) {
|
||||||
|
const glyph = TYPE_GLYPHS[item.type] ?? "·";
|
||||||
|
const pct = helpers.pctRemaining(item);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onClick={onSelect}
|
||||||
|
style={{
|
||||||
|
padding: "14px 16px",
|
||||||
|
background: "var(--surface)",
|
||||||
|
borderRadius: "var(--r-md)",
|
||||||
|
cursor: "pointer",
|
||||||
|
border: "1px solid var(--line)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
|
||||||
|
<div style={{ fontSize: 18, opacity: 0.6 }}>{glyph}</div>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{ fontWeight: 500, fontSize: 14 }}>{item.name}</div>
|
||||||
|
<div style={{ fontSize: 12, color: "var(--ink-3)", marginTop: 2 }}>
|
||||||
|
{helpers.brandName(data, item.brandId)} · {remainingShort(item)} · {Math.round(pct * 100)}%
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 11, color: "var(--ink-3)", marginTop: 6 }}>
|
||||||
|
Checked out {fmt.daysAgo(item.checkoutDate, getStoredTimezone())}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{ display: "flex", gap: 6, marginTop: 10 }}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<Btn variant="sage" icon="check" onClick={onCheckin}>Check in</Btn>
|
||||||
|
<Btn variant="secondary" icon="check" onClick={onConsume}>Consume</Btn>
|
||||||
|
<Btn variant="ghost" icon="bin" onClick={onMarkGone} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CustodyRow({
|
||||||
|
item,
|
||||||
|
data,
|
||||||
|
onSelect,
|
||||||
|
onCheckin,
|
||||||
|
onConsume,
|
||||||
|
onMarkGone,
|
||||||
|
}: {
|
||||||
|
item: Item;
|
||||||
|
data: Bootstrap;
|
||||||
|
onSelect: () => void;
|
||||||
|
onCheckin: () => void;
|
||||||
|
onConsume: () => void;
|
||||||
|
onMarkGone: () => void;
|
||||||
|
}) {
|
||||||
|
const glyph = TYPE_GLYPHS[item.type] ?? "·";
|
||||||
|
const pct = helpers.pctRemaining(item);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: GRID_COLS,
|
||||||
|
padding: "12px 16px",
|
||||||
|
alignItems: "center",
|
||||||
|
borderBottom: "1px solid var(--line)",
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
onClick={onSelect}
|
||||||
|
>
|
||||||
|
<div style={{ fontSize: 16, textAlign: "center", opacity: 0.6 }}>{glyph}</div>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontWeight: 500, fontSize: 14 }}>{item.name}</div>
|
||||||
|
<div className="mono" style={{ fontSize: 11, color: "var(--ink-3)" }}>
|
||||||
|
{item.assetId}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 13, color: "var(--ink-2)" }}>
|
||||||
|
{helpers.brandName(data, item.brandId)}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontFamily: "var(--mono)", fontSize: 12 }}>
|
||||||
|
{remainingShort(item)}
|
||||||
|
<span style={{ color: "var(--ink-3)", marginLeft: 6 }}>
|
||||||
|
{Math.round(pct * 100)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 12, color: "var(--ink-3)" }}>
|
||||||
|
{fmt.daysAgo(item.checkoutDate, getStoredTimezone())}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{ display: "flex", gap: 4, justifyContent: "flex-end" }}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<Btn variant="sage" icon="check" onClick={onCheckin}>
|
||||||
|
Check in
|
||||||
|
</Btn>
|
||||||
|
<Btn variant="secondary" icon="check" onClick={onConsume}>
|
||||||
|
Consume
|
||||||
|
</Btn>
|
||||||
|
<Btn variant="ghost" icon="bin" onClick={onMarkGone} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+77
-20
@@ -1,9 +1,11 @@
|
|||||||
import type { Bootstrap, Item } from "../types.js";
|
import type { Bin, Bootstrap, Item } from "../types.js";
|
||||||
import { helpers, TODAY_STR } from "../types.js";
|
import { helpers } from "../types.js";
|
||||||
|
import { getToday, getStoredTimezone } from "../tz.js";
|
||||||
import type { Stats } from "../stats.js";
|
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> = {
|
||||||
@@ -18,14 +20,19 @@ const TYPE_COLORS: Record<string, string> = {
|
|||||||
export function Dashboard({
|
export function Dashboard({
|
||||||
data,
|
data,
|
||||||
stats,
|
stats,
|
||||||
onAuditItem,
|
onWeighInItem,
|
||||||
|
onWeighInQueue,
|
||||||
|
onBinCheck,
|
||||||
onSelectItem,
|
onSelectItem,
|
||||||
}: {
|
}: {
|
||||||
data: Bootstrap;
|
data: Bootstrap;
|
||||||
stats: Stats;
|
stats: Stats;
|
||||||
onAuditItem: (i: Item) => void;
|
onWeighInItem: (i: Item) => void;
|
||||||
|
onWeighInQueue: (items: Item[]) => void;
|
||||||
|
onBinCheck: (bin?: Bin) => 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);
|
||||||
@@ -36,33 +43,37 @@ export function Dashboard({
|
|||||||
color: TYPE_COLORS[k] ?? "var(--ink-3)",
|
color: TYPE_COLORS[k] ?? "var(--ink-3)",
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const overdue = stats.overdueAudits;
|
const overdue = stats.overdueWeighIns;
|
||||||
|
const overdueBins = stats.overdueBinChecks;
|
||||||
const lowBulk = stats.lowStockBulk;
|
const lowBulk = stats.lowStockBulk;
|
||||||
const lowDiscrete = stats.lowStockDiscreteGroups;
|
const lowDiscrete = stats.lowStockDiscreteGroups;
|
||||||
|
|
||||||
const todayStr = data.today || TODAY_STR;
|
const tz = getStoredTimezone();
|
||||||
const todayDate = new Date(todayStr + "T00:00:00");
|
const todayStr = getToday(tz);
|
||||||
const greetingDate = todayDate.toLocaleDateString("en-US", {
|
const greetingDate = new Date(todayStr + "T12:00:00").toLocaleDateString("en-US", {
|
||||||
weekday: "long",
|
weekday: "long",
|
||||||
month: "long",
|
month: "long",
|
||||||
day: "numeric",
|
day: "numeric",
|
||||||
year: "numeric",
|
year: "numeric",
|
||||||
|
timeZone: tz,
|
||||||
});
|
});
|
||||||
|
|
||||||
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",
|
||||||
@@ -78,15 +89,31 @@ export function Dashboard({
|
|||||||
{stats.goneCount} gone.
|
{stats.goneCount} gone.
|
||||||
{overdue.length > 0 && (
|
{overdue.length > 0 && (
|
||||||
<span style={{ color: "var(--terracotta)" }}>
|
<span style={{ color: "var(--terracotta)" }}>
|
||||||
{" "}· {overdue.length} audit{overdue.length === 1 ? "" : "s"} overdue.
|
{" "}· {overdue.length} weigh-in{overdue.length === 1 ? "" : "s"} overdue.
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{overdueBins.length > 0 && (
|
||||||
|
<span style={{ color: "var(--terracotta)" }}>
|
||||||
|
{" "}· {overdueBins.length} bin check{overdueBins.length === 1 ? "" : "s"} overdue.
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{stats.purchaseCount === 0 ? (
|
||||||
|
<Card style={{ textAlign: "center", padding: "60px 32px" }}>
|
||||||
|
<div className="serif" style={{ fontSize: 28, fontWeight: 500, marginBottom: 8 }}>
|
||||||
|
No inventory yet
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 14, color: "var(--ink-3)", marginBottom: 24, maxWidth: 400, margin: "0 auto 24px" }}>
|
||||||
|
Add your first item to start tracking consumption, audits, and spending.
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
<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,
|
||||||
}}
|
}}
|
||||||
@@ -124,7 +151,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,
|
||||||
}}
|
}}
|
||||||
@@ -138,7 +165,7 @@ export function Dashboard({
|
|||||||
label="Inventory on hand"
|
label="Inventory on hand"
|
||||||
value={stats.inventoryGrams.toFixed(stats.inventoryGrams >= 10 ? 1 : 2)}
|
value={stats.inventoryGrams.toFixed(stats.inventoryGrams >= 10 ? 1 : 2)}
|
||||||
unit="g"
|
unit="g"
|
||||||
sub="Estimated remaining across active jars"
|
sub="Remaining across active jars"
|
||||||
/>
|
/>
|
||||||
<Stat
|
<Stat
|
||||||
label="Spent all-time"
|
label="Spent all-time"
|
||||||
@@ -163,17 +190,43 @@ export function Dashboard({
|
|||||||
>
|
>
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: 18, flexWrap: "wrap" }}>
|
<div style={{ display: "flex", alignItems: "center", gap: 18, flexWrap: "wrap" }}>
|
||||||
<div style={{ flex: 1, minWidth: 240 }}>
|
<div style={{ flex: 1, minWidth: 240 }}>
|
||||||
<div className="smallcaps" style={{ color: "oklch(48% 0.10 75)" }}>Audit overdue</div>
|
<div className="smallcaps" style={{ color: "oklch(48% 0.10 75)" }}>Weigh-ins overdue</div>
|
||||||
<div className="serif" style={{ fontSize: 20, marginTop: 4, color: "var(--ink)" }}>
|
<div className="serif" style={{ fontSize: 20, marginTop: 4, color: "var(--ink)" }}>
|
||||||
{overdue.length} item{overdue.length === 1 ? "" : "s"} haven't been checked in a while
|
{overdue.length} item{overdue.length === 1 ? "" : "s"} need weighing
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: 12, color: "var(--ink-2)", marginTop: 4 }}>
|
<div style={{ fontSize: 12, color: "var(--ink-2)", marginTop: 4 }}>
|
||||||
{overdue.slice(0, 3).map((p) => p.name).join(" · ")}
|
{overdue.slice(0, 3).map((p) => p.name).join(" · ")}
|
||||||
{overdue.length > 3 && ` · +${overdue.length - 3} more`}
|
{overdue.length > 3 && ` · +${overdue.length - 3} more`}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Btn variant="secondary" icon="check" onClick={() => onAuditItem(overdue[0]!)}>
|
<Btn variant="secondary" icon="search" onClick={() => onWeighInQueue(overdue)}>
|
||||||
Run audit
|
Weigh in {overdue.length > 1 ? `all ${overdue.length}` : ""}
|
||||||
|
</Btn>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{overdueBins.length > 0 && (
|
||||||
|
<Card
|
||||||
|
style={{
|
||||||
|
marginBottom: 18,
|
||||||
|
borderColor: "var(--amber)",
|
||||||
|
background: "var(--amber-soft)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 18, flexWrap: "wrap" }}>
|
||||||
|
<div style={{ flex: 1, minWidth: 240 }}>
|
||||||
|
<div className="smallcaps" style={{ color: "oklch(48% 0.10 75)" }}>Bin checks overdue</div>
|
||||||
|
<div className="serif" style={{ fontSize: 20, marginTop: 4, color: "var(--ink)" }}>
|
||||||
|
{overdueBins.length} bin{overdueBins.length === 1 ? "" : "s"} need checking
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 12, color: "var(--ink-2)", marginTop: 4 }}>
|
||||||
|
{overdueBins.slice(0, 3).map((b) => b.name).join(" · ")}
|
||||||
|
{overdueBins.length > 3 && ` · +${overdueBins.length - 3} more`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Btn variant="secondary" icon="bin" onClick={() => onBinCheck()}>
|
||||||
|
Start bin check
|
||||||
</Btn>
|
</Btn>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -325,10 +378,11 @@ export function Dashboard({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{lowBulk.slice(0, 3).map((i) => {
|
{lowBulk.slice(0, 3).map((i) => {
|
||||||
const pct = helpers.pctRemaining(i, TODAY_STR);
|
const pct = helpers.pctRemaining(i);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={i.id}
|
key={i.id}
|
||||||
|
className="inv-row"
|
||||||
onClick={() => onSelectItem(i)}
|
onClick={() => onSelectItem(i)}
|
||||||
style={{
|
style={{
|
||||||
padding: "12px 24px",
|
padding: "12px 24px",
|
||||||
@@ -384,6 +438,7 @@ export function Dashboard({
|
|||||||
{lowDiscrete.slice(0, 2).map((g) => (
|
{lowDiscrete.slice(0, 2).map((g) => (
|
||||||
<div
|
<div
|
||||||
key={g.key}
|
key={g.key}
|
||||||
|
className="inv-row"
|
||||||
onClick={() => onSelectItem(g.items[0]!)}
|
onClick={() => onSelectItem(g.items[0]!)}
|
||||||
style={{
|
style={{
|
||||||
padding: "12px 24px",
|
padding: "12px 24px",
|
||||||
@@ -418,6 +473,8 @@ export function Dashboard({
|
|||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+784
-159
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,20 @@
|
|||||||
import type { Bootstrap } from "../types.js";
|
import type { Bootstrap } from "../types.js";
|
||||||
import { Btn, Card, Stat } from "../components/primitives/index.js";
|
import { Btn, Card, Select, Stat } from "../components/primitives/index.js";
|
||||||
|
import { getBrowserTimezone } from "../tz.js";
|
||||||
|
import { useIsMobile } from "../hooks/useIsMobile.js";
|
||||||
|
|
||||||
|
function getTimezoneOptions(): string[] {
|
||||||
|
try {
|
||||||
|
return (Intl as any).supportedValuesOf("timeZone") as string[];
|
||||||
|
} catch {
|
||||||
|
return [
|
||||||
|
"America/New_York", "America/Chicago", "America/Denver", "America/Los_Angeles",
|
||||||
|
"America/Anchorage", "Pacific/Honolulu", "America/Toronto", "America/Vancouver",
|
||||||
|
"Europe/London", "Europe/Paris", "Europe/Berlin", "Asia/Tokyo",
|
||||||
|
"Australia/Sydney", "Pacific/Auckland", "UTC",
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function download(filename: string, content: string, mime: string) {
|
function download(filename: string, content: string, mime: string) {
|
||||||
const blob = new Blob([content], { type: mime });
|
const blob = new Blob([content], { type: mime });
|
||||||
@@ -75,24 +90,36 @@ export function SettingsView({
|
|||||||
data,
|
data,
|
||||||
theme,
|
theme,
|
||||||
onThemeChange,
|
onThemeChange,
|
||||||
|
timezone,
|
||||||
|
onTimezoneChange,
|
||||||
}: {
|
}: {
|
||||||
data: Bootstrap;
|
data: Bootstrap;
|
||||||
theme: ThemeKey;
|
theme: ThemeKey;
|
||||||
onThemeChange: (t: ThemeKey) => void;
|
onThemeChange: (t: ThemeKey) => void;
|
||||||
|
timezone: string;
|
||||||
|
onTimezoneChange: (tz: string) => void;
|
||||||
}) {
|
}) {
|
||||||
|
const isMobile = useIsMobile();
|
||||||
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: 1400,
|
maxWidth: 1400,
|
||||||
margin: "0 auto",
|
margin: "0 auto",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ marginBottom: 24 }}>
|
<div style={{ marginBottom: isMobile ? 16 : 24 }}>
|
||||||
<div className="smallcaps" style={{ color: "var(--ink-3)" }}>Settings</div>
|
<div className="smallcaps" style={{ color: "var(--ink-3)" }}>Settings</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",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
Preferences
|
Preferences
|
||||||
</h1>
|
</h1>
|
||||||
@@ -132,6 +159,17 @@ export function SettingsView({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
|
<SettingRow label="Timezone" hint={`Dates and "today" are shown in this timezone (detected: ${getBrowserTimezone().replace(/_/g, " ")})`}>
|
||||||
|
<Select
|
||||||
|
value={timezone}
|
||||||
|
onChange={(e) => onTimezoneChange(e.target.value)}
|
||||||
|
style={{ width: 280 }}
|
||||||
|
>
|
||||||
|
{getTimezoneOptions().map((tz) => (
|
||||||
|
<option key={tz} value={tz}>{tz.replace(/_/g, " ")}</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</SettingRow>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|||||||
+426
-97
@@ -1,54 +1,132 @@
|
|||||||
import { useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
|
||||||
import type { Bootstrap, Shop } from "../types.js";
|
import type { Bootstrap, Shop } from "../types.js";
|
||||||
import { api } from "../api.js";
|
import { fmt } from "../format.js";
|
||||||
import { Btn, Card, Pill, Icon } from "../components/primitives/index.js";
|
import { getStoredTimezone } from "../tz.js";
|
||||||
import { ConfirmDialog } from "../components/modals/ConfirmDialog.js";
|
import { Btn, Card, Icon, Select, inputStyle } from "../components/primitives/index.js";
|
||||||
|
import { useIsMobile } from "../hooks/useIsMobile.js";
|
||||||
|
|
||||||
|
interface ShopRow {
|
||||||
|
shop: Shop;
|
||||||
|
itemCount: number;
|
||||||
|
totalSpend: number;
|
||||||
|
avgRating: number | null;
|
||||||
|
ratingCount: number;
|
||||||
|
lastPurchase: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
type SortKey = "name" | "items" | "spent" | "rating" | "recent";
|
||||||
|
|
||||||
|
const GRID_COLS = "2fr 0.7fr 0.8fr 0.8fr 1fr";
|
||||||
|
|
||||||
|
function buildShopRows(data: Bootstrap): ShopRow[] {
|
||||||
|
const itemsByShop = new Map<string, typeof data.inventoryItems>();
|
||||||
|
for (const i of data.inventoryItems) {
|
||||||
|
if (!i.shopId) continue;
|
||||||
|
const arr = itemsByShop.get(i.shopId);
|
||||||
|
if (arr) arr.push(i);
|
||||||
|
else itemsByShop.set(i.shopId, [i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return data.shops.map((shop) => {
|
||||||
|
const items = itemsByShop.get(shop.id) ?? [];
|
||||||
|
|
||||||
|
const totalSpend = items.reduce((s, i) => s + i.price, 0);
|
||||||
|
const rated = items.filter((i) => i.rating != null);
|
||||||
|
const avgRating =
|
||||||
|
rated.length > 0 ? rated.reduce((s, i) => s + i.rating!, 0) / rated.length : null;
|
||||||
|
|
||||||
|
const dates = items.map((i) => i.purchaseDate).sort();
|
||||||
|
const lastPurchase = dates.length > 0 ? dates[dates.length - 1]! : null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
shop,
|
||||||
|
itemCount: items.length,
|
||||||
|
totalSpend,
|
||||||
|
avgRating,
|
||||||
|
ratingCount: rated.length,
|
||||||
|
lastPurchase,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function ShopsView({
|
export function ShopsView({
|
||||||
data,
|
data,
|
||||||
|
onSelectShop,
|
||||||
onAddShop,
|
onAddShop,
|
||||||
onEditShop,
|
|
||||||
}: {
|
}: {
|
||||||
data: Bootstrap;
|
data: Bootstrap;
|
||||||
|
onSelectShop: (shop: Shop) => void;
|
||||||
onAddShop: () => void;
|
onAddShop: () => void;
|
||||||
onEditShop: (shop: Shop) => void;
|
|
||||||
}) {
|
}) {
|
||||||
const qc = useQueryClient();
|
const isMobile = useIsMobile();
|
||||||
const [confirmDelete, setConfirmDelete] = useState<{ id: string; name: string; count: number } | null>(null);
|
const [search, setSearch] = useState("");
|
||||||
|
const [sortBy, setSortBy] = useState<SortKey>("name");
|
||||||
|
|
||||||
const remove = useMutation({
|
const rows = useMemo(() => buildShopRows(data), [data]);
|
||||||
mutationFn: (id: string) => api.deleteShop(id),
|
|
||||||
onSuccess: () => {
|
const filtered = useMemo(() => {
|
||||||
qc.invalidateQueries({ queryKey: ["bootstrap"] });
|
if (!search) return rows;
|
||||||
setConfirmDelete(null);
|
const q = search.toLowerCase();
|
||||||
},
|
return rows.filter(
|
||||||
});
|
(r) =>
|
||||||
|
r.shop.name.toLowerCase().includes(q) ||
|
||||||
|
(r.shop.location && r.shop.location.toLowerCase().includes(q)),
|
||||||
|
);
|
||||||
|
}, [rows, search]);
|
||||||
|
|
||||||
|
const sorted = useMemo(() => {
|
||||||
|
const copy = [...filtered];
|
||||||
|
if (sortBy === "name") copy.sort((a, b) => a.shop.name.localeCompare(b.shop.name));
|
||||||
|
else if (sortBy === "items") copy.sort((a, b) => b.itemCount - a.itemCount);
|
||||||
|
else if (sortBy === "spent") copy.sort((a, b) => b.totalSpend - a.totalSpend);
|
||||||
|
else if (sortBy === "recent")
|
||||||
|
copy.sort(
|
||||||
|
(a, b) =>
|
||||||
|
+(b.lastPurchase ? new Date(b.lastPurchase) : 0) -
|
||||||
|
+(a.lastPurchase ? new Date(a.lastPurchase) : 0),
|
||||||
|
);
|
||||||
|
else if (sortBy === "rating")
|
||||||
|
copy.sort((a, b) => (b.avgRating ?? -1) - (a.avgRating ?? -1));
|
||||||
|
return copy;
|
||||||
|
}, [filtered, sortBy]);
|
||||||
|
|
||||||
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={{ display: "flex", alignItems: "baseline", justifyContent: "space-between", marginBottom: 24 }}>
|
<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)" }}>
|
||||||
{data.shops.length} shop{data.shops.length === 1 ? "" : "s"}
|
{sorted.length} shop{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",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
Shops
|
Shops
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
<Btn variant="primary" icon="plus" onClick={onAddShop}>New shop</Btn>
|
<Btn variant="primary" icon="plus" onClick={onAddShop}>
|
||||||
</div>
|
New shop
|
||||||
<div style={{ fontSize: 14, color: "var(--ink-2)", marginBottom: 24, maxWidth: 600 }}>
|
</Btn>
|
||||||
Where you've purchased from. Used in the shop dropdown when adding a new product.
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{data.shops.length === 0 ? (
|
{data.shops.length === 0 ? (
|
||||||
@@ -57,86 +135,337 @@ export function ShopsView({
|
|||||||
<div style={{ fontSize: 13, color: "var(--ink-3)", marginBottom: 18 }}>
|
<div style={{ fontSize: 13, color: "var(--ink-3)", marginBottom: 18 }}>
|
||||||
Add a shop to start logging where each purchase came from.
|
Add a shop to start logging where each purchase came from.
|
||||||
</div>
|
</div>
|
||||||
<Btn variant="primary" icon="plus" onClick={onAddShop}>Add your first shop</Btn>
|
<Btn variant="primary" icon="plus" onClick={onAddShop}>
|
||||||
|
Add your first shop
|
||||||
|
</Btn>
|
||||||
</Card>
|
</Card>
|
||||||
|
) : isMobile ? (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 8,
|
||||||
|
background: "var(--surface)",
|
||||||
|
border: "1px solid var(--line)",
|
||||||
|
borderRadius: "var(--r-md)",
|
||||||
|
padding: "0 10px",
|
||||||
|
marginBottom: 10,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon name="search" size={14} color="var(--ink-3)" />
|
||||||
|
<input
|
||||||
|
placeholder="Search by name or location..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
style={{
|
||||||
|
border: "none",
|
||||||
|
outline: "none",
|
||||||
|
background: "transparent",
|
||||||
|
padding: "10px 0",
|
||||||
|
fontSize: 14,
|
||||||
|
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={sortBy}
|
||||||
|
onChange={(e) => setSortBy(e.target.value as SortKey)}
|
||||||
|
style={{ ...inputStyle, width: "100%", padding: "8px 10px", marginBottom: 14 }}
|
||||||
|
>
|
||||||
|
<option value="name">Name (A-Z)</option>
|
||||||
|
<option value="items">Most items</option>
|
||||||
|
<option value="spent">Most spent</option>
|
||||||
|
<option value="recent">Recent</option>
|
||||||
|
<option value="rating">Top rated</option>
|
||||||
|
</Select>
|
||||||
|
{sorted.length === 0 && (
|
||||||
|
<div style={{ padding: 40, textAlign: "center", color: "var(--ink-3)", fontSize: 13 }}>
|
||||||
|
No shops match these filters.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{sorted.map((r) => (
|
||||||
|
<MobileShopCard key={r.shop.id} row={r} onClick={() => onSelectShop(r.shop)} />
|
||||||
|
))}
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div
|
<>
|
||||||
style={{
|
<Card style={{ marginBottom: 14, padding: 14 }}>
|
||||||
display: "grid",
|
<div style={{ display: "flex", gap: 12, alignItems: "center", flexWrap: "wrap" }}>
|
||||||
gridTemplateColumns: "repeat(auto-fill, minmax(320px, 1fr))",
|
<div
|
||||||
gap: 14,
|
style={{
|
||||||
}}
|
flex: 1,
|
||||||
>
|
minWidth: 220,
|
||||||
{data.shops.map((s) => {
|
display: "flex",
|
||||||
const count = data.inventoryItems.filter((i) => i.shopId === s.id).length;
|
alignItems: "center",
|
||||||
return (
|
gap: 8,
|
||||||
<Card key={s.id} style={{ display: "flex", alignItems: "center", gap: 12 }}>
|
background: "var(--bg-2)",
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
border: "1px solid var(--line)",
|
||||||
<div className="serif" style={{ fontSize: 22, fontWeight: 500, lineHeight: 1.1 }}>
|
borderRadius: "var(--r-md)",
|
||||||
{s.name}
|
padding: "0 10px",
|
||||||
</div>
|
}}
|
||||||
{s.location && (
|
>
|
||||||
<div style={{ fontSize: 12, color: "var(--ink-3)", marginTop: 4 }}>
|
<Icon name="search" size={14} color="var(--ink-3)" />
|
||||||
{s.location}
|
<input
|
||||||
</div>
|
placeholder="Search by name or location..."
|
||||||
)}
|
value={search}
|
||||||
</div>
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
<Pill tone="outline">
|
|
||||||
{count} purchase{count === 1 ? "" : "s"}
|
|
||||||
</Pill>
|
|
||||||
<button
|
|
||||||
onClick={() => onEditShop(s)}
|
|
||||||
title="Edit shop"
|
|
||||||
aria-label={`Edit shop ${s.name}`}
|
|
||||||
style={{
|
style={{
|
||||||
background: "transparent",
|
|
||||||
border: "none",
|
border: "none",
|
||||||
padding: 4,
|
outline: "none",
|
||||||
borderRadius: "var(--r-sm)",
|
|
||||||
cursor: "pointer",
|
|
||||||
color: "var(--ink-3)",
|
|
||||||
display: "inline-flex",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Icon name="edit" size={14} />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setConfirmDelete({ id: s.id, name: s.name, count })}
|
|
||||||
title="Remove shop"
|
|
||||||
aria-label={`Remove shop ${s.name}`}
|
|
||||||
disabled={remove.isPending}
|
|
||||||
style={{
|
|
||||||
background: "transparent",
|
background: "transparent",
|
||||||
border: "none",
|
padding: "8px 0",
|
||||||
padding: 4,
|
fontSize: 13,
|
||||||
borderRadius: "var(--r-sm)",
|
flex: 1,
|
||||||
cursor: remove.isPending ? "wait" : "pointer",
|
color: "var(--ink)",
|
||||||
color: "var(--ink-3)",
|
|
||||||
display: "inline-flex",
|
|
||||||
}}
|
}}
|
||||||
>
|
/>
|
||||||
<Icon name="bin" size={14} />
|
{search && (
|
||||||
</button>
|
<button
|
||||||
</Card>
|
onClick={() => setSearch("")}
|
||||||
);
|
style={{
|
||||||
})}
|
background: "transparent",
|
||||||
</div>
|
border: "none",
|
||||||
)}
|
cursor: "pointer",
|
||||||
|
padding: 2,
|
||||||
{confirmDelete && (
|
display: "inline-flex",
|
||||||
<ConfirmDialog
|
color: "var(--ink-3)",
|
||||||
title={`Delete "${confirmDelete.name}"?`}
|
}}
|
||||||
message={
|
>
|
||||||
confirmDelete.count > 0
|
<Icon name="close" size={12} />
|
||||||
? `${confirmDelete.count} product${confirmDelete.count === 1 ? "" : "s"} will lose this shop.`
|
</button>
|
||||||
: "This shop will be permanently removed."
|
)}
|
||||||
}
|
</div>
|
||||||
confirmLabel="Delete shop"
|
<Select
|
||||||
onConfirm={() => remove.mutate(confirmDelete.id)}
|
value={sortBy}
|
||||||
onCancel={() => setConfirmDelete(null)}
|
onChange={(e) => setSortBy(e.target.value as SortKey)}
|
||||||
isPending={remove.isPending}
|
style={{ ...inputStyle, width: "auto", padding: "8px 10px" }}
|
||||||
/>
|
>
|
||||||
|
<option value="name">Name (A-Z)</option>
|
||||||
|
<option value="items">Most items</option>
|
||||||
|
<option value="spent">Most spent</option>
|
||||||
|
<option value="recent">Recent purchase</option>
|
||||||
|
<option value="rating">Highest rated</option>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
<Card padded={false}>
|
||||||
|
<ShopHeaderRow sortBy={sortBy} onSort={setSortBy} />
|
||||||
|
{sorted.length === 0 && (
|
||||||
|
<div style={{ padding: 60, textAlign: "center", color: "var(--ink-3)" }}>
|
||||||
|
No shops match these filters.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{sorted.map((r) => (
|
||||||
|
<ShopItemRow key={r.shop.id} row={r} onClick={() => onSelectShop(r.shop)} />
|
||||||
|
))}
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const COL_SORT: (SortKey | null)[] = ["name", "items", "spent", "rating", "recent"];
|
||||||
|
const COL_LABELS = ["Name", "Items", "Spent", "Rating", "Last purchase"];
|
||||||
|
|
||||||
|
function ShopHeaderRow({
|
||||||
|
sortBy,
|
||||||
|
onSort,
|
||||||
|
}: {
|
||||||
|
sortBy: SortKey;
|
||||||
|
onSort: (k: SortKey) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: GRID_COLS,
|
||||||
|
columnGap: 16,
|
||||||
|
padding: "12px 20px",
|
||||||
|
borderBottom: "1px solid var(--line)",
|
||||||
|
background: "var(--bg-2)",
|
||||||
|
fontSize: 11,
|
||||||
|
color: "var(--ink-3)",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
letterSpacing: "0.08em",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{COL_LABELS.map((label, i) => {
|
||||||
|
const sk = COL_SORT[i];
|
||||||
|
if (!sk) return <div key={i}>{label}</div>;
|
||||||
|
const active = sortBy === sk;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
onClick={() => onSort(sk)}
|
||||||
|
style={{
|
||||||
|
background: "none",
|
||||||
|
border: "none",
|
||||||
|
padding: 0,
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: "inherit",
|
||||||
|
textTransform: "inherit",
|
||||||
|
letterSpacing: "inherit",
|
||||||
|
fontWeight: active ? 600 : "inherit",
|
||||||
|
color: active ? "var(--ink)" : "var(--ink-3)",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
{active && <span style={{ fontSize: 9 }}>▼</span>}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MobileShopCard({ row, onClick }: { row: ShopRow; onClick: () => void }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onClick={onClick}
|
||||||
|
style={{
|
||||||
|
padding: "14px 16px",
|
||||||
|
background: "var(--surface)",
|
||||||
|
borderRadius: "var(--r-md)",
|
||||||
|
marginBottom: 8,
|
||||||
|
cursor: "pointer",
|
||||||
|
border: "1px solid var(--line)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontWeight: 500,
|
||||||
|
fontSize: 14,
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{row.shop.name}
|
||||||
|
</div>
|
||||||
|
{row.shop.location && (
|
||||||
|
<div style={{ fontSize: 12, color: "var(--ink-3)", marginTop: 2 }}>
|
||||||
|
{row.shop.location}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span style={{ color: "var(--ink-3)", fontSize: 16 }}>›</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 12,
|
||||||
|
marginTop: 8,
|
||||||
|
paddingTop: 8,
|
||||||
|
borderTop: "1px solid var(--line)",
|
||||||
|
fontSize: 12,
|
||||||
|
color: "var(--ink-2)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="mono">{row.itemCount} item{row.itemCount !== 1 ? "s" : ""}</span>
|
||||||
|
{row.itemCount > 0 && <span className="mono">{fmt.money(row.totalSpend)}</span>}
|
||||||
|
{row.avgRating != null && (
|
||||||
|
<span style={{ display: "flex", alignItems: "center", gap: 3 }}>
|
||||||
|
<Icon name="star" size={11} color="var(--amber)" />
|
||||||
|
<span className="mono">{row.avgRating.toFixed(1)}</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{row.lastPurchase && (
|
||||||
|
<span style={{ marginLeft: "auto", color: "var(--ink-3)" }}>
|
||||||
|
{fmt.dateShort(row.lastPurchase, getStoredTimezone())}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ShopItemRow({ row, onClick }: { row: ShopRow; onClick: () => void }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onClick={onClick}
|
||||||
|
className="inv-row"
|
||||||
|
style={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: GRID_COLS,
|
||||||
|
columnGap: 16,
|
||||||
|
padding: "14px 20px",
|
||||||
|
borderBottom: "1px solid var(--line)",
|
||||||
|
alignItems: "center",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: 13,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ minWidth: 0 }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontWeight: 500,
|
||||||
|
color: "var(--ink)",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{row.shop.name}
|
||||||
|
</div>
|
||||||
|
{row.shop.location && (
|
||||||
|
<div style={{ fontSize: 11, color: "var(--ink-3)" }}>
|
||||||
|
{row.shop.location}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontFamily: "var(--mono)" }}>{row.itemCount}</div>
|
||||||
|
<div style={{ fontFamily: "var(--mono)" }}>
|
||||||
|
{row.itemCount > 0 ? fmt.money(row.totalSpend) : "—"}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 4 }}>
|
||||||
|
{row.avgRating != null ? (
|
||||||
|
<>
|
||||||
|
<Icon name="star" size={12} color="var(--amber)" />
|
||||||
|
<span style={{ fontFamily: "var(--mono)", fontSize: 12 }}>
|
||||||
|
{row.avgRating.toFixed(1)}
|
||||||
|
</span>
|
||||||
|
<span style={{ fontSize: 10, color: "var(--ink-3)" }}>({row.ratingCount})</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span style={{ color: "var(--ink-3)", fontSize: 12, fontStyle: "italic" }}>—</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
|
||||||
|
<span style={{ fontSize: 12, color: "var(--ink-3)" }}>
|
||||||
|
{row.lastPurchase ? fmt.dateShort(row.lastPurchase, getStoredTimezone()) : "—"}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className="inv-row-chevron"
|
||||||
|
style={{ color: "var(--ink-3)", marginLeft: "auto", fontSize: 14 }}
|
||||||
|
>
|
||||||
|
›
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,521 @@
|
|||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import type { Bootstrap, Product } from "../types.js";
|
||||||
|
import { TYPES, helpers } from "../types.js";
|
||||||
|
import { fmt, TYPE_GLYPHS } from "../format.js";
|
||||||
|
import { getStoredTimezone } from "../tz.js";
|
||||||
|
import { Btn, Card, Icon, Pill, Select, inputStyle } from "../components/primitives/index.js";
|
||||||
|
import { useIsMobile } from "../hooks/useIsMobile.js";
|
||||||
|
|
||||||
|
export interface SkuRow {
|
||||||
|
product: Product;
|
||||||
|
name: string;
|
||||||
|
brand: string;
|
||||||
|
itemCount: number;
|
||||||
|
activeCount: number;
|
||||||
|
totalSpend: number;
|
||||||
|
avgPrice: number;
|
||||||
|
avgCostPerGram: number | null;
|
||||||
|
lastPurchase: string | null;
|
||||||
|
avgRating: number | null;
|
||||||
|
ratingCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
type SortKey = "name" | "items" | "spent" | "recent" | "rating";
|
||||||
|
|
||||||
|
const GRID_COLS = "32px 2fr 1fr 0.7fr 0.6fr 0.8fr 0.7fr 0.9fr";
|
||||||
|
|
||||||
|
function buildSkuRows(data: Bootstrap): SkuRow[] {
|
||||||
|
const strainMap = new Map(data.strains.map((s) => [s.id, s]));
|
||||||
|
return data.products.map((p) => {
|
||||||
|
const strain = strainMap.get(p.strainId);
|
||||||
|
const items = data.inventoryItems.filter((i) => i.productId === p.id);
|
||||||
|
const active = items.filter((i) => i.status === "active" || i.status === "checked-out");
|
||||||
|
const totalSpend = items.reduce((s, i) => s + i.price, 0);
|
||||||
|
const rated = items.filter((i) => i.rating != null);
|
||||||
|
const avgRating = rated.length > 0
|
||||||
|
? rated.reduce((s, i) => s + i.rating!, 0) / rated.length
|
||||||
|
: null;
|
||||||
|
|
||||||
|
let avgCostPerGram: number | null = null;
|
||||||
|
const cfg = TYPES.find((t) => t.id === p.type);
|
||||||
|
if (cfg && p.kind === "bulk") {
|
||||||
|
const totalGrams = items.reduce((s, i) => s + i.weight, 0);
|
||||||
|
if (totalGrams > 0) avgCostPerGram = totalSpend / totalGrams;
|
||||||
|
} else if (cfg && p.kind === "discrete") {
|
||||||
|
const totalCount = items.reduce((s, i) => s + i.countOriginal, 0);
|
||||||
|
if (totalCount > 0) avgCostPerGram = totalSpend / totalCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dates = items.map((i) => i.purchaseDate).sort();
|
||||||
|
const lastPurchase = dates.length > 0 ? dates[dates.length - 1]! : null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
product: p,
|
||||||
|
name: strain?.name ?? "(unknown strain)",
|
||||||
|
brand: helpers.brandName(data, p.brandId),
|
||||||
|
itemCount: items.length,
|
||||||
|
activeCount: active.length,
|
||||||
|
totalSpend,
|
||||||
|
avgPrice: items.length > 0 ? totalSpend / items.length : 0,
|
||||||
|
avgCostPerGram,
|
||||||
|
lastPurchase,
|
||||||
|
avgRating,
|
||||||
|
ratingCount: rated.length,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SkusView({
|
||||||
|
data,
|
||||||
|
onSelectSku,
|
||||||
|
onAddSku,
|
||||||
|
}: {
|
||||||
|
data: Bootstrap;
|
||||||
|
onSelectSku: (p: Product) => void;
|
||||||
|
onAddSku: () => void;
|
||||||
|
}) {
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
const [typeFilter, setTypeFilter] = useState("all");
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [sortBy, setSortBy] = useState<SortKey>("name");
|
||||||
|
|
||||||
|
const rows = useMemo(() => buildSkuRows(data), [data]);
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
let out = rows;
|
||||||
|
if (typeFilter !== "all") out = out.filter((r) => r.product.type === typeFilter);
|
||||||
|
if (search) {
|
||||||
|
const q = search.toLowerCase();
|
||||||
|
out = out.filter(
|
||||||
|
(r) =>
|
||||||
|
r.name.toLowerCase().includes(q) ||
|
||||||
|
r.product.sku.toLowerCase().includes(q) ||
|
||||||
|
r.brand.toLowerCase().includes(q),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}, [rows, typeFilter, search]);
|
||||||
|
|
||||||
|
const sorted = useMemo(() => {
|
||||||
|
const copy = [...filtered];
|
||||||
|
if (sortBy === "name") copy.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
else if (sortBy === "items") copy.sort((a, b) => b.itemCount - a.itemCount);
|
||||||
|
else if (sortBy === "spent") copy.sort((a, b) => b.totalSpend - a.totalSpend);
|
||||||
|
else if (sortBy === "recent")
|
||||||
|
copy.sort(
|
||||||
|
(a, b) =>
|
||||||
|
+(b.lastPurchase ? new Date(b.lastPurchase) : 0) -
|
||||||
|
+(a.lastPurchase ? new Date(a.lastPurchase) : 0),
|
||||||
|
);
|
||||||
|
else if (sortBy === "rating")
|
||||||
|
copy.sort((a, b) => (b.avgRating ?? -1) - (a.avgRating ?? -1));
|
||||||
|
return copy;
|
||||||
|
}, [filtered, sortBy]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: isMobile
|
||||||
|
? "20px 16px 80px"
|
||||||
|
: "clamp(32px, 3vw, 64px) clamp(40px, 3vw, 80px) 80px",
|
||||||
|
maxWidth: 2400,
|
||||||
|
margin: "0 auto",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "baseline",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
marginBottom: isMobile ? 16 : 24,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div className="smallcaps" style={{ color: "var(--ink-3)" }}>
|
||||||
|
{sorted.length} SKU{sorted.length === 1 ? "" : "s"}
|
||||||
|
</div>
|
||||||
|
<h1
|
||||||
|
className="serif"
|
||||||
|
style={{
|
||||||
|
fontSize: isMobile ? 28 : 44,
|
||||||
|
margin: "6px 0 0",
|
||||||
|
fontWeight: 500,
|
||||||
|
letterSpacing: "-0.02em",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
SKUs
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<Btn variant="primary" icon="plus" onClick={onAddSku}>
|
||||||
|
Add SKU
|
||||||
|
</Btn>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isMobile ? (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 8,
|
||||||
|
background: "var(--surface)",
|
||||||
|
border: "1px solid var(--line)",
|
||||||
|
borderRadius: "var(--r-md)",
|
||||||
|
padding: "0 10px",
|
||||||
|
marginBottom: 10,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon name="search" size={14} color="var(--ink-3)" />
|
||||||
|
<input
|
||||||
|
placeholder="Search by name, SKU, brand..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
style={{
|
||||||
|
border: "none",
|
||||||
|
outline: "none",
|
||||||
|
background: "transparent",
|
||||||
|
padding: "10px 0",
|
||||||
|
fontSize: 14,
|
||||||
|
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>
|
||||||
|
<div style={{ display: "flex", gap: 8, marginBottom: 14 }}>
|
||||||
|
<Select
|
||||||
|
value={typeFilter}
|
||||||
|
onChange={(e) => setTypeFilter(e.target.value)}
|
||||||
|
style={{ ...inputStyle, flex: 1, 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) => setSortBy(e.target.value as SortKey)}
|
||||||
|
style={{ ...inputStyle, flex: 1, padding: "8px 10px" }}
|
||||||
|
>
|
||||||
|
<option value="name">Name (A-Z)</option>
|
||||||
|
<option value="items">Most items</option>
|
||||||
|
<option value="spent">Most spent</option>
|
||||||
|
<option value="recent">Recent</option>
|
||||||
|
<option value="rating">Top rated</option>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
{sorted.length === 0 && (
|
||||||
|
<div style={{ padding: 40, textAlign: "center", color: "var(--ink-3)", fontSize: 13 }}>
|
||||||
|
No SKUs match these filters.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{sorted.map((r) => (
|
||||||
|
<MobileSkuCard key={r.product.id} row={r} onClick={() => onSelectSku(r.product)} />
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Card style={{ marginBottom: 14, padding: 14 }}>
|
||||||
|
<div style={{ display: "flex", gap: 12, alignItems: "center", flexWrap: "wrap" }}>
|
||||||
|
<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, SKU, brand..."
|
||||||
|
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) => setSortBy(e.target.value as SortKey)}
|
||||||
|
style={{ ...inputStyle, width: "auto", padding: "8px 10px" }}
|
||||||
|
>
|
||||||
|
<option value="name">Name (A-Z)</option>
|
||||||
|
<option value="items">Most items</option>
|
||||||
|
<option value="spent">Most spent</option>
|
||||||
|
<option value="recent">Recent purchase</option>
|
||||||
|
<option value="rating">Highest rated</option>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
<Card padded={false}>
|
||||||
|
<SkuHeaderRow sortBy={sortBy} onSort={setSortBy} />
|
||||||
|
{sorted.length === 0 && (
|
||||||
|
<div style={{ padding: 60, textAlign: "center", color: "var(--ink-3)" }}>
|
||||||
|
No SKUs match these filters.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{sorted.map((r) => (
|
||||||
|
<SkuItemRow key={r.product.id} row={r} onClick={() => onSelectSku(r.product)} />
|
||||||
|
))}
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const COL_SORT: (SortKey | null)[] = [null, "name", null, null, "items", "spent", "rating", "recent"];
|
||||||
|
const COL_LABELS = ["", "Name", "Brand", "Type", "Items", "Spent", "Rating", "Last purchase"];
|
||||||
|
|
||||||
|
function SkuHeaderRow({
|
||||||
|
sortBy,
|
||||||
|
onSort,
|
||||||
|
}: {
|
||||||
|
sortBy: SortKey;
|
||||||
|
onSort: (k: SortKey) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: GRID_COLS,
|
||||||
|
columnGap: 16,
|
||||||
|
padding: "12px 20px",
|
||||||
|
borderBottom: "1px solid var(--line)",
|
||||||
|
background: "var(--bg-2)",
|
||||||
|
fontSize: 11,
|
||||||
|
color: "var(--ink-3)",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
letterSpacing: "0.08em",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{COL_LABELS.map((label, i) => {
|
||||||
|
const sk = COL_SORT[i];
|
||||||
|
if (!sk) return <div key={i}>{label}</div>;
|
||||||
|
const active = sortBy === sk;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
onClick={() => onSort(sk)}
|
||||||
|
style={{
|
||||||
|
background: "none",
|
||||||
|
border: "none",
|
||||||
|
padding: 0,
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: "inherit",
|
||||||
|
textTransform: "inherit",
|
||||||
|
letterSpacing: "inherit",
|
||||||
|
fontWeight: active ? 600 : "inherit",
|
||||||
|
color: active ? "var(--ink)" : "var(--ink-3)",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
{active && <span style={{ fontSize: 9 }}>▼</span>}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MobileSkuCard({ row, onClick }: { row: SkuRow; onClick: () => void }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onClick={onClick}
|
||||||
|
style={{
|
||||||
|
padding: "14px 16px",
|
||||||
|
background: "var(--surface)",
|
||||||
|
borderRadius: "var(--r-md)",
|
||||||
|
marginBottom: 8,
|
||||||
|
cursor: "pointer",
|
||||||
|
border: "1px solid var(--line)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
|
||||||
|
<div style={{ fontFamily: "var(--serif)", fontSize: 20, color: "var(--ink-3)", width: 24 }}>
|
||||||
|
{TYPE_GLYPHS[row.product.type]}
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontWeight: 500,
|
||||||
|
fontSize: 14,
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{row.name}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 12, color: "var(--ink-3)", marginTop: 2 }}>
|
||||||
|
{row.brand} · {row.product.type}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span style={{ color: "var(--ink-3)", fontSize: 16 }}>›</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 12,
|
||||||
|
marginTop: 10,
|
||||||
|
paddingTop: 10,
|
||||||
|
borderTop: "1px solid var(--line)",
|
||||||
|
fontSize: 12,
|
||||||
|
color: "var(--ink-2)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="mono">
|
||||||
|
{row.itemCount} item{row.itemCount !== 1 ? "s" : ""}
|
||||||
|
{row.activeCount > 0 && row.activeCount < row.itemCount && (
|
||||||
|
<span style={{ color: "var(--ink-3)" }}> ({row.activeCount} active)</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
{row.itemCount > 0 && (
|
||||||
|
<span className="mono">{fmt.money(row.totalSpend)}</span>
|
||||||
|
)}
|
||||||
|
{row.avgRating != null && (
|
||||||
|
<span style={{ display: "flex", alignItems: "center", gap: 3 }}>
|
||||||
|
<Icon name="star" size={11} color="var(--amber)" />
|
||||||
|
<span className="mono">{row.avgRating.toFixed(1)}</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{row.lastPurchase && (
|
||||||
|
<span style={{ marginLeft: "auto", color: "var(--ink-3)" }}>
|
||||||
|
{fmt.dateShort(row.lastPurchase, getStoredTimezone())}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SkuItemRow({ row, onClick }: { row: SkuRow; onClick: () => void }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onClick={onClick}
|
||||||
|
className="inv-row"
|
||||||
|
style={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: GRID_COLS,
|
||||||
|
columnGap: 16,
|
||||||
|
padding: "14px 20px",
|
||||||
|
borderBottom: "1px solid var(--line)",
|
||||||
|
alignItems: "center",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: 13,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontFamily: "var(--serif)", fontSize: 18, color: "var(--ink-3)" }}>
|
||||||
|
{TYPE_GLYPHS[row.product.type]}
|
||||||
|
</div>
|
||||||
|
<div style={{ minWidth: 0 }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontWeight: 500,
|
||||||
|
color: "var(--ink)",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{row.name}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 11, color: "var(--ink-3)", fontFamily: "var(--mono)" }}>
|
||||||
|
{row.product.sku}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ color: "var(--ink-2)" }}>{row.brand}</div>
|
||||||
|
<div style={{ color: "var(--ink-3)", fontSize: 12 }}>
|
||||||
|
{row.product.type} · {row.product.kind}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontFamily: "var(--mono)" }}>
|
||||||
|
{row.itemCount}
|
||||||
|
{row.activeCount > 0 && row.activeCount < row.itemCount && (
|
||||||
|
<span style={{ color: "var(--ink-3)", fontSize: 11 }}> ({row.activeCount} active)</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontFamily: "var(--mono)" }}>{row.itemCount > 0 ? fmt.money(row.totalSpend) : "—"}</div>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 4 }}>
|
||||||
|
{row.avgRating != null ? (
|
||||||
|
<>
|
||||||
|
<Icon name="star" size={12} color="var(--amber)" />
|
||||||
|
<span style={{ fontFamily: "var(--mono)", fontSize: 12 }}>
|
||||||
|
{row.avgRating.toFixed(1)}
|
||||||
|
</span>
|
||||||
|
<span style={{ fontSize: 10, color: "var(--ink-3)" }}>({row.ratingCount})</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span style={{ color: "var(--ink-3)", fontSize: 12, fontStyle: "italic" }}>—</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
|
||||||
|
<span style={{ fontSize: 12, color: "var(--ink-3)" }}>
|
||||||
|
{row.lastPurchase ? fmt.dateShort(row.lastPurchase, getStoredTimezone()) : "—"}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className="inv-row-chevron"
|
||||||
|
style={{ color: "var(--ink-3)", marginLeft: "auto", fontSize: 14 }}
|
||||||
|
>
|
||||||
|
›
|
||||||
|
</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: {
|
||||||
|
|||||||
@@ -1,25 +0,0 @@
|
|||||||
# CODING AGENTS: READ THIS FIRST
|
|
||||||
|
|
||||||
This is a **handoff bundle** from Claude Design (claude.ai/design).
|
|
||||||
|
|
||||||
A user mocked up designs in HTML/CSS/JS using an AI design tool, then exported this bundle so a coding agent can implement the designs for real.
|
|
||||||
|
|
||||||
## What you should do — IMPORTANT
|
|
||||||
|
|
||||||
**Read the chat transcripts first.** There are 1 chat transcript(s) in `weed-tracker/chats/`. The transcripts show the full back-and-forth between the user and the design assistant — they tell you **what the user actually wants** and **where they landed** after iterating. Don't skip them. The final HTML files are the output, but the chat is where the intent lives.
|
|
||||||
|
|
||||||
**Read `weed-tracker/project/Apothecary - Inventory.html` in full.** The user had this file open when they triggered the handoff, so it's almost certainly the primary design they want built. Read it top to bottom — don't skim. Then **follow its imports**: open every file it pulls in (shared components, CSS, scripts) so you understand how the pieces fit together before you start implementing.
|
|
||||||
|
|
||||||
**If anything is ambiguous, ask the user to confirm before you start implementing.** It's much cheaper to clarify scope up front than to build the wrong thing.
|
|
||||||
|
|
||||||
## About the design files
|
|
||||||
|
|
||||||
The design medium is **HTML/CSS/JS** — these are prototypes, not production code. Your job is to **recreate them pixel-perfectly** in whatever technology makes sense for the target codebase (React, Vue, native, whatever fits). Match the visual output; don't copy the prototype's internal structure unless it happens to fit.
|
|
||||||
|
|
||||||
**Don't render these files in a browser or take screenshots unless the user asks you to.** Everything you need — dimensions, colors, layout rules — is spelled out in the source. Read the HTML and CSS directly; a screenshot won't tell you anything they don't.
|
|
||||||
|
|
||||||
## Bundle contents
|
|
||||||
|
|
||||||
- `weed-tracker/README.md` — this file
|
|
||||||
- `weed-tracker/chats/` — conversation transcripts (read these!)
|
|
||||||
- `weed-tracker/project/` — the `Weed Tracker` project files (HTML prototypes, assets, components)
|
|
||||||
@@ -1,819 +0,0 @@
|
|||||||
# Weed Inventory Tracker
|
|
||||||
|
|
||||||
_Started 2026-04-25 14:53 UTC_
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## User
|
|
||||||
|
|
||||||
I want to create an inventory system for personal weed tracking
|
|
||||||
|
|
||||||
Dashboard:
|
|
||||||
- Should contain interesting stats like grams consumed daily, weekly, monthly / average $ per gram / average spend, etc
|
|
||||||
|
|
||||||
Inventory:
|
|
||||||
Should be somewhat of a standard inventory system. Products have SKUs and sometimes asset tags if we don't have SKUs
|
|
||||||
Products have name, price, weight (g), THC%, CBD%, Total Cannabinoids %, shop (where we bought it), brand (who made it), type (concentrate, bud, edible, vaporizer, etc), purchase date
|
|
||||||
|
|
||||||
We can mark products as gone / consumed, but that does not necessarily mean that they are not tracked anymore. They just do not live within a bin anymore (they're gone)
|
|
||||||
|
|
||||||
## Assistant
|
|
||||||
|
|
||||||
I'll help you design a personal inventory tracking system. Before I start building, I have some questions to make sure I design this right for you.
|
|
||||||
|
|
||||||
_[tool: questions_v2]_
|
|
||||||
|
|
||||||
## User
|
|
||||||
|
|
||||||
Questions answered:
|
|
||||||
- output_format: Both — prototype + deck
|
|
||||||
- platform: Responsive (works everywhere)
|
|
||||||
- design_context: No, design something original for me
|
|
||||||
- aesthetic: Apothecary / botanical (warm, earthy, refined)
|
|
||||||
- color_palette: Earthy / warm neutrals
|
|
||||||
- screens: Dashboard with stats, Inventory list (all products), Single product detail view, Add new product flow, Mark as consumed flow, Bins / storage view, Spend over time / charts, Filters & search, Settings
|
|
||||||
- stats_priorities: Daily/weekly/monthly grams, Avg $ per gram, Avg spend per period, THC consumed (mg), Consumption streaks / patterns, Current inventory value, Favorite shop / brand, Type breakdown (bud vs concentrate, etc), Cost per session / day estimate, Days of supply remaining
|
|
||||||
- bins: Physical bins (e.g. 'Top drawer', 'Safe')
|
|
||||||
- consumed_tracking: Date finished + final notes (rating, etc)
|
|
||||||
- variations: 2-3 directions to compare
|
|
||||||
- tweaks: None / decide for me
|
|
||||||
- tone: Decide for me
|
|
||||||
|
|
||||||
## Assistant
|
|
||||||
|
|
||||||
I've got everything I need. Let me lay out the plan and start building.
|
|
||||||
|
|
||||||
**Aesthetic commitment** — Apothecary / botanical, warm earthy neutrals. Type pairing: **Fraktur Mono / Söhne**-feeling pairing... actually, let me commit: **GT Sectra Display** (or similar — I'll use **Cormorant Garamond** for serif headers + **JetBrains Mono** for data/SKUs + **Inter** for UI). Palette: parchment/bone background (oklch warm off-white), deep ink, sage green accent, terracotta accent for warnings/consumed states.
|
|
||||||
|
|
||||||
**Plan:**
|
|
||||||
1. Build prototype first (responsive, all 9 screens, 2-3 variations as tweaks)
|
|
||||||
2. Then build the pitch deck showing the concept
|
|
||||||
3. Both share the visual language
|
|
||||||
|
|
||||||
Let me set up the project structure.
|
|
||||||
|
|
||||||
_[tool: update_todos]_
|
|
||||||
|
|
||||||
_[tool: copy_starter_component]_
|
|
||||||
|
|
||||||
_[tool: copy_starter_component]_
|
|
||||||
|
|
||||||
_[tool: write_file]_
|
|
||||||
|
|
||||||
_[tool: write_file]_
|
|
||||||
|
|
||||||
Now the main app file and components:
|
|
||||||
|
|
||||||
_[tool: write_file]_
|
|
||||||
|
|
||||||
Now the screens:
|
|
||||||
|
|
||||||
_[tool: write_file]_
|
|
||||||
|
|
||||||
_[tool: snip]_
|
|
||||||
|
|
||||||
_[tool: write_file]_
|
|
||||||
|
|
||||||
_[tool: snip]_
|
|
||||||
|
|
||||||
_[tool: write_file]_
|
|
||||||
|
|
||||||
_[tool: show_html]_
|
|
||||||
|
|
||||||
_[tool: snip]_
|
|
||||||
|
|
||||||
_[tool: show_html]_
|
|
||||||
|
|
||||||
_[tool: get_webview_logs]_
|
|
||||||
|
|
||||||
I need to fix the `padded` prop being passed to DOM, then build the deck.
|
|
||||||
|
|
||||||
_[tool: snip]_
|
|
||||||
|
|
||||||
_[tool: str_replace_edit]_
|
|
||||||
|
|
||||||
_[tool: snip]_
|
|
||||||
|
|
||||||
Now let me build the pitch deck:
|
|
||||||
|
|
||||||
_[tool: write_file]_
|
|
||||||
|
|
||||||
_[tool: register_assets]_
|
|
||||||
|
|
||||||
_[tool: snip]_
|
|
||||||
|
|
||||||
_[tool: done]_
|
|
||||||
|
|
||||||
_[tool: fork_verifier_agent]_
|
|
||||||
|
|
||||||
I built two pieces under the **Apothecary** concept — warm parchment, deep ink, sage + terracotta accents, Cormorant Garamond serif paired with Inter and JetBrains Mono.
|
|
||||||
|
|
||||||
**Prototype** (`Apothecary - Inventory.html`)
|
|
||||||
- All 9 screens: dashboard, inventory list, product detail (right-drawer), add product, log session / mark consumed, bins, patterns/charts (90-day bars + spend by month/shop + heatmap), settings, plus filters & search
|
|
||||||
- Full product schema: SKU, optional asset tag, name, brand, shop, type, weight, price, THC%, CBD%, total cannabinoids, purchase date, bin
|
|
||||||
- Consumed items stay in the archive with rating + final notes
|
|
||||||
- **3 dashboard variants** as tweaks (Editorial / Data-dense / Minimal), plus light/dark and a tone switch (Botanical / Neutral / Discreet code-name mode)
|
|
||||||
|
|
||||||
**Deck** (`Apothecary - Deck.html`)
|
|
||||||
- 13 slides, 1920×1080, parallel section-header style, alternating light parchment / ink / sage / terracotta backgrounds for visual rhythm
|
|
||||||
|
|
||||||
**Caveats / next steps**
|
|
||||||
- Form submissions are non-persistent (mock data) — wire to localStorage when you're ready
|
|
||||||
- No barcode/receipt scanner yet (in roadmap on slide 12)
|
|
||||||
- Charts are hand-rolled SVG — fine for prototyping, swap for a real library if you build this
|
|
||||||
|
|
||||||
## User
|
|
||||||
|
|
||||||
Couple thoughts
|
|
||||||
|
|
||||||
1. Let's do away with the idea of sessions. It's not likely that heavy users will bother will tracking every single session, and it's not likely that moderate to light users will bother using this product at all
|
|
||||||
|
|
||||||
2. I like the theme, but the buttons can be hard to read especially when adding a new product for example the submit button is very light and impossible to read
|
|
||||||
|
|
||||||
## Assistant
|
|
||||||
|
|
||||||
_[tool: snip]_
|
|
||||||
|
|
||||||
_[tool: read_file]_
|
|
||||||
|
|
||||||
Good points. Let me address both:
|
|
||||||
|
|
||||||
1. Replace "Sessions" with simpler tracking — just decrement remaining when something gets used (or skip entirely and rely on "mark finished" + purchase patterns)
|
|
||||||
2. Fix button contrast — the "primary" button uses `--ink` which is correct, but the issue is likely text color in dark mode or the danger button. Let me audit.
|
|
||||||
|
|
||||||
_[tool: snip]_
|
|
||||||
|
|
||||||
_[tool: read_file]_
|
|
||||||
|
|
||||||
I see the issue — `var(--bg)` on the primary button is parchment (light) on `var(--ink)` (dark) which should be fine, BUT the disabled state at opacity 0.4 makes the "Save product" button unreadable until name is filled. Let me fix both: remove sessions concept, and improve disabled button contrast.
|
|
||||||
|
|
||||||
_[tool: snip]_
|
|
||||||
|
|
||||||
_[tool: str_replace_edit]_
|
|
||||||
|
|
||||||
_[tool: snip]_
|
|
||||||
|
|
||||||
Now remove sessions — replace ConsumeFlow with a simpler "Mark finished" + "Use some" flow, and rework dashboard stats away from session-based metrics.
|
|
||||||
|
|
||||||
_[tool: snip]_
|
|
||||||
|
|
||||||
_[tool: str_replace_edit]_
|
|
||||||
|
|
||||||
_[tool: snip]_
|
|
||||||
|
|
||||||
Now rework the dashboard stats and data model to remove session-based metrics, and update the deck/copy that references "sessions".
|
|
||||||
|
|
||||||
_[tool: snip]_
|
|
||||||
|
|
||||||
_[tool: read_file]_
|
|
||||||
|
|
||||||
I'll rework stats around purchases-only (no session log). The approach: derive daily/weekly/monthly grams from purchase patterns over time (grams purchased ÷ days in period), drop streak entirely, and replace "cost per session" with "average lifespan" (days between purchase and consumed).
|
|
||||||
|
|
||||||
_[tool: snip]_
|
|
||||||
|
|
||||||
_[tool: str_replace_edit]_
|
|
||||||
|
|
||||||
_[tool: snip]_
|
|
||||||
|
|
||||||
_[tool: read_file]_
|
|
||||||
|
|
||||||
_[tool: snip]_
|
|
||||||
|
|
||||||
_[tool: str_replace_edit]_
|
|
||||||
|
|
||||||
_[tool: snip]_
|
|
||||||
|
|
||||||
_[tool: grep]_
|
|
||||||
|
|
||||||
_[tool: snip]_
|
|
||||||
|
|
||||||
_[tool: read_file]_
|
|
||||||
|
|
||||||
_[tool: snip]_
|
|
||||||
|
|
||||||
_[tool: str_replace_edit]_
|
|
||||||
|
|
||||||
_[tool: str_replace_edit]_
|
|
||||||
|
|
||||||
_[tool: str_replace_edit]_
|
|
||||||
|
|
||||||
_[tool: str_replace_edit]_
|
|
||||||
|
|
||||||
_[tool: str_replace_edit]_
|
|
||||||
|
|
||||||
_[tool: str_replace_edit]_
|
|
||||||
|
|
||||||
_[tool: snip]_
|
|
||||||
|
|
||||||
_[tool: grep]_
|
|
||||||
|
|
||||||
_[tool: grep]_
|
|
||||||
|
|
||||||
I need to remove the user's request to track sessions/streaks since they only asked for "date finished + final notes" — not session-level tracking. Let me clean these up.
|
|
||||||
|
|
||||||
_[tool: snip]_
|
|
||||||
|
|
||||||
_[tool: read_file]_
|
|
||||||
|
|
||||||
I see — the inventory HTML has its own data.log references and dashboard variants. Since the user said "date finished + final notes" (not session journaling), I should remove the consumption log entirely and pivot stats to be derived from purchase + finished dates only. Let me look at the full picture first.
|
|
||||||
|
|
||||||
_[tool: grep]_
|
|
||||||
|
|
||||||
_[tool: grep]_
|
|
||||||
|
|
||||||
Now I understand the full scope. The user only wants "date finished + final notes" — they don't track sessions/streaks. I need to:
|
|
||||||
|
|
||||||
1. Replace data.log with derived series from purchase/consumed dates
|
|
||||||
2. Remove session/streak references
|
|
||||||
3. Update stats accordingly
|
|
||||||
|
|
||||||
_[tool: read_file]_
|
|
||||||
|
|
||||||
Now I'll completely refactor the data model to drop the consumption log (since user only wants date finished + final notes), and derive all stats from purchase + finished records.
|
|
||||||
|
|
||||||
_[tool: grep]_
|
|
||||||
|
|
||||||
Good, computeStats already exists. Let me look at it.
|
|
||||||
|
|
||||||
_[tool: read_file]_
|
|
||||||
|
|
||||||
Good — computeStats already provides `series7`, `series30`, `series90`, `avgLifespan`, `avgGap`, `consumedCount` (likely). Let me check the return + cleanup the remaining stale references.
|
|
||||||
|
|
||||||
_[tool: read_file]_
|
|
||||||
|
|
||||||
Now let me fix all the remaining session/streak/log references in screens-2.jsx and Apothecary - Inventory.html (the dashboard variants and patterns screen):
|
|
||||||
|
|
||||||
_[tool: read_file]_
|
|
||||||
|
|
||||||
Let me see the heatmap part too:
|
|
||||||
|
|
||||||
_[tool: read_file]_
|
|
||||||
|
|
||||||
Now I'll fix the ChartsView to use stats.series90 instead of building from data.log:
|
|
||||||
|
|
||||||
_[tool: str_replace_edit]_
|
|
||||||
|
|
||||||
_[tool: str_replace_edit]_
|
|
||||||
|
|
||||||
_[tool: str_replace_edit]_
|
|
||||||
|
|
||||||
Now fix the Inventory.html dashboard variants and remove `Log session` references:
|
|
||||||
|
|
||||||
_[tool: read_file]_
|
|
||||||
|
|
||||||
Now let me see the minimal dashboard and finish reading:
|
|
||||||
|
|
||||||
_[tool: read_file]_
|
|
||||||
|
|
||||||
Now I'll fix all references in Inventory.html:
|
|
||||||
|
|
||||||
_[tool: str_replace_edit]_
|
|
||||||
|
|
||||||
Now let me also clean up the data.js to remove the LOG (no longer needed) and find any other stale references. Let me also check ConsumeFlow's copy:
|
|
||||||
|
|
||||||
_[tool: grep]_
|
|
||||||
|
|
||||||
_[tool: str_replace_edit]_
|
|
||||||
|
|
||||||
_[tool: snip]_
|
|
||||||
|
|
||||||
_[tool: str_replace_edit]_
|
|
||||||
|
|
||||||
Let me read what's around the second "Streak" reference and apply edits more uniquely:
|
|
||||||
|
|
||||||
_[tool: read_file]_
|
|
||||||
|
|
||||||
There's only one literal "Streak" but probably another. Let me search:
|
|
||||||
|
|
||||||
_[tool: grep]_
|
|
||||||
|
|
||||||
Now let me apply edits with proper unique context:
|
|
||||||
|
|
||||||
_[tool: str_replace_edit]_
|
|
||||||
|
|
||||||
Now let me check primitives.jsx for any leftover stats keys (thcLast7 should still work — uses series7), and verify the inventory loads:
|
|
||||||
|
|
||||||
_[tool: grep]_
|
|
||||||
|
|
||||||
All cleaned up. Let me verify the prototype loads:
|
|
||||||
|
|
||||||
_[tool: done]_
|
|
||||||
|
|
||||||
Clean. Let me also verify the deck:
|
|
||||||
|
|
||||||
_[tool: show_html]_
|
|
||||||
|
|
||||||
_[tool: get_webview_logs]_
|
|
||||||
|
|
||||||
Both files load cleanly. I've removed all the session/streak tracking that crept in — your spec was "date finished + final notes," not session journaling.
|
|
||||||
|
|
||||||
**What changed:**
|
|
||||||
- Dropped the synthetic 90-day session log; daily/weekly/monthly grams are now **inferred** by prorating each item's used weight across its lifespan (purchase → finished, or purchase → today for active items)
|
|
||||||
- Replaced "Cost per session" / "Log streak" stats with **Avg lifespan** (days from purchase to finished) and **Avg days between buys**
|
|
||||||
- "Log session" buttons → "Mark finished" everywhere
|
|
||||||
- Heatmap relabeled "Inferred consumption"
|
|
||||||
- Deck slide updated to match
|
|
||||||
|
|
||||||
_[tool: fork_verifier_agent]_
|
|
||||||
|
|
||||||
Done. The tracker now matches your spec: products are tracked from purchase to finished date with rating + final notes, and all dashboard stats derive from those records — no per-session logging.
|
|
||||||
|
|
||||||
## User
|
|
||||||
|
|
||||||
Verifier agent check completed
|
|
||||||
|
|
||||||
## Assistant
|
|
||||||
|
|
||||||
Let me investigate both issues:
|
|
||||||
|
|
||||||
_[tool: grep]_
|
|
||||||
|
|
||||||
Confirmed — let me see how Btn spreads `v`:
|
|
||||||
|
|
||||||
_[tool: read_file]_
|
|
||||||
|
|
||||||
Confirmed: `...v` spreads `bg` (not a CSS prop) into the style. Fix the variants to use `background`:
|
|
||||||
|
|
||||||
_[tool: str_replace_edit]_
|
|
||||||
|
|
||||||
Now fix the dashboard greeting layout. Let me look at the header:
|
|
||||||
|
|
||||||
_[tool: read_file]_
|
|
||||||
|
|
||||||
The header layout looks fine to me — but verifier reports overlap. The issue: `marginBottom: 8` on the row might not give enough space, and the Btn flex container could wrap. Let me add `flexWrap: nowrap` and proper alignment plus more bottom margin:
|
|
||||||
|
|
||||||
_[tool: str_replace_edit]_
|
|
||||||
|
|
||||||
Now verify the fix:
|
|
||||||
|
|
||||||
_[tool: done]_
|
|
||||||
|
|
||||||
_[tool: fork_verifier_agent]_
|
|
||||||
|
|
||||||
Fixed both bugs:
|
|
||||||
- **Button contrast** — variants map used `bg:` instead of `background:`, so the spread never set the button's background. Renamed throughout.
|
|
||||||
- **Header overlap** — slightly reduced the greeting (44→36px), tightened line-height, switched the row to `align-items: center` with `flex-wrap` and `flex-shrink: 0` on the buttons so they can't squeeze the title into a wrap.
|
|
||||||
|
|
||||||
## User
|
|
||||||
|
|
||||||
Verifier agent check completed
|
|
||||||
|
|
||||||
## Assistant
|
|
||||||
|
|
||||||
All clear — both fixes verified.
|
|
||||||
|
|
||||||
## User
|
|
||||||
|
|
||||||
Okay, I think we have a really good start to this. I have a number of thoughts to flesh it out a bit
|
|
||||||
|
|
||||||
1. I noticed that you included a low stock running out section, but we removed the sessions which told us how much of a particular product that we have. I think that this adds some interesting depth. For example we can have multiple of 1 product; for a example a particular pre roll that we like where we have multiple versus a jar of a particular strain. It's easy to tell how many we have left at all times versus bud where we will show as having 14g for example until the day I mark it as completely consumed. I think we should prompt an audit on a product after say 30 days where we re-weigh the product. This should be based on product type though. Edibles, pre-rolls we should just confirm whether it's still there or not (require entering serial or asset to confirm) whereas bud would get reweighed and wax would get estimated
|
|
||||||
|
|
||||||
2. We do not currently have a way to add a new shop
|
|
||||||
|
|
||||||
3. Brands should be similar to shops where they are a dropdown and we should similarly add a section to add a new brand
|
|
||||||
|
|
||||||
## Assistant
|
|
||||||
|
|
||||||
Great direction. Let me think through the model changes before coding:
|
|
||||||
|
|
||||||
**Multi-unit products** — items like pre-rolls/edibles have a `count` (e.g. 5 pre-rolls). Bud/wax have `weight` only. Tracking different product types differently:
|
|
||||||
- **Discrete (count-based):** Pre-roll, Edible, Vaporizer → track `quantity` (units left). Audit = "do you still have N?" with SKU/asset confirmation.
|
|
||||||
- **Bulk (weight-based):** Flower, Concentrate, Tincture → track `remaining` weight. Audit = re-weigh (Flower) or estimate (Concentrate/Tincture).
|
|
||||||
|
|
||||||
**Audits** — after 30 days since purchase or last audit, prompt re-weigh/re-count. Track audit history with date + value.
|
|
||||||
|
|
||||||
**Shops & brands** — both become managed lists with "+ Add new" inline in dropdowns, plus a Settings section to manage them.
|
|
||||||
|
|
||||||
Let me ask a couple of quick clarifications before I build:
|
|
||||||
|
|
||||||
_[tool: questions_v2]_
|
|
||||||
|
|
||||||
## User
|
|
||||||
|
|
||||||
Questions answered:
|
|
||||||
- audit_trigger: Configurable per-type cadence (e.g. flower 14d, edibles 60d)
|
|
||||||
- audit_surface: Inline badge on each product in inventory + dashboard banner
|
|
||||||
- audit_confirm_method: Type SKU or asset tag
|
|
||||||
- discrepancy: The point is not to find a discrepancy with weights. We are checking weights (for certain types) to better track inventory and usage. We are checking whether or not the item is still there for other types like edibles and pre rolls that is more where we will find a discrepancy. If one is found then we should have a modal that gives the option to mark it as consumed or gone. Difference is gone counts as money spent but not weed consumed (for metrics)
|
|
||||||
- shop_brand_metadata: New Shop (Name + Location), New Brand (Name)
|
|
||||||
- low_stock_logic: For edibles and prerolls we should do count less than or equal to 2, but do it by brand rather than strain (sku). For wax and bud do it by original % (25)
|
|
||||||
- remaining_display: Show estimated remaining (decays based on avg lifespan) + actual last audit
|
|
||||||
|
|
||||||
## Assistant
|
|
||||||
|
|
||||||
Great answers. Let me plan the build:
|
|
||||||
|
|
||||||
_[tool: update_todos]_
|
|
||||||
|
|
||||||
_[tool: snip]_
|
|
||||||
|
|
||||||
_[tool: read_file]_
|
|
||||||
|
|
||||||
Now I'll rewrite data.js with the new model:
|
|
||||||
|
|
||||||
**Model decisions:**
|
|
||||||
- `kind: "bulk"` (Flower, Concentrate, Tincture) — track `weight` (g/ml)
|
|
||||||
- `kind: "discrete"` (Pre-roll, Edible, Vaporizer) — track `count` (units)
|
|
||||||
- `unitWeight` for discrete bulk-equivalent (e.g. each pre-roll = 1g)
|
|
||||||
- `audits[]` — `{date, type: "weigh"|"presence", value, byUser}`
|
|
||||||
- `auditCadenceDays` per type (Flower 14, Concentrate 21, Tincture 30, Pre-roll 30, Edible 60, Vaporizer 30)
|
|
||||||
- `consumed` (used up via use) vs `gone` (lost / discarded — counts as $ but not consumption)
|
|
||||||
- Shops: `[{id, name, location}]`, Brands: `[{id, name}]`
|
|
||||||
|
|
||||||
_[tool: write_file]_
|
|
||||||
|
|
||||||
_[tool: snip]_
|
|
||||||
|
|
||||||
_[tool: read_file]_
|
|
||||||
|
|
||||||
_[tool: snip]_
|
|
||||||
|
|
||||||
_[tool: read_file]_
|
|
||||||
|
|
||||||
Now rewrite computeStats for the new model. Key changes:
|
|
||||||
- `p.status` (active/consumed/gone) instead of `p.consumed`
|
|
||||||
- bulk: use estimatedRemaining decay; discrete: use countLastAudit × unitWeight for grams
|
|
||||||
- Gone items: count $ but NOT grams in consumption stats
|
|
||||||
- favShop/favBrand keyed by shopId/brandId — return name+count
|
|
||||||
- typeBreakdown by total est. grams (discrete uses count × unitWeight)
|
|
||||||
- New: overdueAudits count
|
|
||||||
|
|
||||||
_[tool: str_replace_edit]_
|
|
||||||
|
|
||||||
_[tool: snip]_
|
|
||||||
|
|
||||||
_[tool: grep]_
|
|
||||||
|
|
||||||
_[tool: snip]_
|
|
||||||
|
|
||||||
Lots of references. Let me read screens-1.jsx fully and rewrite it cleanly:
|
|
||||||
|
|
||||||
_[tool: read_file]_
|
|
||||||
|
|
||||||
_[tool: snip]_
|
|
||||||
|
|
||||||
Now let me check the rest of the codebase to see what needs updating:
|
|
||||||
|
|
||||||
_[tool: list_files]_
|
|
||||||
|
|
||||||
_[tool: grep]_
|
|
||||||
|
|
||||||
Let me check data.js and the main HTML file to understand the current state:
|
|
||||||
|
|
||||||
_[tool: read_file]_
|
|
||||||
|
|
||||||
_[tool: snip]_
|
|
||||||
|
|
||||||
Now let me check the main HTML, primitives, and screens-2:
|
|
||||||
|
|
||||||
_[tool: read_file]_
|
|
||||||
|
|
||||||
_[tool: read_file]_
|
|
||||||
|
|
||||||
_[tool: snip]_
|
|
||||||
|
|
||||||
Now I have a complete picture. The data model has `status: "active" | "consumed" | "gone"` but `screens-1.jsx` still uses old `p.consumed`, `p.remaining`, `p.weight` patterns and references `data.products[].brand`/`shop` strings. I need to rewrite screens-1.jsx to use the new model.
|
|
||||||
|
|
||||||
Let me look at screens-2.jsx briefly to see how it's structured and check for existing usage patterns:
|
|
||||||
|
|
||||||
_[tool: read_file]_
|
|
||||||
|
|
||||||
_[tool: snip]_
|
|
||||||
|
|
||||||
Now I have the full picture. The screens use the OLD model (`p.consumed`, `p.brand`, `p.shop`, `p.remaining`, `p.weight`) but data.js uses the NEW model (`p.status`, `p.brandId`, `p.shopId`, `p.kind`, `p.countLastAudit`, etc.). I need to update all three screens files plus the main HTML to use the new model. Let me also check tokens.css briefly and then rewrite.
|
|
||||||
|
|
||||||
_[tool: read_file]_
|
|
||||||
|
|
||||||
_[tool: snip]_
|
|
||||||
|
|
||||||
Now let me look at the user's most recent ask. I should scroll back to see what they want me to do. Let me check what's going on with the file currently — does it have errors?
|
|
||||||
|
|
||||||
_[tool: show_html]_
|
|
||||||
|
|
||||||
_[tool: get_webview_logs]_
|
|
||||||
|
|
||||||
No errors so far. Let me screenshot to see the actual rendered state:
|
|
||||||
|
|
||||||
_[tool: save_screenshot]_
|
|
||||||
|
|
||||||
_[tool: view_image]_
|
|
||||||
|
|
||||||
_[tool: snip]_
|
|
||||||
|
|
||||||
The page renders but the data shows 0g/0$ everywhere because the screens are reading the OLD model fields (`p.consumed`, `p.brand`, `p.shop`, `p.remaining`, `p.weight` for grams) which no longer exist directly. I need to update screens-1.jsx, screens-2.jsx, and the main HTML to use the new model with `status`, `brandId`/`shopId` lookups, and helpers for est-remaining and discrete vs bulk.
|
|
||||||
|
|
||||||
Let me delete the old screen files and clean rewrite them. First screens-1.jsx (Dashboard, Inventory, ProductDetail):
|
|
||||||
|
|
||||||
_[tool: write_file]_
|
|
||||||
|
|
||||||
_[tool: snip]_
|
|
||||||
|
|
||||||
Now let me write a smaller screens-2.jsx replacement and a separate screens-3.jsx for the audit/mark-gone modals. First update screens-2:
|
|
||||||
|
|
||||||
_[tool: write_file]_
|
|
||||||
|
|
||||||
_[tool: snip]_
|
|
||||||
|
|
||||||
Now update the main HTML to wire up the new modals (audit, mark-gone) and update the dense/minimal dashboard variants to use the new model:
|
|
||||||
|
|
||||||
_[tool: str_replace_edit]_
|
|
||||||
|
|
||||||
_[tool: str_replace_edit]_
|
|
||||||
|
|
||||||
_[tool: str_replace_edit]_
|
|
||||||
|
|
||||||
_[tool: str_replace_edit]_
|
|
||||||
|
|
||||||
_[tool: snip]_
|
|
||||||
|
|
||||||
Now I need to fix the dense and minimal dashboard variants in the HTML — they still reference `p.consumed`, `p.brand`, `p.shop`, `p.remaining`. Let me update those:
|
|
||||||
|
|
||||||
_[tool: str_replace_edit]_
|
|
||||||
|
|
||||||
_[tool: str_replace_edit]_
|
|
||||||
|
|
||||||
_[tool: str_replace_edit]_
|
|
||||||
|
|
||||||
_[tool: snip]_
|
|
||||||
|
|
||||||
_[tool: show_html]_
|
|
||||||
|
|
||||||
_[tool: snip]_
|
|
||||||
|
|
||||||
_[tool: get_webview_logs]_
|
|
||||||
|
|
||||||
_[tool: snip]_
|
|
||||||
|
|
||||||
_[tool: save_screenshot]_
|
|
||||||
|
|
||||||
_[tool: snip]_
|
|
||||||
|
|
||||||
_[tool: view_image]_
|
|
||||||
|
|
||||||
_[tool: snip]_
|
|
||||||
|
|
||||||
The screen looks like an old cached state. The data showing "0.00 g/day" "0g weekly" suggests the new data with audits + decay isn't computing consumption. Let me look at the issue: the screenshot may have been cached. Let me reload and check fresh:
|
|
||||||
|
|
||||||
_[tool: save_screenshot]_
|
|
||||||
|
|
||||||
Hmm — `screenshot` after `location.reload()` doesn't actually wait for the reload before screenshotting (the screenshot may capture mid-reload). Let me wait:
|
|
||||||
|
|
||||||
_[tool: show_html]_
|
|
||||||
|
|
||||||
Now wait + screenshot:
|
|
||||||
|
|
||||||
_[tool: save_screenshot]_
|
|
||||||
|
|
||||||
_[tool: view_image]_
|
|
||||||
|
|
||||||
Same render — but the new dashboard should now show "Mark finished" + "Audit" buttons (3 buttons), and counts like "consumed/gone". Looking at the header: "5 bins · 17.9 g on hand · 0 archived" — that's old! Plus only 2 buttons. The new screens-1.jsx isn't loading. Let me check `eval_js` for loaded:
|
|
||||||
|
|
||||||
_[tool: eval_js]_
|
|
||||||
|
|
||||||
Dashboard exists, but `H` (DATA_HELPERS) is undefined and the products don't have brandId. The data.js isn't the new one I read. Wait — I read data.js as new, but at runtime it's old? Let me check:
|
|
||||||
|
|
||||||
_[tool: eval_js]_
|
|
||||||
|
|
||||||
The data.js in the cached webview is the OLD version. But I rewrote it. Let me verify the file on disk:
|
|
||||||
|
|
||||||
_[tool: grep]_
|
|
||||||
|
|
||||||
File on disk is correct. The webview cached the old version. Let me hard reload:
|
|
||||||
|
|
||||||
_[tool: eval_js]_
|
|
||||||
|
|
||||||
Right, but DATA_HELPERS is undefined and SAMPLE_DATA has old fields. So data.js is being served stale. Let me cache-bust by adding a version comment-edit, or by checking what's actually happening — let me reload the iframe with a hard reload:
|
|
||||||
|
|
||||||
_[tool: eval_js]_
|
|
||||||
|
|
||||||
So the FILE is fine, but the webview cached an older version. Cache-busting via a query param works. The iframe just held an old script. Let me force reload the page (and verify):
|
|
||||||
|
|
||||||
_[tool: show_html]_
|
|
||||||
|
|
||||||
_[tool: sleep]_
|
|
||||||
|
|
||||||
_[tool: eval_js]_
|
|
||||||
|
|
||||||
Hmm. Despite the network fetching fresh data, the script tag itself loads stale. There must be a syntax error in data.js causing it to fail silently then fall back to old. Let me check console:
|
|
||||||
|
|
||||||
_[tool: get_webview_logs]_
|
|
||||||
|
|
||||||
No errors but DATA_HELPERS is undefined. So data.js executes but the helpers aren't being attached? Let me check if SAMPLE_DATA itself has new structure:
|
|
||||||
|
|
||||||
_[tool: eval_js]_
|
|
||||||
|
|
||||||
SAMPLE_DATA has both old AND new keys: it has top-level `shops`, `brands`, `types` (new) but products use OLD `brand`, `shop` strings. That means data.js was edited inconsistently. Let me look at data.js carefully:
|
|
||||||
|
|
||||||
_[tool: read_file]_
|
|
||||||
|
|
||||||
So data.js should produce products with `brandId`, `kind`, etc. But what the iframe runtime shows is a product with old `brand`/`shop` and no `kind`. There must be TWO data.js loads — let me look at the HTML:
|
|
||||||
|
|
||||||
_[tool: grep]_
|
|
||||||
|
|
||||||
Single data.js. The runtime shows old keys. Let me check — does data.js have a stale section that sets old keys? Maybe I added new `BRANDS` array but didn't actually change the products inside the function:
|
|
||||||
|
|
||||||
_[tool: grep]_
|
|
||||||
|
|
||||||
No old fields exist in source. The runtime is using a stale cached version. Let me cache-bust the script tag:
|
|
||||||
|
|
||||||
_[tool: str_replace_edit]_
|
|
||||||
|
|
||||||
Wait — earlier `eval_js` of fetch directly showed `data.js?bust=...` HAS DATA_HELPERS. So fresh fetch is fine. The script tag without query string was returning OLD content. That's a service-worker caching issue or similar. The query-string bust should fix it.
|
|
||||||
|
|
||||||
_[tool: grep]_
|
|
||||||
|
|
||||||
Let me bust those too and verify:
|
|
||||||
|
|
||||||
_[tool: str_replace_edit]_
|
|
||||||
|
|
||||||
Now let me verify everything renders:
|
|
||||||
|
|
||||||
_[tool: show_html]_
|
|
||||||
|
|
||||||
_[tool: sleep]_
|
|
||||||
|
|
||||||
_[tool: eval_js]_
|
|
||||||
|
|
||||||
DATA_HELPERS is now loaded, products have new fields. Let me check console + screenshot:
|
|
||||||
|
|
||||||
_[tool: get_webview_logs]_
|
|
||||||
|
|
||||||
Clean. Let me screenshot:
|
|
||||||
|
|
||||||
_[tool: save_screenshot]_
|
|
||||||
|
|
||||||
_[tool: view_image]_
|
|
||||||
|
|
||||||
_[tool: snip]_
|
|
||||||
|
|
||||||
Looking at the dashboard now:
|
|
||||||
|
|
||||||
**Issues:**
|
|
||||||
1. "Good evening" overlaps with "Cuts of... 5 bins · 17.9 g on hand · 0 archived" subtitle
|
|
||||||
2. All stats show 0: "0.00 g/day", "$0.00 avg cost per gram", "0 mg THC last 7 days" — but "30-day spend $555". The compute is broken for consumption (which derives from audits + estimated decay).
|
|
||||||
3. The header still says "17.9 g on hand" — but with new estimated-remaining decay, this should reflect estimated total.
|
|
||||||
|
|
||||||
Let me read the Dashboard component:
|
|
||||||
|
|
||||||
_[tool: grep]_
|
|
||||||
|
|
||||||
Code references `stats.activeCount`, `stats.consumedCount`, `stats.goneCount`, `stats.dailyAvg`. Let me check computeStats:
|
|
||||||
|
|
||||||
_[tool: grep]_
|
|
||||||
|
|
||||||
Let me look at how series30 (consumption) is computed:
|
|
||||||
|
|
||||||
_[tool: read_file]_
|
|
||||||
|
|
||||||
OK so computeStats is rich and should produce non-zero numbers. But UI shows 0.00. Let me check what's actually returned at runtime:
|
|
||||||
|
|
||||||
_[tool: eval_js]_
|
|
||||||
|
|
||||||
So the numbers are correctly computed: 0.49 g/day, $18/g, $639 spend30, 1473mg THC last 7, etc. But the UI shows 0. So screens-1.jsx is rendering with the old data? Let me check what stats the React component actually sees — the React app's `useMemo` may have cached old stats. The `data` reference might still be stale. Actually — React stale closure: `data = window.SAMPLE_DATA` was captured before the new data.js loaded. But it should re-render now.
|
|
||||||
|
|
||||||
Actually I bet what's rendering on screen is from a render BEFORE data.js loaded fresh. Let me force a real reload:
|
|
||||||
|
|
||||||
_[tool: save_screenshot]_
|
|
||||||
|
|
||||||
Hmm actually looking back — in eval_js the `computeStats` call returned correct values right now. So why does the screenshot show 0? Maybe my reload screenshot races. Let me wait then evaluate the DOM:
|
|
||||||
|
|
||||||
_[tool: sleep]_
|
|
||||||
|
|
||||||
Wait actually let me check. After `location.reload()`, the page reloads and `computeStats` would run on data with fresh helpers. But reload may have lost iframe state. Let me re-show + grab fresh screenshot:
|
|
||||||
|
|
||||||
_[tool: show_html]_
|
|
||||||
|
|
||||||
_[tool: sleep]_
|
|
||||||
|
|
||||||
Going to check the DOM:
|
|
||||||
|
|
||||||
_[tool: eval_js]_
|
|
||||||
|
|
||||||
Now the dashboard renders correctly with new stats: 15 active, 4 consumed, 2 gone, 0.49 g/day, $17.96/g, $639 spend, 1473mg THC, audit overdue alert for Slate Cherry.
|
|
||||||
|
|
||||||
Now let me screenshot:
|
|
||||||
|
|
||||||
_[tool: save_screenshot]_
|
|
||||||
|
|
||||||
Header says "Cuts of..." was likely an old screenshot rendering issue. Let me view:
|
|
||||||
|
|
||||||
_[tool: view_image]_
|
|
||||||
|
|
||||||
_[tool: snip]_
|
|
||||||
|
|
||||||
The screenshot shows a STALE render — innerText reports the correct content (15 active, 4 consumed, 2 gone, 0.49 g/day, $17.96/g, $639) but the image shows the OLD render (15 items, 17.9g on hand, 0.00 g/day, $0.00, $555).
|
|
||||||
|
|
||||||
This is `save_screenshot` capturing a stale rasterization. The DOM is correct. Let me try again with extra delay:
|
|
||||||
|
|
||||||
_[tool: save_screenshot]_
|
|
||||||
|
|
||||||
Wait — the discrepancy is clear: innerText shows "21 items" and "15 active items"; screenshot shows "15 items" (sidebar) and old text. So this is two different DOMs being measured. innerText was after fresh reload; screenshot may have triggered a NEW reload that hasn't completed.
|
|
||||||
|
|
||||||
Actually `show_html` reloads. Looking at sidebar in screenshot: "v0.4 · 15 items" — this is OLD count. After my data update there are 21 products. So the screenshot is from BEFORE the refresh. The verifier's iframe may be different from eval_js iframe.
|
|
||||||
|
|
||||||
Let me try `multi_screenshot` which reuses the existing rendered DOM:
|
|
||||||
|
|
||||||
_[tool: multi_screenshot]_
|
|
||||||
|
|
||||||
Now the dashboard renders correctly! Stats are populated: 0.49 g/day, $17.96/g, $639 spend, 1,473mg THC, audit overdue alert for Slate Cherry, sparkline visible.
|
|
||||||
|
|
||||||
But I see one issue: **"Good evening." overlaps with the subtitle text** — "Good evening." block height collides with the active-items line. The h1 has `lineHeight: 1.1` and `margin: "6px 0 0"`, then below is a div with `marginBottom: 28`. Let me check the structure:
|
|
||||||
|
|
||||||
_[tool: read_file]_
|
|
||||||
|
|
||||||
The header div uses `alignItems: "center"`. With "Good evening." being two lines wrapped (because the buttons take width and the title flexs to 2 lines), it goes below the buttons. The subtitle below then overlaps because of the negative-ish lineHeight.
|
|
||||||
|
|
||||||
Actually looking again — "Good evening." is wrapping to two lines. That's because the title container is too narrow. Buttons take 3 of them now. Let me make this responsive — let the heading not wrap, and shrink:
|
|
||||||
|
|
||||||
_[tool: str_replace_edit]_
|
|
||||||
|
|
||||||
Now done:
|
|
||||||
|
|
||||||
_[tool: done]_
|
|
||||||
|
|
||||||
The dashboard now renders correctly with the new data model — 15 active / 4 consumed / 2 gone items, 0.49 g/day average, $17.96/g cost, $639 30-day spend, an audit-overdue card flagging Slate Cherry, and the new "Audit" button alongside Mark finished. Cache-busted script tags so the iframe picks up the fresh JS.
|
|
||||||
|
|
||||||
_[tool: fork_verifier_agent]_
|
|
||||||
|
|
||||||
Dashboard's working with the audit + gone/consumed split. Let me know if you want me to keep going on the rest of the screens (Inventory list with audit badges, the AuditModal, etc.) or pause here.
|
|
||||||
|
|
||||||
## User
|
|
||||||
|
|
||||||
Apply comment
|
|
||||||
|
|
||||||
## Assistant
|
|
||||||
|
|
||||||
Let me look at that slide:
|
|
||||||
|
|
||||||
_[tool: grep]_
|
|
||||||
|
|
||||||
Let me see the full line:
|
|
||||||
|
|
||||||
_[tool: read_file]_
|
|
||||||
|
|
||||||
The text is corrupted — "the cabinet you'veidn't realize you were on." is gibberish. Let me fix it to something clear:
|
|
||||||
|
|
||||||
_[tool: str_replace_edit]_
|
|
||||||
|
|
||||||
Fixed — that sentence was garbled (the second half was corrupted text). Now reads cleanly: "The morning glance. Daily averages, spend trends, days of supply — everything you need to see in a single look."
|
|
||||||
|
|
||||||
_[tool: done]_
|
|
||||||
|
|
||||||
_[tool: snip]_
|
|
||||||
|
|
||||||
Fixed the garbled sentence. The font-size warnings are decorative micro-labels (mono captions, eyebrow text) — intentional design choices, not real problems.
|
|
||||||
|
|
||||||
## User
|
|
||||||
|
|
||||||
Apply comment
|
|
||||||
|
|
||||||
## Assistant
|
|
||||||
|
|
||||||
The "Dashboard." title at 200px is way too big and getting clipped by the slide bounds. Let me check the slide and fix:
|
|
||||||
|
|
||||||
_[tool: grep]_
|
|
||||||
|
|
||||||
_[tool: snip]_
|
|
||||||
|
|
||||||
Let me check slide-pad layout — likely set to flex column with center-justify, but the 200px text overflows. Let me look at the slide-pad CSS and the deck-stage size:
|
|
||||||
|
|
||||||
_[tool: grep]_
|
|
||||||
|
|
||||||
_[tool: snip]_
|
|
||||||
|
|
||||||
The 200px section titles are too big for the slide. With slide-pad padding 100px, the available width is 1720px, but more critically the height with `justify-content: center` plus 200px line-height pushes content out. Let me reduce to 160px which still feels strong:
|
|
||||||
|
|
||||||
_[tool: str_replace_edit]_
|
|
||||||
|
|
||||||
_[tool: done]_
|
|
||||||
|
|
||||||
_[tool: snip]_
|
|
||||||
|
|
||||||
Brought the section titles down from 200px to 160px so they fit cleanly within the slide.
|
|
||||||
|
|
||||||
## User
|
|
||||||
|
|
||||||
No I think it's the color that makes it invisible lol
|
|
||||||
|
|
||||||
## Assistant
|
|
||||||
|
|
||||||
Ha — let me check. The slide is `.bg-ink` (dark) and `.h-display` color likely defaults to `var(--ink)` which is also dark on dark.
|
|
||||||
|
|
||||||
_[tool: grep]_
|
|
||||||
|
|
||||||
Confirmed — `.h-display` color is `var(--ink)` which is dark, on a `bg-ink` (also dark) background. The `.bg-ink .h2, .bg-ink .h1` rule covers h1/h2 but NOT `.h-display`. Same issue exists for `.bg-sage` and `.bg-terra`. Let me fix:
|
|
||||||
|
|
||||||
_[tool: str_replace_edit]_
|
|
||||||
|
|
||||||
_[tool: str_replace_edit]_
|
|
||||||
|
|
||||||
_[tool: snip]_
|
|
||||||
|
|
||||||
You were right — `.h-display` color was `var(--ink)` which made it invisible on the dark, sage, and terra backgrounds. Added it to the override rules alongside `.h1` and `.h2`.
|
|
||||||
|
|
||||||
_[tool: done]_
|
|
||||||
|
|
||||||
_[tool: snip]_
|
|
||||||
|
|
||||||
Section titles should now be visible on all three colored section dividers.
|
|
||||||
|
|
||||||
@@ -1,411 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
||||||
<title>Apothecary — A personal inventory system</title>
|
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:wght@400;500;600;700&family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
|
|
||||||
<link rel="stylesheet" href="tokens.css" />
|
|
||||||
<style>
|
|
||||||
body { background: #1a1a1a; }
|
|
||||||
deck-stage { background: #1a1a1a; }
|
|
||||||
deck-stage > section {
|
|
||||||
background: var(--bg);
|
|
||||||
color: var(--ink);
|
|
||||||
font-family: var(--sans);
|
|
||||||
overflow: hidden;
|
|
||||||
display: flex;
|
|
||||||
}
|
|
||||||
.slide-pad { padding: 100px; width: 100%; height: 100%; display: flex; flex-direction: column; box-sizing: border-box; }
|
|
||||||
.eyebrow {
|
|
||||||
font-family: var(--mono);
|
|
||||||
font-size: 22px;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.16em;
|
|
||||||
color: var(--ink-3);
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
.h-display {
|
|
||||||
font-family: var(--serif);
|
|
||||||
font-size: 140px;
|
|
||||||
font-weight: 500;
|
|
||||||
line-height: 0.95;
|
|
||||||
letter-spacing: -0.025em;
|
|
||||||
color: var(--ink);
|
|
||||||
}
|
|
||||||
.h1 {
|
|
||||||
font-family: var(--serif);
|
|
||||||
font-size: 96px;
|
|
||||||
font-weight: 500;
|
|
||||||
line-height: 1;
|
|
||||||
letter-spacing: -0.02em;
|
|
||||||
color: var(--ink);
|
|
||||||
}
|
|
||||||
.h2 {
|
|
||||||
font-family: var(--serif);
|
|
||||||
font-size: 64px;
|
|
||||||
font-weight: 500;
|
|
||||||
line-height: 1.05;
|
|
||||||
letter-spacing: -0.015em;
|
|
||||||
color: var(--ink);
|
|
||||||
}
|
|
||||||
.body {
|
|
||||||
font-size: 34px;
|
|
||||||
line-height: 1.45;
|
|
||||||
color: var(--ink-2);
|
|
||||||
text-wrap: pretty;
|
|
||||||
}
|
|
||||||
.small {
|
|
||||||
font-size: 28px;
|
|
||||||
line-height: 1.45;
|
|
||||||
color: var(--ink-2);
|
|
||||||
}
|
|
||||||
.micro {
|
|
||||||
font-size: 24px;
|
|
||||||
color: var(--ink-3);
|
|
||||||
}
|
|
||||||
.mono-label {
|
|
||||||
font-family: var(--mono);
|
|
||||||
font-size: 22px;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.12em;
|
|
||||||
color: var(--ink-3);
|
|
||||||
}
|
|
||||||
.hairline { border-top: 1px solid var(--line); }
|
|
||||||
.footer {
|
|
||||||
position: absolute;
|
|
||||||
bottom: 60px;
|
|
||||||
left: 100px;
|
|
||||||
right: 100px;
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
font-family: var(--mono);
|
|
||||||
font-size: 18px;
|
|
||||||
color: var(--ink-3);
|
|
||||||
letter-spacing: 0.08em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
.swatch-card {
|
|
||||||
background: var(--surface);
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
border-radius: 14px;
|
|
||||||
padding: 36px;
|
|
||||||
}
|
|
||||||
.stat-display {
|
|
||||||
font-family: var(--serif);
|
|
||||||
font-size: 200px;
|
|
||||||
font-weight: 400;
|
|
||||||
line-height: 0.9;
|
|
||||||
letter-spacing: -0.03em;
|
|
||||||
color: var(--ink);
|
|
||||||
}
|
|
||||||
.quote {
|
|
||||||
font-family: var(--serif);
|
|
||||||
font-size: 76px;
|
|
||||||
font-weight: 400;
|
|
||||||
font-style: italic;
|
|
||||||
line-height: 1.2;
|
|
||||||
letter-spacing: -0.015em;
|
|
||||||
color: var(--ink);
|
|
||||||
text-wrap: balance;
|
|
||||||
}
|
|
||||||
.bg-parchment { background: var(--bg-2); }
|
|
||||||
.bg-ink { background: var(--ink); color: var(--bg); }
|
|
||||||
.bg-ink .h2, .bg-ink .h1, .bg-ink .h-display { color: var(--bg); }
|
|
||||||
.bg-ink .eyebrow, .bg-ink .micro, .bg-ink .body, .bg-ink .small { color: oklch(80% 0.012 75); }
|
|
||||||
.bg-sage { background: oklch(45% 0.06 145); color: oklch(95% 0.02 145); }
|
|
||||||
.bg-sage .h1, .bg-sage .h2, .bg-sage .h-display, .bg-sage .quote { color: oklch(96% 0.02 145); }
|
|
||||||
.bg-sage .eyebrow, .bg-sage .body, .bg-sage .micro { color: oklch(85% 0.04 145); }
|
|
||||||
.bg-terra { background: oklch(48% 0.10 40); color: oklch(96% 0.02 40); }
|
|
||||||
.bg-terra .h1, .bg-terra .h2, .bg-terra .h-display { color: oklch(96% 0.02 40); }
|
|
||||||
.bg-terra .eyebrow, .bg-terra .body, .bg-terra .micro { color: oklch(86% 0.04 40); }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
|
|
||||||
<deck-stage width="1920" height="1080">
|
|
||||||
|
|
||||||
<!-- 01 — Cover -->
|
|
||||||
<section data-label="01 Cover" style="position: relative; flex-direction: column; justify-content: space-between;">
|
|
||||||
<div class="slide-pad" style="justify-content: space-between;">
|
|
||||||
<div style="display: flex; align-items: center; gap: 14px;">
|
|
||||||
<div style="width: 56px; height: 56px; border-radius: 50%; border: 1.5px solid var(--ink); display: flex; align-items: center; justify-content: center; font-family: var(--serif); font-size: 30px; font-style: italic;">A</div>
|
|
||||||
<div>
|
|
||||||
<div style="font-family: var(--serif); font-size: 32px; font-weight: 500; line-height: 1;">Apothecary</div>
|
|
||||||
<div class="mono-label" style="font-size: 16px;">A personal log</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div class="eyebrow" style="margin-bottom: 28px;">Concept · Apr 2026</div>
|
|
||||||
<div class="h-display" style="font-style: italic;">A quiet, careful<br/>record of what's<br/>in your cabinet.</div>
|
|
||||||
<div class="body" style="margin-top: 36px; max-width: 1100px; font-size: 36px;">
|
|
||||||
A personal inventory system for keeping clean, professional records of cannabis purchases — what you have, where it lives, what you've used, and what's worth buying again.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div style="display: flex; justify-content: space-between; align-items: flex-end;">
|
|
||||||
<div class="micro">For personal use · Local-first · No accounts</div>
|
|
||||||
<div class="mono-label" style="font-size: 16px;">v0.4 · CONCEPT DECK</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- 02 — Why -->
|
|
||||||
<section data-label="02 Why this exists">
|
|
||||||
<div class="slide-pad">
|
|
||||||
<div class="eyebrow">Why this exists</div>
|
|
||||||
<div class="h1" style="margin-top: 32px; max-width: 1500px;">Notes apps and spreadsheets can't keep up with a real apothecary.</div>
|
|
||||||
<div style="display: grid; grid-template-columns: repeat(3, 1fr); gap: 60px; margin-top: 100px; padding-top: 60px; border-top: 1px solid var(--line);">
|
|
||||||
<div>
|
|
||||||
<div class="mono-label" style="margin-bottom: 20px;">01 Memory fades</div>
|
|
||||||
<div class="small">You forget what you paid, where you bought it, or whether the last batch was any good.</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div class="mono-label" style="margin-bottom: 20px;">02 Spend goes unmeasured</div>
|
|
||||||
<div class="small">Without a real per-gram view, it's easy to over-pay and not realize the trend.</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div class="mono-label" style="margin-bottom: 20px;">03 Habits are invisible</div>
|
|
||||||
<div class="small">Daily, weekly, and monthly use are hard to see clearly without a structured log.</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- 03 — Big stat -->
|
|
||||||
<section data-label="03 The market" class="bg-parchment">
|
|
||||||
<div class="slide-pad" style="justify-content: center;">
|
|
||||||
<div class="eyebrow" style="margin-bottom: 40px;">The premise</div>
|
|
||||||
<div class="stat-display">$1,847</div>
|
|
||||||
<div class="h2" style="margin-top: 30px; max-width: 1300px;">is what a moderate consumer might spend in a year — without a single line item to show for it.</div>
|
|
||||||
<div class="micro" style="margin-top: 60px;">Apothecary turns that into a record you actually own.</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- 04 — What it is -->
|
|
||||||
<section data-label="04 What it is">
|
|
||||||
<div class="slide-pad">
|
|
||||||
<div class="eyebrow">What it is</div>
|
|
||||||
<div class="h1" style="margin-top: 32px;">An inventory system,<br/><span style="font-style: italic; color: var(--ink-2);">designed like a library catalog.</span></div>
|
|
||||||
<div style="display: grid; grid-template-columns: repeat(2, 1fr); gap: 40px; margin-top: 80px;">
|
|
||||||
<div class="swatch-card">
|
|
||||||
<div class="mono-label">Every item is tracked</div>
|
|
||||||
<div class="h2" style="font-size: 42px; margin-top: 14px;">SKU, asset tag, weight, THC, CBD, total cannabinoids, shop, brand, type, date.</div>
|
|
||||||
</div>
|
|
||||||
<div class="swatch-card">
|
|
||||||
<div class="mono-label">Every item has a place</div>
|
|
||||||
<div class="h2" style="font-size: 42px; margin-top: 14px;">Physical bins — Top Drawer, Apothecary Box, The Safe — with capacity limits.</div>
|
|
||||||
</div>
|
|
||||||
<div class="swatch-card">
|
|
||||||
<div class="mono-label">Mark when finished</div>
|
|
||||||
<div class="h2" style="font-size: 42px; margin-top: 14px;">Date finished, rating, and final notes — the archive becomes a tasting library.</div>
|
|
||||||
</div>
|
|
||||||
<div class="swatch-card">
|
|
||||||
<div class="mono-label">Nothing leaves the archive</div>
|
|
||||||
<div class="h2" style="font-size: 42px; margin-top: 14px;">Consumed items keep their record — final notes, rating, lifespan, all preserved.</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- 05 — Section header -->
|
|
||||||
<section data-label="05 Section: Dashboard" class="bg-ink">
|
|
||||||
<div class="slide-pad" style="justify-content: center; align-items: flex-start;">
|
|
||||||
<div class="mono-label" style="color: oklch(70% 0.04 145);">Part 01 / 03</div>
|
|
||||||
<div class="h-display" style="font-size: 160px; margin-top: 24px;">Dashboard.</div>
|
|
||||||
<div class="body" style="margin-top: 40px; max-width: 1200px; font-size: 36px; color: oklch(80% 0.012 75);">The morning glance. Daily averages, spend trends, days of supply — everything you need to see in a single look.</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- 06 — Stats showcase -->
|
|
||||||
<section data-label="06 The numbers">
|
|
||||||
<div class="slide-pad">
|
|
||||||
<div class="eyebrow">What the dashboard answers</div>
|
|
||||||
<div class="h2" style="margin-top: 28px; max-width: 1500px;">Ten quiet questions you've never asked out loud.</div>
|
|
||||||
<div style="display: grid; grid-template-columns: repeat(5, 1fr); gap: 1px; margin-top: 80px; background: var(--line); border: 1px solid var(--line); border-radius: 14px; overflow: hidden;">
|
|
||||||
<div style="background: var(--surface); padding: 36px;"><div class="mono-label" style="font-size: 18px; margin-bottom: 12px;">Daily avg</div><div style="font-family: var(--serif); font-size: 56px; line-height: 1;">0.52<span style="font-size: 22px; color: var(--ink-3); margin-left: 6px;">g</span></div></div>
|
|
||||||
<div style="background: var(--surface); padding: 36px;"><div class="mono-label" style="font-size: 18px; margin-bottom: 12px;">Avg $/g</div><div style="font-family: var(--serif); font-size: 56px; line-height: 1;">$13.40</div></div>
|
|
||||||
<div style="background: var(--surface); padding: 36px;"><div class="mono-label" style="font-size: 18px; margin-bottom: 12px;">30d spend</div><div style="font-family: var(--serif); font-size: 56px; line-height: 1;">$284</div></div>
|
|
||||||
<div style="background: var(--surface); padding: 36px;"><div class="mono-label" style="font-size: 18px; margin-bottom: 12px;">7d THC</div><div style="font-family: var(--serif); font-size: 56px; line-height: 1;">766<span style="font-size: 22px; color: var(--ink-3); margin-left: 6px;">mg</span></div></div>
|
|
||||||
<div style="background: var(--surface); padding: 36px;"><div class="mono-label" style="font-size: 18px; margin-bottom: 12px;">Avg lifespan</div><div style="font-family: var(--serif); font-size: 56px; line-height: 1;">37<span style="font-size: 22px; color: var(--ink-3); margin-left: 6px;">d</span></div></div>
|
|
||||||
<div style="background: var(--surface); padding: 36px;"><div class="mono-label" style="font-size: 18px; margin-bottom: 12px;">Inv. value</div><div style="font-family: var(--serif); font-size: 56px; line-height: 1;">$398</div></div>
|
|
||||||
<div style="background: var(--surface); padding: 36px;"><div class="mono-label" style="font-size: 18px; margin-bottom: 12px;">Days supply</div><div style="font-family: var(--serif); font-size: 56px; line-height: 1;">28<span style="font-size: 22px; color: var(--ink-3); margin-left: 6px;">d</span></div></div>
|
|
||||||
<div style="background: var(--surface); padding: 36px;"><div class="mono-label" style="font-size: 18px; margin-bottom: 12px;">Avg gap</div><div style="font-family: var(--serif); font-size: 56px; line-height: 1;">8.4<span style="font-size: 22px; color: var(--ink-3); margin-left: 6px;">d</span></div></div>
|
|
||||||
<div style="background: var(--surface); padding: 36px;"><div class="mono-label" style="font-size: 18px; margin-bottom: 12px;">Top shop</div><div style="font-family: var(--serif); font-size: 36px; line-height: 1.05; margin-top: 14px;">Greenleaf Co-op</div></div>
|
|
||||||
<div style="background: var(--surface); padding: 36px;"><div class="mono-label" style="font-size: 18px; margin-bottom: 12px;">Top brand</div><div style="font-family: var(--serif); font-size: 36px; line-height: 1.05; margin-top: 14px;">Foxglove Farms</div></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- 07 — Section header -->
|
|
||||||
<section data-label="07 Section: Inventory" class="bg-sage">
|
|
||||||
<div class="slide-pad" style="justify-content: center; align-items: flex-start;">
|
|
||||||
<div class="mono-label">Part 02 / 03</div>
|
|
||||||
<div class="h-display" style="font-size: 160px; margin-top: 24px;">Inventory.</div>
|
|
||||||
<div class="body" style="margin-top: 40px; max-width: 1200px; font-size: 36px;">Each product gets a SKU, an optional asset tag, a bin, a price, a chemistry. The catalog of a small, careful library.</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- 08 — Product anatomy -->
|
|
||||||
<section data-label="08 Product anatomy">
|
|
||||||
<div class="slide-pad">
|
|
||||||
<div class="eyebrow">Anatomy of a product</div>
|
|
||||||
<div class="h2" style="margin-top: 28px;">Every entry, comprehensive.</div>
|
|
||||||
<div style="display: grid; grid-template-columns: 1.4fr 1fr; gap: 80px; margin-top: 60px; align-items: flex-start;">
|
|
||||||
<div style="border: 1px solid var(--line); border-radius: 14px; padding: 50px; background: var(--surface);">
|
|
||||||
<div class="mono-label" style="font-size: 18px;">◆ Concentrate · Active</div>
|
|
||||||
<div style="font-family: var(--serif); font-size: 72px; font-weight: 500; line-height: 1; margin-top: 14px; letter-spacing: -0.02em;">Indigo Cellar<br/>Live Rosin</div>
|
|
||||||
<div class="small" style="margin-top: 14px; font-size: 28px;">Heirloom Botanicals · from The Field House</div>
|
|
||||||
<div style="display: grid; grid-template-columns: repeat(4, 1fr); gap: 1px; margin-top: 50px; background: var(--line); border: 1px solid var(--line); border-radius: 10px; overflow: hidden;">
|
|
||||||
<div style="background: var(--surface); padding: 22px;"><div class="mono-label" style="font-size: 14px;">Price</div><div style="font-family: var(--serif); font-size: 38px; margin-top: 8px;">$65</div></div>
|
|
||||||
<div style="background: var(--surface); padding: 22px;"><div class="mono-label" style="font-size: 14px;">Weight</div><div style="font-family: var(--serif); font-size: 38px; margin-top: 8px;">1.0 g</div></div>
|
|
||||||
<div style="background: var(--surface); padding: 22px;"><div class="mono-label" style="font-size: 14px;">THC</div><div style="font-family: var(--serif); font-size: 38px; margin-top: 8px;">78.4%</div></div>
|
|
||||||
<div style="background: var(--surface); padding: 22px;"><div class="mono-label" style="font-size: 14px;">CBD</div><div style="font-family: var(--serif); font-size: 38px; margin-top: 8px;">0.2%</div></div>
|
|
||||||
</div>
|
|
||||||
<div style="margin-top: 30px; font-family: var(--mono); font-size: 22px; color: var(--ink-3); display: flex; gap: 24px;">
|
|
||||||
<span>SKU-A39FQX</span>
|
|
||||||
<span>·</span>
|
|
||||||
<span>AT-0042</span>
|
|
||||||
<span>·</span>
|
|
||||||
<span>The Safe</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div class="mono-label" style="margin-bottom: 30px;">Tracked fields</div>
|
|
||||||
<ul style="list-style: none; padding: 0; margin: 0; font-size: 26px; line-height: 1.9; color: var(--ink-2);">
|
|
||||||
<li>— Name & brand</li>
|
|
||||||
<li>— SKU + optional asset tag</li>
|
|
||||||
<li>— Type (flower, concentrate, edible, vape, pre-roll, tincture)</li>
|
|
||||||
<li>— Shop & purchase date</li>
|
|
||||||
<li>— Price + computed cost-per-gram</li>
|
|
||||||
<li>— Weight, THC%, CBD%, total cannabinoids%</li>
|
|
||||||
<li>— Bin location & remaining quantity</li>
|
|
||||||
<li>— Final notes & rating after consumed</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- 09 — Bins -->
|
|
||||||
<section data-label="09 Bins" class="bg-parchment">
|
|
||||||
<div class="slide-pad">
|
|
||||||
<div class="eyebrow">Storage</div>
|
|
||||||
<div class="h1" style="margin-top: 32px;">Every product knows<br/>where it lives.</div>
|
|
||||||
<div style="display: grid; grid-template-columns: repeat(5, 1fr); gap: 24px; margin-top: 90px;">
|
|
||||||
<div style="background: var(--surface); border: 1px solid var(--line); border-radius: 12px; padding: 28px; aspect-ratio: 0.85; display: flex; flex-direction: column;">
|
|
||||||
<div class="mono-label" style="font-size: 16px;">Bin 01</div>
|
|
||||||
<div style="font-family: var(--serif); font-size: 40px; font-weight: 500; line-height: 1.05; margin-top: 14px;">Top Drawer</div>
|
|
||||||
<div class="micro" style="margin-top: 8px;">Bedroom</div>
|
|
||||||
<div style="flex: 1;"></div>
|
|
||||||
<div style="height: 4px; background: oklch(85% 0.02 75); border-radius: 2px; margin-bottom: 10px;"><div style="width: 30%; height: 100%; background: var(--sage); border-radius: 2px;"></div></div>
|
|
||||||
<div class="mono-label" style="font-size: 14px;">4 / 14</div>
|
|
||||||
</div>
|
|
||||||
<div style="background: var(--surface); border: 1px solid var(--line); border-radius: 12px; padding: 28px; aspect-ratio: 0.85; display: flex; flex-direction: column;">
|
|
||||||
<div class="mono-label" style="font-size: 16px;">Bin 02</div>
|
|
||||||
<div style="font-family: var(--serif); font-size: 40px; font-weight: 500; line-height: 1.05; margin-top: 14px;">Apothecary Box</div>
|
|
||||||
<div class="micro" style="margin-top: 8px;">Office shelf</div>
|
|
||||||
<div style="flex: 1;"></div>
|
|
||||||
<div style="height: 4px; background: oklch(85% 0.02 75); border-radius: 2px; margin-bottom: 10px;"><div style="width: 20%; height: 100%; background: var(--sage); border-radius: 2px;"></div></div>
|
|
||||||
<div class="mono-label" style="font-size: 14px;">2 / 10</div>
|
|
||||||
</div>
|
|
||||||
<div style="background: var(--surface); border: 1px solid var(--line); border-radius: 12px; padding: 28px; aspect-ratio: 0.85; display: flex; flex-direction: column;">
|
|
||||||
<div class="mono-label" style="font-size: 16px;">Bin 03</div>
|
|
||||||
<div style="font-family: var(--serif); font-size: 40px; font-weight: 500; line-height: 1.05; margin-top: 14px;">The Safe</div>
|
|
||||||
<div class="micro" style="margin-top: 8px;">Closet</div>
|
|
||||||
<div style="flex: 1;"></div>
|
|
||||||
<div style="height: 4px; background: oklch(85% 0.02 75); border-radius: 2px; margin-bottom: 10px;"><div style="width: 38%; height: 100%; background: var(--sage); border-radius: 2px;"></div></div>
|
|
||||||
<div class="mono-label" style="font-size: 14px;">3 / 8</div>
|
|
||||||
</div>
|
|
||||||
<div style="background: var(--surface); border: 1px solid var(--line); border-radius: 12px; padding: 28px; aspect-ratio: 0.85; display: flex; flex-direction: column;">
|
|
||||||
<div class="mono-label" style="font-size: 16px;">Bin 04</div>
|
|
||||||
<div style="font-family: var(--serif); font-size: 40px; font-weight: 500; line-height: 1.05; margin-top: 14px;">Travel Tin</div>
|
|
||||||
<div class="micro" style="margin-top: 8px;">Backpack</div>
|
|
||||||
<div style="flex: 1;"></div>
|
|
||||||
<div style="height: 4px; background: oklch(85% 0.02 75); border-radius: 2px; margin-bottom: 10px;"><div style="width: 50%; height: 100%; background: var(--amber); border-radius: 2px;"></div></div>
|
|
||||||
<div class="mono-label" style="font-size: 14px;">2 / 4</div>
|
|
||||||
</div>
|
|
||||||
<div style="background: var(--surface); border: 1px solid var(--line); border-radius: 12px; padding: 28px; aspect-ratio: 0.85; display: flex; flex-direction: column;">
|
|
||||||
<div class="mono-label" style="font-size: 16px;">Bin 05</div>
|
|
||||||
<div style="font-family: var(--serif); font-size: 40px; font-weight: 500; line-height: 1.05; margin-top: 14px;">Cold Storage</div>
|
|
||||||
<div class="micro" style="margin-top: 8px;">Fridge — back</div>
|
|
||||||
<div style="flex: 1;"></div>
|
|
||||||
<div style="height: 4px; background: oklch(85% 0.02 75); border-radius: 2px; margin-bottom: 10px;"><div style="width: 17%; height: 100%; background: var(--sage); border-radius: 2px;"></div></div>
|
|
||||||
<div class="mono-label" style="font-size: 14px;">1 / 6</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="micro" style="margin-top: 60px; max-width: 1400px;">When a product is consumed, it leaves its bin but stays in the archive — keeping the record of what worked, what didn't, and what's worth a rebuy.</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- 10 — Section: Habit -->
|
|
||||||
<section data-label="10 Section: Patterns" class="bg-terra">
|
|
||||||
<div class="slide-pad" style="justify-content: center; align-items: flex-start;">
|
|
||||||
<div class="mono-label">Part 03 / 03</div>
|
|
||||||
<div class="h-display" style="font-size: 160px; margin-top: 24px;">Patterns.</div>
|
|
||||||
<div class="body" style="margin-top: 40px; max-width: 1200px; font-size: 36px;">Where the data quietly becomes useful — habits made visible, spend made measurable.</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- 11 — Quote -->
|
|
||||||
<section data-label="11 Quote">
|
|
||||||
<div class="slide-pad" style="justify-content: center;">
|
|
||||||
<div style="max-width: 1500px;">
|
|
||||||
<div class="quote">"You can't keep<br/>what you don't<br/>write down."</div>
|
|
||||||
<div class="mono-label" style="margin-top: 60px;">— Operating principle</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- 12 — Roadmap / next -->
|
|
||||||
<section data-label="12 Next">
|
|
||||||
<div class="slide-pad">
|
|
||||||
<div class="eyebrow">What's next</div>
|
|
||||||
<div class="h1" style="margin-top: 32px;">Three releases<br/>to feel done.</div>
|
|
||||||
<div style="display: grid; grid-template-columns: repeat(3, 1fr); gap: 60px; margin-top: 100px; padding-top: 50px; border-top: 1px solid var(--line);">
|
|
||||||
<div>
|
|
||||||
<div class="mono-label">v0.5 · May</div>
|
|
||||||
<div class="h2" style="font-size: 44px; margin-top: 16px;">Add & consume</div>
|
|
||||||
<div class="small" style="margin-top: 18px; font-size: 26px;">Polish on the entry forms, barcode & receipt photo capture, faster bulk add.</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div class="mono-label">v0.7 · July</div>
|
|
||||||
<div class="h2" style="font-size: 44px; margin-top: 16px;">Charts & export</div>
|
|
||||||
<div class="small" style="margin-top: 18px; font-size: 26px;">Heatmap, monthly spend, shop comparisons. CSV & JSON export with no cloud.</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div class="mono-label">v1.0 · September</div>
|
|
||||||
<div class="h2" style="font-size: 44px; margin-top: 16px;">Mobile app</div>
|
|
||||||
<div class="small" style="margin-top: 18px; font-size: 26px;">Native iOS / Android with biometric lock. Same local-first principle.</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- 13 — End -->
|
|
||||||
<section data-label="13 End" class="bg-ink">
|
|
||||||
<div class="slide-pad" style="justify-content: space-between;">
|
|
||||||
<div style="display: flex; align-items: center; gap: 14px;">
|
|
||||||
<div style="width: 56px; height: 56px; border-radius: 50%; border: 1.5px solid var(--bg); display: flex; align-items: center; justify-content: center; font-family: var(--serif); font-size: 30px; font-style: italic; color: var(--bg);">A</div>
|
|
||||||
<div>
|
|
||||||
<div style="font-family: var(--serif); font-size: 32px; font-weight: 500; line-height: 1; color: var(--bg);">Apothecary</div>
|
|
||||||
<div class="mono-label" style="font-size: 16px; color: oklch(70% 0.04 145);">A personal log</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div class="h-display" style="font-style: italic; color: var(--bg); font-size: 180px;">Keep<br/>a record.</div>
|
|
||||||
<div class="body" style="margin-top: 50px; font-size: 36px; color: oklch(80% 0.012 75); max-width: 1100px;">A small, careful inventory of a small, careful library.</div>
|
|
||||||
</div>
|
|
||||||
<div style="display: flex; justify-content: space-between; align-items: flex-end;">
|
|
||||||
<div class="mono-label" style="color: oklch(70% 0.04 145);">Live prototype available</div>
|
|
||||||
<div class="mono-label" style="color: oklch(70% 0.04 145); font-size: 16px;">END · 13 / 13</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
</deck-stage>
|
|
||||||
|
|
||||||
<script src="deck-stage.js"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,405 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
||||||
<title>Apothecary — Personal Inventory</title>
|
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:wght@400;500;600&family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
|
|
||||||
<link rel="stylesheet" href="tokens.css" />
|
|
||||||
<style>
|
|
||||||
/* App chrome */
|
|
||||||
.app-shell {
|
|
||||||
min-height: 100vh;
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 220px 1fr;
|
|
||||||
}
|
|
||||||
.sidebar {
|
|
||||||
border-right: 1px solid var(--line);
|
|
||||||
background: var(--bg-2);
|
|
||||||
padding: 28px 18px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 4px;
|
|
||||||
position: sticky;
|
|
||||||
top: 0;
|
|
||||||
height: 100vh;
|
|
||||||
overflow: auto;
|
|
||||||
}
|
|
||||||
.brand {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
padding: 0 8px 28px;
|
|
||||||
}
|
|
||||||
.brand-mark {
|
|
||||||
width: 32px; height: 32px;
|
|
||||||
border-radius: 50%;
|
|
||||||
border: 1px solid var(--ink);
|
|
||||||
display: flex; align-items: center; justify-content: center;
|
|
||||||
font-family: var(--serif);
|
|
||||||
font-size: 18px;
|
|
||||||
font-style: italic;
|
|
||||||
color: var(--ink);
|
|
||||||
}
|
|
||||||
.nav-link {
|
|
||||||
display: flex; align-items: center; gap: 10px;
|
|
||||||
padding: 9px 12px;
|
|
||||||
border-radius: var(--r-md);
|
|
||||||
color: var(--ink-2);
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 500;
|
|
||||||
border: none;
|
|
||||||
background: transparent;
|
|
||||||
text-align: left;
|
|
||||||
transition: background 100ms;
|
|
||||||
}
|
|
||||||
.nav-link:hover { background: var(--bg-3); color: var(--ink); }
|
|
||||||
.nav-link.active { background: var(--surface); color: var(--ink); box-shadow: var(--shadow-sm); border: 1px solid var(--line); }
|
|
||||||
.nav-section {
|
|
||||||
font-size: 10px; text-transform: uppercase;
|
|
||||||
letter-spacing: 0.12em;
|
|
||||||
color: var(--ink-3);
|
|
||||||
padding: 16px 12px 6px;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
.inv-row:hover { background: var(--bg-2); }
|
|
||||||
|
|
||||||
@media (max-width: 880px) {
|
|
||||||
.app-shell { grid-template-columns: 1fr; }
|
|
||||||
.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; }
|
|
||||||
.nav-link { white-space: nowrap; padding: 8px 12px; }
|
|
||||||
.main { padding-bottom: 60px; }
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="root"></div>
|
|
||||||
|
|
||||||
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
|
|
||||||
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
|
|
||||||
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
|
|
||||||
|
|
||||||
<script src="data.js?v=4"></script>
|
|
||||||
<script type="text/babel" src="primitives.jsx?v=4"></script>
|
|
||||||
<script type="text/babel" src="screens-1.jsx?v=4"></script>
|
|
||||||
<script type="text/babel" src="screens-2.jsx?v=4"></script>
|
|
||||||
<script type="text/babel" src="tweaks-panel.jsx?v=4"></script>
|
|
||||||
<script type="text/babel">
|
|
||||||
const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
|
|
||||||
"theme": "light",
|
|
||||||
"dashboard": "editorial",
|
|
||||||
"tone": "botanical",
|
|
||||||
"accent": "sage"
|
|
||||||
}/*EDITMODE-END*/;
|
|
||||||
|
|
||||||
const NAV = [
|
|
||||||
{ key: "dashboard", label: "Dashboard", icon: "home" },
|
|
||||||
{ key: "inventory", label: "Inventory", icon: "box" },
|
|
||||||
{ key: "bins", label: "Bins", icon: "bin" },
|
|
||||||
{ key: "charts", label: "Patterns", icon: "chart" },
|
|
||||||
{ key: "settings", label: "Settings", icon: "settings" }
|
|
||||||
];
|
|
||||||
|
|
||||||
const TONE_LABELS = {
|
|
||||||
botanical: { brand: "Apothecary", tagline: "A personal log" },
|
|
||||||
neutral: { brand: "Inventory", tagline: "Personal stockkeeping" },
|
|
||||||
discreet: { brand: "The Cabinet", tagline: "Private register" }
|
|
||||||
};
|
|
||||||
|
|
||||||
function App() {
|
|
||||||
const [tweaks, setTweaks] = useTweaks(TWEAK_DEFAULTS);
|
|
||||||
const [view, setView] = React.useState("dashboard");
|
|
||||||
const [selected, setSelected] = React.useState(null);
|
|
||||||
const [modal, setModal] = React.useState(null); // 'add' | 'consume' | 'gone' | 'audit'
|
|
||||||
const [modalProduct, setModalProduct] = React.useState(null);
|
|
||||||
const [tweaksOpen, setTweaksOpen] = React.useState(false);
|
|
||||||
|
|
||||||
const data = window.SAMPLE_DATA;
|
|
||||||
const stats = React.useMemo(() => computeStats(data), [data]);
|
|
||||||
|
|
||||||
// Apply theme
|
|
||||||
React.useEffect(() => {
|
|
||||||
document.documentElement.setAttribute("data-theme", tweaks.theme);
|
|
||||||
}, [tweaks.theme]);
|
|
||||||
|
|
||||||
// Tweaks toggle wiring
|
|
||||||
React.useEffect(() => {
|
|
||||||
const handler = (e) => {
|
|
||||||
if (e.data?.type === "__activate_edit_mode") setTweaksOpen(true);
|
|
||||||
if (e.data?.type === "__deactivate_edit_mode") setTweaksOpen(false);
|
|
||||||
};
|
|
||||||
window.addEventListener("message", handler);
|
|
||||||
window.parent.postMessage({type: "__edit_mode_available"}, "*");
|
|
||||||
return () => window.removeEventListener("message", handler);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const labels = TONE_LABELS[tweaks.tone] || TONE_LABELS.botanical;
|
|
||||||
|
|
||||||
const onNav = (target) => {
|
|
||||||
if (target === "add") { setModalProduct(null); setModal("add"); }
|
|
||||||
else if (target === "consume") { setModalProduct(null); setModal("consume"); }
|
|
||||||
else if (target === "gone") { setModalProduct(null); setModal("gone"); }
|
|
||||||
else if (target === "audit") { setModalProduct(null); setModal("audit"); }
|
|
||||||
else setView(target);
|
|
||||||
};
|
|
||||||
const openAudit = (p) => { setModalProduct(p); setModal("audit"); };
|
|
||||||
const openGone = (p) => { setModalProduct(p); setSelected(null); setModal("gone"); };
|
|
||||||
const openConsume = (p) => { setModalProduct(p); setSelected(null); setModal("consume"); };
|
|
||||||
|
|
||||||
const handleSave = () => {
|
|
||||||
setModal(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="app-shell" data-screen-label="App">
|
|
||||||
{/* Sidebar */}
|
|
||||||
<aside className="sidebar">
|
|
||||||
<div className="brand">
|
|
||||||
<div className="brand-mark">A</div>
|
|
||||||
<div>
|
|
||||||
<div className="serif" style={{fontSize: 18, fontWeight: 500, lineHeight: 1}}>{labels.brand}</div>
|
|
||||||
<div style={{fontSize: 10, color: "var(--ink-3)", textTransform: "uppercase", letterSpacing: "0.1em"}}>{labels.tagline}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="nav-section">Workspace</div>
|
|
||||||
{NAV.map(n => (
|
|
||||||
<button key={n.key} className={"nav-link " + (view === n.key ? "active" : "")} onClick={() => setView(n.key)}>
|
|
||||||
<Icon name={n.icon} size={16} />
|
|
||||||
{n.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
<div className="nav-section">Quick</div>
|
|
||||||
<button className="nav-link" onClick={() => setModal("add")}>
|
|
||||||
<Icon name="plus" size={16} /> Add product
|
|
||||||
</button>
|
|
||||||
<button className="nav-link" onClick={() => setModal("consume")}>
|
|
||||||
<Icon name="check" size={16} /> Mark finished
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div style={{flex: 1}} />
|
|
||||||
<div style={{padding: 12, fontSize: 11, color: "var(--ink-3)", borderTop: "1px solid var(--line)", marginTop: 12}}>
|
|
||||||
<div className="mono">v0.4 · {data.products.length} items</div>
|
|
||||||
<div style={{marginTop: 4}}>Local-only · Apr 2026</div>
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
{/* Main */}
|
|
||||||
<main className="main parchment" style={{minWidth: 0}}>
|
|
||||||
{view === "dashboard" && (
|
|
||||||
<DashboardSwitch
|
|
||||||
variant={tweaks.dashboard}
|
|
||||||
data={data}
|
|
||||||
stats={stats}
|
|
||||||
onNav={onNav}
|
|
||||||
onSelectProduct={setSelected}
|
|
||||||
onAudit={openAudit}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{view === "inventory" && <Inventory data={data} onSelectProduct={setSelected} onNav={onNav} />}
|
|
||||||
{view === "bins" && <BinsView data={data} onSelectProduct={setSelected} />}
|
|
||||||
{view === "charts" && <ChartsView data={data} stats={stats} />}
|
|
||||||
{view === "settings" && <SettingsView data={data} tweaks={tweaks} onTweakChange={(k,v) => setTweaks({...tweaks, [k]: v})} />}
|
|
||||||
</main>
|
|
||||||
|
|
||||||
{selected && (
|
|
||||||
<ProductDetail
|
|
||||||
product={selected}
|
|
||||||
data={data}
|
|
||||||
onClose={() => setSelected(null)}
|
|
||||||
onConsume={openConsume}
|
|
||||||
onMarkGone={openGone}
|
|
||||||
onAudit={openAudit}
|
|
||||||
onEdit={() => {}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{modal === "add" && <AddProductFlow data={data} onClose={() => setModal(null)} onSave={handleSave} />}
|
|
||||||
{modal === "consume" && <ConsumeFlow data={data} onClose={() => setModal(null)} product={modalProduct} />}
|
|
||||||
{modal === "gone" && <MarkGoneFlow data={data} onClose={() => setModal(null)} product={modalProduct} />}
|
|
||||||
{modal === "audit" && <AuditFlow data={data} onClose={() => setModal(null)} product={modalProduct} />}
|
|
||||||
|
|
||||||
{tweaksOpen && (
|
|
||||||
<TweaksPanel onClose={() => setTweaksOpen(false)} title="Tweaks">
|
|
||||||
<TweakSection title="Appearance">
|
|
||||||
<TweakRadio label="Theme" value={tweaks.theme} options={[["light","Light"],["dark","Dark"]]} onChange={v => setTweaks({...tweaks, theme: v})} />
|
|
||||||
<TweakRadio label="Dashboard layout" value={tweaks.dashboard} options={[["editorial","Editorial"],["dense","Data-dense"],["minimal","Minimal"]]} onChange={v => setTweaks({...tweaks, dashboard: v})} />
|
|
||||||
</TweakSection>
|
|
||||||
<TweakSection title="Copy">
|
|
||||||
<TweakRadio label="Tone" value={tweaks.tone} options={[["botanical","Botanical"],["neutral","Neutral"],["discreet","Discreet"]]} onChange={v => setTweaks({...tweaks, tone: v})} />
|
|
||||||
</TweakSection>
|
|
||||||
</TweaksPanel>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Switches between 3 dashboard variants
|
|
||||||
const DashboardSwitch = ({variant, ...props}) => {
|
|
||||||
if (variant === "dense") return <DashboardDense {...props} />;
|
|
||||||
if (variant === "minimal") return <DashboardMinimal {...props} />;
|
|
||||||
return <Dashboard {...props} />;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Variation 2: data-dense
|
|
||||||
const DashboardDense = ({data, stats, onNav, onSelectProduct}) => {
|
|
||||||
const series = stats.series90.map(s => ({date: s.date, value: s.grams}));
|
|
||||||
return (
|
|
||||||
<div style={{padding: "20px 28px 60px"}}>
|
|
||||||
<div style={{display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 18, paddingBottom: 14, borderBottom: "1px solid var(--line)"}}>
|
|
||||||
<div style={{display: "flex", alignItems: "baseline", gap: 16}}>
|
|
||||||
<h1 className="mono" style={{fontSize: 13, margin: 0, color: "var(--ink-2)", textTransform: "uppercase", letterSpacing: "0.08em"}}>Dashboard / 2026-04-25</h1>
|
|
||||||
<span style={{fontSize: 11, color: "var(--ink-3)"}}>· {stats.activeCount} active · {stats.consumedCount} archived</span>
|
|
||||||
</div>
|
|
||||||
<div style={{display: "flex", gap: 6}}>
|
|
||||||
<Btn variant="secondary" icon="plus" onClick={() => onNav("add")}>Product</Btn>
|
|
||||||
<Btn variant="primary" icon="check" onClick={() => onNav("consume")}>Finish</Btn>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{display: "grid", gridTemplateColumns: "repeat(8, 1fr)", gap: 1, background: "var(--line)", border: "1px solid var(--line)", borderRadius: "var(--r-md)", overflow: "hidden", marginBottom: 14}}>
|
|
||||||
{[
|
|
||||||
["DAILY g", stats.dailyAvg.toFixed(2)],
|
|
||||||
["WEEKLY g", stats.weeklyAvg.toFixed(1)],
|
|
||||||
["MONTHLY g", stats.monthlyAvg.toFixed(1)],
|
|
||||||
["AVG $/g", fmt.money(stats.avgPerGram)],
|
|
||||||
["30D SPEND", fmt.moneyShort(stats.spend30)],
|
|
||||||
["INV VALUE", fmt.moneyShort(stats.inventoryValue)],
|
|
||||||
["7D THC mg", stats.thcLast7],
|
|
||||||
["DAYS SUPPLY", Math.round(stats.daysOfSupply)]
|
|
||||||
].map(([l, v]) => (
|
|
||||||
<div key={l} style={{padding: "12px 14px", background: "var(--surface)"}}>
|
|
||||||
<div className="mono" style={{fontSize: 10, color: "var(--ink-3)", letterSpacing: "0.04em"}}>{l}</div>
|
|
||||||
<div className="mono" style={{fontSize: 20, marginTop: 4, fontWeight: 500}}>{v}</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{display: "grid", gridTemplateColumns: "1fr 1fr", gap: 14, marginBottom: 14}}>
|
|
||||||
<Card padded={false}>
|
|
||||||
<div style={{padding: "12px 16px", borderBottom: "1px solid var(--line)", display: "flex", justifyContent: "space-between"}}>
|
|
||||||
<span className="mono" style={{fontSize: 11, color: "var(--ink-3)", textTransform: "uppercase", letterSpacing: "0.08em"}}>g / day · 90d</span>
|
|
||||||
<span className="mono" style={{fontSize: 11, color: "var(--ink-2)"}}>{series.reduce((s,e)=>s+e.value,0).toFixed(1)} g total</span>
|
|
||||||
</div>
|
|
||||||
<div style={{padding: 14}}>
|
|
||||||
<BarChart data={series} height={120} color="var(--sage)" />
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
<Card padded={false}>
|
|
||||||
<div style={{padding: "12px 16px", borderBottom: "1px solid var(--line)"}}>
|
|
||||||
<span className="mono" style={{fontSize: 11, color: "var(--ink-3)", textTransform: "uppercase", letterSpacing: "0.08em"}}>recent purchases</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
{[...data.products].sort((a,b)=>new Date(b.purchaseDate)-new Date(a.purchaseDate)).slice(0, 5).map(p => (
|
|
||||||
<div key={p.id} onClick={() => onSelectProduct(p)} style={{padding: "10px 16px", borderBottom: "1px solid var(--line)", display: "flex", justifyContent: "space-between", alignItems: "center", cursor: "pointer", fontSize: 12}}>
|
|
||||||
<div>
|
|
||||||
<div style={{fontWeight: 500, fontSize: 13}}>{p.name}</div>
|
|
||||||
<div style={{fontSize: 11, color: "var(--ink-3)"}}>{window.DATA_HELPERS.brandName(data, p.brandId)} · {window.DATA_HELPERS.shopName(data, p.shopId)}</div>
|
|
||||||
</div>
|
|
||||||
<div className="mono" style={{textAlign: "right"}}>
|
|
||||||
<div>{fmt.money(p.price)}</div>
|
|
||||||
<div style={{fontSize: 10, color: "var(--ink-3)"}}>{fmt.dateShort(p.purchaseDate)}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Card padded={false}>
|
|
||||||
<div style={{padding: "12px 16px", borderBottom: "1px solid var(--line)"}}>
|
|
||||||
<span className="mono" style={{fontSize: 11, color: "var(--ink-3)", textTransform: "uppercase", letterSpacing: "0.08em"}}>active inventory · {stats.activeCount} rows</span>
|
|
||||||
</div>
|
|
||||||
<div style={{display: "grid", gridTemplateColumns: "2fr 1fr 0.6fr 0.6fr 0.6fr 0.8fr", padding: "8px 16px", borderBottom: "1px solid var(--line)", fontSize: 10, color: "var(--ink-3)", textTransform: "uppercase", letterSpacing: "0.08em", fontFamily: "var(--mono)"}}>
|
|
||||||
<div>NAME / SKU</div><div>BRAND</div><div style={{textAlign: "right"}}>THC%</div><div style={{textAlign: "right"}}>$</div><div style={{textAlign: "right"}}>REM g</div><div>BIN</div>
|
|
||||||
</div>
|
|
||||||
{data.products.filter(p=>p.status==="active").slice(0, 8).map(p => {
|
|
||||||
const bin = data.bins.find(b => b.id === p.binId);
|
|
||||||
return (
|
|
||||||
<div key={p.id} onClick={() => onSelectProduct(p)} className="inv-row" style={{display: "grid", gridTemplateColumns: "2fr 1fr 0.6fr 0.6fr 0.6fr 0.8fr", padding: "8px 16px", borderBottom: "1px solid var(--line)", fontSize: 12, cursor: "pointer", alignItems: "center"}}>
|
|
||||||
<div>
|
|
||||||
<div style={{fontWeight: 500}}>{p.name}</div>
|
|
||||||
<div className="mono" style={{fontSize: 10, color: "var(--ink-3)"}}>{p.sku}</div>
|
|
||||||
</div>
|
|
||||||
<div style={{color: "var(--ink-2)"}}>{window.DATA_HELPERS.brandName(data, p.brandId)}</div>
|
|
||||||
<div className="mono" style={{textAlign: "right"}}>{p.thc.toFixed(1)}</div>
|
|
||||||
<div className="mono" style={{textAlign: "right"}}>{fmt.money(p.price)}</div>
|
|
||||||
<div className="mono" style={{textAlign: "right"}}>{window.remainingShort(p)}</div>
|
|
||||||
<div style={{fontSize: 11, color: "var(--ink-3)"}}>{bin?.name || "—"}</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Variation 3: minimal / editorial-quiet
|
|
||||||
const DashboardMinimal = ({data, stats, onNav, onSelectProduct}) => {
|
|
||||||
return (
|
|
||||||
<div style={{padding: "80px 60px", maxWidth: 900, margin: "0 auto"}}>
|
|
||||||
<div style={{textAlign: "center", marginBottom: 80}}>
|
|
||||||
<div className="smallcaps" style={{color: "var(--ink-3)", marginBottom: 20}}>Saturday · April 25, 2026</div>
|
|
||||||
<div className="serif" style={{fontSize: 96, fontWeight: 400, letterSpacing: "-0.03em", lineHeight: 1, fontStyle: "italic"}}>
|
|
||||||
{stats.dailyAvg.toFixed(2)}<span style={{fontSize: 36, fontStyle: "normal", color: "var(--ink-3)", marginLeft: 8}}>g/day</span>
|
|
||||||
</div>
|
|
||||||
<div style={{marginTop: 20, fontSize: 14, color: "var(--ink-2)", maxWidth: 520, margin: "20px auto 0"}}>
|
|
||||||
{stats.activeCount} items in your cabinet, valued around {fmt.moneyShort(stats.inventoryValue)}, with roughly {Math.round(stats.daysOfSupply)} days of flower at this pace.
|
|
||||||
</div>
|
|
||||||
<div style={{marginTop: 8, fontSize: 13, color: "var(--ink-3)", maxWidth: 520, margin: "8px auto 0"}}>
|
|
||||||
{stats.consumedCount} items archived · avg lifespan {Math.round(stats.avgLifespan)} days.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{display: "grid", gridTemplateColumns: "repeat(3, 1fr)", gap: 0, marginBottom: 80}}>
|
|
||||||
{[
|
|
||||||
["Avg $ per gram", fmt.money(stats.avgPerGram)],
|
|
||||||
["30-day spend", fmt.moneyShort(stats.spend30)],
|
|
||||||
["7-day THC", `${stats.thcLast7} mg`]
|
|
||||||
].map(([l,v], i) => (
|
|
||||||
<div key={l} style={{textAlign: "center", padding: "0 20px", borderRight: i < 2 ? "1px solid var(--line)" : "none"}}>
|
|
||||||
<div className="smallcaps" style={{color: "var(--ink-3)", marginBottom: 10}}>{l}</div>
|
|
||||||
<div className="serif" style={{fontSize: 36, fontWeight: 500}}>{v}</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{borderTop: "1px solid var(--line)", paddingTop: 40}}>
|
|
||||||
<div style={{display: "flex", justifyContent: "space-between", alignItems: "baseline", marginBottom: 18}}>
|
|
||||||
<div className="smallcaps" style={{color: "var(--ink-3)"}}>Recent additions</div>
|
|
||||||
<button onClick={() => onNav("inventory")} style={{background: "none", border: "none", fontSize: 12, color: "var(--ink-2)", cursor: "pointer", textDecoration: "underline"}}>See all →</button>
|
|
||||||
</div>
|
|
||||||
{[...data.products].filter(p=>p.status==="active").sort((a,b)=>new Date(b.purchaseDate)-new Date(a.purchaseDate)).slice(0, 5).map(p => (
|
|
||||||
<div key={p.id} onClick={() => onSelectProduct(p)} style={{display: "flex", justifyContent: "space-between", alignItems: "baseline", padding: "20px 0", borderBottom: "1px solid var(--line)", cursor: "pointer"}}>
|
|
||||||
<div>
|
|
||||||
<div className="serif" style={{fontSize: 24, fontWeight: 500}}>{p.name}</div>
|
|
||||||
<div style={{fontSize: 12, color: "var(--ink-3)", marginTop: 2}}>{window.DATA_HELPERS.brandName(data, p.brandId)} · {window.DATA_HELPERS.shopName(data, p.shopId)} · {fmt.dateShort(p.purchaseDate)}</div>
|
|
||||||
</div>
|
|
||||||
<div className="mono" style={{fontSize: 13, color: "var(--ink-2)"}}>{window.remainingShort(p)} · {fmt.money(p.price)}</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{marginTop: 60, display: "flex", gap: 12, justifyContent: "center"}}>
|
|
||||||
<Btn variant="secondary" icon="plus" onClick={() => onNav("add")}>Add product</Btn>
|
|
||||||
<Btn variant="primary" icon="check" onClick={() => onNav("consume")}>Mark finished</Btn>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById("root")).render(<App />);
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 35 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 35 KiB |
@@ -1,378 +0,0 @@
|
|||||||
// Sample inventory data
|
|
||||||
window.SAMPLE_DATA = (function() {
|
|
||||||
const TODAY = "2026-04-25";
|
|
||||||
const daysAgo = (n) => {
|
|
||||||
const d = new Date(TODAY); d.setDate(d.getDate() - n);
|
|
||||||
return d.toISOString().slice(0, 10);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Shops are objects now
|
|
||||||
const SHOPS = [
|
|
||||||
{ id: "shp-01", name: "Greenleaf Co-op", location: "Capitol Hill" },
|
|
||||||
{ id: "shp-02", name: "Verdant Apothecary", location: "Ballard" },
|
|
||||||
{ id: "shp-03", name: "The Field House", location: "Fremont" },
|
|
||||||
{ id: "shp-04", name: "Northstar Dispensary",location: "Roosevelt" },
|
|
||||||
{ id: "shp-05", name: "Wildwood Provisions", location: "West Seattle" }
|
|
||||||
];
|
|
||||||
|
|
||||||
// Brands are objects too
|
|
||||||
const BRANDS = [
|
|
||||||
{ id: "brd-01", name: "Foxglove Farms" },
|
|
||||||
{ id: "brd-02", name: "Slow Burn" },
|
|
||||||
{ id: "brd-03", name: "Heirloom Botanicals" },
|
|
||||||
{ id: "brd-04", name: "Terra Vera" },
|
|
||||||
{ id: "brd-05", name: "North Field" },
|
|
||||||
{ id: "brd-06", name: "Cinder & Sage" },
|
|
||||||
{ id: "brd-07", name: "Old Forest" },
|
|
||||||
{ id: "brd-08", name: "Marigold Ext." }
|
|
||||||
];
|
|
||||||
|
|
||||||
// Type config — kind + audit cadence
|
|
||||||
// bulk: track weight (g) — audit re-weighs (or estimates for concentrate)
|
|
||||||
// discrete: track count (units) — audit confirms presence by SKU/asset
|
|
||||||
const TYPES = [
|
|
||||||
{ id: "Flower", kind: "bulk", auditMode: "weigh", cadenceDays: 14, unit: "g", weighable: true },
|
|
||||||
{ id: "Concentrate", kind: "bulk", auditMode: "estimate", cadenceDays: 21, unit: "g", weighable: true },
|
|
||||||
{ id: "Tincture", kind: "bulk", auditMode: "estimate", cadenceDays: 30, unit: "ml", weighable: false },
|
|
||||||
{ id: "Pre-roll", kind: "discrete", auditMode: "presence", cadenceDays: 30, unit: "ct", weighable: false },
|
|
||||||
{ id: "Edible", kind: "discrete", auditMode: "presence", cadenceDays: 60, unit: "ct", weighable: false },
|
|
||||||
{ id: "Vaporizer", kind: "discrete", auditMode: "presence", cadenceDays: 30, unit: "ct", weighable: false }
|
|
||||||
];
|
|
||||||
|
|
||||||
const BINS = [
|
|
||||||
{ id: "bin-01", name: "Top Drawer", location: "Bedroom", capacity: 14 },
|
|
||||||
{ id: "bin-02", name: "Apothecary Box", location: "Office shelf", capacity: 10 },
|
|
||||||
{ id: "bin-03", name: "The Safe", location: "Closet", capacity: 8 },
|
|
||||||
{ id: "bin-04", name: "Travel Tin", location: "Backpack", capacity: 4 },
|
|
||||||
{ id: "bin-05", name: "Cold Storage", location: "Fridge — back", capacity: 6 }
|
|
||||||
];
|
|
||||||
|
|
||||||
let n = 1;
|
|
||||||
const mk = (o) => {
|
|
||||||
const id = "prd-" + String(n).padStart(4, "0");
|
|
||||||
n++;
|
|
||||||
const sku = o.sku || ("SKU-" + Math.random().toString(36).slice(2, 8).toUpperCase());
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
sku,
|
|
||||||
assetTag: null,
|
|
||||||
name: "",
|
|
||||||
brandId: null,
|
|
||||||
shopId: null,
|
|
||||||
type: "Flower",
|
|
||||||
kind: "bulk", // "bulk" or "discrete"
|
|
||||||
// Bulk fields
|
|
||||||
weight: 0, // total at purchase (g/ml)
|
|
||||||
lastAuditWeight: null, // last measured weight
|
|
||||||
// Discrete fields
|
|
||||||
countOriginal: 0, // units at purchase
|
|
||||||
countLastAudit: null, // units confirmed at last audit
|
|
||||||
unitWeight: 0, // bulk-equivalent grams per unit (for grams stats)
|
|
||||||
// Pricing
|
|
||||||
price: 0,
|
|
||||||
thc: 0,
|
|
||||||
cbd: 0,
|
|
||||||
totalCannabinoids: 0,
|
|
||||||
purchaseDate: TODAY,
|
|
||||||
binId: "bin-01",
|
|
||||||
// Lifecycle
|
|
||||||
status: "active", // "active" | "consumed" | "gone"
|
|
||||||
consumedDate: null,
|
|
||||||
goneDate: null,
|
|
||||||
rating: null,
|
|
||||||
notes: null,
|
|
||||||
// Audits — newest last
|
|
||||||
audits: [],
|
|
||||||
...o
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const products = [];
|
|
||||||
|
|
||||||
// ─── BULK: FLOWER ────────────────────────────────────────────────
|
|
||||||
products.push(mk({
|
|
||||||
name: "Garden Ghost", brandId: "brd-01", shopId: "shp-01",
|
|
||||||
type: "Flower", kind: "bulk",
|
|
||||||
weight: 3.5, lastAuditWeight: 2.4,
|
|
||||||
price: 48, thc: 24.6, cbd: 0.3, totalCannabinoids: 28.4,
|
|
||||||
purchaseDate: daysAgo(17), binId: "bin-01",
|
|
||||||
audits: [
|
|
||||||
{ date: daysAgo(10), mode: "weigh", value: 3.0, prev: 3.5 },
|
|
||||||
{ date: daysAgo(3), mode: "weigh", value: 2.4, prev: 3.0 }
|
|
||||||
]
|
|
||||||
}));
|
|
||||||
products.push(mk({
|
|
||||||
name: "Honeydew Pine", brandId: "brd-02", shopId: "shp-02",
|
|
||||||
type: "Flower", kind: "bulk",
|
|
||||||
weight: 7, lastAuditWeight: 5.6,
|
|
||||||
price: 85, thc: 21.0, cbd: 0.5, totalCannabinoids: 25.1,
|
|
||||||
purchaseDate: daysAgo(11), binId: "bin-01",
|
|
||||||
audits: [
|
|
||||||
{ date: daysAgo(2), mode: "weigh", value: 5.6, prev: 7.0 }
|
|
||||||
]
|
|
||||||
}));
|
|
||||||
products.push(mk({
|
|
||||||
name: "Late Pear", brandId: "brd-01", shopId: "shp-01",
|
|
||||||
type: "Flower", kind: "bulk",
|
|
||||||
weight: 3.5, lastAuditWeight: 3.5,
|
|
||||||
price: 50, thc: 25.2, cbd: 0.2, totalCannabinoids: 28.9,
|
|
||||||
purchaseDate: daysAgo(6), binId: "bin-05"
|
|
||||||
// recently bought, no audit yet
|
|
||||||
}));
|
|
||||||
products.push(mk({
|
|
||||||
name: "Copper Fennel", brandId: "brd-02", shopId: "shp-02",
|
|
||||||
type: "Flower", kind: "bulk",
|
|
||||||
weight: 3.5, lastAuditWeight: 0.5,
|
|
||||||
price: 42, thc: 20.4, cbd: 0.5, totalCannabinoids: 24.0,
|
|
||||||
purchaseDate: daysAgo(26), binId: "bin-01",
|
|
||||||
audits: [
|
|
||||||
{ date: daysAgo(12), mode: "weigh", value: 1.6, prev: 3.5 },
|
|
||||||
{ date: daysAgo(2), mode: "weigh", value: 0.5, prev: 1.6 }
|
|
||||||
]
|
|
||||||
}));
|
|
||||||
// Overdue audit — Flower past 14d cadence
|
|
||||||
products.push(mk({
|
|
||||||
name: "Slate Cherry", brandId: "brd-06", shopId: "shp-04",
|
|
||||||
type: "Flower", kind: "bulk",
|
|
||||||
weight: 3.5, lastAuditWeight: 3.5,
|
|
||||||
price: 46, thc: 22.8, cbd: 0.4, totalCannabinoids: 26.5,
|
|
||||||
purchaseDate: daysAgo(22), binId: "bin-01"
|
|
||||||
// No audit since purchase, 22d > 14d cadence → overdue
|
|
||||||
}));
|
|
||||||
|
|
||||||
// ─── BULK: CONCENTRATE ──────────────────────────────────────────
|
|
||||||
products.push(mk({
|
|
||||||
name: "Indigo Cellar Live Rosin", brandId: "brd-03", shopId: "shp-03",
|
|
||||||
type: "Concentrate", kind: "bulk",
|
|
||||||
weight: 1, lastAuditWeight: 0.6,
|
|
||||||
price: 65, thc: 78.4, cbd: 0.2, totalCannabinoids: 84.0,
|
|
||||||
purchaseDate: daysAgo(28), binId: "bin-03",
|
|
||||||
assetTag: "AT-0042",
|
|
||||||
audits: [
|
|
||||||
{ date: daysAgo(15), mode: "estimate", value: 0.8, prev: 1.0 },
|
|
||||||
{ date: daysAgo(2), mode: "estimate", value: 0.6, prev: 0.8 }
|
|
||||||
]
|
|
||||||
}));
|
|
||||||
products.push(mk({
|
|
||||||
name: "Slate Apricot Hash", brandId: "brd-04", shopId: "shp-04",
|
|
||||||
type: "Concentrate", kind: "bulk",
|
|
||||||
weight: 2, lastAuditWeight: 1.4,
|
|
||||||
price: 80, thc: 62.0, cbd: 1.1, totalCannabinoids: 70.5,
|
|
||||||
purchaseDate: daysAgo(23), binId: "bin-03",
|
|
||||||
audits: [
|
|
||||||
{ date: daysAgo(8), mode: "estimate", value: 1.4, prev: 2.0 }
|
|
||||||
]
|
|
||||||
}));
|
|
||||||
products.push(mk({
|
|
||||||
name: "Birchwater Live Resin", brandId: "brd-03", shopId: "shp-04",
|
|
||||||
type: "Concentrate", kind: "bulk",
|
|
||||||
weight: 1, lastAuditWeight: 1.0,
|
|
||||||
price: 70, thc: 76.0, cbd: 0.3, totalCannabinoids: 82.1,
|
|
||||||
purchaseDate: daysAgo(4), binId: "bin-03"
|
|
||||||
}));
|
|
||||||
|
|
||||||
// ─── BULK: TINCTURE ─────────────────────────────────────────────
|
|
||||||
products.push(mk({
|
|
||||||
name: "Nightjar Tincture 30ml", brandId: "brd-08", shopId: "shp-02",
|
|
||||||
type: "Tincture", kind: "bulk",
|
|
||||||
weight: 30, lastAuditWeight: 22,
|
|
||||||
price: 60, thc: 0.5, cbd: 18.0, totalCannabinoids: 19.2,
|
|
||||||
purchaseDate: daysAgo(62), binId: "bin-02",
|
|
||||||
audits: [
|
|
||||||
{ date: daysAgo(31), mode: "estimate", value: 26, prev: 30 },
|
|
||||||
{ date: daysAgo(5), mode: "estimate", value: 22, prev: 26 }
|
|
||||||
]
|
|
||||||
}));
|
|
||||||
|
|
||||||
// ─── DISCRETE: PRE-ROLLS ───────────────────────────────────────
|
|
||||||
products.push(mk({
|
|
||||||
name: "Mossback Pre-rolls", brandId: "brd-07", shopId: "shp-03",
|
|
||||||
type: "Pre-roll", kind: "discrete",
|
|
||||||
countOriginal: 5, countLastAudit: 3, unitWeight: 0.7,
|
|
||||||
price: 38, thc: 19.8, cbd: 0.4, totalCannabinoids: 23.0,
|
|
||||||
purchaseDate: daysAgo(9), binId: "bin-04",
|
|
||||||
audits: [
|
|
||||||
{ date: daysAgo(2), mode: "presence", value: 3, prev: 5, confirmedBy: "SKU" }
|
|
||||||
]
|
|
||||||
}));
|
|
||||||
products.push(mk({
|
|
||||||
name: "Mossback Pre-rolls", brandId: "brd-07", shopId: "shp-03",
|
|
||||||
type: "Pre-roll", kind: "discrete",
|
|
||||||
countOriginal: 5, countLastAudit: 5, unitWeight: 0.7,
|
|
||||||
price: 38, thc: 19.8, cbd: 0.4, totalCannabinoids: 23.0,
|
|
||||||
purchaseDate: daysAgo(3), binId: "bin-04"
|
|
||||||
}));
|
|
||||||
products.push(mk({
|
|
||||||
name: "Quiet Meadow Singles", brandId: "brd-05", shopId: "shp-05",
|
|
||||||
type: "Pre-roll", kind: "discrete",
|
|
||||||
countOriginal: 3, countLastAudit: 1, unitWeight: 1.0,
|
|
||||||
price: 24, thc: 22.0, cbd: 0.3, totalCannabinoids: 25.0,
|
|
||||||
purchaseDate: daysAgo(34), binId: "bin-04",
|
|
||||||
audits: [
|
|
||||||
{ date: daysAgo(20), mode: "presence", value: 2, prev: 3, confirmedBy: "SKU" },
|
|
||||||
{ date: daysAgo(2), mode: "presence", value: 1, prev: 2, confirmedBy: "SKU" }
|
|
||||||
]
|
|
||||||
}));
|
|
||||||
|
|
||||||
// ─── DISCRETE: EDIBLES ─────────────────────────────────────────
|
|
||||||
products.push(mk({
|
|
||||||
name: "Ember Lily Gummies (10 ct)", brandId: "brd-06", shopId: "shp-01",
|
|
||||||
type: "Edible", kind: "discrete",
|
|
||||||
countOriginal: 10, countLastAudit: 6, unitWeight: 0,
|
|
||||||
price: 22, thc: 5.0, cbd: 1.0, totalCannabinoids: 6.4,
|
|
||||||
purchaseDate: daysAgo(20), binId: "bin-02",
|
|
||||||
audits: [
|
|
||||||
{ date: daysAgo(5), mode: "presence", value: 6, prev: 10, confirmedBy: "SKU" }
|
|
||||||
]
|
|
||||||
}));
|
|
||||||
products.push(mk({
|
|
||||||
name: "Marigold Mints (20 ct)", brandId: "brd-08", shopId: "shp-02",
|
|
||||||
type: "Edible", kind: "discrete",
|
|
||||||
countOriginal: 20, countLastAudit: 14, unitWeight: 0,
|
|
||||||
price: 28, thc: 2.5, cbd: 0, totalCannabinoids: 3.0,
|
|
||||||
purchaseDate: daysAgo(48), binId: "bin-02",
|
|
||||||
audits: [
|
|
||||||
{ date: daysAgo(7), mode: "presence", value: 14, prev: 20, confirmedBy: "SKU" }
|
|
||||||
]
|
|
||||||
}));
|
|
||||||
|
|
||||||
// ─── DISCRETE: VAPORIZER ───────────────────────────────────────
|
|
||||||
products.push(mk({
|
|
||||||
name: "Quiet Meadow Disposable", brandId: "brd-05", shopId: "shp-05",
|
|
||||||
type: "Vaporizer", kind: "discrete",
|
|
||||||
countOriginal: 1, countLastAudit: 1, unitWeight: 1.0,
|
|
||||||
price: 55, thc: 84.0, cbd: 0.1, totalCannabinoids: 88.2,
|
|
||||||
purchaseDate: daysAgo(14), binId: "bin-04",
|
|
||||||
audits: [
|
|
||||||
{ date: daysAgo(1), mode: "presence", value: 1, prev: 1, confirmedBy: "SKU" }
|
|
||||||
]
|
|
||||||
}));
|
|
||||||
|
|
||||||
// ─── CONSUMED (used up — counts as consumption) ─────────────────
|
|
||||||
products.push(mk({
|
|
||||||
name: "Oolong 19", brandId: "brd-04", shopId: "shp-01",
|
|
||||||
type: "Flower", kind: "bulk",
|
|
||||||
weight: 3.5, lastAuditWeight: 0,
|
|
||||||
price: 46, thc: 23.0, cbd: 0.4, totalCannabinoids: 27.0,
|
|
||||||
purchaseDate: daysAgo(66), binId: null,
|
|
||||||
status: "consumed", consumedDate: daysAgo(31),
|
|
||||||
rating: 4, notes: "Smooth, citrusy. Daytime favorite."
|
|
||||||
}));
|
|
||||||
products.push(mk({
|
|
||||||
name: "Stonefruit OG", brandId: "brd-07", shopId: "shp-03",
|
|
||||||
type: "Flower", kind: "bulk",
|
|
||||||
weight: 7, lastAuditWeight: 0,
|
|
||||||
price: 78, thc: 22.5, cbd: 0.6, totalCannabinoids: 26.4,
|
|
||||||
purchaseDate: daysAgo(103), binId: null,
|
|
||||||
status: "consumed", consumedDate: daysAgo(56),
|
|
||||||
rating: 5, notes: "Best of the season. Rebuy."
|
|
||||||
}));
|
|
||||||
products.push(mk({
|
|
||||||
name: "Lavender Coast", brandId: "brd-01", shopId: "shp-05",
|
|
||||||
type: "Flower", kind: "bulk",
|
|
||||||
weight: 3.5, lastAuditWeight: 0,
|
|
||||||
price: 44, thc: 19.0, cbd: 0.8, totalCannabinoids: 22.2,
|
|
||||||
purchaseDate: daysAgo(80), binId: null,
|
|
||||||
status: "consumed", consumedDate: daysAgo(47),
|
|
||||||
rating: 3, notes: "Mellow but underwhelming."
|
|
||||||
}));
|
|
||||||
products.push(mk({
|
|
||||||
name: "Violet Tea", brandId: "brd-06", shopId: "shp-04",
|
|
||||||
type: "Flower", kind: "bulk",
|
|
||||||
weight: 3.5, lastAuditWeight: 0,
|
|
||||||
price: 50, thc: 24.1, cbd: 0.3, totalCannabinoids: 28.0,
|
|
||||||
purchaseDate: daysAgo(55), binId: null,
|
|
||||||
status: "consumed", consumedDate: daysAgo(21),
|
|
||||||
rating: 4, notes: "Floral nose, nice evenings."
|
|
||||||
}));
|
|
||||||
|
|
||||||
// ─── GONE (lost / damaged — counts as $ spent, NOT consumption) ─
|
|
||||||
products.push(mk({
|
|
||||||
name: "Quiet Meadow Singles", brandId: "brd-05", shopId: "shp-05",
|
|
||||||
type: "Pre-roll", kind: "discrete",
|
|
||||||
countOriginal: 3, countLastAudit: 0, unitWeight: 1.0,
|
|
||||||
price: 24, thc: 22.0, cbd: 0.3, totalCannabinoids: 25.0,
|
|
||||||
purchaseDate: daysAgo(72), binId: null,
|
|
||||||
status: "gone", goneDate: daysAgo(40),
|
|
||||||
notes: "Pack went through the wash. Lesson learned.",
|
|
||||||
audits: [
|
|
||||||
{ date: daysAgo(40), mode: "presence", value: 0, prev: 3, confirmedBy: "lost" }
|
|
||||||
]
|
|
||||||
}));
|
|
||||||
products.push(mk({
|
|
||||||
name: "Ember Lily Gummies (10 ct)", brandId: "brd-06", shopId: "shp-01",
|
|
||||||
type: "Edible", kind: "discrete",
|
|
||||||
countOriginal: 10, countLastAudit: 4, unitWeight: 0,
|
|
||||||
price: 22, thc: 5.0, cbd: 1.0, totalCannabinoids: 6.4,
|
|
||||||
purchaseDate: daysAgo(95), binId: null,
|
|
||||||
status: "gone", goneDate: daysAgo(15),
|
|
||||||
notes: "Expired. Tossed the rest."
|
|
||||||
}));
|
|
||||||
|
|
||||||
return {
|
|
||||||
products,
|
|
||||||
bins: BINS,
|
|
||||||
shops: SHOPS,
|
|
||||||
brands: BRANDS,
|
|
||||||
types: TYPES,
|
|
||||||
today: TODAY
|
|
||||||
};
|
|
||||||
})();
|
|
||||||
|
|
||||||
// Helpers — exported so screens can use consistent logic
|
|
||||||
window.DATA_HELPERS = {
|
|
||||||
shopName: (data, id) => data.shops.find(s => s.id === id)?.name || "—",
|
|
||||||
brandName: (data, id) => data.brands.find(b => b.id === id)?.name || "—",
|
|
||||||
typeConfig: (data, id) => data.types.find(t => t.id === id) || data.types[0],
|
|
||||||
|
|
||||||
// Days since a given ISO date string
|
|
||||||
daysSince: (iso, today = "2026-04-25") => {
|
|
||||||
if (!iso) return Infinity;
|
|
||||||
return Math.floor((new Date(today) - new Date(iso)) / 86400000);
|
|
||||||
},
|
|
||||||
|
|
||||||
// Last audit record (newest)
|
|
||||||
lastAudit: (p) => p.audits && p.audits.length ? p.audits[p.audits.length - 1] : null,
|
|
||||||
|
|
||||||
// Days since last audit (or since purchase if none)
|
|
||||||
daysSinceCheck: (p, today = "2026-04-25") => {
|
|
||||||
const last = p.audits && p.audits.length ? p.audits[p.audits.length - 1].date : p.purchaseDate;
|
|
||||||
return Math.floor((new Date(today) - new Date(last)) / 86400000);
|
|
||||||
},
|
|
||||||
|
|
||||||
// Is audit overdue based on per-type cadence
|
|
||||||
auditOverdue: (data, p, today = "2026-04-25") => {
|
|
||||||
if (p.status !== "active") return false;
|
|
||||||
const cfg = data.types.find(t => t.id === p.type);
|
|
||||||
if (!cfg) return false;
|
|
||||||
return window.DATA_HELPERS.daysSinceCheck(p, today) >= cfg.cadenceDays;
|
|
||||||
},
|
|
||||||
|
|
||||||
// Estimated remaining (bulk: decay from last audit; discrete: countLastAudit)
|
|
||||||
estimatedRemaining: (p, today = "2026-04-25") => {
|
|
||||||
if (p.status !== "active") return 0;
|
|
||||||
if (p.kind === "discrete") {
|
|
||||||
return p.countLastAudit != null ? p.countLastAudit : p.countOriginal;
|
|
||||||
}
|
|
||||||
// Bulk: linear decay between last audit and average lifespan estimate
|
|
||||||
const last = window.DATA_HELPERS.lastAudit(p);
|
|
||||||
const baseDate = last ? last.date : p.purchaseDate;
|
|
||||||
const baseValue = last ? last.value : p.weight;
|
|
||||||
const daysSinceBase = Math.max(0, Math.floor((new Date(today) - new Date(baseDate)) / 86400000));
|
|
||||||
// Decay rate: assume original weight is consumed over (purchase → expected lifespan ~ 35d for flower, 40 concentrate, 90 tincture)
|
|
||||||
const expectedLifespan = p.type === "Flower" ? 35 : p.type === "Concentrate" ? 40 : 90;
|
|
||||||
const dailyBurn = p.weight / expectedLifespan;
|
|
||||||
const est = Math.max(0, baseValue - dailyBurn * daysSinceBase);
|
|
||||||
return est;
|
|
||||||
},
|
|
||||||
|
|
||||||
// Original-percent remaining (for low-stock on bulk)
|
|
||||||
pctRemaining: (p, today = "2026-04-25") => {
|
|
||||||
if (p.kind === "discrete") {
|
|
||||||
const cur = p.countLastAudit != null ? p.countLastAudit : p.countOriginal;
|
|
||||||
return p.countOriginal > 0 ? cur / p.countOriginal : 0;
|
|
||||||
}
|
|
||||||
const est = window.DATA_HELPERS.estimatedRemaining(p, today);
|
|
||||||
return p.weight > 0 ? est / p.weight : 0;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,619 +0,0 @@
|
|||||||
/**
|
|
||||||
* <deck-stage> — reusable web component for HTML decks.
|
|
||||||
*
|
|
||||||
* Handles:
|
|
||||||
* (a) speaker notes — reads <script type="application/json" id="speaker-notes">
|
|
||||||
* and posts {slideIndexChanged: N} to the parent window on nav.
|
|
||||||
* (b) keyboard navigation — ←/→, PgUp/PgDn, Space, Home/End, number keys.
|
|
||||||
* (c) press R to reset to slide 0 (with a tasteful keyboard hint).
|
|
||||||
* (d) bottom-center overlay showing slide count + hints, fades out on idle.
|
|
||||||
* (e) auto-scaling — inner canvas is a fixed design size (default 1920×1080)
|
|
||||||
* scaled with `transform: scale()` to fit the viewport, letterboxed.
|
|
||||||
* Set the `noscale` attribute to render at authored size (1:1) — the
|
|
||||||
* PPTX exporter sets this so its DOM capture sees unscaled geometry.
|
|
||||||
* (f) print — `@media print` lays every slide out as its own page at the
|
|
||||||
* design size, so the browser's Print → Save as PDF produces a clean
|
|
||||||
* one-page-per-slide PDF with no extra setup.
|
|
||||||
*
|
|
||||||
* Slides are HIDDEN, not unmounted. Non-active slides stay in the DOM with
|
|
||||||
* `visibility: hidden` + `opacity: 0`, so their state (videos, iframes,
|
|
||||||
* form inputs, React trees) is preserved across navigation.
|
|
||||||
*
|
|
||||||
* Lifecycle event — the component dispatches a `slidechange` CustomEvent on
|
|
||||||
* itself whenever the active slide changes (including the initial mount).
|
|
||||||
* The event bubbles and composes out of shadow DOM, so you can listen on
|
|
||||||
* the <deck-stage> element or on document:
|
|
||||||
*
|
|
||||||
* document.querySelector('deck-stage').addEventListener('slidechange', (e) => {
|
|
||||||
* e.detail.index // new 0-based index
|
|
||||||
* e.detail.previousIndex // previous index, or -1 on init
|
|
||||||
* e.detail.total // total slide count
|
|
||||||
* e.detail.slide // the new active slide element
|
|
||||||
* e.detail.previousSlide // the prior slide element, or null on init
|
|
||||||
* e.detail.reason // 'init' | 'keyboard' | 'click' | 'tap' | 'api'
|
|
||||||
* });
|
|
||||||
*
|
|
||||||
* Persistence: none at the deck level. The host app keeps the current slide
|
|
||||||
* in its own URL (?slide=) and re-delivers it via location.hash on load, so a
|
|
||||||
* bare load with no hash always starts at slide 1.
|
|
||||||
*
|
|
||||||
* Usage:
|
|
||||||
* <deck-stage width="1920" height="1080">
|
|
||||||
* <section data-label="Title">...</section>
|
|
||||||
* <section data-label="Agenda">...</section>
|
|
||||||
* </deck-stage>
|
|
||||||
*
|
|
||||||
* Slides are the direct element children of <deck-stage>. Each slide is
|
|
||||||
* automatically tagged with:
|
|
||||||
* - data-screen-label="NN Label" (1-indexed, for comment flow)
|
|
||||||
* - data-om-validate="no_overflowing_text,no_overlapping_text,slide_sized_text"
|
|
||||||
*/
|
|
||||||
|
|
||||||
(() => {
|
|
||||||
const DESIGN_W_DEFAULT = 1920;
|
|
||||||
const DESIGN_H_DEFAULT = 1080;
|
|
||||||
const OVERLAY_HIDE_MS = 1800;
|
|
||||||
const VALIDATE_ATTR = 'no_overflowing_text,no_overlapping_text,slide_sized_text';
|
|
||||||
|
|
||||||
const pad2 = (n) => String(n).padStart(2, '0');
|
|
||||||
|
|
||||||
const stylesheet = `
|
|
||||||
:host {
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
display: block;
|
|
||||||
background: #000;
|
|
||||||
color: #fff;
|
|
||||||
font-family: -apple-system, BlinkMacSystemFont, "Helvetica Neue", Helvetica, Arial, sans-serif;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stage {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.canvas {
|
|
||||||
position: relative;
|
|
||||||
transform-origin: center center;
|
|
||||||
flex-shrink: 0;
|
|
||||||
background: #fff;
|
|
||||||
will-change: transform;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Slides live in light DOM (via <slot>) so authored CSS still applies.
|
|
||||||
We absolutely position each slotted child to stack them. */
|
|
||||||
::slotted(*) {
|
|
||||||
position: absolute !important;
|
|
||||||
inset: 0 !important;
|
|
||||||
width: 100% !important;
|
|
||||||
height: 100% !important;
|
|
||||||
box-sizing: border-box !important;
|
|
||||||
overflow: hidden;
|
|
||||||
opacity: 0;
|
|
||||||
pointer-events: none;
|
|
||||||
visibility: hidden;
|
|
||||||
}
|
|
||||||
::slotted([data-deck-active]) {
|
|
||||||
opacity: 1;
|
|
||||||
pointer-events: auto;
|
|
||||||
visibility: visible;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Tap zones for mobile — back/forward thirds like Stories.
|
|
||||||
Transparent, no visible UI, don't block the overlay. */
|
|
||||||
.tapzones {
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
display: flex;
|
|
||||||
z-index: 2147482000;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
.tapzone {
|
|
||||||
flex: 1;
|
|
||||||
pointer-events: auto;
|
|
||||||
-webkit-tap-highlight-color: transparent;
|
|
||||||
}
|
|
||||||
/* Only activate tap zones on coarse pointers (touch devices). */
|
|
||||||
@media (hover: hover) and (pointer: fine) {
|
|
||||||
.tapzones { display: none; }
|
|
||||||
}
|
|
||||||
|
|
||||||
.overlay {
|
|
||||||
position: fixed;
|
|
||||||
left: 50%;
|
|
||||||
bottom: 22px;
|
|
||||||
transform: translate(-50%, 6px) scale(0.92);
|
|
||||||
filter: blur(6px);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 4px;
|
|
||||||
padding: 4px;
|
|
||||||
background: #000;
|
|
||||||
color: #fff;
|
|
||||||
border-radius: 999px;
|
|
||||||
font-size: 12px;
|
|
||||||
font-feature-settings: "tnum" 1;
|
|
||||||
letter-spacing: 0.01em;
|
|
||||||
opacity: 0;
|
|
||||||
pointer-events: none;
|
|
||||||
transition: opacity 260ms ease, transform 260ms cubic-bezier(.2,.8,.2,1), filter 260ms ease;
|
|
||||||
transform-origin: center bottom;
|
|
||||||
z-index: 2147483000;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
.overlay[data-visible] {
|
|
||||||
opacity: 1;
|
|
||||||
pointer-events: auto;
|
|
||||||
transform: translate(-50%, 0) scale(1);
|
|
||||||
filter: blur(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn {
|
|
||||||
appearance: none;
|
|
||||||
-webkit-appearance: none;
|
|
||||||
background: transparent;
|
|
||||||
border: 0;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
color: inherit;
|
|
||||||
font: inherit;
|
|
||||||
cursor: default;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
height: 28px;
|
|
||||||
min-width: 28px;
|
|
||||||
border-radius: 999px;
|
|
||||||
color: rgba(255,255,255,0.72);
|
|
||||||
transition: background 140ms ease, color 140ms ease;
|
|
||||||
-webkit-tap-highlight-color: transparent;
|
|
||||||
}
|
|
||||||
.btn:hover { background: rgba(255,255,255,0.12); color: #fff; }
|
|
||||||
.btn:active { background: rgba(255,255,255,0.18); }
|
|
||||||
.btn:focus { outline: none; }
|
|
||||||
.btn:focus-visible { outline: none; }
|
|
||||||
.btn::-moz-focus-inner { border: 0; }
|
|
||||||
.btn svg { width: 14px; height: 14px; display: block; }
|
|
||||||
.btn.reset {
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 500;
|
|
||||||
letter-spacing: 0.02em;
|
|
||||||
padding: 0 10px 0 12px;
|
|
||||||
gap: 6px;
|
|
||||||
color: rgba(255,255,255,0.72);
|
|
||||||
}
|
|
||||||
.btn.reset .kbd {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
min-width: 16px;
|
|
||||||
height: 16px;
|
|
||||||
padding: 0 4px;
|
|
||||||
font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
|
|
||||||
font-size: 10px;
|
|
||||||
line-height: 1;
|
|
||||||
color: rgba(255,255,255,0.88);
|
|
||||||
background: rgba(255,255,255,0.12);
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.count {
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
color: #fff;
|
|
||||||
font-weight: 500;
|
|
||||||
padding: 0 8px;
|
|
||||||
min-width: 42px;
|
|
||||||
text-align: center;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
.count .sep { color: rgba(255,255,255,0.45); margin: 0 3px; font-weight: 400; }
|
|
||||||
.count .total { color: rgba(255,255,255,0.55); }
|
|
||||||
|
|
||||||
.divider {
|
|
||||||
width: 1px;
|
|
||||||
height: 14px;
|
|
||||||
background: rgba(255,255,255,0.18);
|
|
||||||
margin: 0 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Print: one page per slide, no chrome ────────────────────────────
|
|
||||||
The screen layout stacks every slide at inset:0 inside a scaled
|
|
||||||
canvas; for print we want them in document flow at the authored
|
|
||||||
design size so the browser paginates one slide per sheet. The
|
|
||||||
@page size is set from the width/height attributes via the inline
|
|
||||||
<style id="deck-stage-print-page"> that connectedCallback injects
|
|
||||||
into <head> (the @page at-rule has no effect inside shadow DOM). */
|
|
||||||
@media print {
|
|
||||||
:host {
|
|
||||||
position: static;
|
|
||||||
inset: auto;
|
|
||||||
background: none;
|
|
||||||
overflow: visible;
|
|
||||||
color: inherit;
|
|
||||||
}
|
|
||||||
.stage { position: static; display: block; }
|
|
||||||
.canvas {
|
|
||||||
transform: none !important;
|
|
||||||
width: auto !important;
|
|
||||||
height: auto !important;
|
|
||||||
background: none;
|
|
||||||
will-change: auto;
|
|
||||||
}
|
|
||||||
::slotted(*) {
|
|
||||||
position: relative !important;
|
|
||||||
inset: auto !important;
|
|
||||||
width: var(--deck-design-w) !important;
|
|
||||||
height: var(--deck-design-h) !important;
|
|
||||||
box-sizing: border-box !important;
|
|
||||||
opacity: 1 !important;
|
|
||||||
visibility: visible !important;
|
|
||||||
pointer-events: auto;
|
|
||||||
break-after: page;
|
|
||||||
page-break-after: always;
|
|
||||||
break-inside: avoid;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
::slotted(*:last-child) {
|
|
||||||
break-after: auto;
|
|
||||||
page-break-after: auto;
|
|
||||||
}
|
|
||||||
.overlay, .tapzones { display: none !important; }
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
class DeckStage extends HTMLElement {
|
|
||||||
static get observedAttributes() { return ['width', 'height', 'noscale']; }
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
super();
|
|
||||||
this._root = this.attachShadow({ mode: 'open' });
|
|
||||||
this._index = 0;
|
|
||||||
this._slides = [];
|
|
||||||
this._notes = [];
|
|
||||||
this._hideTimer = null;
|
|
||||||
this._mouseIdleTimer = null;
|
|
||||||
|
|
||||||
this._onKey = this._onKey.bind(this);
|
|
||||||
this._onResize = this._onResize.bind(this);
|
|
||||||
this._onSlotChange = this._onSlotChange.bind(this);
|
|
||||||
this._onMouseMove = this._onMouseMove.bind(this);
|
|
||||||
this._onTapBack = this._onTapBack.bind(this);
|
|
||||||
this._onTapForward = this._onTapForward.bind(this);
|
|
||||||
}
|
|
||||||
|
|
||||||
get designWidth() {
|
|
||||||
return parseInt(this.getAttribute('width'), 10) || DESIGN_W_DEFAULT;
|
|
||||||
}
|
|
||||||
get designHeight() {
|
|
||||||
return parseInt(this.getAttribute('height'), 10) || DESIGN_H_DEFAULT;
|
|
||||||
}
|
|
||||||
|
|
||||||
connectedCallback() {
|
|
||||||
this._render();
|
|
||||||
this._loadNotes();
|
|
||||||
this._syncPrintPageRule();
|
|
||||||
window.addEventListener('keydown', this._onKey);
|
|
||||||
window.addEventListener('resize', this._onResize);
|
|
||||||
window.addEventListener('mousemove', this._onMouseMove, { passive: true });
|
|
||||||
// Initial collection + layout happens via slotchange, which fires on mount.
|
|
||||||
}
|
|
||||||
|
|
||||||
disconnectedCallback() {
|
|
||||||
window.removeEventListener('keydown', this._onKey);
|
|
||||||
window.removeEventListener('resize', this._onResize);
|
|
||||||
window.removeEventListener('mousemove', this._onMouseMove);
|
|
||||||
if (this._hideTimer) clearTimeout(this._hideTimer);
|
|
||||||
if (this._mouseIdleTimer) clearTimeout(this._mouseIdleTimer);
|
|
||||||
}
|
|
||||||
|
|
||||||
attributeChangedCallback() {
|
|
||||||
if (this._canvas) {
|
|
||||||
this._canvas.style.width = this.designWidth + 'px';
|
|
||||||
this._canvas.style.height = this.designHeight + 'px';
|
|
||||||
this._canvas.style.setProperty('--deck-design-w', this.designWidth + 'px');
|
|
||||||
this._canvas.style.setProperty('--deck-design-h', this.designHeight + 'px');
|
|
||||||
this._fit();
|
|
||||||
this._syncPrintPageRule();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_render() {
|
|
||||||
const style = document.createElement('style');
|
|
||||||
style.textContent = stylesheet;
|
|
||||||
|
|
||||||
const stage = document.createElement('div');
|
|
||||||
stage.className = 'stage';
|
|
||||||
|
|
||||||
const canvas = document.createElement('div');
|
|
||||||
canvas.className = 'canvas';
|
|
||||||
canvas.style.width = this.designWidth + 'px';
|
|
||||||
canvas.style.height = this.designHeight + 'px';
|
|
||||||
canvas.style.setProperty('--deck-design-w', this.designWidth + 'px');
|
|
||||||
canvas.style.setProperty('--deck-design-h', this.designHeight + 'px');
|
|
||||||
|
|
||||||
const slot = document.createElement('slot');
|
|
||||||
slot.addEventListener('slotchange', this._onSlotChange);
|
|
||||||
canvas.appendChild(slot);
|
|
||||||
stage.appendChild(canvas);
|
|
||||||
|
|
||||||
// Tap zones (mobile): left third = back, right third = forward.
|
|
||||||
const tapzones = document.createElement('div');
|
|
||||||
tapzones.className = 'tapzones export-hidden';
|
|
||||||
tapzones.setAttribute('aria-hidden', 'true');
|
|
||||||
tapzones.setAttribute('data-noncommentable', '');
|
|
||||||
const tzBack = document.createElement('div');
|
|
||||||
tzBack.className = 'tapzone tapzone--back';
|
|
||||||
const tzMid = document.createElement('div');
|
|
||||||
tzMid.className = 'tapzone tapzone--mid';
|
|
||||||
tzMid.style.pointerEvents = 'none';
|
|
||||||
const tzFwd = document.createElement('div');
|
|
||||||
tzFwd.className = 'tapzone tapzone--fwd';
|
|
||||||
tzBack.addEventListener('click', this._onTapBack);
|
|
||||||
tzFwd.addEventListener('click', this._onTapForward);
|
|
||||||
tapzones.append(tzBack, tzMid, tzFwd);
|
|
||||||
|
|
||||||
// Overlay: compact, solid black, with clickable controls.
|
|
||||||
const overlay = document.createElement('div');
|
|
||||||
overlay.className = 'overlay export-hidden';
|
|
||||||
overlay.setAttribute('role', 'toolbar');
|
|
||||||
overlay.setAttribute('aria-label', 'Deck controls');
|
|
||||||
overlay.setAttribute('data-noncommentable', '');
|
|
||||||
overlay.innerHTML = `
|
|
||||||
<button class="btn prev" type="button" aria-label="Previous slide" title="Previous (←)">
|
|
||||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10 3L5 8l5 5"/></svg>
|
|
||||||
</button>
|
|
||||||
<span class="count" aria-live="polite"><span class="current">1</span><span class="sep">/</span><span class="total">1</span></span>
|
|
||||||
<button class="btn next" type="button" aria-label="Next slide" title="Next (→)">
|
|
||||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 3l5 5-5 5"/></svg>
|
|
||||||
</button>
|
|
||||||
<span class="divider"></span>
|
|
||||||
<button class="btn reset" type="button" aria-label="Reset to first slide" title="Reset (R)">Reset<span class="kbd">R</span></button>
|
|
||||||
`;
|
|
||||||
|
|
||||||
overlay.querySelector('.prev').addEventListener('click', () => this._go(this._index - 1, 'click'));
|
|
||||||
overlay.querySelector('.next').addEventListener('click', () => this._go(this._index + 1, 'click'));
|
|
||||||
overlay.querySelector('.reset').addEventListener('click', () => this._go(0, 'click'));
|
|
||||||
|
|
||||||
this._root.append(style, stage, tapzones, overlay);
|
|
||||||
this._canvas = canvas;
|
|
||||||
this._slot = slot;
|
|
||||||
this._overlay = overlay;
|
|
||||||
this._countEl = overlay.querySelector('.current');
|
|
||||||
this._totalEl = overlay.querySelector('.total');
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @page must live in the document stylesheet — it's a no-op inside
|
|
||||||
* shadow DOM. Inject/update a single <head> style tag so the print
|
|
||||||
* sheet matches the design size and Save-as-PDF yields one slide per
|
|
||||||
* page with no margins. */
|
|
||||||
_syncPrintPageRule() {
|
|
||||||
const id = 'deck-stage-print-page';
|
|
||||||
let tag = document.getElementById(id);
|
|
||||||
if (!tag) {
|
|
||||||
tag = document.createElement('style');
|
|
||||||
tag.id = id;
|
|
||||||
document.head.appendChild(tag);
|
|
||||||
}
|
|
||||||
tag.textContent =
|
|
||||||
'@page { size: ' + this.designWidth + 'px ' + this.designHeight + 'px; margin: 0; } ' +
|
|
||||||
'@media print { html, body { margin: 0 !important; padding: 0 !important; background: none !important; overflow: visible !important; height: auto !important; } ' +
|
|
||||||
'* { -webkit-print-color-adjust: exact; print-color-adjust: exact; } }';
|
|
||||||
}
|
|
||||||
|
|
||||||
_onSlotChange() {
|
|
||||||
this._collectSlides();
|
|
||||||
this._restoreIndex();
|
|
||||||
this._applyIndex({ showOverlay: false, broadcast: true, reason: 'init' });
|
|
||||||
this._fit();
|
|
||||||
}
|
|
||||||
|
|
||||||
_collectSlides() {
|
|
||||||
const assigned = this._slot.assignedElements({ flatten: true });
|
|
||||||
this._slides = assigned.filter((el) => {
|
|
||||||
// Skip template/style/script nodes even if someone slots them.
|
|
||||||
const tag = el.tagName;
|
|
||||||
return tag !== 'TEMPLATE' && tag !== 'SCRIPT' && tag !== 'STYLE';
|
|
||||||
});
|
|
||||||
|
|
||||||
this._slides.forEach((slide, i) => {
|
|
||||||
const n = i + 1;
|
|
||||||
// Determine a label for comment flow: prefer explicit data-label,
|
|
||||||
// then an existing data-screen-label, then first heading, else "Slide".
|
|
||||||
let label = slide.getAttribute('data-label');
|
|
||||||
if (!label) {
|
|
||||||
const existing = slide.getAttribute('data-screen-label');
|
|
||||||
if (existing) {
|
|
||||||
// Strip any leading number the author may have included.
|
|
||||||
label = existing.replace(/^\s*\d+\s*/, '').trim() || existing;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!label) {
|
|
||||||
const h = slide.querySelector('h1, h2, h3, [data-title]');
|
|
||||||
if (h) label = (h.textContent || '').trim().slice(0, 40);
|
|
||||||
}
|
|
||||||
if (!label) label = 'Slide';
|
|
||||||
slide.setAttribute('data-screen-label', `${pad2(n)} ${label}`);
|
|
||||||
|
|
||||||
// Validation attribute for comment flow / auto-checks.
|
|
||||||
if (!slide.hasAttribute('data-om-validate')) {
|
|
||||||
slide.setAttribute('data-om-validate', VALIDATE_ATTR);
|
|
||||||
}
|
|
||||||
|
|
||||||
slide.setAttribute('data-deck-slide', String(i));
|
|
||||||
});
|
|
||||||
|
|
||||||
if (this._totalEl) this._totalEl.textContent = String(this._slides.length || 1);
|
|
||||||
if (this._index >= this._slides.length) this._index = Math.max(0, this._slides.length - 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
_loadNotes() {
|
|
||||||
const tag = document.getElementById('speaker-notes');
|
|
||||||
if (!tag) { this._notes = []; return; }
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(tag.textContent || '[]');
|
|
||||||
if (Array.isArray(parsed)) this._notes = parsed;
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('[deck-stage] Failed to parse #speaker-notes JSON:', e);
|
|
||||||
this._notes = [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_restoreIndex() {
|
|
||||||
// The host's ?slide= param is delivered as a #<int> hash (1-indexed) on
|
|
||||||
// the iframe src. No hash → slide 1; the deck itself keeps no position
|
|
||||||
// state across loads.
|
|
||||||
const h = (location.hash || '').match(/^#(\d+)$/);
|
|
||||||
if (h) {
|
|
||||||
const n = parseInt(h[1], 10) - 1;
|
|
||||||
if (n >= 0 && n < this._slides.length) this._index = n;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_applyIndex({ showOverlay = true, broadcast = true, reason = 'init' } = {}) {
|
|
||||||
if (!this._slides.length) return;
|
|
||||||
const prev = this._prevIndex == null ? -1 : this._prevIndex;
|
|
||||||
const curr = this._index;
|
|
||||||
// Keep the iframe's own hash in sync so an in-iframe location.reload()
|
|
||||||
// (reload banner path in viewer-handle.ts) lands on the current slide,
|
|
||||||
// not the stale deep-link hash from initial load.
|
|
||||||
try { history.replaceState(null, '', '#' + (curr + 1)); } catch (e) {}
|
|
||||||
this._slides.forEach((s, i) => {
|
|
||||||
if (i === curr) s.setAttribute('data-deck-active', '');
|
|
||||||
else s.removeAttribute('data-deck-active');
|
|
||||||
});
|
|
||||||
if (this._countEl) this._countEl.textContent = String(curr + 1);
|
|
||||||
|
|
||||||
if (broadcast) {
|
|
||||||
// (1) Legacy: host-window postMessage for speaker-notes renderers.
|
|
||||||
try { window.postMessage({ slideIndexChanged: curr }, '*'); } catch (e) {}
|
|
||||||
|
|
||||||
// (2) In-page CustomEvent on the <deck-stage> element itself.
|
|
||||||
// Bubbles and composes out of shadow DOM so slide code can listen:
|
|
||||||
// document.querySelector('deck-stage').addEventListener('slidechange', e => {
|
|
||||||
// e.detail.index, e.detail.previousIndex, e.detail.total, e.detail.slide, e.detail.reason
|
|
||||||
// });
|
|
||||||
const detail = {
|
|
||||||
index: curr,
|
|
||||||
previousIndex: prev,
|
|
||||||
total: this._slides.length,
|
|
||||||
slide: this._slides[curr] || null,
|
|
||||||
previousSlide: prev >= 0 ? (this._slides[prev] || null) : null,
|
|
||||||
reason: reason, // 'init' | 'keyboard' | 'click' | 'tap' | 'api'
|
|
||||||
};
|
|
||||||
this.dispatchEvent(new CustomEvent('slidechange', {
|
|
||||||
detail,
|
|
||||||
bubbles: true,
|
|
||||||
composed: true,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
this._prevIndex = curr;
|
|
||||||
if (showOverlay) this._flashOverlay();
|
|
||||||
}
|
|
||||||
|
|
||||||
_flashOverlay() {
|
|
||||||
if (!this._overlay) return;
|
|
||||||
this._overlay.setAttribute('data-visible', '');
|
|
||||||
if (this._hideTimer) clearTimeout(this._hideTimer);
|
|
||||||
this._hideTimer = setTimeout(() => {
|
|
||||||
this._overlay.removeAttribute('data-visible');
|
|
||||||
}, OVERLAY_HIDE_MS);
|
|
||||||
}
|
|
||||||
|
|
||||||
_fit() {
|
|
||||||
if (!this._canvas) return;
|
|
||||||
// PPTX export sets noscale so the DOM capture sees authored-size
|
|
||||||
// geometry — the scaled canvas is in shadow DOM, so the exporter's
|
|
||||||
// resetTransformSelector can't reach .canvas.style.transform directly.
|
|
||||||
if (this.hasAttribute('noscale')) {
|
|
||||||
this._canvas.style.transform = 'none';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const vw = window.innerWidth;
|
|
||||||
const vh = window.innerHeight;
|
|
||||||
const s = Math.min(vw / this.designWidth, vh / this.designHeight);
|
|
||||||
this._canvas.style.transform = `scale(${s})`;
|
|
||||||
}
|
|
||||||
|
|
||||||
_onResize() { this._fit(); }
|
|
||||||
|
|
||||||
_onMouseMove() {
|
|
||||||
// Keep overlay visible while mouse moves; hide after idle.
|
|
||||||
this._flashOverlay();
|
|
||||||
}
|
|
||||||
|
|
||||||
_onTapBack(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
this._go(this._index - 1, 'tap');
|
|
||||||
}
|
|
||||||
|
|
||||||
_onTapForward(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
this._go(this._index + 1, 'tap');
|
|
||||||
}
|
|
||||||
|
|
||||||
_onKey(e) {
|
|
||||||
// Ignore when the user is typing.
|
|
||||||
const t = e.target;
|
|
||||||
if (t && (t.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName))) return;
|
|
||||||
if (e.metaKey || e.ctrlKey || e.altKey) return;
|
|
||||||
|
|
||||||
const key = e.key;
|
|
||||||
let handled = true;
|
|
||||||
|
|
||||||
if (key === 'ArrowRight' || key === 'PageDown' || key === ' ' || key === 'Spacebar') {
|
|
||||||
this._go(this._index + 1, 'keyboard');
|
|
||||||
} else if (key === 'ArrowLeft' || key === 'PageUp') {
|
|
||||||
this._go(this._index - 1, 'keyboard');
|
|
||||||
} else if (key === 'Home') {
|
|
||||||
this._go(0, 'keyboard');
|
|
||||||
} else if (key === 'End') {
|
|
||||||
this._go(this._slides.length - 1, 'keyboard');
|
|
||||||
} else if (key === 'r' || key === 'R') {
|
|
||||||
this._go(0, 'keyboard');
|
|
||||||
} else if (/^[0-9]$/.test(key)) {
|
|
||||||
// 1..9 jump to that slide; 0 jumps to 10.
|
|
||||||
const n = key === '0' ? 9 : parseInt(key, 10) - 1;
|
|
||||||
if (n < this._slides.length) this._go(n, 'keyboard');
|
|
||||||
} else {
|
|
||||||
handled = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (handled) {
|
|
||||||
e.preventDefault();
|
|
||||||
this._flashOverlay();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_go(i, reason = 'api') {
|
|
||||||
if (!this._slides.length) return;
|
|
||||||
const clamped = Math.max(0, Math.min(this._slides.length - 1, i));
|
|
||||||
if (clamped === this._index) {
|
|
||||||
this._flashOverlay();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this._index = clamped;
|
|
||||||
this._applyIndex({ showOverlay: true, broadcast: true, reason });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Public API ------------------------------------------------------------
|
|
||||||
|
|
||||||
/** Current slide index (0-based). */
|
|
||||||
get index() { return this._index; }
|
|
||||||
/** Total slide count. */
|
|
||||||
get length() { return this._slides.length; }
|
|
||||||
/** Programmatically navigate. */
|
|
||||||
goTo(i) { this._go(i, 'api'); }
|
|
||||||
next() { this._go(this._index + 1, 'api'); }
|
|
||||||
prev() { this._go(this._index - 1, 'api'); }
|
|
||||||
reset() { this._go(0, 'api'); }
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!customElements.get('deck-stage')) {
|
|
||||||
customElements.define('deck-stage', DeckStage);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
@@ -1,457 +0,0 @@
|
|||||||
// Shared utilities + small primitives
|
|
||||||
// Exported via window at bottom
|
|
||||||
|
|
||||||
const fmt = {
|
|
||||||
g: (n) => (n == null ? "—" : `${(+n).toFixed(2).replace(/\.?0+$/, "") || "0"} g`),
|
|
||||||
money: (n) => (n == null ? "—" : `$${(+n).toFixed(2)}`),
|
|
||||||
moneyShort: (n) => (n == null ? "—" : n >= 100 ? `$${Math.round(n)}` : `$${(+n).toFixed(2)}`),
|
|
||||||
pct: (n) => (n == null ? "—" : `${(+n).toFixed(1)}%`),
|
|
||||||
date: (s) => {
|
|
||||||
if (!s) return "—";
|
|
||||||
const d = new Date(s);
|
|
||||||
return d.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
|
|
||||||
},
|
|
||||||
dateShort: (s) => {
|
|
||||||
if (!s) return "—";
|
|
||||||
const d = new Date(s);
|
|
||||||
return d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
|
||||||
},
|
|
||||||
daysAgo: (s) => {
|
|
||||||
if (!s) return "—";
|
|
||||||
const ms = Date.now() - new Date(s).getTime();
|
|
||||||
const d = Math.floor(ms / 86400000);
|
|
||||||
if (d === 0) return "today";
|
|
||||||
if (d === 1) return "yesterday";
|
|
||||||
if (d < 30) return `${d}d ago`;
|
|
||||||
if (d < 365) return `${Math.floor(d/30)}mo ago`;
|
|
||||||
return `${Math.floor(d/365)}y ago`;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const TYPE_GLYPHS = {
|
|
||||||
"Flower": "✿",
|
|
||||||
"Concentrate": "◆",
|
|
||||||
"Edible": "◐",
|
|
||||||
"Vaporizer": "▢",
|
|
||||||
"Pre-roll": "│",
|
|
||||||
"Tincture": "◯"
|
|
||||||
};
|
|
||||||
|
|
||||||
// Compute aggregate stats — derived from purchases + consumed/gone records + audits
|
|
||||||
function computeStats(data) {
|
|
||||||
const today = new Date(data.today || "2026-04-25");
|
|
||||||
const todayStr = today.toISOString().slice(0, 10);
|
|
||||||
const products = data.products;
|
|
||||||
const H = window.DATA_HELPERS;
|
|
||||||
const dayKey = (d) => d.toISOString().slice(0, 10);
|
|
||||||
|
|
||||||
const active = products.filter(p => p.status === "active");
|
|
||||||
const consumed = products.filter(p => p.status === "consumed" && p.consumedDate);
|
|
||||||
const gone = products.filter(p => p.status === "gone");
|
|
||||||
|
|
||||||
// Window helper for purchases
|
|
||||||
const purchasesIn = (days) => {
|
|
||||||
const cutoff = new Date(today); cutoff.setDate(cutoff.getDate() - days);
|
|
||||||
return products.filter(p => new Date(p.purchaseDate) >= cutoff);
|
|
||||||
};
|
|
||||||
const last7p = purchasesIn(7);
|
|
||||||
const last30p = purchasesIn(30);
|
|
||||||
const last90p = purchasesIn(90);
|
|
||||||
|
|
||||||
// Bulk-grams equivalent: bulk uses weight (or ml for tincture, ignored from grams);
|
|
||||||
// discrete uses unitWeight × units consumed
|
|
||||||
const bulkGrams = (p) => {
|
|
||||||
if (p.type === "Tincture" || p.type === "Edible") return 0; // not "weed grams"
|
|
||||||
if (p.kind === "bulk") return p.weight;
|
|
||||||
return (p.countOriginal || 0) * (p.unitWeight || 0);
|
|
||||||
};
|
|
||||||
const bulkGramsConsumed = (p) => {
|
|
||||||
// For consumed products: full weight equivalent
|
|
||||||
if (p.type === "Tincture" || p.type === "Edible") return 0;
|
|
||||||
if (p.kind === "bulk") return p.weight;
|
|
||||||
return (p.countOriginal || 0) * (p.unitWeight || 0);
|
|
||||||
};
|
|
||||||
const bulkGramsUsedSoFar = (p) => {
|
|
||||||
// For active products: estimated grams used to date
|
|
||||||
if (p.type === "Tincture" || p.type === "Edible") return 0;
|
|
||||||
if (p.kind === "bulk") {
|
|
||||||
const est = H.estimatedRemaining(p, todayStr);
|
|
||||||
return Math.max(0, p.weight - est);
|
|
||||||
}
|
|
||||||
// discrete: (original - current) × unitWeight
|
|
||||||
const cur = p.countLastAudit != null ? p.countLastAudit : p.countOriginal;
|
|
||||||
return Math.max(0, (p.countOriginal - cur)) * (p.unitWeight || 0);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Daily attribution: spread used grams over (purchase → consumed/today) for consumed/active.
|
|
||||||
// GONE items contribute $0 NOT grams (they were lost, not used).
|
|
||||||
const dailyGramsAttribution = {};
|
|
||||||
consumed.forEach(p => {
|
|
||||||
const g = bulkGramsConsumed(p);
|
|
||||||
if (g <= 0) return;
|
|
||||||
const start = new Date(p.purchaseDate);
|
|
||||||
const end = new Date(p.consumedDate);
|
|
||||||
const days = Math.max(1, Math.round((end - start) / 86400000));
|
|
||||||
const perDay = g / days;
|
|
||||||
for (let i = 0; i < days; i++) {
|
|
||||||
const d = new Date(start); d.setDate(d.getDate() + i);
|
|
||||||
dailyGramsAttribution[dayKey(d)] = (dailyGramsAttribution[dayKey(d)] || 0) + perDay;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
active.forEach(p => {
|
|
||||||
const used = bulkGramsUsedSoFar(p);
|
|
||||||
if (used <= 0) return;
|
|
||||||
const start = new Date(p.purchaseDate);
|
|
||||||
const days = Math.max(1, Math.round((today - start) / 86400000));
|
|
||||||
const perDay = used / days;
|
|
||||||
for (let i = 0; i < days; i++) {
|
|
||||||
const d = new Date(start); d.setDate(d.getDate() + i);
|
|
||||||
dailyGramsAttribution[dayKey(d)] = (dailyGramsAttribution[dayKey(d)] || 0) + perDay;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const seriesFor = (days) => {
|
|
||||||
const out = [];
|
|
||||||
for (let i = days - 1; i >= 0; i--) {
|
|
||||||
const d = new Date(today); d.setDate(d.getDate() - i);
|
|
||||||
const k = dayKey(d);
|
|
||||||
out.push({ date: k, grams: dailyGramsAttribution[k] || 0 });
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
};
|
|
||||||
const series7 = seriesFor(7);
|
|
||||||
const series30 = seriesFor(30);
|
|
||||||
const series90 = seriesFor(90);
|
|
||||||
|
|
||||||
const sumG = (xs) => xs.reduce((s, x) => s + x.grams, 0);
|
|
||||||
const dailyAvg = sumG(series30) / 30;
|
|
||||||
const weeklyAvg = sumG(series30) / (30/7);
|
|
||||||
const monthlyAvg = sumG(series90) / 3;
|
|
||||||
|
|
||||||
// Spend
|
|
||||||
const totalSpend = products.reduce((s, p) => s + p.price, 0);
|
|
||||||
const goneSpend = gone.reduce((s, p) => s + p.price, 0);
|
|
||||||
const totalGrams = products.reduce((s, p) => s + bulkGrams(p), 0);
|
|
||||||
const avgPerGram = totalGrams ? totalSpend / totalGrams : 0;
|
|
||||||
const spend30 = last30p.reduce((s, p) => s + p.price, 0);
|
|
||||||
const spend7 = last7p.reduce((s, p) => s + p.price, 0);
|
|
||||||
const spend90 = last90p.reduce((s, p) => s + p.price, 0);
|
|
||||||
|
|
||||||
// Inventory value (active, prorated by est. remaining %)
|
|
||||||
const inventoryValue = active.reduce((s, p) => {
|
|
||||||
return s + p.price * H.pctRemaining(p, todayStr);
|
|
||||||
}, 0);
|
|
||||||
|
|
||||||
// THC mg using avg THC of products
|
|
||||||
const avgThc = products.length ? products.reduce((s,p)=>s+p.thc,0) / products.length : 20;
|
|
||||||
const thcLast7 = Math.round(sumG(series7) * avgThc * 10);
|
|
||||||
const thcLast30 = Math.round(sumG(series30) * avgThc * 10);
|
|
||||||
|
|
||||||
// Avg lifespan of consumed
|
|
||||||
const lifespans = consumed.map(p => Math.max(1, Math.round((new Date(p.consumedDate) - new Date(p.purchaseDate))/86400000)));
|
|
||||||
const avgLifespan = lifespans.length ? lifespans.reduce((a,b)=>a+b,0) / lifespans.length : 0;
|
|
||||||
|
|
||||||
// Favorite shop / brand — keyed by id, look up name
|
|
||||||
const shopCount = {};
|
|
||||||
const brandCount = {};
|
|
||||||
products.forEach(p => {
|
|
||||||
if (p.shopId) shopCount[p.shopId] = (shopCount[p.shopId] || 0) + 1;
|
|
||||||
if (p.brandId) brandCount[p.brandId] = (brandCount[p.brandId] || 0) + 1;
|
|
||||||
});
|
|
||||||
const topShopEntry = Object.entries(shopCount).sort((a,b)=>b[1]-a[1])[0];
|
|
||||||
const topBrandEntry = Object.entries(brandCount).sort((a,b)=>b[1]-a[1])[0];
|
|
||||||
const favShop = topShopEntry ? [H.shopName(data, topShopEntry[0]), topShopEntry[1]] : ["—", 0];
|
|
||||||
const favBrand = topBrandEntry ? [H.brandName(data, topBrandEntry[0]), topBrandEntry[1]] : ["—", 0];
|
|
||||||
|
|
||||||
// Type breakdown by est. grams on hand (active only)
|
|
||||||
const typeBreakdown = {};
|
|
||||||
active.forEach(p => {
|
|
||||||
let g;
|
|
||||||
if (p.type === "Tincture") g = H.estimatedRemaining(p, todayStr) * 0.5; // ml → display weight rough
|
|
||||||
else if (p.type === "Edible") g = (p.countLastAudit != null ? p.countLastAudit : p.countOriginal) * 0.3; // each gummy ~0.3g for chart only
|
|
||||||
else if (p.kind === "bulk") g = H.estimatedRemaining(p, todayStr);
|
|
||||||
else g = (p.countLastAudit != null ? p.countLastAudit : p.countOriginal) * (p.unitWeight || 0);
|
|
||||||
if (g > 0) typeBreakdown[p.type] = (typeBreakdown[p.type] || 0) + g;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Flower-equivalent supply
|
|
||||||
const flowerEquivalent = active
|
|
||||||
.filter(p => p.type === "Flower" || p.type === "Pre-roll")
|
|
||||||
.reduce((s, p) => {
|
|
||||||
if (p.kind === "bulk") return s + H.estimatedRemaining(p, todayStr);
|
|
||||||
return s + (p.countLastAudit != null ? p.countLastAudit : p.countOriginal) * (p.unitWeight || 0);
|
|
||||||
}, 0);
|
|
||||||
const daysOfSupply = dailyAvg > 0 ? flowerEquivalent / dailyAvg : 0;
|
|
||||||
|
|
||||||
// Avg days between buys
|
|
||||||
const sortedDates = [...products].sort((a,b)=>new Date(a.purchaseDate)-new Date(b.purchaseDate)).map(p => new Date(p.purchaseDate));
|
|
||||||
const gaps = [];
|
|
||||||
for (let i = 1; i < sortedDates.length; i++) {
|
|
||||||
gaps.push((sortedDates[i] - sortedDates[i-1]) / 86400000);
|
|
||||||
}
|
|
||||||
const avgGap = gaps.length ? gaps.reduce((a,b)=>a+b,0)/gaps.length : 0;
|
|
||||||
|
|
||||||
// ─── Audits & low stock ───────────────────────────────────────
|
|
||||||
const overdueAudits = active.filter(p => H.auditOverdue(data, p, todayStr));
|
|
||||||
|
|
||||||
// Low stock:
|
|
||||||
// - Bulk: pctRemaining < 0.25
|
|
||||||
// - Discrete: GROUPED BY (brand + type) — total count ≤ 2
|
|
||||||
const lowStockBulk = active.filter(p => p.kind === "bulk" && H.pctRemaining(p, todayStr) < 0.25);
|
|
||||||
|
|
||||||
const discreteBrandGroups = {};
|
|
||||||
active.filter(p => p.kind === "discrete").forEach(p => {
|
|
||||||
const k = `${p.brandId}|${p.type}|${p.name}`;
|
|
||||||
if (!discreteBrandGroups[k]) {
|
|
||||||
discreteBrandGroups[k] = {
|
|
||||||
key: k, name: p.name, type: p.type, brandId: p.brandId,
|
|
||||||
items: [], totalCount: 0
|
|
||||||
};
|
|
||||||
}
|
|
||||||
discreteBrandGroups[k].items.push(p);
|
|
||||||
discreteBrandGroups[k].totalCount += (p.countLastAudit != null ? p.countLastAudit : p.countOriginal);
|
|
||||||
});
|
|
||||||
const lowStockDiscreteGroups = Object.values(discreteBrandGroups).filter(g => g.totalCount <= 2);
|
|
||||||
|
|
||||||
return {
|
|
||||||
dailyAvg, weeklyAvg, monthlyAvg,
|
|
||||||
totalSpend, avgPerGram, spend7, spend30, spend90, goneSpend,
|
|
||||||
inventoryValue,
|
|
||||||
thcLast7, thcLast30,
|
|
||||||
avgLifespan,
|
|
||||||
favShop, favBrand,
|
|
||||||
typeBreakdown,
|
|
||||||
daysOfSupply,
|
|
||||||
avgGap,
|
|
||||||
series7, series30, series90,
|
|
||||||
activeCount: active.length,
|
|
||||||
consumedCount: consumed.length,
|
|
||||||
goneCount: gone.length,
|
|
||||||
archivedCount: consumed.length + gone.length,
|
|
||||||
overdueAudits,
|
|
||||||
lowStockBulk,
|
|
||||||
lowStockDiscreteGroups
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Subtle inline icons (1px stroke)
|
|
||||||
const Icon = ({name, size = 18, color = "currentColor"}) => {
|
|
||||||
const paths = {
|
|
||||||
home: "M3 11l9-8 9 8M5 9v12h5v-7h4v7h5V9",
|
|
||||||
box: "M3 7l9-4 9 4v10l-9 4-9-4V7zM3 7l9 4 9-4M12 11v10",
|
|
||||||
chart: "M3 21V3M3 21h18M7 17v-7M11 17v-4M15 17v-9M19 17v-2",
|
|
||||||
plus: "M12 5v14M5 12h14",
|
|
||||||
check: "M5 13l4 4L19 7",
|
|
||||||
settings: "M12 3v3M12 18v3M5 5l2 2M17 17l2 2M3 12h3M18 12h3M5 19l2-2M17 7l2-2M12 8a4 4 0 100 8 4 4 0 000-8z",
|
|
||||||
search: "M11 19a8 8 0 100-16 8 8 0 000 16zM21 21l-4-4",
|
|
||||||
filter: "M3 5h18M6 12h12M10 19h4",
|
|
||||||
bin: "M4 7h16M9 7V4h6v3M6 7v13h12V7",
|
|
||||||
leaf: "M5 19c0-7 5-14 14-14 0 9-5 14-14 14zM5 19l7-7",
|
|
||||||
flame: "M12 3c1 4 4 5 4 9a4 4 0 11-8 0c0-2 2-3 2-6 0-1 1-2 2-3z",
|
|
||||||
droplet: "M12 3l5 7a5 5 0 11-10 0l5-7z",
|
|
||||||
arrow: "M5 12h14M13 5l7 7-7 7",
|
|
||||||
arrowDown: "M12 5v14M5 13l7 7 7-7",
|
|
||||||
close: "M6 6l12 12M18 6L6 18",
|
|
||||||
edit: "M4 20h4l10-10-4-4L4 16v4zM14 6l4 4",
|
|
||||||
star: "M12 3l3 6 7 1-5 5 1 7-6-3-6 3 1-7-5-5 7-1z",
|
|
||||||
calendar: "M5 5h14v15H5zM3 10h18M9 3v4M15 3v4",
|
|
||||||
tag: "M3 12V3h9l9 9-9 9-9-9zM7 7h.01"
|
|
||||||
};
|
|
||||||
return (
|
|
||||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke={color} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" style={{flexShrink: 0}}>
|
|
||||||
<path d={paths[name] || ""} />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Sparkline
|
|
||||||
const Sparkline = ({values, width = 120, height = 32, color = "var(--ink)", fill = false}) => {
|
|
||||||
if (!values || values.length === 0) return null;
|
|
||||||
const max = Math.max(...values, 0.001);
|
|
||||||
const min = Math.min(...values, 0);
|
|
||||||
const span = max - min || 1;
|
|
||||||
const step = width / (values.length - 1 || 1);
|
|
||||||
const pts = values.map((v, i) => [i * step, height - ((v - min) / span) * (height - 4) - 2]);
|
|
||||||
const path = pts.map((p, i) => (i === 0 ? "M" : "L") + p[0].toFixed(1) + " " + p[1].toFixed(1)).join(" ");
|
|
||||||
const fillPath = fill ? path + ` L ${width} ${height} L 0 ${height} Z` : null;
|
|
||||||
return (
|
|
||||||
<svg width={width} height={height} style={{display: "block", overflow: "visible"}}>
|
|
||||||
{fillPath && <path d={fillPath} fill={color} opacity="0.12" />}
|
|
||||||
<path d={path} stroke={color} strokeWidth="1.5" fill="none" strokeLinecap="round" strokeLinejoin="round" />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Bar chart
|
|
||||||
const BarChart = ({data, height = 160, color = "var(--sage)", labels = false}) => {
|
|
||||||
if (!data || !data.length) return null;
|
|
||||||
const max = Math.max(...data.map(d => d.value), 0.001);
|
|
||||||
return (
|
|
||||||
<div style={{display: "flex", alignItems: "flex-end", gap: 2, height, width: "100%"}}>
|
|
||||||
{data.map((d, i) => (
|
|
||||||
<div key={i} style={{flex: 1, display: "flex", flexDirection: "column", alignItems: "center", gap: 4, minWidth: 0}}>
|
|
||||||
<div style={{
|
|
||||||
width: "100%",
|
|
||||||
height: `${(d.value / max) * 100}%`,
|
|
||||||
background: d.value > 0 ? color : "var(--line)",
|
|
||||||
borderRadius: "2px 2px 0 0",
|
|
||||||
minHeight: d.value > 0 ? 2 : 1,
|
|
||||||
opacity: d.muted ? 0.4 : 1
|
|
||||||
}} />
|
|
||||||
{labels && <div style={{fontSize: 9, color: "var(--ink-3)", fontFamily: "var(--mono)"}}>{d.label}</div>}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Donut chart
|
|
||||||
const Donut = ({segments, size = 160, thickness = 22}) => {
|
|
||||||
const total = segments.reduce((s, x) => s + x.value, 0);
|
|
||||||
const r = size / 2 - thickness / 2;
|
|
||||||
const c = 2 * Math.PI * r;
|
|
||||||
let offset = 0;
|
|
||||||
return (
|
|
||||||
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} style={{transform: "rotate(-90deg)"}}>
|
|
||||||
<circle cx={size/2} cy={size/2} r={r} stroke="var(--line)" strokeWidth={thickness} fill="none" opacity="0.3" />
|
|
||||||
{segments.map((s, i) => {
|
|
||||||
const len = (s.value / total) * c;
|
|
||||||
const dash = `${len} ${c - len}`;
|
|
||||||
const el = (
|
|
||||||
<circle key={i} cx={size/2} cy={size/2} r={r} stroke={s.color} strokeWidth={thickness} fill="none"
|
|
||||||
strokeDasharray={dash} strokeDashoffset={-offset} strokeLinecap="butt" />
|
|
||||||
);
|
|
||||||
offset += len;
|
|
||||||
return el;
|
|
||||||
})}
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Reusable card
|
|
||||||
const Card = ({children, style, padded = true, ...rest}) => {
|
|
||||||
const { padded: _ignored, ...domProps } = rest;
|
|
||||||
return (
|
|
||||||
<div {...domProps} style={{
|
|
||||||
background: "var(--surface)",
|
|
||||||
border: "1px solid var(--line)",
|
|
||||||
borderRadius: "var(--r-md)",
|
|
||||||
padding: padded ? 20 : 0,
|
|
||||||
...style
|
|
||||||
}}>{children}</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Stat card
|
|
||||||
const Stat = ({label, value, unit, sub, spark, accent, big}) => (
|
|
||||||
<div style={{
|
|
||||||
background: "var(--surface)",
|
|
||||||
border: "1px solid var(--line)",
|
|
||||||
borderRadius: "var(--r-md)",
|
|
||||||
padding: 18,
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
gap: 8,
|
|
||||||
minWidth: 0
|
|
||||||
}}>
|
|
||||||
<div className="smallcaps" style={{color: "var(--ink-3)"}}>{label}</div>
|
|
||||||
<div style={{display: "flex", alignItems: "baseline", gap: 6}}>
|
|
||||||
<div className="serif" style={{fontSize: big ? 44 : 32, lineHeight: 1, color: accent || "var(--ink)", fontWeight: 500, letterSpacing: "-0.01em"}}>{value}</div>
|
|
||||||
{unit && <div style={{fontSize: 13, color: "var(--ink-3)", fontFamily: "var(--mono)"}}>{unit}</div>}
|
|
||||||
</div>
|
|
||||||
{sub && <div style={{fontSize: 12, color: "var(--ink-3)"}}>{sub}</div>}
|
|
||||||
{spark && <div style={{marginTop: 4}}>{spark}</div>}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
// Pill / badge
|
|
||||||
const Pill = ({children, tone = "neutral", style}) => {
|
|
||||||
const tones = {
|
|
||||||
neutral: { bg: "var(--bg-3)", color: "var(--ink-2)" },
|
|
||||||
sage: { bg: "var(--sage-soft)", color: "var(--sage)" },
|
|
||||||
terra: { bg: "var(--terracotta-soft)", color: "var(--terracotta)" },
|
|
||||||
amber: { bg: "var(--amber-soft)", color: "oklch(48% 0.10 75)" },
|
|
||||||
outline: { bg: "transparent", color: "var(--ink-2)", border: "1px solid var(--line-strong)" }
|
|
||||||
};
|
|
||||||
const t = tones[tone] || tones.neutral;
|
|
||||||
return (
|
|
||||||
<span style={{
|
|
||||||
display: "inline-flex",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: 4,
|
|
||||||
padding: "3px 8px",
|
|
||||||
borderRadius: 999,
|
|
||||||
background: t.bg,
|
|
||||||
color: t.color,
|
|
||||||
fontSize: 11,
|
|
||||||
fontWeight: 500,
|
|
||||||
letterSpacing: "0.02em",
|
|
||||||
border: t.border || "1px solid transparent",
|
|
||||||
...style
|
|
||||||
}}>{children}</span>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Button — high contrast across themes
|
|
||||||
const Btn = ({children, variant = "ghost", icon, onClick, style, type, disabled}) => {
|
|
||||||
// Disabled: keep solid surface, dim the text only — never become low-contrast on bg
|
|
||||||
const variants = {
|
|
||||||
primary: disabled
|
|
||||||
? { background: "var(--bg-3)", color: "var(--ink-3)", border: "1px solid var(--line-strong)" }
|
|
||||||
: { background: "var(--ink)", color: "var(--bg)", border: "1px solid var(--ink)" },
|
|
||||||
secondary: { background: "var(--surface)", color: "var(--ink)", border: "1px solid var(--line-strong)" },
|
|
||||||
ghost: { background: "transparent", color: "var(--ink-2)", border: "1px solid transparent" },
|
|
||||||
danger: { background: "var(--terracotta)", color: "oklch(98% 0.01 40)", border: "1px solid var(--terracotta)" },
|
|
||||||
sage: { background: "var(--sage)", color: "oklch(98% 0.01 145)", border: "1px solid var(--sage)" }
|
|
||||||
};
|
|
||||||
const v = variants[variant];
|
|
||||||
return (
|
|
||||||
<button type={type} onClick={onClick} disabled={disabled} style={{
|
|
||||||
display: "inline-flex",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: 6,
|
|
||||||
padding: "8px 14px",
|
|
||||||
borderRadius: "var(--r-md)",
|
|
||||||
fontSize: 13,
|
|
||||||
fontWeight: 600,
|
|
||||||
transition: "all 120ms",
|
|
||||||
cursor: disabled ? "not-allowed" : "pointer",
|
|
||||||
...v,
|
|
||||||
...style
|
|
||||||
}}>
|
|
||||||
{icon && <Icon name={icon} size={14} />}
|
|
||||||
{children}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Field
|
|
||||||
const Field = ({label, children, hint, span = 1}) => (
|
|
||||||
<label style={{display: "flex", flexDirection: "column", gap: 6, gridColumn: `span ${span}`}}>
|
|
||||||
<span className="smallcaps" style={{color: "var(--ink-3)"}}>{label}</span>
|
|
||||||
{children}
|
|
||||||
{hint && <span style={{fontSize: 11, color: "var(--ink-3)"}}>{hint}</span>}
|
|
||||||
</label>
|
|
||||||
);
|
|
||||||
|
|
||||||
const inputStyle = {
|
|
||||||
background: "var(--bg)",
|
|
||||||
border: "1px solid var(--line)",
|
|
||||||
borderRadius: "var(--r-md)",
|
|
||||||
padding: "10px 12px",
|
|
||||||
fontSize: 13,
|
|
||||||
color: "var(--ink)",
|
|
||||||
outline: "none",
|
|
||||||
fontFamily: "var(--sans)",
|
|
||||||
width: "100%"
|
|
||||||
};
|
|
||||||
|
|
||||||
const Input = (props) => <input style={inputStyle} {...props} />;
|
|
||||||
const Select = ({children, ...rest}) => <select style={{...inputStyle, appearance: "auto"}} {...rest}>{children}</select>;
|
|
||||||
const Textarea = (props) => <textarea style={{...inputStyle, minHeight: 80, resize: "vertical"}} {...props} />;
|
|
||||||
|
|
||||||
Object.assign(window, {
|
|
||||||
fmt, computeStats, TYPE_GLYPHS,
|
|
||||||
Icon, Sparkline, BarChart, Donut,
|
|
||||||
Card, Stat, Pill, Btn, Field, Input, Select, Textarea, inputStyle
|
|
||||||
});
|
|
||||||
@@ -1,578 +0,0 @@
|
|||||||
// Dashboard, Inventory, Product detail screens
|
|
||||||
|
|
||||||
const H = window.DATA_HELPERS;
|
|
||||||
const TODAY_STR = "2026-04-25";
|
|
||||||
|
|
||||||
// ─── Helpers shared across screens ─────────────────────────────────
|
|
||||||
const remainingDisplay = (p) => {
|
|
||||||
const cfg = window.SAMPLE_DATA.types.find(t => t.id === p.type);
|
|
||||||
if (p.kind === "discrete") {
|
|
||||||
const cur = p.countLastAudit != null ? p.countLastAudit : p.countOriginal;
|
|
||||||
return `${cur} / ${p.countOriginal} ${cfg?.unit || "ct"}`;
|
|
||||||
}
|
|
||||||
const est = H.estimatedRemaining(p, TODAY_STR);
|
|
||||||
return `${est.toFixed(2).replace(/\.?0+$/,"")} / ${p.weight} ${cfg?.unit || "g"}`;
|
|
||||||
};
|
|
||||||
const remainingShort = (p) => {
|
|
||||||
if (p.kind === "discrete") {
|
|
||||||
const cur = p.countLastAudit != null ? p.countLastAudit : p.countOriginal;
|
|
||||||
return `${cur} ct`;
|
|
||||||
}
|
|
||||||
const cfg = window.SAMPLE_DATA.types.find(t => t.id === p.type);
|
|
||||||
const est = H.estimatedRemaining(p, TODAY_STR);
|
|
||||||
return `${est.toFixed(2).replace(/\.?0+$/,"") || "0"} ${cfg?.unit || "g"}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const Dashboard = ({data, stats, onNav, onSelectProduct, onAudit, onMarkGone}) => {
|
|
||||||
const series30 = stats.series30.map(d => ({ date: d.date, value: d.grams, label: "" }));
|
|
||||||
|
|
||||||
// Type breakdown
|
|
||||||
const typeColors = {
|
|
||||||
"Flower": "var(--sage)",
|
|
||||||
"Concentrate": "var(--terracotta)",
|
|
||||||
"Edible": "var(--amber)",
|
|
||||||
"Vaporizer": "var(--plum)",
|
|
||||||
"Pre-roll": "oklch(50% 0.06 200)",
|
|
||||||
"Tincture": "oklch(55% 0.06 270)"
|
|
||||||
};
|
|
||||||
const segments = Object.entries(stats.typeBreakdown).map(([k, v]) => ({
|
|
||||||
label: k, value: v, color: typeColors[k] || "var(--ink-3)"
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Sparklines
|
|
||||||
const last7Series = stats.series7.map(l => l.grams);
|
|
||||||
const last30Series = series30.map(d => d.value);
|
|
||||||
|
|
||||||
const overdue = stats.overdueAudits;
|
|
||||||
const lowBulk = stats.lowStockBulk;
|
|
||||||
const lowDiscrete = stats.lowStockDiscreteGroups;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{padding: "32px 40px 80px", maxWidth: 1400, margin: "0 auto"}}>
|
|
||||||
{/* Header */}
|
|
||||||
<div style={{display: "flex", alignItems: "center", justifyContent: "space-between", gap: 24, marginBottom: 16, flexWrap: "wrap"}}>
|
|
||||||
<div style={{minWidth: 0}}>
|
|
||||||
<div className="smallcaps" style={{color: "var(--ink-3)"}}>Saturday · April 25, 2026</div>
|
|
||||||
<h1 className="serif" style={{fontSize: 36, margin: "6px 0 0", fontWeight: 500, letterSpacing: "-0.02em", lineHeight: 1.1, whiteSpace: "nowrap"}}>Good evening.</h1>
|
|
||||||
</div>
|
|
||||||
<div style={{display: "flex", gap: 8, flexShrink: 0}}>
|
|
||||||
<Btn variant="secondary" icon="plus" onClick={() => onNav("add")}>New product</Btn>
|
|
||||||
<Btn variant="secondary" icon="check" onClick={() => onNav("audit")}>Audit</Btn>
|
|
||||||
<Btn variant="primary" icon="check" onClick={() => onNav("consume")}>Mark finished</Btn>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{fontSize: 14, color: "var(--ink-2)", marginBottom: 28, maxWidth: 700}}>
|
|
||||||
{stats.activeCount} active items across {data.bins.length} bins · {stats.consumedCount} consumed · {stats.goneCount} gone.
|
|
||||||
{overdue.length > 0 && <span style={{color: "var(--terracotta)"}}> · {overdue.length} audit{overdue.length === 1 ? "" : "s"} overdue.</span>}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Top stats row */}
|
|
||||||
<div style={{display: "grid", gridTemplateColumns: "repeat(4, 1fr)", gap: 14, marginBottom: 14}}>
|
|
||||||
<Stat
|
|
||||||
label="Daily average"
|
|
||||||
value={stats.dailyAvg.toFixed(2)}
|
|
||||||
unit="g / day"
|
|
||||||
sub={`${fmt.g(stats.weeklyAvg)} weekly · ${fmt.g(stats.monthlyAvg)} monthly`}
|
|
||||||
spark={<Sparkline values={last30Series} width={240} height={28} color="var(--sage)" fill />}
|
|
||||||
/>
|
|
||||||
<Stat
|
|
||||||
label="Avg cost per gram"
|
|
||||||
value={fmt.money(stats.avgPerGram)}
|
|
||||||
sub={`Across ${data.products.length} purchases`}
|
|
||||||
/>
|
|
||||||
<Stat
|
|
||||||
label="30-day spend"
|
|
||||||
value={fmt.moneyShort(stats.spend30)}
|
|
||||||
sub={`Inventory value: ${fmt.money(stats.inventoryValue)}${stats.goneSpend > 0 ? ` · ${fmt.money(stats.goneSpend)} lost` : ""}`}
|
|
||||||
/>
|
|
||||||
<Stat
|
|
||||||
label="THC last 7 days"
|
|
||||||
value={stats.thcLast7.toLocaleString()}
|
|
||||||
unit="mg"
|
|
||||||
sub={`Last 30: ${(stats.thcLast30/1000).toFixed(1)} g THC`}
|
|
||||||
spark={<Sparkline values={last7Series} width={240} height={28} color="var(--terracotta)" />}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Audit alert strip */}
|
|
||||||
{overdue.length > 0 && (
|
|
||||||
<Card style={{marginBottom: 14, borderColor: "var(--amber)", background: "var(--amber-soft)"}}>
|
|
||||||
<div style={{display: "flex", alignItems: "center", gap: 14, flexWrap: "wrap"}}>
|
|
||||||
<div style={{flex: 1, minWidth: 240}}>
|
|
||||||
<div className="smallcaps" style={{color: "oklch(48% 0.10 75)"}}>Audit overdue</div>
|
|
||||||
<div className="serif" style={{fontSize: 20, marginTop: 4, color: "var(--ink)"}}>
|
|
||||||
{overdue.length} item{overdue.length === 1 ? "" : "s"} haven't been checked in a while
|
|
||||||
</div>
|
|
||||||
<div style={{fontSize: 12, color: "var(--ink-2)", marginTop: 4}}>
|
|
||||||
{overdue.slice(0, 3).map(p => p.name).join(" · ")}
|
|
||||||
{overdue.length > 3 && ` · +${overdue.length - 3} more`}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Btn variant="secondary" icon="check" onClick={() => onAudit && onAudit(overdue[0])}>Run audit</Btn>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Main grid */}
|
|
||||||
<div style={{display: "grid", gridTemplateColumns: "2fr 1fr", gap: 14, marginBottom: 14}}>
|
|
||||||
{/* Consumption chart */}
|
|
||||||
<Card>
|
|
||||||
<div style={{display: "flex", justifyContent: "space-between", alignItems: "baseline", marginBottom: 18}}>
|
|
||||||
<div>
|
|
||||||
<div className="smallcaps" style={{color: "var(--ink-3)"}}>Consumption</div>
|
|
||||||
<div className="serif" style={{fontSize: 22, marginTop: 4}}>Last 30 days</div>
|
|
||||||
</div>
|
|
||||||
<div style={{display: "flex", gap: 16, fontSize: 12, color: "var(--ink-3)"}}>
|
|
||||||
<div><span style={{color: "var(--ink)"}} className="serif" >{fmt.g(stats.series30.reduce((s,l)=>s+l.grams,0))}</span> est. total</div>
|
|
||||||
<div><span style={{color: "var(--ink)"}} className="serif">{stats.avgGap.toFixed(0)}</span> day avg between buys</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<BarChart data={series30.map(d => ({...d, label: ""}))} height={140} color="var(--sage)" />
|
|
||||||
<div style={{display: "flex", justifyContent: "space-between", marginTop: 8, fontSize: 10, color: "var(--ink-3)", fontFamily: "var(--mono)"}}>
|
|
||||||
<span>30 days ago</span>
|
|
||||||
<span>15 days ago</span>
|
|
||||||
<span>today</span>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Type breakdown */}
|
|
||||||
<Card>
|
|
||||||
<div className="smallcaps" style={{color: "var(--ink-3)"}}>By type · grams on hand</div>
|
|
||||||
<div className="serif" style={{fontSize: 22, marginTop: 4, marginBottom: 16}}>Inventory</div>
|
|
||||||
<div style={{display: "flex", alignItems: "center", gap: 20}}>
|
|
||||||
<Donut segments={segments} size={140} thickness={20} />
|
|
||||||
<div style={{flex: 1, display: "flex", flexDirection: "column", gap: 8}}>
|
|
||||||
{segments.map(s => (
|
|
||||||
<div key={s.label} style={{display: "flex", alignItems: "center", gap: 8, fontSize: 12}}>
|
|
||||||
<div style={{width: 8, height: 8, borderRadius: 2, background: s.color}} />
|
|
||||||
<div style={{flex: 1, color: "var(--ink-2)"}}>{s.label}</div>
|
|
||||||
<div className="mono" style={{color: "var(--ink)"}}>{s.value.toFixed(1)}g</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: 14, marginBottom: 14}}>
|
|
||||||
<Stat
|
|
||||||
label="Days of supply"
|
|
||||||
value={Math.round(stats.daysOfSupply)}
|
|
||||||
unit="days"
|
|
||||||
sub="Flower & pre-rolls at current pace"
|
|
||||||
/>
|
|
||||||
<Stat
|
|
||||||
label="Avg lifespan"
|
|
||||||
value={Math.round(stats.avgLifespan)}
|
|
||||||
unit="days"
|
|
||||||
sub="From purchase to finished"
|
|
||||||
/>
|
|
||||||
<Stat
|
|
||||||
label="Days between buys"
|
|
||||||
value={stats.avgGap.toFixed(1)}
|
|
||||||
unit="days"
|
|
||||||
sub="Average across all purchases"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Bottom row: shop + brand + low stock */}
|
|
||||||
<div style={{display: "grid", gridTemplateColumns: "1fr 1fr 1.4fr", gap: 14}}>
|
|
||||||
<Card>
|
|
||||||
<div className="smallcaps" style={{color: "var(--ink-3)"}}>Favorite shop</div>
|
|
||||||
<div className="serif" style={{fontSize: 28, marginTop: 6, fontWeight: 500}}>{stats.favShop[0]}</div>
|
|
||||||
<div style={{fontSize: 12, color: "var(--ink-3)", marginTop: 4}}>{stats.favShop[1]} of {data.products.length} purchases</div>
|
|
||||||
</Card>
|
|
||||||
<Card>
|
|
||||||
<div className="smallcaps" style={{color: "var(--ink-3)"}}>Favorite brand</div>
|
|
||||||
<div className="serif" style={{fontSize: 28, marginTop: 6, fontWeight: 500}}>{stats.favBrand[0]}</div>
|
|
||||||
<div style={{fontSize: 12, color: "var(--ink-3)", marginTop: 4}}>{stats.favBrand[1]} purchases</div>
|
|
||||||
</Card>
|
|
||||||
<Card padded={false}>
|
|
||||||
<div style={{padding: "20px 20px 12px", display: "flex", justifyContent: "space-between", alignItems: "baseline"}}>
|
|
||||||
<div className="smallcaps" style={{color: "var(--ink-3)"}}>Low stock · running out</div>
|
|
||||||
<div style={{fontSize: 11, color: "var(--ink-3)"}}>{lowBulk.length + lowDiscrete.length} item{(lowBulk.length + lowDiscrete.length) === 1 ? "" : "s"}</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
{(lowBulk.length + lowDiscrete.length) === 0 && <div style={{padding: "0 20px 20px", fontSize: 13, color: "var(--ink-3)"}}>Nothing running low.</div>}
|
|
||||||
{lowBulk.slice(0, 3).map(p => {
|
|
||||||
const pct = H.pctRemaining(p, TODAY_STR);
|
|
||||||
return (
|
|
||||||
<div key={p.id} onClick={() => onSelectProduct(p)} style={{padding: "10px 20px", borderTop: "1px solid var(--line)", display: "flex", alignItems: "center", gap: 12, cursor: "pointer"}}>
|
|
||||||
<div style={{flex: 1, minWidth: 0}}>
|
|
||||||
<div style={{fontSize: 13, fontWeight: 500, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis"}}>{p.name}</div>
|
|
||||||
<div style={{fontSize: 11, color: "var(--ink-3)"}}>{H.brandName(data, p.brandId)} · {p.type}</div>
|
|
||||||
</div>
|
|
||||||
<div style={{width: 60, height: 4, background: "var(--bg-3)", borderRadius: 2, overflow: "hidden"}}>
|
|
||||||
<div style={{width: `${pct * 100}%`, height: "100%", background: pct < 0.15 ? "var(--terracotta)" : "var(--amber)"}} />
|
|
||||||
</div>
|
|
||||||
<div className="mono" style={{fontSize: 11, color: "var(--ink-2)", width: 60, textAlign: "right"}}>{remainingShort(p)}</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
{lowDiscrete.slice(0, 2).map(g => (
|
|
||||||
<div key={g.key} onClick={() => onSelectProduct(g.items[0])} style={{padding: "10px 20px", borderTop: "1px solid var(--line)", display: "flex", alignItems: "center", gap: 12, cursor: "pointer"}}>
|
|
||||||
<div style={{flex: 1, minWidth: 0}}>
|
|
||||||
<div style={{fontSize: 13, fontWeight: 500, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis"}}>{g.name}</div>
|
|
||||||
<div style={{fontSize: 11, color: "var(--ink-3)"}}>{H.brandName(data, g.brandId)} · {g.type}</div>
|
|
||||||
</div>
|
|
||||||
<Pill tone="amber" style={{fontSize: 10}}>{g.totalCount} left</Pill>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// ─── INVENTORY ─────────────────────────────────────────────────────
|
|
||||||
const Inventory = ({data, onSelectProduct, onNav}) => {
|
|
||||||
const [filter, setFilter] = React.useState("active"); // active | consumed | gone | all
|
|
||||||
const [typeFilter, setTypeFilter] = React.useState("all");
|
|
||||||
const [sortBy, setSortBy] = React.useState("recent");
|
|
||||||
const [search, setSearch] = React.useState("");
|
|
||||||
|
|
||||||
let products = data.products;
|
|
||||||
if (filter === "active") products = products.filter(p => p.status === "active");
|
|
||||||
else if (filter === "consumed") products = products.filter(p => p.status === "consumed");
|
|
||||||
else if (filter === "gone") products = products.filter(p => p.status === "gone");
|
|
||||||
if (typeFilter !== "all") products = products.filter(p => p.type === typeFilter);
|
|
||||||
if (search) {
|
|
||||||
const q = search.toLowerCase();
|
|
||||||
products = products.filter(p => {
|
|
||||||
const brand = H.brandName(data, p.brandId).toLowerCase();
|
|
||||||
const shop = H.shopName(data, p.shopId).toLowerCase();
|
|
||||||
return p.name.toLowerCase().includes(q) ||
|
|
||||||
brand.includes(q) ||
|
|
||||||
shop.includes(q) ||
|
|
||||||
p.sku.toLowerCase().includes(q);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
products = [...products].sort((a, b) => {
|
|
||||||
if (sortBy === "recent") return new Date(b.purchaseDate) - new Date(a.purchaseDate);
|
|
||||||
if (sortBy === "name") return a.name.localeCompare(b.name);
|
|
||||||
if (sortBy === "thc") return b.thc - a.thc;
|
|
||||||
if (sortBy === "remaining") return H.estimatedRemaining(b, TODAY_STR) - H.estimatedRemaining(a, TODAY_STR);
|
|
||||||
if (sortBy === "price") return b.price - a.price;
|
|
||||||
if (sortBy === "audit") return H.daysSinceCheck(b, TODAY_STR) - H.daysSinceCheck(a, TODAY_STR);
|
|
||||||
return 0;
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{padding: "32px 40px 80px", maxWidth: 1400, margin: "0 auto"}}>
|
|
||||||
<div style={{display: "flex", alignItems: "baseline", justifyContent: "space-between", marginBottom: 24}}>
|
|
||||||
<div>
|
|
||||||
<div className="smallcaps" style={{color: "var(--ink-3)"}}>{products.length} items</div>
|
|
||||||
<h1 className="serif" style={{fontSize: 44, margin: "6px 0 0", fontWeight: 500, letterSpacing: "-0.02em"}}>Inventory</h1>
|
|
||||||
</div>
|
|
||||||
<div style={{display: "flex", gap: 8}}>
|
|
||||||
<Btn variant="secondary" icon="check" onClick={() => onNav("audit")}>Audit</Btn>
|
|
||||||
<Btn variant="primary" icon="plus" onClick={() => onNav("add")}>New product</Btn>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Toolbar */}
|
|
||||||
<Card style={{marginBottom: 14, padding: 14}}>
|
|
||||||
<div style={{display: "flex", gap: 12, alignItems: "center", flexWrap: "wrap"}}>
|
|
||||||
{/* Tabs */}
|
|
||||||
<div style={{display: "inline-flex", background: "var(--bg-2)", border: "1px solid var(--line)", borderRadius: "var(--r-md)", padding: 3}}>
|
|
||||||
{[["active", "Active"], ["consumed", "Consumed"], ["gone", "Gone"], ["all", "All"]].map(([k, l]) => (
|
|
||||||
<button key={k} onClick={() => setFilter(k)} style={{
|
|
||||||
padding: "6px 14px",
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: 500,
|
|
||||||
borderRadius: 6,
|
|
||||||
border: "none",
|
|
||||||
background: filter === k ? "var(--surface)" : "transparent",
|
|
||||||
color: filter === k ? "var(--ink)" : "var(--ink-3)",
|
|
||||||
boxShadow: filter === k ? "var(--shadow-sm)" : "none",
|
|
||||||
cursor: "pointer"
|
|
||||||
}}>{l}</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Search */}
|
|
||||||
<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…"
|
|
||||||
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)"}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Select value={typeFilter} onChange={e => setTypeFilter(e.target.value)} style={{...inputStyle, width: "auto", padding: "8px 10px"}}>
|
|
||||||
<option value="all">All types</option>
|
|
||||||
{data.types.map(t => <option key={t.id} value={t.id}>{t.id}</option>)}
|
|
||||||
</Select>
|
|
||||||
|
|
||||||
<Select value={sortBy} onChange={e => setSortBy(e.target.value)} style={{...inputStyle, width: "auto", padding: "8px 10px"}}>
|
|
||||||
<option value="recent">Recent first</option>
|
|
||||||
<option value="name">Name (A–Z)</option>
|
|
||||||
<option value="thc">THC % (high)</option>
|
|
||||||
<option value="remaining">Remaining (high)</option>
|
|
||||||
<option value="price">Price (high)</option>
|
|
||||||
<option value="audit">Audit overdue first</option>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Table */}
|
|
||||||
<Card padded={false}>
|
|
||||||
<div style={{display: "grid", gridTemplateColumns: "32px 2fr 1fr 1fr 0.6fr 0.6fr 0.9fr 0.9fr 0.8fr", padding: "12px 20px", borderBottom: "1px solid var(--line)", background: "var(--bg-2)", fontSize: 11, color: "var(--ink-3)", textTransform: "uppercase", letterSpacing: "0.08em"}}>
|
|
||||||
<div></div>
|
|
||||||
<div>Product</div>
|
|
||||||
<div>Brand</div>
|
|
||||||
<div>Shop</div>
|
|
||||||
<div style={{textAlign: "right"}}>THC %</div>
|
|
||||||
<div style={{textAlign: "right"}}>Price</div>
|
|
||||||
<div style={{textAlign: "right"}}>Remaining</div>
|
|
||||||
<div>Last checked</div>
|
|
||||||
<div>Bin</div>
|
|
||||||
</div>
|
|
||||||
{products.length === 0 && (
|
|
||||||
<div style={{padding: 60, textAlign: "center", color: "var(--ink-3)"}}>No items match these filters.</div>
|
|
||||||
)}
|
|
||||||
{products.map(p => {
|
|
||||||
const bin = data.bins.find(b => b.id === p.binId);
|
|
||||||
const pctRemaining = H.pctRemaining(p, TODAY_STR);
|
|
||||||
const overdue = H.auditOverdue(data, p, TODAY_STR);
|
|
||||||
const sinceCheck = H.daysSinceCheck(p, TODAY_STR);
|
|
||||||
const last = H.lastAudit(p);
|
|
||||||
const isInactive = p.status !== "active";
|
|
||||||
return (
|
|
||||||
<div key={p.id} onClick={() => onSelectProduct(p)} className="inv-row" style={{
|
|
||||||
display: "grid",
|
|
||||||
gridTemplateColumns: "32px 2fr 1fr 1fr 0.6fr 0.6fr 0.9fr 0.9fr 0.8fr",
|
|
||||||
padding: "14px 20px",
|
|
||||||
borderBottom: "1px solid var(--line)",
|
|
||||||
alignItems: "center",
|
|
||||||
cursor: "pointer",
|
|
||||||
opacity: isInactive ? 0.55 : 1,
|
|
||||||
fontSize: 13
|
|
||||||
}}>
|
|
||||||
<div style={{fontFamily: "var(--serif)", fontSize: 18, color: "var(--ink-3)"}}>{TYPE_GLYPHS[p.type]}</div>
|
|
||||||
<div style={{minWidth: 0}}>
|
|
||||||
<div style={{fontWeight: 500, color: "var(--ink)", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis"}}>
|
|
||||||
{p.name}
|
|
||||||
{p.status === "consumed" && <Pill tone="terra" style={{marginLeft: 6, fontSize: 10}}>Consumed</Pill>}
|
|
||||||
{p.status === "gone" && <Pill tone="amber" style={{marginLeft: 6, fontSize: 10}}>Gone</Pill>}
|
|
||||||
{p.status === "active" && overdue && <Pill tone="amber" style={{marginLeft: 6, fontSize: 10}}>Audit due</Pill>}
|
|
||||||
</div>
|
|
||||||
<div style={{fontSize: 11, color: "var(--ink-3)", fontFamily: "var(--mono)"}}>{p.sku}{p.assetTag ? ` · ${p.assetTag}` : ""}</div>
|
|
||||||
</div>
|
|
||||||
<div style={{color: "var(--ink-2)"}}>{H.brandName(data, p.brandId)}</div>
|
|
||||||
<div style={{color: "var(--ink-3)", fontSize: 12}}>{H.shopName(data, p.shopId)}</div>
|
|
||||||
<div style={{textAlign: "right", fontFamily: "var(--mono)", color: "var(--ink-2)"}}>{p.thc.toFixed(1)}</div>
|
|
||||||
<div style={{textAlign: "right", fontFamily: "var(--mono)"}}>{fmt.money(p.price)}</div>
|
|
||||||
<div style={{textAlign: "right", display: "flex", flexDirection: "column", alignItems: "flex-end", gap: 4}}>
|
|
||||||
<div style={{fontFamily: "var(--mono)", fontSize: 12}}>{remainingShort(p)}</div>
|
|
||||||
{p.status === "active" && p.kind === "bulk" && (
|
|
||||||
<div style={{width: 50, height: 3, 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>
|
|
||||||
<div style={{fontSize: 11, color: overdue ? "var(--terracotta)" : "var(--ink-3)"}}>
|
|
||||||
{p.status !== "active"
|
|
||||||
? <span style={{fontStyle: "italic"}}>archived</span>
|
|
||||||
: last
|
|
||||||
? <span><span className="mono">{sinceCheck}d</span> ago · {last.mode}</span>
|
|
||||||
: <span style={{fontStyle: "italic"}}>never</span>}
|
|
||||||
</div>
|
|
||||||
<div style={{fontSize: 12, color: "var(--ink-3)"}}>{bin ? bin.name : <span style={{fontStyle: "italic"}}>—</span>}</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// ─── PRODUCT DETAIL ────────────────────────────────────────────────
|
|
||||||
const ProductDetail = ({product, data, onClose, onConsume, onMarkGone, onAudit, onEdit}) => {
|
|
||||||
const bin = data.bins.find(b => b.id === product.binId);
|
|
||||||
const cfg = data.types.find(t => t.id === product.type);
|
|
||||||
const pctRemaining = H.pctRemaining(product, TODAY_STR);
|
|
||||||
const est = H.estimatedRemaining(product, TODAY_STR);
|
|
||||||
const last = H.lastAudit(product);
|
|
||||||
const overdue = H.auditOverdue(data, product, TODAY_STR);
|
|
||||||
const sinceCheck = H.daysSinceCheck(product, TODAY_STR);
|
|
||||||
|
|
||||||
const isActive = product.status === "active";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{position: "fixed", inset: 0, background: "oklch(20% 0.02 60 / 0.4)", zIndex: 50, display: "flex", justifyContent: "flex-end"}} onClick={onClose}>
|
|
||||||
<div onClick={e => e.stopPropagation()} style={{
|
|
||||||
width: "min(720px, 100vw)",
|
|
||||||
height: "100%",
|
|
||||||
background: "var(--bg)",
|
|
||||||
borderLeft: "1px solid var(--line)",
|
|
||||||
overflow: "auto",
|
|
||||||
boxShadow: "var(--shadow-lg)"
|
|
||||||
}}>
|
|
||||||
{/* Header */}
|
|
||||||
<div style={{padding: "20px 32px", borderBottom: "1px solid var(--line)", display: "flex", alignItems: "center", justifyContent: "space-between", position: "sticky", top: 0, background: "var(--bg)", zIndex: 1}}>
|
|
||||||
<div className="smallcaps" style={{color: "var(--ink-3)"}}>Product · {product.sku}</div>
|
|
||||||
<div style={{display: "flex", gap: 6}}>
|
|
||||||
{isActive && <Btn variant="ghost" icon="check" onClick={() => onAudit(product)}>Audit</Btn>}
|
|
||||||
{isActive && <Btn variant="secondary" icon="check" onClick={() => onConsume(product)}>Mark finished</Btn>}
|
|
||||||
{isActive && <Btn variant="ghost" icon="bin" onClick={() => onMarkGone(product)}>Mark gone</Btn>}
|
|
||||||
<Btn variant="ghost" icon="close" onClick={onClose} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{padding: "32px 32px 60px"}}>
|
|
||||||
{/* Identity */}
|
|
||||||
<div style={{display: "flex", alignItems: "baseline", gap: 16, marginBottom: 8}}>
|
|
||||||
<div className="serif" style={{fontSize: 18, color: "var(--ink-3)"}}>{TYPE_GLYPHS[product.type]} {product.type}</div>
|
|
||||||
{product.status === "consumed" && <Pill tone="terra">Consumed · {fmt.daysAgo(product.consumedDate)}</Pill>}
|
|
||||||
{product.status === "gone" && <Pill tone="amber">Gone · {fmt.daysAgo(product.goneDate)}</Pill>}
|
|
||||||
{isActive && overdue && <Pill tone="amber">Audit overdue · {sinceCheck}d</Pill>}
|
|
||||||
</div>
|
|
||||||
<h1 className="serif" style={{fontSize: 48, margin: "0 0 4px", fontWeight: 500, letterSpacing: "-0.02em", lineHeight: 1.1}}>{product.name}</h1>
|
|
||||||
<div style={{fontSize: 16, color: "var(--ink-2)"}}>{H.brandName(data, product.brandId)} · from {H.shopName(data, product.shopId)}</div>
|
|
||||||
|
|
||||||
{/* Hero stats */}
|
|
||||||
<div style={{display: "grid", gridTemplateColumns: "repeat(4, 1fr)", gap: 1, marginTop: 32, background: "var(--line)", border: "1px solid var(--line)", borderRadius: "var(--r-md)", overflow: "hidden"}}>
|
|
||||||
{[
|
|
||||||
["Price", fmt.money(product.price)],
|
|
||||||
[product.kind === "discrete" ? "Quantity" : "Size",
|
|
||||||
product.kind === "discrete"
|
|
||||||
? `${product.countOriginal} ${cfg?.unit || "ct"}`
|
|
||||||
: `${product.weight} ${cfg?.unit || "g"}`
|
|
||||||
],
|
|
||||||
["THC", `${product.thc.toFixed(1)}%`],
|
|
||||||
["CBD", `${product.cbd.toFixed(1)}%`]
|
|
||||||
].map(([l, v]) => (
|
|
||||||
<div key={l} style={{padding: "18px 16px", background: "var(--surface)"}}>
|
|
||||||
<div className="smallcaps" style={{color: "var(--ink-3)"}}>{l}</div>
|
|
||||||
<div className="serif" style={{fontSize: 26, marginTop: 4, fontWeight: 500}}>{v}</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Remaining + audit */}
|
|
||||||
{isActive && (
|
|
||||||
<div style={{marginTop: 20}}>
|
|
||||||
<div style={{display: "flex", justifyContent: "space-between", alignItems: "baseline", marginBottom: 8}}>
|
|
||||||
<div className="smallcaps" style={{color: "var(--ink-3)"}}>
|
|
||||||
{product.kind === "discrete" ? "Units remaining" : "Estimated remaining"}
|
|
||||||
</div>
|
|
||||||
<div style={{fontFamily: "var(--mono)", fontSize: 13}}>
|
|
||||||
{product.kind === "discrete"
|
|
||||||
? `${product.countLastAudit != null ? product.countLastAudit : product.countOriginal} of ${product.countOriginal}`
|
|
||||||
: `${est.toFixed(2)} of ${product.weight} ${cfg?.unit || "g"}`}
|
|
||||||
<span style={{color: "var(--ink-3)", marginLeft: 8}}>{Math.round(pctRemaining*100)}%</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div style={{height: 8, background: "var(--bg-3)", borderRadius: 4, overflow: "hidden"}}>
|
|
||||||
<div style={{width: `${pctRemaining*100}%`, height: "100%", background: pctRemaining < 0.25 ? "var(--terracotta)" : pctRemaining < 0.5 ? "var(--amber)" : "var(--sage)"}} />
|
|
||||||
</div>
|
|
||||||
{product.kind === "bulk" && last && (
|
|
||||||
<div style={{fontSize: 11, color: "var(--ink-3)", marginTop: 6, fontStyle: "italic"}}>
|
|
||||||
Estimated by linear decay since last {last.mode} on {fmt.dateShort(last.date)} ({last.value}{cfg?.unit}). Re-audit to update.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Audit history */}
|
|
||||||
<div style={{marginTop: 36}}>
|
|
||||||
<div style={{display: "flex", justifyContent: "space-between", alignItems: "baseline", marginBottom: 12}}>
|
|
||||||
<div className="smallcaps" style={{color: "var(--ink-3)"}}>Audit history</div>
|
|
||||||
{isActive && <button onClick={() => onAudit(product)} style={{background: "none", border: "none", fontSize: 12, color: "var(--ink-2)", cursor: "pointer", textDecoration: "underline"}}>+ New audit</button>}
|
|
||||||
</div>
|
|
||||||
{(!product.audits || product.audits.length === 0) ? (
|
|
||||||
<div style={{fontSize: 13, color: "var(--ink-3)", fontStyle: "italic", padding: "12px 0"}}>
|
|
||||||
No audits recorded. Cadence for {product.type}: every {cfg?.cadenceDays || "—"} days.
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div style={{display: "flex", flexDirection: "column", gap: 0, border: "1px solid var(--line)", borderRadius: "var(--r-md)", overflow: "hidden"}}>
|
|
||||||
{[...product.audits].reverse().map((a, i) => (
|
|
||||||
<div key={i} style={{padding: "12px 16px", borderBottom: i < product.audits.length - 1 ? "1px solid var(--line)" : "none", display: "flex", alignItems: "center", gap: 12, background: "var(--surface)"}}>
|
|
||||||
<div style={{width: 8, height: 8, borderRadius: "50%", background: a.mode === "weigh" ? "var(--sage)" : a.mode === "estimate" ? "var(--amber)" : "var(--plum)"}} />
|
|
||||||
<div style={{flex: 1}}>
|
|
||||||
<div style={{fontSize: 13, fontWeight: 500}}>
|
|
||||||
{a.mode === "weigh" && "Weighed"}
|
|
||||||
{a.mode === "estimate" && "Estimated"}
|
|
||||||
{a.mode === "presence" && (a.confirmedBy === "lost" ? "Marked lost" : "Confirmed presence")}
|
|
||||||
</div>
|
|
||||||
<div style={{fontSize: 11, color: "var(--ink-3)"}}>{fmt.date(a.date)} · {fmt.daysAgo(a.date)}</div>
|
|
||||||
</div>
|
|
||||||
<div className="mono" style={{fontSize: 13, textAlign: "right"}}>
|
|
||||||
<div>{a.value} {cfg?.unit}</div>
|
|
||||||
<div style={{fontSize: 10, color: "var(--ink-3)"}}>was {a.prev} {cfg?.unit}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Details list */}
|
|
||||||
<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"}}>
|
|
||||||
{[
|
|
||||||
["SKU", <span className="mono">{product.sku}</span>],
|
|
||||||
["Asset tag", product.assetTag ? <span className="mono">{product.assetTag}</span> : <span style={{color: "var(--ink-3)"}}>None</span>],
|
|
||||||
["Type", `${product.type} · ${product.kind}`],
|
|
||||||
["Brand", H.brandName(data, product.brandId)],
|
|
||||||
["Shop", H.shopName(data, product.shopId)],
|
|
||||||
["Total cannabinoids", `${product.totalCannabinoids.toFixed(1)}%`],
|
|
||||||
["Purchase date", fmt.date(product.purchaseDate)],
|
|
||||||
["Bin", bin ? `${bin.name} — ${bin.location}` : <span style={{color: "var(--ink-3)"}}>—</span>],
|
|
||||||
["Audit cadence", `Every ${cfg?.cadenceDays || "—"} days · ${cfg?.auditMode || "—"}`],
|
|
||||||
["Cost per gram",
|
|
||||||
product.kind === "bulk" && product.weight > 0
|
|
||||||
? fmt.money(product.price / product.weight)
|
|
||||||
: product.kind === "discrete" && product.unitWeight > 0
|
|
||||||
? `${fmt.money(product.price / (product.countOriginal * product.unitWeight))} (effective)`
|
|
||||||
: "—"
|
|
||||||
],
|
|
||||||
...(product.status === "consumed" ? [
|
|
||||||
["Date finished", fmt.date(product.consumedDate)],
|
|
||||||
["Lasted", `${Math.round((new Date(product.consumedDate) - new Date(product.purchaseDate))/86400000)} days`]
|
|
||||||
] : []),
|
|
||||||
...(product.status === "gone" ? [
|
|
||||||
["Date gone", fmt.date(product.goneDate)],
|
|
||||||
["After", `${Math.round((new Date(product.goneDate) - new Date(product.purchaseDate))/86400000)} days`]
|
|
||||||
] : [])
|
|
||||||
].map(([l, v], i) => (
|
|
||||||
<div key={i} style={{display: "flex", justifyContent: "space-between", paddingBottom: 12, borderBottom: "1px solid var(--line)"}}>
|
|
||||||
<span style={{color: "var(--ink-3)", fontSize: 12}}>{l}</span>
|
|
||||||
<span style={{fontSize: 13, fontWeight: 500, textAlign: "right"}}>{v}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Final notes */}
|
|
||||||
{(product.status === "consumed" || product.status === "gone") && (
|
|
||||||
<div style={{marginTop: 36, padding: 24, background: "var(--bg-2)", border: "1px solid var(--line)", borderRadius: "var(--r-md)"}}>
|
|
||||||
<div style={{display: "flex", alignItems: "baseline", justifyContent: "space-between", marginBottom: 12}}>
|
|
||||||
<div className="smallcaps" style={{color: "var(--ink-3)"}}>{product.status === "gone" ? "Why it's gone" : "Final notes"}</div>
|
|
||||||
{product.status === "consumed" && (
|
|
||||||
<div style={{display: "flex", gap: 2}}>
|
|
||||||
{[1,2,3,4,5].map(n => (
|
|
||||||
<Icon key={n} name="star" size={14} color={n <= (product.rating || 0) ? "var(--amber)" : "var(--ink-4)"} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="serif" style={{fontSize: 18, lineHeight: 1.5, color: "var(--ink-2)", fontStyle: "italic"}}>
|
|
||||||
"{product.notes || 'No notes recorded.'}"
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
Object.assign(window, { Dashboard, Inventory, ProductDetail, remainingDisplay, remainingShort });
|
|
||||||
@@ -1,664 +0,0 @@
|
|||||||
// Add Product, Mark Consumed/Gone, Audit, Bins, Charts, Settings
|
|
||||||
|
|
||||||
const AddProductFlow = ({data, onClose, onSave}) => {
|
|
||||||
const [form, setForm] = React.useState({
|
|
||||||
name: "", brandId: data.brands[0].id, shopId: data.shops[0].id, type: "Flower",
|
|
||||||
weight: 3.5, countOriginal: 1, unitWeight: 0.7,
|
|
||||||
price: 45, thc: 22, cbd: 0.4, totalCannabinoids: 26,
|
|
||||||
purchaseDate: "2026-04-25", binId: data.bins[0].id,
|
|
||||||
sku: "", assetTag: ""
|
|
||||||
});
|
|
||||||
const update = (k, v) => setForm(f => ({...f, [k]: v}));
|
|
||||||
const cfg = data.types.find(t => t.id === form.type);
|
|
||||||
const isDiscrete = cfg?.kind === "discrete";
|
|
||||||
const cpg = !isDiscrete && form.weight > 0 ? form.price / form.weight : 0;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{position: "fixed", inset: 0, background: "oklch(20% 0.02 60 / 0.4)", zIndex: 50, display: "flex", justifyContent: "center", alignItems: "flex-start", overflow: "auto"}} onClick={onClose}>
|
|
||||||
<div onClick={e => e.stopPropagation()} style={{
|
|
||||||
width: "min(840px, 96vw)", margin: "40px 20px", background: "var(--bg)",
|
|
||||||
border: "1px solid var(--line)", borderRadius: "var(--r-lg)", boxShadow: "var(--shadow-lg)"
|
|
||||||
}}>
|
|
||||||
<div style={{padding: "20px 32px", borderBottom: "1px solid var(--line)", display: "flex", justifyContent: "space-between", alignItems: "center"}}>
|
|
||||||
<div>
|
|
||||||
<div className="smallcaps" style={{color: "var(--ink-3)"}}>New entry</div>
|
|
||||||
<h2 className="serif" style={{fontSize: 28, margin: "4px 0 0", fontWeight: 500}}>Add a product</h2>
|
|
||||||
</div>
|
|
||||||
<Btn variant="ghost" icon="close" onClick={onClose} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{padding: 32}}>
|
|
||||||
<div className="smallcaps" style={{color: "var(--ink-3)", marginBottom: 16}}>Identity</div>
|
|
||||||
<div style={{display: "grid", gridTemplateColumns: "repeat(2, 1fr)", gap: 16, marginBottom: 28}}>
|
|
||||||
<Field label="Product name" span={2}><Input value={form.name} placeholder="e.g. Garden Ghost" onChange={e => update("name", e.target.value)} /></Field>
|
|
||||||
<Field label="Brand"><Select value={form.brandId} onChange={e => update("brandId", e.target.value)}>{data.brands.map(b => <option key={b.id} value={b.id}>{b.name}</option>)}</Select></Field>
|
|
||||||
<Field label="Shop"><Select value={form.shopId} onChange={e => update("shopId", e.target.value)}>{data.shops.map(s => <option key={s.id} value={s.id}>{s.name}</option>)}</Select></Field>
|
|
||||||
<Field label="Type"><Select value={form.type} onChange={e => update("type", e.target.value)}>{data.types.map(t => <option key={t.id} value={t.id}>{t.id} ({t.kind})</option>)}</Select></Field>
|
|
||||||
<Field label="Bin"><Select value={form.binId} onChange={e => update("binId", e.target.value)}>{data.bins.map(b => <option key={b.id} value={b.id}>{b.name} — {b.location}</option>)}</Select></Field>
|
|
||||||
<Field label="SKU" hint="Leave blank — we'll generate one"><Input value={form.sku} placeholder="SKU-…" onChange={e => update("sku", e.target.value)} /></Field>
|
|
||||||
<Field label="Asset tag (optional)" hint="If you've physically tagged the item"><Input value={form.assetTag} placeholder="AT-0000" onChange={e => update("assetTag", e.target.value)} /></Field>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="smallcaps" style={{color: "var(--ink-3)", marginBottom: 16}}>Acquisition</div>
|
|
||||||
<div style={{display: "grid", gridTemplateColumns: "repeat(4, 1fr)", gap: 16, marginBottom: 8}}>
|
|
||||||
{isDiscrete ? (
|
|
||||||
<>
|
|
||||||
<Field label={`Quantity (${cfg.unit})`}><Input type="number" step="1" value={form.countOriginal} onChange={e => update("countOriginal", +e.target.value)} /></Field>
|
|
||||||
<Field label="Per-unit weight (g)" hint="For grams stats"><Input type="number" step="0.1" value={form.unitWeight} onChange={e => update("unitWeight", +e.target.value)} /></Field>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<Field label={`Size (${cfg?.unit || "g"})`} span={2}><Input type="number" step="0.1" value={form.weight} onChange={e => update("weight", +e.target.value)} /></Field>
|
|
||||||
)}
|
|
||||||
<Field label="Price ($)"><Input type="number" step="0.01" value={form.price} onChange={e => update("price", +e.target.value)} /></Field>
|
|
||||||
<Field label="Purchase date"><Input type="date" value={form.purchaseDate} onChange={e => update("purchaseDate", e.target.value)} /></Field>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{!isDiscrete && cpg > 0 && (
|
|
||||||
<div style={{marginTop: 12, fontSize: 12, color: "var(--ink-3)"}}>
|
|
||||||
Cost per {cfg?.unit || "g"}: <span className="mono" style={{color: "var(--ink-2)"}}>{fmt.money(cpg)}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="smallcaps" style={{color: "var(--ink-3)", margin: "28px 0 16px"}}>Cannabinoid profile</div>
|
|
||||||
<div style={{display: "grid", gridTemplateColumns: "repeat(3, 1fr)", gap: 16}}>
|
|
||||||
<Field label="THC %"><Input type="number" step="0.1" value={form.thc} onChange={e => update("thc", +e.target.value)} /></Field>
|
|
||||||
<Field label="CBD %"><Input type="number" step="0.1" value={form.cbd} onChange={e => update("cbd", +e.target.value)} /></Field>
|
|
||||||
<Field label="Total cannabinoids %"><Input type="number" step="0.1" value={form.totalCannabinoids} onChange={e => update("totalCannabinoids", +e.target.value)} /></Field>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{padding: "16px 32px", 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)"}}>
|
|
||||||
<div style={{fontSize: 12, color: "var(--ink-3)"}}>
|
|
||||||
{form.name ? `"${form.name}" → ${data.bins.find(b=>b.id===form.binId)?.name}.` : "Fill in the name to continue."}
|
|
||||||
</div>
|
|
||||||
<div style={{display: "flex", gap: 8}}>
|
|
||||||
<Btn variant="ghost" onClick={onClose}>Cancel</Btn>
|
|
||||||
<Btn variant="primary" icon="check" disabled={!form.name} onClick={() => onSave(form)}>Save product</Btn>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// ─── CONSUME (mark finished) ──────────────────────────────────────
|
|
||||||
const ConsumeFlow = ({data, onClose, product: initialProduct}) => {
|
|
||||||
const active = data.products.filter(p => p.status === "active");
|
|
||||||
const [productId, setProductId] = React.useState(initialProduct?.id || active[0]?.id);
|
|
||||||
const [rating, setRating] = React.useState(4);
|
|
||||||
const [notes, setNotes] = React.useState("");
|
|
||||||
const [date, setDate] = React.useState("2026-04-25");
|
|
||||||
|
|
||||||
const product = data.products.find(p => p.id === productId);
|
|
||||||
if (!product) return null;
|
|
||||||
const bin = data.bins.find(b => b.id === product.binId);
|
|
||||||
const lifespan = Math.round((new Date(date) - new Date(product.purchaseDate))/86400000);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{position: "fixed", inset: 0, background: "oklch(20% 0.02 60 / 0.4)", zIndex: 50, display: "flex", justifyContent: "center", alignItems: "flex-start", overflow: "auto"}} onClick={onClose}>
|
|
||||||
<div onClick={e => e.stopPropagation()} style={{
|
|
||||||
width: "min(720px, 96vw)", margin: "40px 20px", background: "var(--bg)",
|
|
||||||
border: "1px solid var(--line)", borderRadius: "var(--r-lg)", boxShadow: "var(--shadow-lg)"
|
|
||||||
}}>
|
|
||||||
<div style={{padding: "20px 32px", borderBottom: "1px solid var(--line)", display: "flex", justifyContent: "space-between", alignItems: "center"}}>
|
|
||||||
<div>
|
|
||||||
<div className="smallcaps" style={{color: "var(--ink-3)"}}>Archive · used up</div>
|
|
||||||
<h2 className="serif" style={{fontSize: 28, margin: "4px 0 0", fontWeight: 500}}>Mark as finished</h2>
|
|
||||||
</div>
|
|
||||||
<Btn variant="ghost" icon="close" onClick={onClose} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{padding: 32}}>
|
|
||||||
<Field label="Product">
|
|
||||||
<Select value={productId} onChange={e => setProductId(e.target.value)}>
|
|
||||||
{active.map(p => <option key={p.id} value={p.id}>{p.name} — {H.brandName(data, p.brandId)} ({remainingShort(p)} left)</option>)}
|
|
||||||
</Select>
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<div style={{marginTop: 16, padding: 16, background: "var(--bg-2)", border: "1px solid var(--line)", borderRadius: "var(--r-md)", display: "flex", justifyContent: "space-between", alignItems: "center"}}>
|
|
||||||
<div>
|
|
||||||
<div className="serif" style={{fontSize: 22, fontWeight: 500}}>{product.name}</div>
|
|
||||||
<div style={{fontSize: 12, color: "var(--ink-3)"}}>{H.brandName(data, product.brandId)} · {bin?.name} · purchased {fmt.dateShort(product.purchaseDate)}</div>
|
|
||||||
</div>
|
|
||||||
<div style={{textAlign: "right"}}>
|
|
||||||
<div className="mono" style={{fontSize: 11, color: "var(--ink-3)"}}>LASTED</div>
|
|
||||||
<div className="serif" style={{fontSize: 24}}>{lifespan} days</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{display: "grid", gridTemplateColumns: "1fr 1fr", gap: 16, marginTop: 24}}>
|
|
||||||
<Field label="Date finished"><Input type="date" value={date} onChange={e => setDate(e.target.value)} /></Field>
|
|
||||||
<Field label="Rating">
|
|
||||||
<div style={{display: "flex", gap: 4, alignItems: "center", padding: "10px 12px", background: "var(--bg)", border: "1px solid var(--line)", borderRadius: "var(--r-md)"}}>
|
|
||||||
{[1,2,3,4,5].map(n => (
|
|
||||||
<button key={n} onClick={() => setRating(n)} style={{border: "none", background: "transparent", cursor: "pointer", padding: 2}}>
|
|
||||||
<Icon name="star" size={20} color={n <= rating ? "var(--amber)" : "var(--ink-4)"} />
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
<span style={{marginLeft: "auto", fontSize: 12, color: "var(--ink-3)", fontFamily: "var(--mono)"}}>{rating}/5</span>
|
|
||||||
</div>
|
|
||||||
</Field>
|
|
||||||
</div>
|
|
||||||
<div style={{marginTop: 16}}>
|
|
||||||
<Field label="Final notes" hint="Flavor, effects, would you rebuy">
|
|
||||||
<Textarea value={notes} onChange={e => setNotes(e.target.value)} placeholder="What stood out?" />
|
|
||||||
</Field>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{padding: "16px 32px", borderTop: "1px solid var(--line)", display: "flex", justifyContent: "flex-end", gap: 8, background: "var(--bg-2)", borderRadius: "0 0 var(--r-lg) var(--r-lg)"}}>
|
|
||||||
<Btn variant="ghost" onClick={onClose}>Cancel</Btn>
|
|
||||||
<Btn variant="primary" icon="check" onClick={onClose}>Mark finished</Btn>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// ─── MARK GONE (lost / damaged / expired) ─────────────────────────
|
|
||||||
const MarkGoneFlow = ({data, onClose, product: initialProduct}) => {
|
|
||||||
const active = data.products.filter(p => p.status === "active");
|
|
||||||
const [productId, setProductId] = React.useState(initialProduct?.id || active[0]?.id);
|
|
||||||
const [reason, setReason] = React.useState("lost");
|
|
||||||
const [notes, setNotes] = React.useState("");
|
|
||||||
const [date, setDate] = React.useState("2026-04-25");
|
|
||||||
const product = data.products.find(p => p.id === productId);
|
|
||||||
if (!product) return null;
|
|
||||||
|
|
||||||
const reasons = [
|
|
||||||
["lost", "Lost / misplaced"],
|
|
||||||
["damaged", "Damaged"],
|
|
||||||
["expired", "Expired"],
|
|
||||||
["gifted", "Gifted away"],
|
|
||||||
["other", "Other"]
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{position: "fixed", inset: 0, background: "oklch(20% 0.02 60 / 0.4)", zIndex: 50, display: "flex", justifyContent: "center", alignItems: "flex-start", overflow: "auto"}} onClick={onClose}>
|
|
||||||
<div onClick={e => e.stopPropagation()} style={{
|
|
||||||
width: "min(640px, 96vw)", margin: "40px 20px", background: "var(--bg)",
|
|
||||||
border: "1px solid var(--line)", borderRadius: "var(--r-lg)", boxShadow: "var(--shadow-lg)"
|
|
||||||
}}>
|
|
||||||
<div style={{padding: "20px 32px", borderBottom: "1px solid var(--line)", display: "flex", justifyContent: "space-between", alignItems: "center"}}>
|
|
||||||
<div>
|
|
||||||
<div className="smallcaps" style={{color: "var(--terracotta)"}}>Archive · not consumed</div>
|
|
||||||
<h2 className="serif" style={{fontSize: 28, margin: "4px 0 0", fontWeight: 500}}>Mark as gone</h2>
|
|
||||||
</div>
|
|
||||||
<Btn variant="ghost" icon="close" onClick={onClose} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{padding: 32}}>
|
|
||||||
<div style={{fontSize: 13, color: "var(--ink-2)", marginBottom: 20, padding: 14, background: "var(--amber-soft)", borderRadius: "var(--r-md)"}}>
|
|
||||||
Use this when an item is lost, damaged, expired, or gifted away. Counts as <strong>spend</strong> but not as <strong>consumption</strong>, so daily averages stay accurate.
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Field label="Product">
|
|
||||||
<Select value={productId} onChange={e => setProductId(e.target.value)}>
|
|
||||||
{active.map(p => <option key={p.id} value={p.id}>{p.name} — {H.brandName(data, p.brandId)} ({remainingShort(p)} left)</option>)}
|
|
||||||
</Select>
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<div style={{display: "grid", gridTemplateColumns: "1fr 1fr", gap: 16, marginTop: 16}}>
|
|
||||||
<Field label="Reason">
|
|
||||||
<Select value={reason} onChange={e => setReason(e.target.value)}>
|
|
||||||
{reasons.map(([k,l]) => <option key={k} value={k}>{l}</option>)}
|
|
||||||
</Select>
|
|
||||||
</Field>
|
|
||||||
<Field label="Date">
|
|
||||||
<Input type="date" value={date} onChange={e => setDate(e.target.value)} />
|
|
||||||
</Field>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{marginTop: 16}}>
|
|
||||||
<Field label="Notes (optional)" hint="What happened">
|
|
||||||
<Textarea value={notes} onChange={e => setNotes(e.target.value)} placeholder="e.g. Pack went through the wash" />
|
|
||||||
</Field>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{padding: "16px 32px", borderTop: "1px solid var(--line)", display: "flex", justifyContent: "flex-end", gap: 8, background: "var(--bg-2)", borderRadius: "0 0 var(--r-lg) var(--r-lg)"}}>
|
|
||||||
<Btn variant="ghost" onClick={onClose}>Cancel</Btn>
|
|
||||||
<Btn variant="danger" icon="bin" onClick={onClose}>Mark gone</Btn>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// ─── AUDIT MODAL ──────────────────────────────────────────────────
|
|
||||||
const AuditFlow = ({data, onClose, product: initialProduct}) => {
|
|
||||||
const overdueFirst = [...data.products]
|
|
||||||
.filter(p => p.status === "active")
|
|
||||||
.sort((a, b) => H.daysSinceCheck(b) - H.daysSinceCheck(a));
|
|
||||||
const [productId, setProductId] = React.useState(initialProduct?.id || overdueFirst[0]?.id);
|
|
||||||
const [date, setDate] = React.useState("2026-04-25");
|
|
||||||
const product = data.products.find(p => p.id === productId);
|
|
||||||
const cfg = product ? data.types.find(t => t.id === product.type) : null;
|
|
||||||
|
|
||||||
// For bulk: weighed/estimated value. For discrete: count + confirmedBy.
|
|
||||||
const last = product ? H.lastAudit(product) : null;
|
|
||||||
const initialValue = product
|
|
||||||
? (product.kind === "discrete"
|
|
||||||
? (product.countLastAudit != null ? product.countLastAudit : product.countOriginal)
|
|
||||||
: H.estimatedRemaining(product, "2026-04-25").toFixed(2))
|
|
||||||
: 0;
|
|
||||||
const [value, setValue] = React.useState(initialValue);
|
|
||||||
const [confirmedBy, setConfirmedBy] = React.useState("SKU");
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (product) {
|
|
||||||
setValue(product.kind === "discrete"
|
|
||||||
? (product.countLastAudit != null ? product.countLastAudit : product.countOriginal)
|
|
||||||
: H.estimatedRemaining(product, "2026-04-25").toFixed(2));
|
|
||||||
}
|
|
||||||
}, [productId]);
|
|
||||||
|
|
||||||
if (!product) return null;
|
|
||||||
const auditMode = cfg?.auditMode || "weigh";
|
|
||||||
const prevValue = product.kind === "discrete"
|
|
||||||
? (product.countLastAudit != null ? product.countLastAudit : product.countOriginal)
|
|
||||||
: (last ? last.value : product.weight);
|
|
||||||
|
|
||||||
const auditModeLabels = {
|
|
||||||
weigh: { title: "Reweigh on a scale", desc: "Place the jar (minus tare) and record the new weight." },
|
|
||||||
estimate: { title: "Visual estimate", desc: "Eyeball the remaining amount — quick and approximate." },
|
|
||||||
presence: { title: "Confirm presence", desc: "Verify the item is still where you left it. Count units if applicable." }
|
|
||||||
};
|
|
||||||
const ml = auditModeLabels[auditMode];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{position: "fixed", inset: 0, background: "oklch(20% 0.02 60 / 0.4)", zIndex: 50, display: "flex", justifyContent: "center", alignItems: "flex-start", overflow: "auto"}} onClick={onClose}>
|
|
||||||
<div onClick={e => e.stopPropagation()} style={{
|
|
||||||
width: "min(720px, 96vw)", margin: "40px 20px", background: "var(--bg)",
|
|
||||||
border: "1px solid var(--line)", borderRadius: "var(--r-lg)", boxShadow: "var(--shadow-lg)"
|
|
||||||
}}>
|
|
||||||
<div style={{padding: "20px 32px", borderBottom: "1px solid var(--line)", display: "flex", justifyContent: "space-between", alignItems: "center"}}>
|
|
||||||
<div>
|
|
||||||
<div className="smallcaps" style={{color: "var(--ink-3)"}}>Audit</div>
|
|
||||||
<h2 className="serif" style={{fontSize: 28, margin: "4px 0 0", fontWeight: 500}}>{ml.title}</h2>
|
|
||||||
</div>
|
|
||||||
<Btn variant="ghost" icon="close" onClick={onClose} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{padding: 32}}>
|
|
||||||
<Field label="Product">
|
|
||||||
<Select value={productId} onChange={e => setProductId(e.target.value)}>
|
|
||||||
{overdueFirst.map(p => {
|
|
||||||
const od = H.auditOverdue(data, p);
|
|
||||||
const sc = H.daysSinceCheck(p);
|
|
||||||
return <option key={p.id} value={p.id}>{od ? "⚠ " : ""}{p.name} — {H.brandName(data, p.brandId)} · {sc}d since check</option>;
|
|
||||||
})}
|
|
||||||
</Select>
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<div style={{marginTop: 16, padding: 16, background: "var(--bg-2)", border: "1px solid var(--line)", borderRadius: "var(--r-md)"}}>
|
|
||||||
<div style={{display: "flex", justifyContent: "space-between", alignItems: "center"}}>
|
|
||||||
<div>
|
|
||||||
<div className="serif" style={{fontSize: 20, fontWeight: 500}}>{product.name}</div>
|
|
||||||
<div style={{fontSize: 12, color: "var(--ink-3)"}}>
|
|
||||||
{product.type} · {product.kind} · cadence every {cfg?.cadenceDays}d
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div style={{textAlign: "right"}}>
|
|
||||||
<div className="mono" style={{fontSize: 11, color: "var(--ink-3)"}}>LAST CHECKED</div>
|
|
||||||
<div className="serif" style={{fontSize: 18}}>{last ? `${H.daysSinceCheck(product)}d ago` : "Never"}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div style={{fontSize: 12, color: "var(--ink-2)", marginTop: 10, fontStyle: "italic"}}>{ml.desc}</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{display: "grid", gridTemplateColumns: auditMode === "presence" ? "1fr 1fr 1fr" : "1fr 1fr", gap: 16, marginTop: 24}}>
|
|
||||||
<Field label={
|
|
||||||
product.kind === "discrete"
|
|
||||||
? `Count now (${cfg?.unit})`
|
|
||||||
: auditMode === "weigh" ? `Weight now (${cfg?.unit})` : `Estimate now (${cfg?.unit})`
|
|
||||||
}>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
step={product.kind === "discrete" ? "1" : "0.1"}
|
|
||||||
value={value}
|
|
||||||
onChange={e => setValue(e.target.value)}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
<Field label="Date"><Input type="date" value={date} onChange={e => setDate(e.target.value)} /></Field>
|
|
||||||
{auditMode === "presence" && (
|
|
||||||
<Field label="Confirmed by">
|
|
||||||
<Select value={confirmedBy} onChange={e => setConfirmedBy(e.target.value)}>
|
|
||||||
<option value="SKU">SKU label</option>
|
|
||||||
<option value="asset">Asset tag</option>
|
|
||||||
<option value="visual">Visual ID</option>
|
|
||||||
</Select>
|
|
||||||
</Field>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{marginTop: 20, padding: 14, background: "var(--surface)", border: "1px solid var(--line)", borderRadius: "var(--r-md)", display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: 16}}>
|
|
||||||
<div>
|
|
||||||
<div className="smallcaps" style={{color: "var(--ink-3)"}}>Was</div>
|
|
||||||
<div className="serif" style={{fontSize: 22}}>{prevValue} {cfg?.unit}</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="smallcaps" style={{color: "var(--ink-3)"}}>Now</div>
|
|
||||||
<div className="serif" style={{fontSize: 22, color: "var(--sage)"}}>{value} {cfg?.unit}</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="smallcaps" style={{color: "var(--ink-3)"}}>Δ since last</div>
|
|
||||||
<div className="serif" style={{fontSize: 22, color: (+value - +prevValue) < 0 ? "var(--terracotta)" : "var(--ink)"}}>
|
|
||||||
{(+value - +prevValue).toFixed(product.kind === "discrete" ? 0 : 2)} {cfg?.unit}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{padding: "16px 32px", 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)"}}>
|
|
||||||
<div style={{fontSize: 12, color: "var(--ink-3)"}}>Next audit due in {cfg?.cadenceDays}d</div>
|
|
||||||
<div style={{display: "flex", gap: 8}}>
|
|
||||||
<Btn variant="ghost" onClick={onClose}>Cancel</Btn>
|
|
||||||
<Btn variant="primary" icon="check" onClick={onClose}>Save audit</Btn>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// ─── BINS ─────────────────────────────────────────────────────────
|
|
||||||
const BinsView = ({data, onSelectProduct}) => {
|
|
||||||
return (
|
|
||||||
<div style={{padding: "32px 40px 80px", maxWidth: 1400, margin: "0 auto"}}>
|
|
||||||
<div style={{display: "flex", alignItems: "baseline", justifyContent: "space-between", marginBottom: 24}}>
|
|
||||||
<div>
|
|
||||||
<div className="smallcaps" style={{color: "var(--ink-3)"}}>{data.bins.length} bins</div>
|
|
||||||
<h1 className="serif" style={{fontSize: 44, margin: "6px 0 0", fontWeight: 500, letterSpacing: "-0.02em"}}>Bins & storage</h1>
|
|
||||||
</div>
|
|
||||||
<Btn variant="secondary" icon="plus">New bin</Btn>
|
|
||||||
</div>
|
|
||||||
<div style={{fontSize: 14, color: "var(--ink-2)", marginBottom: 24, maxWidth: 600}}>
|
|
||||||
Where each active product physically lives. Archived items aren't assigned to a bin.
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(380px, 1fr))", gap: 14}}>
|
|
||||||
{data.bins.map(bin => {
|
|
||||||
const items = data.products.filter(p => p.binId === bin.id && p.status === "active");
|
|
||||||
const fillPct = items.length / bin.capacity;
|
|
||||||
const totalValue = items.reduce((s, p) => s + p.price * H.pctRemaining(p, "2026-04-25"), 0);
|
|
||||||
return (
|
|
||||||
<Card key={bin.id} padded={false} style={{display: "flex", flexDirection: "column"}}>
|
|
||||||
<div style={{padding: "20px 22px 16px", borderBottom: "1px solid var(--line)"}}>
|
|
||||||
<div style={{display: "flex", alignItems: "baseline", justifyContent: "space-between", marginBottom: 4}}>
|
|
||||||
<h3 className="serif" style={{fontSize: 24, margin: 0, fontWeight: 500}}>{bin.name}</h3>
|
|
||||||
<Pill tone="outline">{items.length} / {bin.capacity}</Pill>
|
|
||||||
</div>
|
|
||||||
<div style={{fontSize: 12, color: "var(--ink-3)", display: "flex", justifyContent: "space-between"}}>
|
|
||||||
<span>{bin.location}</span>
|
|
||||||
<span className="mono">{fmt.money(totalValue)}</span>
|
|
||||||
</div>
|
|
||||||
<div style={{marginTop: 12, height: 4, background: "var(--bg-3)", borderRadius: 2, overflow: "hidden"}}>
|
|
||||||
<div style={{width: `${Math.min(fillPct, 1)*100}%`, height: "100%", background: fillPct > 0.9 ? "var(--terracotta)" : fillPct > 0.7 ? "var(--amber)" : "var(--sage)"}} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div style={{padding: 8, flex: 1}}>
|
|
||||||
{items.length === 0 && <div style={{padding: 30, textAlign: "center", fontSize: 12, color: "var(--ink-3)", fontStyle: "italic"}}>Empty</div>}
|
|
||||||
{items.map(p => (
|
|
||||||
<div key={p.id} onClick={() => onSelectProduct(p)} style={{display: "flex", alignItems: "center", gap: 10, padding: "8px 14px", borderRadius: "var(--r-sm)", cursor: "pointer"}}>
|
|
||||||
<div style={{fontFamily: "var(--serif)", fontSize: 18, color: "var(--ink-3)", width: 18}}>{TYPE_GLYPHS[p.type]}</div>
|
|
||||||
<div style={{flex: 1, minWidth: 0}}>
|
|
||||||
<div style={{fontSize: 13, fontWeight: 500, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis"}}>{p.name}</div>
|
|
||||||
<div style={{fontSize: 11, color: "var(--ink-3)"}}>{H.brandName(data, p.brandId)}</div>
|
|
||||||
</div>
|
|
||||||
<div className="mono" style={{fontSize: 11, color: "var(--ink-2)"}}>{remainingShort(p)}</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// ─── CHARTS ───────────────────────────────────────────────────────
|
|
||||||
const ChartsView = ({data, stats}) => {
|
|
||||||
const series = stats.series90.map(s => ({date: s.date, grams: s.grams, label: ""}));
|
|
||||||
|
|
||||||
const spendByMonth = {};
|
|
||||||
data.products.forEach(p => {
|
|
||||||
const k = p.purchaseDate.slice(0, 7);
|
|
||||||
spendByMonth[k] = (spendByMonth[k] || 0) + p.price;
|
|
||||||
});
|
|
||||||
const months = Object.entries(spendByMonth).sort();
|
|
||||||
|
|
||||||
const spendByShop = {};
|
|
||||||
data.products.forEach(p => {
|
|
||||||
const name = H.shopName(data, p.shopId);
|
|
||||||
spendByShop[name] = (spendByShop[name] || 0) + p.price;
|
|
||||||
});
|
|
||||||
const shopRanked = Object.entries(spendByShop).sort((a,b) => b[1]-a[1]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{padding: "32px 40px 80px", maxWidth: 1400, margin: "0 auto"}}>
|
|
||||||
<div style={{marginBottom: 24}}>
|
|
||||||
<div className="smallcaps" style={{color: "var(--ink-3)"}}>Last 90 days</div>
|
|
||||||
<h1 className="serif" style={{fontSize: 44, margin: "6px 0 0", fontWeight: 500, letterSpacing: "-0.02em"}}>Patterns & spend</h1>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Card style={{marginBottom: 14}}>
|
|
||||||
<div style={{display: "flex", justifyContent: "space-between", alignItems: "baseline", marginBottom: 18}}>
|
|
||||||
<div className="serif" style={{fontSize: 22}}>Daily grams · 90 days</div>
|
|
||||||
<div style={{display: "flex", gap: 24, fontSize: 12, color: "var(--ink-3)"}}>
|
|
||||||
<div>Total <span className="serif" style={{fontSize: 18, color: "var(--ink)"}}>{series.reduce((s,e)=>s+e.grams,0).toFixed(1)} g</span></div>
|
|
||||||
<div>Avg <span className="serif" style={{fontSize: 18, color: "var(--ink)"}}>{(series.reduce((s,e)=>s+e.grams,0)/90).toFixed(2)} g/day</span></div>
|
|
||||||
<div>Items finished <span className="serif" style={{fontSize: 18, color: "var(--ink)"}}>{stats.consumedCount}</span></div>
|
|
||||||
{stats.goneCount > 0 && <div>Items gone <span className="serif" style={{fontSize: 18, color: "var(--ink)"}}>{stats.goneCount}</span></div>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<BarChart data={series.map(s => ({value: s.grams, label: ""}))} height={180} color="var(--sage)" />
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<div style={{display: "grid", gridTemplateColumns: "1fr 1fr", gap: 14, marginBottom: 14}}>
|
|
||||||
<Card>
|
|
||||||
<div className="serif" style={{fontSize: 22, marginBottom: 18}}>Spend by month</div>
|
|
||||||
<div style={{display: "flex", flexDirection: "column", gap: 14}}>
|
|
||||||
{months.map(([m, v]) => {
|
|
||||||
const max = Math.max(...months.map(x => x[1]));
|
|
||||||
const d = new Date(m + "-01");
|
|
||||||
return (
|
|
||||||
<div key={m} style={{display: "flex", alignItems: "center", gap: 12}}>
|
|
||||||
<div className="smallcaps" style={{color: "var(--ink-3)", width: 60}}>{d.toLocaleDateString("en-US", {month: "short", year: "2-digit"})}</div>
|
|
||||||
<div style={{flex: 1, height: 24, background: "var(--bg-2)", borderRadius: 4, position: "relative"}}>
|
|
||||||
<div style={{width: `${(v/max)*100}%`, height: "100%", background: "var(--terracotta)", borderRadius: 4, opacity: 0.85}} />
|
|
||||||
</div>
|
|
||||||
<div className="mono" style={{width: 70, textAlign: "right", fontSize: 13}}>{fmt.moneyShort(v)}</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<div className="serif" style={{fontSize: 22, marginBottom: 18}}>Spend by shop</div>
|
|
||||||
<div style={{display: "flex", flexDirection: "column", gap: 14}}>
|
|
||||||
{shopRanked.map(([s, v]) => {
|
|
||||||
const max = shopRanked[0][1];
|
|
||||||
return (
|
|
||||||
<div key={s} style={{display: "flex", alignItems: "center", gap: 12}}>
|
|
||||||
<div style={{flex: 1.5, fontSize: 13, color: "var(--ink-2)"}}>{s}</div>
|
|
||||||
<div style={{flex: 2, height: 8, background: "var(--bg-2)", borderRadius: 4, position: "relative"}}>
|
|
||||||
<div style={{width: `${(v/max)*100}%`, height: "100%", background: "var(--sage)", borderRadius: 4}} />
|
|
||||||
</div>
|
|
||||||
<div className="mono" style={{width: 70, textAlign: "right", fontSize: 13}}>{fmt.moneyShort(v)}</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<div className="serif" style={{fontSize: 22, marginBottom: 6}}>Inferred consumption heatmap</div>
|
|
||||||
<div style={{fontSize: 12, color: "var(--ink-3)", marginBottom: 18}}>13 weeks · darker = higher inferred daily use, prorated across each item's lifespan</div>
|
|
||||||
<Heatmap series={series} />
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const Heatmap = ({series}) => {
|
|
||||||
const first = new Date(series[0].date);
|
|
||||||
const offset = first.getDay();
|
|
||||||
const cells = [];
|
|
||||||
for (let i = 0; i < offset; i++) cells.push(null);
|
|
||||||
series.forEach(s => cells.push(s));
|
|
||||||
while (cells.length < 13 * 7) cells.push(null);
|
|
||||||
|
|
||||||
const max = Math.max(...series.map(s => s.grams), 0.001);
|
|
||||||
const colorFor = (g) => {
|
|
||||||
if (g === 0) return "var(--bg-3)";
|
|
||||||
const t = g / max;
|
|
||||||
return `oklch(${72 - t * 30}% ${0.04 + t * 0.06} 145)`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const days = ["S","M","T","W","T","F","S"];
|
|
||||||
return (
|
|
||||||
<div style={{display: "flex", gap: 8, alignItems: "flex-start"}}>
|
|
||||||
<div style={{display: "flex", flexDirection: "column", gap: 3, paddingTop: 18}}>
|
|
||||||
{days.map((d, i) => <div key={i} style={{height: 14, fontSize: 9, color: "var(--ink-3)", fontFamily: "var(--mono)"}}>{d}</div>)}
|
|
||||||
</div>
|
|
||||||
<div style={{flex: 1}}>
|
|
||||||
<div style={{display: "grid", gridTemplateColumns: "repeat(13, 1fr)", gap: 3, marginBottom: 4}}>
|
|
||||||
{Array.from({length: 13}).map((_, w) => {
|
|
||||||
const firstDay = cells[w * 7];
|
|
||||||
return <div key={w} style={{fontSize: 9, color: "var(--ink-3)", fontFamily: "var(--mono)", textAlign: "center"}}>{firstDay && new Date(firstDay.date).getDate() <= 7 ? new Date(firstDay.date).toLocaleDateString("en-US", {month: "short"}) : ""}</div>;
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
<div style={{display: "grid", gridTemplateRows: "repeat(7, 1fr)", gridAutoFlow: "column", gap: 3}}>
|
|
||||||
{cells.map((c, i) => (
|
|
||||||
<div key={i} title={c ? `${c.date}: ${c.grams.toFixed(2)}g` : ""} style={{
|
|
||||||
aspectRatio: "1",
|
|
||||||
minHeight: 14,
|
|
||||||
background: c ? colorFor(c.grams) : "transparent",
|
|
||||||
borderRadius: 2
|
|
||||||
}} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div style={{display: "flex", justifyContent: "flex-end", alignItems: "center", gap: 6, marginTop: 14, fontSize: 10, color: "var(--ink-3)", fontFamily: "var(--mono)"}}>
|
|
||||||
<span>Less</span>
|
|
||||||
{[0, 0.25, 0.5, 0.75, 1].map(t => (
|
|
||||||
<div key={t} style={{width: 14, height: 14, background: t === 0 ? "var(--bg-3)" : `oklch(${72 - t * 30}% ${0.04 + t * 0.06} 145)`, borderRadius: 2}} />
|
|
||||||
))}
|
|
||||||
<span>More</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// ─── SETTINGS ─────────────────────────────────────────────────────
|
|
||||||
const SettingsView = ({data, tweaks, onTweakChange}) => {
|
|
||||||
return (
|
|
||||||
<div style={{padding: "32px 40px 80px", maxWidth: 800, margin: "0 auto"}}>
|
|
||||||
<div style={{marginBottom: 24}}>
|
|
||||||
<div className="smallcaps" style={{color: "var(--ink-3)"}}>Settings</div>
|
|
||||||
<h1 className="serif" style={{fontSize: 44, margin: "6px 0 0", fontWeight: 500, letterSpacing: "-0.02em"}}>Preferences</h1>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Card style={{marginBottom: 14}}>
|
|
||||||
<div className="serif" style={{fontSize: 22, marginBottom: 16}}>Appearance</div>
|
|
||||||
<div style={{display: "flex", flexDirection: "column", gap: 14}}>
|
|
||||||
<SettingRow label="Theme" hint="Light parchment or dim ink">
|
|
||||||
<div style={{display: "inline-flex", background: "var(--bg-2)", border: "1px solid var(--line)", borderRadius: "var(--r-md)", padding: 3}}>
|
|
||||||
{["light", "dark"].map(t => (
|
|
||||||
<button key={t} onClick={() => onTweakChange("theme", t)} style={{padding: "6px 14px", fontSize: 12, fontWeight: 500, borderRadius: 6, border: "none", background: tweaks.theme === t ? "var(--surface)" : "transparent", color: tweaks.theme === t ? "var(--ink)" : "var(--ink-3)", cursor: "pointer", textTransform: "capitalize"}}>{t}</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</SettingRow>
|
|
||||||
<SettingRow label="Dashboard layout" hint="Editorial leans on type; data-dense packs more in">
|
|
||||||
<div style={{display: "inline-flex", background: "var(--bg-2)", border: "1px solid var(--line)", borderRadius: "var(--r-md)", padding: 3}}>
|
|
||||||
{[["editorial","Editorial"], ["dense","Data-dense"], ["minimal","Minimal"]].map(([k,l]) => (
|
|
||||||
<button key={k} onClick={() => onTweakChange("dashboard", k)} style={{padding: "6px 14px", fontSize: 12, fontWeight: 500, borderRadius: 6, border: "none", background: tweaks.dashboard === k ? "var(--surface)" : "transparent", color: tweaks.dashboard === k ? "var(--ink)" : "var(--ink-3)", cursor: "pointer"}}>{l}</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</SettingRow>
|
|
||||||
<SettingRow label="Tone">
|
|
||||||
<Select value={tweaks.tone} onChange={e => onTweakChange("tone", e.target.value)} style={{...inputStyle, width: 200}}>
|
|
||||||
<option value="botanical">Botanical (default)</option>
|
|
||||||
<option value="neutral">Neutral inventory</option>
|
|
||||||
<option value="discreet">Discreet (code names)</option>
|
|
||||||
</Select>
|
|
||||||
</SettingRow>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Shops */}
|
|
||||||
<Card style={{marginBottom: 14}}>
|
|
||||||
<div style={{display: "flex", justifyContent: "space-between", alignItems: "baseline", marginBottom: 14}}>
|
|
||||||
<div className="serif" style={{fontSize: 22}}>Shops</div>
|
|
||||||
<Btn variant="ghost" icon="plus">Add shop</Btn>
|
|
||||||
</div>
|
|
||||||
<div style={{border: "1px solid var(--line)", borderRadius: "var(--r-md)", overflow: "hidden"}}>
|
|
||||||
{data.shops.map((s, i) => {
|
|
||||||
const count = data.products.filter(p => p.shopId === s.id).length;
|
|
||||||
return (
|
|
||||||
<div key={s.id} style={{padding: "12px 16px", borderBottom: i < data.shops.length-1 ? "1px solid var(--line)" : "none", display: "flex", alignItems: "center", gap: 12}}>
|
|
||||||
<div style={{flex: 1}}>
|
|
||||||
<div style={{fontSize: 14, fontWeight: 500}}>{s.name}</div>
|
|
||||||
<div style={{fontSize: 11, color: "var(--ink-3)"}}>{s.location}</div>
|
|
||||||
</div>
|
|
||||||
<Pill tone="outline">{count} purchase{count===1?"":"s"}</Pill>
|
|
||||||
<Btn variant="ghost" icon="edit" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Brands */}
|
|
||||||
<Card style={{marginBottom: 14}}>
|
|
||||||
<div style={{display: "flex", justifyContent: "space-between", alignItems: "baseline", marginBottom: 14}}>
|
|
||||||
<div className="serif" style={{fontSize: 22}}>Brands</div>
|
|
||||||
<Btn variant="ghost" icon="plus">Add brand</Btn>
|
|
||||||
</div>
|
|
||||||
<div style={{display: "grid", gridTemplateColumns: "repeat(2, 1fr)", gap: 8}}>
|
|
||||||
{data.brands.map(b => {
|
|
||||||
const count = data.products.filter(p => p.brandId === b.id).length;
|
|
||||||
return (
|
|
||||||
<div key={b.id} style={{padding: "10px 14px", border: "1px solid var(--line)", borderRadius: "var(--r-md)", display: "flex", alignItems: "center", gap: 12}}>
|
|
||||||
<div style={{flex: 1, fontSize: 13, fontWeight: 500}}>{b.name}</div>
|
|
||||||
<span className="mono" style={{fontSize: 11, color: "var(--ink-3)"}}>{count}</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card style={{marginBottom: 14}}>
|
|
||||||
<div className="serif" style={{fontSize: 22, marginBottom: 16}}>Library</div>
|
|
||||||
<div style={{display: "grid", gridTemplateColumns: "repeat(4, 1fr)", gap: 12, marginBottom: 16}}>
|
|
||||||
<Stat label="Active" value={data.products.filter(p=>p.status==="active").length} />
|
|
||||||
<Stat label="Consumed" value={data.products.filter(p=>p.status==="consumed").length} />
|
|
||||||
<Stat label="Gone" value={data.products.filter(p=>p.status==="gone").length} />
|
|
||||||
<Stat label="Bins" value={data.bins.length} />
|
|
||||||
</div>
|
|
||||||
<div style={{fontSize: 12, color: "var(--ink-3)"}}>All data is stored locally. Export anytime.</div>
|
|
||||||
<div style={{display: "flex", gap: 8, marginTop: 12}}>
|
|
||||||
<Btn variant="secondary">Export CSV</Btn>
|
|
||||||
<Btn variant="secondary">Export JSON</Btn>
|
|
||||||
<Btn variant="ghost" style={{color: "var(--terracotta)"}}>Reset all data</Btn>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const SettingRow = ({label, hint, children}) => (
|
|
||||||
<div style={{display: "flex", alignItems: "center", justifyContent: "space-between", paddingBottom: 14, borderBottom: "1px solid var(--line)"}}>
|
|
||||||
<div style={{flex: 1}}>
|
|
||||||
<div style={{fontSize: 13, fontWeight: 500}}>{label}</div>
|
|
||||||
{hint && <div style={{fontSize: 12, color: "var(--ink-3)", marginTop: 2}}>{hint}</div>}
|
|
||||||
</div>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
Object.assign(window, { AddProductFlow, ConsumeFlow, MarkGoneFlow, AuditFlow, BinsView, ChartsView, SettingsView });
|
|
||||||
@@ -1,113 +0,0 @@
|
|||||||
/* Apothecary / Botanical design tokens
|
|
||||||
Warm earthy neutrals — parchment, ink, sage, terracotta */
|
|
||||||
|
|
||||||
:root {
|
|
||||||
/* Surfaces — parchment-toned */
|
|
||||||
--bg: oklch(96% 0.012 80); /* parchment */
|
|
||||||
--bg-2: oklch(93% 0.014 80); /* slightly darker parchment */
|
|
||||||
--bg-3: oklch(89% 0.016 78); /* card divider, hover */
|
|
||||||
--surface: oklch(98% 0.008 82); /* card surface */
|
|
||||||
--surface-sunken: oklch(91% 0.014 80);
|
|
||||||
|
|
||||||
/* Ink — warm near-blacks */
|
|
||||||
--ink: oklch(22% 0.012 60); /* primary text */
|
|
||||||
--ink-2: oklch(38% 0.012 60); /* secondary text */
|
|
||||||
--ink-3: oklch(56% 0.014 65); /* tertiary, captions */
|
|
||||||
--ink-4: oklch(72% 0.014 70); /* faint, dividers */
|
|
||||||
|
|
||||||
/* Lines */
|
|
||||||
--line: oklch(82% 0.014 75);
|
|
||||||
--line-strong: oklch(68% 0.016 70);
|
|
||||||
|
|
||||||
/* Accents — share chroma 0.08, lightness 52% */
|
|
||||||
--sage: oklch(52% 0.06 145); /* primary action / good */
|
|
||||||
--sage-2: oklch(64% 0.05 145);
|
|
||||||
--sage-soft: oklch(88% 0.03 145);
|
|
||||||
|
|
||||||
--terracotta: oklch(58% 0.10 40); /* warning / consumed */
|
|
||||||
--terracotta-soft: oklch(90% 0.04 40);
|
|
||||||
|
|
||||||
--amber: oklch(68% 0.10 75); /* low stock / attention */
|
|
||||||
--amber-soft: oklch(91% 0.04 75);
|
|
||||||
|
|
||||||
--plum: oklch(48% 0.06 340); /* secondary accent */
|
|
||||||
|
|
||||||
/* Typography */
|
|
||||||
--serif: "Cormorant Garamond", "GT Sectra", "Playfair Display", Georgia, serif;
|
|
||||||
--sans: "Inter", "Söhne", -apple-system, BlinkMacSystemFont, sans-serif;
|
|
||||||
--mono: "JetBrains Mono", "IBM Plex Mono", ui-monospace, Menlo, monospace;
|
|
||||||
|
|
||||||
/* Radii */
|
|
||||||
--r-sm: 4px;
|
|
||||||
--r-md: 8px;
|
|
||||||
--r-lg: 14px;
|
|
||||||
--r-xl: 20px;
|
|
||||||
|
|
||||||
/* 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);
|
|
||||||
--shadow-lg: 0 8px 24px oklch(20% 0.02 60 / 0.10);
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-theme="dark"] {
|
|
||||||
--bg: oklch(20% 0.012 60);
|
|
||||||
--bg-2: oklch(23% 0.012 60);
|
|
||||||
--bg-3: oklch(27% 0.014 65);
|
|
||||||
--surface: oklch(25% 0.012 60);
|
|
||||||
--surface-sunken: oklch(18% 0.012 60);
|
|
||||||
|
|
||||||
--ink: oklch(94% 0.008 80);
|
|
||||||
--ink-2: oklch(78% 0.012 75);
|
|
||||||
--ink-3: oklch(62% 0.014 70);
|
|
||||||
--ink-4: oklch(46% 0.014 65);
|
|
||||||
|
|
||||||
--line: oklch(34% 0.014 65);
|
|
||||||
--line-strong: oklch(48% 0.016 65);
|
|
||||||
|
|
||||||
--sage: oklch(70% 0.07 145);
|
|
||||||
--sage-2: oklch(60% 0.06 145);
|
|
||||||
--sage-soft: oklch(32% 0.04 145);
|
|
||||||
|
|
||||||
--terracotta: oklch(70% 0.10 40);
|
|
||||||
--terracotta-soft: oklch(32% 0.05 40);
|
|
||||||
|
|
||||||
--amber: oklch(78% 0.10 75);
|
|
||||||
--amber-soft: oklch(32% 0.05 75);
|
|
||||||
}
|
|
||||||
|
|
||||||
* { box-sizing: border-box; }
|
|
||||||
|
|
||||||
html, body {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
background: var(--bg);
|
|
||||||
color: var(--ink);
|
|
||||||
font-family: var(--sans);
|
|
||||||
font-size: 14px;
|
|
||||||
line-height: 1.5;
|
|
||||||
-webkit-font-smoothing: antialiased;
|
|
||||||
text-rendering: optimizeLegibility;
|
|
||||||
}
|
|
||||||
|
|
||||||
button { font-family: inherit; cursor: pointer; }
|
|
||||||
input, select, textarea { font-family: inherit; }
|
|
||||||
|
|
||||||
/* Subtle parchment texture */
|
|
||||||
.parchment {
|
|
||||||
background-image:
|
|
||||||
radial-gradient(oklch(85% 0.03 75 / 0.15) 1px, transparent 1px),
|
|
||||||
radial-gradient(oklch(80% 0.03 75 / 0.10) 1px, transparent 1px);
|
|
||||||
background-size: 24px 24px, 36px 36px;
|
|
||||||
background-position: 0 0, 12px 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Utility */
|
|
||||||
.serif { font-family: var(--serif); font-weight: 500; }
|
|
||||||
.mono { font-family: var(--mono); font-feature-settings: "ss01", "cv11"; }
|
|
||||||
.smallcaps {
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.12em;
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
.hairline { border-top: 1px solid var(--line); }
|
|
||||||
@@ -1,419 +0,0 @@
|
|||||||
|
|
||||||
// tweaks-panel.jsx
|
|
||||||
// Reusable Tweaks shell + form-control helpers.
|
|
||||||
//
|
|
||||||
// Owns the host protocol (listens for __activate_edit_mode / __deactivate_edit_mode,
|
|
||||||
// posts __edit_mode_available / __edit_mode_set_keys / __edit_mode_dismissed) so
|
|
||||||
// individual prototypes don't re-roll it. Ships a consistent set of controls so you
|
|
||||||
// don't hand-draw <input type="range">, segmented radios, steppers, etc.
|
|
||||||
//
|
|
||||||
// Usage (in an HTML file that loads React + Babel):
|
|
||||||
//
|
|
||||||
// const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
|
|
||||||
// "primaryColor": "#D97757",
|
|
||||||
// "fontSize": 16,
|
|
||||||
// "density": "regular",
|
|
||||||
// "dark": false
|
|
||||||
// }/*EDITMODE-END*/;
|
|
||||||
//
|
|
||||||
// function App() {
|
|
||||||
// const [t, setTweak] = useTweaks(TWEAK_DEFAULTS);
|
|
||||||
// return (
|
|
||||||
// <div style={{ fontSize: t.fontSize, color: t.primaryColor }}>
|
|
||||||
// Hello
|
|
||||||
// <TweaksPanel>
|
|
||||||
// <TweakSection label="Typography" />
|
|
||||||
// <TweakSlider label="Font size" value={t.fontSize} min={10} max={32} unit="px"
|
|
||||||
// onChange={(v) => setTweak('fontSize', v)} />
|
|
||||||
// <TweakRadio label="Density" value={t.density}
|
|
||||||
// options={['compact', 'regular', 'comfy']}
|
|
||||||
// onChange={(v) => setTweak('density', v)} />
|
|
||||||
// <TweakSection label="Theme" />
|
|
||||||
// <TweakColor label="Primary" value={t.primaryColor}
|
|
||||||
// onChange={(v) => setTweak('primaryColor', v)} />
|
|
||||||
// <TweakToggle label="Dark mode" value={t.dark}
|
|
||||||
// onChange={(v) => setTweak('dark', v)} />
|
|
||||||
// </TweaksPanel>
|
|
||||||
// </div>
|
|
||||||
// );
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const __TWEAKS_STYLE = `
|
|
||||||
.twk-panel{position:fixed;right:16px;bottom:16px;z-index:2147483646;width:280px;
|
|
||||||
max-height:calc(100vh - 32px);display:flex;flex-direction:column;
|
|
||||||
background:rgba(250,249,247,.78);color:#29261b;
|
|
||||||
-webkit-backdrop-filter:blur(24px) saturate(160%);backdrop-filter:blur(24px) saturate(160%);
|
|
||||||
border:.5px solid rgba(255,255,255,.6);border-radius:14px;
|
|
||||||
box-shadow:0 1px 0 rgba(255,255,255,.5) inset,0 12px 40px rgba(0,0,0,.18);
|
|
||||||
font:11.5px/1.4 ui-sans-serif,system-ui,-apple-system,sans-serif;overflow:hidden}
|
|
||||||
.twk-hd{display:flex;align-items:center;justify-content:space-between;
|
|
||||||
padding:10px 8px 10px 14px;cursor:move;user-select:none}
|
|
||||||
.twk-hd b{font-size:12px;font-weight:600;letter-spacing:.01em}
|
|
||||||
.twk-x{appearance:none;border:0;background:transparent;color:rgba(41,38,27,.55);
|
|
||||||
width:22px;height:22px;border-radius:6px;cursor:default;font-size:13px;line-height:1}
|
|
||||||
.twk-x:hover{background:rgba(0,0,0,.06);color:#29261b}
|
|
||||||
.twk-body{padding:2px 14px 14px;display:flex;flex-direction:column;gap:10px;
|
|
||||||
overflow-y:auto;overflow-x:hidden;min-height:0;
|
|
||||||
scrollbar-width:thin;scrollbar-color:rgba(0,0,0,.15) transparent}
|
|
||||||
.twk-body::-webkit-scrollbar{width:8px}
|
|
||||||
.twk-body::-webkit-scrollbar-track{background:transparent;margin:2px}
|
|
||||||
.twk-body::-webkit-scrollbar-thumb{background:rgba(0,0,0,.15);border-radius:4px;
|
|
||||||
border:2px solid transparent;background-clip:content-box}
|
|
||||||
.twk-body::-webkit-scrollbar-thumb:hover{background:rgba(0,0,0,.25);
|
|
||||||
border:2px solid transparent;background-clip:content-box}
|
|
||||||
.twk-row{display:flex;flex-direction:column;gap:5px}
|
|
||||||
.twk-row-h{flex-direction:row;align-items:center;justify-content:space-between;gap:10px}
|
|
||||||
.twk-lbl{display:flex;justify-content:space-between;align-items:baseline;
|
|
||||||
color:rgba(41,38,27,.72)}
|
|
||||||
.twk-lbl>span:first-child{font-weight:500}
|
|
||||||
.twk-val{color:rgba(41,38,27,.5);font-variant-numeric:tabular-nums}
|
|
||||||
|
|
||||||
.twk-sect{font-size:10px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;
|
|
||||||
color:rgba(41,38,27,.45);padding:10px 0 0}
|
|
||||||
.twk-sect:first-child{padding-top:0}
|
|
||||||
|
|
||||||
.twk-field{appearance:none;width:100%;height:26px;padding:0 8px;
|
|
||||||
border:.5px solid rgba(0,0,0,.1);border-radius:7px;
|
|
||||||
background:rgba(255,255,255,.6);color:inherit;font:inherit;outline:none}
|
|
||||||
.twk-field:focus{border-color:rgba(0,0,0,.25);background:rgba(255,255,255,.85)}
|
|
||||||
select.twk-field{padding-right:22px;
|
|
||||||
background-image:url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'><path fill='rgba(0,0,0,.5)' d='M0 0h10L5 6z'/></svg>");
|
|
||||||
background-repeat:no-repeat;background-position:right 8px center}
|
|
||||||
|
|
||||||
.twk-slider{appearance:none;-webkit-appearance:none;width:100%;height:4px;margin:6px 0;
|
|
||||||
border-radius:999px;background:rgba(0,0,0,.12);outline:none}
|
|
||||||
.twk-slider::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;
|
|
||||||
width:14px;height:14px;border-radius:50%;background:#fff;
|
|
||||||
border:.5px solid rgba(0,0,0,.12);box-shadow:0 1px 3px rgba(0,0,0,.2);cursor:default}
|
|
||||||
.twk-slider::-moz-range-thumb{width:14px;height:14px;border-radius:50%;
|
|
||||||
background:#fff;border:.5px solid rgba(0,0,0,.12);box-shadow:0 1px 3px rgba(0,0,0,.2);cursor:default}
|
|
||||||
|
|
||||||
.twk-seg{position:relative;display:flex;padding:2px;border-radius:8px;
|
|
||||||
background:rgba(0,0,0,.06);user-select:none}
|
|
||||||
.twk-seg-thumb{position:absolute;top:2px;bottom:2px;border-radius:6px;
|
|
||||||
background:rgba(255,255,255,.9);box-shadow:0 1px 2px rgba(0,0,0,.12);
|
|
||||||
transition:left .15s cubic-bezier(.3,.7,.4,1),width .15s}
|
|
||||||
.twk-seg.dragging .twk-seg-thumb{transition:none}
|
|
||||||
.twk-seg button{appearance:none;position:relative;z-index:1;flex:1;border:0;
|
|
||||||
background:transparent;color:inherit;font:inherit;font-weight:500;height:22px;
|
|
||||||
border-radius:6px;cursor:default;padding:0}
|
|
||||||
|
|
||||||
.twk-toggle{position:relative;width:32px;height:18px;border:0;border-radius:999px;
|
|
||||||
background:rgba(0,0,0,.15);transition:background .15s;cursor:default;padding:0}
|
|
||||||
.twk-toggle[data-on="1"]{background:#34c759}
|
|
||||||
.twk-toggle i{position:absolute;top:2px;left:2px;width:14px;height:14px;border-radius:50%;
|
|
||||||
background:#fff;box-shadow:0 1px 2px rgba(0,0,0,.25);transition:transform .15s}
|
|
||||||
.twk-toggle[data-on="1"] i{transform:translateX(14px)}
|
|
||||||
|
|
||||||
.twk-num{display:flex;align-items:center;height:26px;padding:0 0 0 8px;
|
|
||||||
border:.5px solid rgba(0,0,0,.1);border-radius:7px;background:rgba(255,255,255,.6)}
|
|
||||||
.twk-num-lbl{font-weight:500;color:rgba(41,38,27,.6);cursor:ew-resize;
|
|
||||||
user-select:none;padding-right:8px}
|
|
||||||
.twk-num input{flex:1;min-width:0;height:100%;border:0;background:transparent;
|
|
||||||
font:inherit;font-variant-numeric:tabular-nums;text-align:right;padding:0 8px 0 0;
|
|
||||||
outline:none;color:inherit;-moz-appearance:textfield}
|
|
||||||
.twk-num input::-webkit-inner-spin-button,.twk-num input::-webkit-outer-spin-button{
|
|
||||||
-webkit-appearance:none;margin:0}
|
|
||||||
.twk-num-unit{padding-right:8px;color:rgba(41,38,27,.45)}
|
|
||||||
|
|
||||||
.twk-btn{appearance:none;height:26px;padding:0 12px;border:0;border-radius:7px;
|
|
||||||
background:rgba(0,0,0,.78);color:#fff;font:inherit;font-weight:500;cursor:default}
|
|
||||||
.twk-btn:hover{background:rgba(0,0,0,.88)}
|
|
||||||
.twk-btn.secondary{background:rgba(0,0,0,.06);color:inherit}
|
|
||||||
.twk-btn.secondary:hover{background:rgba(0,0,0,.1)}
|
|
||||||
|
|
||||||
.twk-swatch{appearance:none;-webkit-appearance:none;width:56px;height:22px;
|
|
||||||
border:.5px solid rgba(0,0,0,.1);border-radius:6px;padding:0;cursor:default;
|
|
||||||
background:transparent;flex-shrink:0}
|
|
||||||
.twk-swatch::-webkit-color-swatch-wrapper{padding:0}
|
|
||||||
.twk-swatch::-webkit-color-swatch{border:0;border-radius:5.5px}
|
|
||||||
.twk-swatch::-moz-color-swatch{border:0;border-radius:5.5px}
|
|
||||||
`;
|
|
||||||
|
|
||||||
// ── useTweaks ───────────────────────────────────────────────────────────────
|
|
||||||
// Single source of truth for tweak values. setTweak persists via the host
|
|
||||||
// (__edit_mode_set_keys → host rewrites the EDITMODE block on disk).
|
|
||||||
function useTweaks(defaults) {
|
|
||||||
const [values, setValues] = React.useState(defaults);
|
|
||||||
const setTweak = React.useCallback((key, val) => {
|
|
||||||
setValues((prev) => ({ ...prev, [key]: val }));
|
|
||||||
window.parent.postMessage({ type: '__edit_mode_set_keys', edits: { [key]: val } }, '*');
|
|
||||||
}, []);
|
|
||||||
return [values, setTweak];
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── TweaksPanel ─────────────────────────────────────────────────────────────
|
|
||||||
// Floating shell. Registers the protocol listener BEFORE announcing
|
|
||||||
// availability — if the announce ran first, the host's activate could land
|
|
||||||
// before our handler exists and the toolbar toggle would silently no-op.
|
|
||||||
// The close button posts __edit_mode_dismissed so the host's toolbar toggle
|
|
||||||
// flips off in lockstep; the host echoes __deactivate_edit_mode back which
|
|
||||||
// is what actually hides the panel.
|
|
||||||
function TweaksPanel({ title = 'Tweaks', children }) {
|
|
||||||
const [open, setOpen] = React.useState(false);
|
|
||||||
const dragRef = React.useRef(null);
|
|
||||||
const offsetRef = React.useRef({ x: 16, y: 16 });
|
|
||||||
const PAD = 16;
|
|
||||||
|
|
||||||
const clampToViewport = React.useCallback(() => {
|
|
||||||
const panel = dragRef.current;
|
|
||||||
if (!panel) return;
|
|
||||||
const w = panel.offsetWidth, h = panel.offsetHeight;
|
|
||||||
const maxRight = Math.max(PAD, window.innerWidth - w - PAD);
|
|
||||||
const maxBottom = Math.max(PAD, window.innerHeight - h - PAD);
|
|
||||||
offsetRef.current = {
|
|
||||||
x: Math.min(maxRight, Math.max(PAD, offsetRef.current.x)),
|
|
||||||
y: Math.min(maxBottom, Math.max(PAD, offsetRef.current.y)),
|
|
||||||
};
|
|
||||||
panel.style.right = offsetRef.current.x + 'px';
|
|
||||||
panel.style.bottom = offsetRef.current.y + 'px';
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (!open) return;
|
|
||||||
clampToViewport();
|
|
||||||
if (typeof ResizeObserver === 'undefined') {
|
|
||||||
window.addEventListener('resize', clampToViewport);
|
|
||||||
return () => window.removeEventListener('resize', clampToViewport);
|
|
||||||
}
|
|
||||||
const ro = new ResizeObserver(clampToViewport);
|
|
||||||
ro.observe(document.documentElement);
|
|
||||||
return () => ro.disconnect();
|
|
||||||
}, [open, clampToViewport]);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
const onMsg = (e) => {
|
|
||||||
const t = e?.data?.type;
|
|
||||||
if (t === '__activate_edit_mode') setOpen(true);
|
|
||||||
else if (t === '__deactivate_edit_mode') setOpen(false);
|
|
||||||
};
|
|
||||||
window.addEventListener('message', onMsg);
|
|
||||||
window.parent.postMessage({ type: '__edit_mode_available' }, '*');
|
|
||||||
return () => window.removeEventListener('message', onMsg);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const dismiss = () => {
|
|
||||||
setOpen(false);
|
|
||||||
window.parent.postMessage({ type: '__edit_mode_dismissed' }, '*');
|
|
||||||
};
|
|
||||||
|
|
||||||
const onDragStart = (e) => {
|
|
||||||
const panel = dragRef.current;
|
|
||||||
if (!panel) return;
|
|
||||||
const r = panel.getBoundingClientRect();
|
|
||||||
const sx = e.clientX, sy = e.clientY;
|
|
||||||
const startRight = window.innerWidth - r.right;
|
|
||||||
const startBottom = window.innerHeight - r.bottom;
|
|
||||||
const move = (ev) => {
|
|
||||||
offsetRef.current = {
|
|
||||||
x: startRight - (ev.clientX - sx),
|
|
||||||
y: startBottom - (ev.clientY - sy),
|
|
||||||
};
|
|
||||||
clampToViewport();
|
|
||||||
};
|
|
||||||
const up = () => {
|
|
||||||
window.removeEventListener('mousemove', move);
|
|
||||||
window.removeEventListener('mouseup', up);
|
|
||||||
};
|
|
||||||
window.addEventListener('mousemove', move);
|
|
||||||
window.addEventListener('mouseup', up);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!open) return null;
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<style>{__TWEAKS_STYLE}</style>
|
|
||||||
<div ref={dragRef} className="twk-panel"
|
|
||||||
style={{ right: offsetRef.current.x, bottom: offsetRef.current.y }}>
|
|
||||||
<div className="twk-hd" onMouseDown={onDragStart}>
|
|
||||||
<b>{title}</b>
|
|
||||||
<button className="twk-x" aria-label="Close tweaks"
|
|
||||||
onMouseDown={(e) => e.stopPropagation()}
|
|
||||||
onClick={dismiss}>✕</button>
|
|
||||||
</div>
|
|
||||||
<div className="twk-body">{children}</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Layout helpers ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function TweakSection({ label, children }) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="twk-sect">{label}</div>
|
|
||||||
{children}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function TweakRow({ label, value, children, inline = false }) {
|
|
||||||
return (
|
|
||||||
<div className={inline ? 'twk-row twk-row-h' : 'twk-row'}>
|
|
||||||
<div className="twk-lbl">
|
|
||||||
<span>{label}</span>
|
|
||||||
{value != null && <span className="twk-val">{value}</span>}
|
|
||||||
</div>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Controls ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function TweakSlider({ label, value, min = 0, max = 100, step = 1, unit = '', onChange }) {
|
|
||||||
return (
|
|
||||||
<TweakRow label={label} value={`${value}${unit}`}>
|
|
||||||
<input type="range" className="twk-slider" min={min} max={max} step={step}
|
|
||||||
value={value} onChange={(e) => onChange(Number(e.target.value))} />
|
|
||||||
</TweakRow>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function TweakToggle({ label, value, onChange }) {
|
|
||||||
return (
|
|
||||||
<div className="twk-row twk-row-h">
|
|
||||||
<div className="twk-lbl"><span>{label}</span></div>
|
|
||||||
<button type="button" className="twk-toggle" data-on={value ? '1' : '0'}
|
|
||||||
role="switch" aria-checked={!!value}
|
|
||||||
onClick={() => onChange(!value)}><i /></button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function TweakRadio({ label, value, options, onChange }) {
|
|
||||||
const trackRef = React.useRef(null);
|
|
||||||
const [dragging, setDragging] = React.useState(false);
|
|
||||||
const opts = options.map((o) => (typeof o === 'object' ? o : { value: o, label: o }));
|
|
||||||
const idx = Math.max(0, opts.findIndex((o) => o.value === value));
|
|
||||||
const n = opts.length;
|
|
||||||
|
|
||||||
// The active value is read by pointer-move handlers attached for the lifetime
|
|
||||||
// of a drag — ref it so a stale closure doesn't fire onChange for every move.
|
|
||||||
const valueRef = React.useRef(value);
|
|
||||||
valueRef.current = value;
|
|
||||||
|
|
||||||
const segAt = (clientX) => {
|
|
||||||
const r = trackRef.current.getBoundingClientRect();
|
|
||||||
const inner = r.width - 4;
|
|
||||||
const i = Math.floor(((clientX - r.left - 2) / inner) * n);
|
|
||||||
return opts[Math.max(0, Math.min(n - 1, i))].value;
|
|
||||||
};
|
|
||||||
|
|
||||||
const onPointerDown = (e) => {
|
|
||||||
setDragging(true);
|
|
||||||
const v0 = segAt(e.clientX);
|
|
||||||
if (v0 !== valueRef.current) onChange(v0);
|
|
||||||
const move = (ev) => {
|
|
||||||
if (!trackRef.current) return;
|
|
||||||
const v = segAt(ev.clientX);
|
|
||||||
if (v !== valueRef.current) onChange(v);
|
|
||||||
};
|
|
||||||
const up = () => {
|
|
||||||
setDragging(false);
|
|
||||||
window.removeEventListener('pointermove', move);
|
|
||||||
window.removeEventListener('pointerup', up);
|
|
||||||
};
|
|
||||||
window.addEventListener('pointermove', move);
|
|
||||||
window.addEventListener('pointerup', up);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TweakRow label={label}>
|
|
||||||
<div ref={trackRef} role="radiogroup" onPointerDown={onPointerDown}
|
|
||||||
className={dragging ? 'twk-seg dragging' : 'twk-seg'}>
|
|
||||||
<div className="twk-seg-thumb"
|
|
||||||
style={{ left: `calc(2px + ${idx} * (100% - 4px) / ${n})`,
|
|
||||||
width: `calc((100% - 4px) / ${n})` }} />
|
|
||||||
{opts.map((o) => (
|
|
||||||
<button key={o.value} type="button" role="radio" aria-checked={o.value === value}>
|
|
||||||
{o.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</TweakRow>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function TweakSelect({ label, value, options, onChange }) {
|
|
||||||
return (
|
|
||||||
<TweakRow label={label}>
|
|
||||||
<select className="twk-field" value={value} onChange={(e) => onChange(e.target.value)}>
|
|
||||||
{options.map((o) => {
|
|
||||||
const v = typeof o === 'object' ? o.value : o;
|
|
||||||
const l = typeof o === 'object' ? o.label : o;
|
|
||||||
return <option key={v} value={v}>{l}</option>;
|
|
||||||
})}
|
|
||||||
</select>
|
|
||||||
</TweakRow>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function TweakText({ label, value, placeholder, onChange }) {
|
|
||||||
return (
|
|
||||||
<TweakRow label={label}>
|
|
||||||
<input className="twk-field" type="text" value={value} placeholder={placeholder}
|
|
||||||
onChange={(e) => onChange(e.target.value)} />
|
|
||||||
</TweakRow>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function TweakNumber({ label, value, min, max, step = 1, unit = '', onChange }) {
|
|
||||||
const clamp = (n) => {
|
|
||||||
if (min != null && n < min) return min;
|
|
||||||
if (max != null && n > max) return max;
|
|
||||||
return n;
|
|
||||||
};
|
|
||||||
const startRef = React.useRef({ x: 0, val: 0 });
|
|
||||||
const onScrubStart = (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
startRef.current = { x: e.clientX, val: value };
|
|
||||||
const decimals = (String(step).split('.')[1] || '').length;
|
|
||||||
const move = (ev) => {
|
|
||||||
const dx = ev.clientX - startRef.current.x;
|
|
||||||
const raw = startRef.current.val + dx * step;
|
|
||||||
const snapped = Math.round(raw / step) * step;
|
|
||||||
onChange(clamp(Number(snapped.toFixed(decimals))));
|
|
||||||
};
|
|
||||||
const up = () => {
|
|
||||||
window.removeEventListener('pointermove', move);
|
|
||||||
window.removeEventListener('pointerup', up);
|
|
||||||
};
|
|
||||||
window.addEventListener('pointermove', move);
|
|
||||||
window.addEventListener('pointerup', up);
|
|
||||||
};
|
|
||||||
return (
|
|
||||||
<div className="twk-num">
|
|
||||||
<span className="twk-num-lbl" onPointerDown={onScrubStart}>{label}</span>
|
|
||||||
<input type="number" value={value} min={min} max={max} step={step}
|
|
||||||
onChange={(e) => onChange(clamp(Number(e.target.value)))} />
|
|
||||||
{unit && <span className="twk-num-unit">{unit}</span>}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function TweakColor({ label, value, onChange }) {
|
|
||||||
return (
|
|
||||||
<div className="twk-row twk-row-h">
|
|
||||||
<div className="twk-lbl"><span>{label}</span></div>
|
|
||||||
<input type="color" className="twk-swatch" value={value}
|
|
||||||
onChange={(e) => onChange(e.target.value)} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function TweakButton({ label, onClick, secondary = false }) {
|
|
||||||
return (
|
|
||||||
<button type="button" className={secondary ? 'twk-btn secondary' : 'twk-btn'}
|
|
||||||
onClick={onClick}>{label}</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Object.assign(window, {
|
|
||||||
useTweaks, TweaksPanel, TweakSection, TweakRow,
|
|
||||||
TweakSlider, TweakToggle, TweakRadio, TweakSelect,
|
|
||||||
TweakText, TweakNumber, TweakColor, TweakButton,
|
|
||||||
});
|
|
||||||
Reference in New Issue
Block a user