diff --git a/README.md b/README.md index 85de2ac..1cad3d3 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ inventory_summary() | | | |---|---| | **Tools** | `inventory_summary` · `inventory_list` · `inventory_get` · `inventory_upsert_lot` · `inventory_upsert_item` · `inventory_delete_item` · `inventory_set_price` · `inventory_mark_sold` · `inventory_listing` · `inventory_import_csv` · `inventory_export_csv` · `inventory_reprice_plan` · `inventory_stale` | -| **View** | a rail panel: the item grid (double-click to edit name/category/condition/qty; status select; click a price for the Price dialog), Price / Sold / Listing / Edit / Delete per row, an optional **game system** per item (filter, ordering, inline edit with suggestions), multi-select with **Copy list** (also ⌘C) — a for-sale post per game system: the system as a header, `(all NoS)` when the group shares a condition, `Name (condition) — $price` per line, pending/listed items under their own marker, lots with their P&L, sales, an activity log, CSV import (file or paste, with the mapping report) and export | +| **View** | a rail panel: the item grid (double-click to edit name/category/condition/qty; status select; click a price for the Price dialog), Price / Sold / Listing / Edit / Delete per row, an optional **game system** per item (filter, ordering, inline edit with suggestions), multi-select with **Copy list** (also ⌘C) — a for-sale post per game system: the system as a header, then `Name NoS — $20` per line (condition inline, whole-dollar prices), lots with their P&L, sales, an activity log, CSV import (file or paste, with the mapping report) and export | | **API** | bearer-gated JSON under `/api/plugins/inventory` — `summary`, `lots`, `items`, `items/{id}/price`, `items/{id}/sold`, `items/{id}/listings`, `listings/{id}/end`, `sales`, `stale`, `audit`, `import`, `export` | | **Automations** | `weekly_review: true` arms a plugin-owned recurring turn (`weekly_review_cron`, default Monday 09:00 in `review_timezone`) that re-prices stale evidence from eBay sold comps through the same tools, reports stale listings with a recommendation (it never changes a listing itself), and posts the per-lot P&L. Cancelled when the plugin is disabled. | | **Events** | `inventory.item.changed`, `inventory.lot.changed`, `inventory.sale.recorded`, `inventory.imported` | diff --git a/protoagent.plugin.yaml b/protoagent.plugin.yaml index 46903e7..3ac8bff 100644 --- a/protoagent.plugin.yaml +++ b/protoagent.plugin.yaml @@ -1,6 +1,6 @@ id: inventory name: Inventory (resale source of truth) -version: 0.4.1 +version: 0.4.2 description: >- The resale inventory as a source of truth: lots (what you bought and for how much), items (what each lot split into, its condition and status), listings (where each item is diff --git a/pyproject.toml b/pyproject.toml index 7887139..a444e0f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "inventory-plugin" -version = "0.4.1" +version = "0.4.2" description = "Resale inventory as a source of truth for protoAgent — lots, items, listings, sales, price evidence, audit" requires-python = ">=3.11" diff --git a/view.py b/view.py index 05d34c2..111cf04 100644 --- a/view.py +++ b/view.py @@ -116,37 +116,21 @@ // Chat-friendly price: whole dollars without cents ($20), cents only when they matter ($57.69). const chatPrice = (v) => (v === null || v === undefined || v === "" ? "" : (Number(v) < 0 ? "-" : "") + "$" + Math.abs(Number(v)).toLocaleString(undefined, Number.isInteger(Number(v)) ? { maximumFractionDigits: 0 } : { minimumFractionDigits: 2, maximumFractionDigits: 2 })); const clean = (s) => String(s || "").replace(/\s+/g, " ").trim(); - // One line per item, the way it reads in a group chat: "Name (condition) — $price". - const chatLine = (it, withCondition = true) => { - const cond = withCondition && clean(it.condition) ? " (" + clean(it.condition) + ")" : ""; + // One line per item, the way it reads in a group chat: "Name NoS — $20" — the condition + // inline after the name, then the price. Josh's own post is the spec. + const chatLine = (it) => { + const cond = clean(it.condition) ? " " + clean(it.condition) : ""; const price = chatPrice(it.target); return clean(it.name) + cond + (price ? " — " + price : ""); }; // The copied document, shaped like a for-sale post: the game system as a plain header - // (blank → "Other", last); "(all )" under it when every item in the group shares - // one condition, otherwise the condition per line; items that are already pending/listed/sold - // gathered after a "(Pending)" / "(Listed)" / "(Sold)" marker. No bullets, no lots, no categories. - const STATUS_SECTIONS = [["", ["planned", "available", "kept", "withdrawn"]], ["(Listed)", ["listed"]], ["(Pending)", ["pending"]], ["(Sold)", ["sold"]]]; + // (blank → "Other", last), then one line per item in the grid's order. No bullets, no lots, + // no categories, no status sections. function chatDoc(items) { const groups = new Map(); for (const it of items) { const k = clean(it.system); if (!groups.has(k)) groups.set(k, []); groups.get(k).push(it); } const order = (keys) => keys.filter((k) => k).sort((a, b) => a.localeCompare(b)).concat(keys.includes("") ? [""] : []); - const blocks = []; - for (const sys of order([...groups.keys()])) { - const rows = groups.get(sys); - const conds = new Set(rows.map((it) => clean(it.condition).toLowerCase())); - const shared = conds.size === 1 && !conds.has("") ? clean(rows[0].condition) : ""; - const lines = [sys || "Other"]; - if (shared) lines.push("(all " + shared + ")"); - for (const [marker, statuses] of STATUS_SECTIONS) { - const part = rows.filter((it) => statuses.includes(it.status)); - if (!part.length) continue; - if (marker) lines.push(marker); - lines.push(...part.map((it) => chatLine(it, !shared))); - } - blocks.push(lines.join("\n")); - } - return blocks.join("\n\n") + "\n"; + return order([...groups.keys()]).map((sys) => [sys || "Other", ...groups.get(sys).map(chatLine)].join("\n")).join("\n\n") + "\n"; } async function copyText(text) { try { await navigator.clipboard.writeText(text); return true; } catch (e) { /* denied in this frame, or no secure context */ } @@ -280,7 +264,7 @@ const n = state.selected.size; if (!n) return ""; const visible = state.items.filter((it) => state.selected.has(it.id)).length; return '
' + n + " selected" + (visible !== n ? " (" + visible + " visible)" : "") + "" + - '' + + '' + '' + '⌘C
'; } @@ -361,7 +345,7 @@ title: item ? "Edit " + item.id : "New item", submit: item ? "Save" : "Add", body: field("name", "Name", it.name, { required: true, span2: true }) + field("lot_id", "Lot", it.lot_id, { type: "select", options: lotOptions(it.lot_id) }) + field("system", "Game system", it.system || "", { placeholder: "Warhammer 40K, Blood Bowl…", list: "systems-dl", hint: "Optional. Lists and copied Markdown group by it." }) + - field("category", "Category", it.category) + field("condition", "Condition", it.condition, { placeholder: "NoS, NIB, Sealed, Assembled, Painted…", list: "conditions-dl", hint: "Printed next to the name in a copied list." }) + + field("category", "Category", it.category) + field("condition", "Condition", it.condition, { placeholder: "NoS, NIB, Sealed, Assembled, Painted…", list: "conditions-dl", hint: "Printed inline after the name in a copied list." }) + field("status", "Status", it.status, { type: "select", options: STATUSES.filter((s) => s !== "sold" || it.status === "sold") }) + field("quantity", "Quantity", it.quantity ?? 1, { type: "number", step: "1" }) + field("model_count", "Model count", it.model_count ?? "", { type: "number", step: "1" }) + field("retail", "Retail ($)", it.retail ?? "", { type: "number", step: "0.01", hint: "The anchor price, not a target." }) + field("cost_basis", "Cost basis ($)", it.cost_basis ?? "", { type: "number", step: "0.01" }) +