This page requires JavaScript to display.
Unpacking...
+ (c / 100).toFixed(2);\nconst TODAY = '24 Aug 2026';\n\nconst PRODUCTS = [\n { id: 'p1', name: 'Apple Motorbreath 1g Cart', brand: 'Halcyon', cat: 'Vape cartridge', format: '1 g cartridge, 30 per case', sku: 'HLNV-000001', strain: 'Apple Motorbreath · hybrid', band: 'In stock', price: 1850, priceFrom: '2 Jul 2026',\n lots: [{ tag: '1A4060300019F21000004182', packaged: '13 Aug 2026' }, { tag: '1A4060300019F21000003907', packaged: '24 Jul 2026' }] },\n { id: 'p2', name: \"Tiger's Blood 1g Cart\", brand: 'Halcyon', cat: 'Vape cartridge', format: '1 g cartridge, 30 per case', sku: 'HLNV-000022', strain: \"Tiger's Blood · hybrid\", band: 'In stock', price: 1850, priceFrom: '19 Jun 2026',\n lots: [{ tag: '1A4060300019F21000004310', packaged: '10 Aug 2026' }] },\n { id: 'p3', name: 'Watermelon Zkittlez 0.5g Cart', brand: 'Northline', cat: 'Vape cartridge', format: '0.5 g cartridge, 30 per case', sku: 'NRLV-000008', strain: 'Watermelon Zkittlez · hybrid', band: 'Out of stock', oos: true, price: 1290, priceFrom: '11 Mar 2026',\n lots: [{ tag: '1A4060300021C08000001174', packaged: '3 Mar 2026' }] },\n { id: 'p4', name: 'New York Sunrise Gummies 10ct', brand: 'Wren & Ash', cat: 'Infused edible', format: '10 count pouch, 24 per case', sku: 'WRAE-000007', strain: 'New York Sunrise · hybrid', band: 'In stock', price: 1420, priceFrom: '4 Aug 2026',\n lots: [{ tag: '1A4060300018B44000009021', packaged: '18 Aug 2026' }, { tag: '1A4060300018B44000008765', packaged: '4 Aug 2026' }] },\n { id: 'p5', name: 'Blueberry Chews 10ct', brand: 'Kestrel', cat: 'Infused edible', format: '10 count pouch, 24 per case', sku: 'KSTE-000014', strain: 'Blueberry · hybrid', band: 'In stock', price: 1380, priceFrom: '22 May 2026',\n lots: [{ tag: '1A4060300018B44000007702', packaged: '2 Jul 2026' }] },\n { id: 'p6', name: 'Killer Kiwi Infused Pre-Roll', brand: 'Halcyon', cat: 'Infused pre-roll', format: '1 g single, 50 per case', sku: 'HLNF-000002', strain: 'Killer Kiwi', band: 'In stock', price: 640, priceFrom: '7 Aug 2026',\n lots: [{ tag: '1A4060300017E19000006643', packaged: '7 Aug 2026' }] },\n { id: 'p7', name: 'Grape Gas Infused Pre-Roll 5pk', brand: 'Foundry Line', cat: 'Infused pre-roll', format: '5 pack, 20 per case', sku: 'FDLF-000011', strain: 'Grape Gas', band: 'In stock', price: 2450, priceFrom: '28 May 2026',\n lots: [{ tag: '1A4060300017E19000005512', packaged: '21 May 2026' }] },\n { id: 'p8', name: 'Singapore Sling 3.5g Flower', brand: 'urbanXtracts', cat: 'Packaged flower', format: '3.5 g jar, 12 per case', sku: 'UXTB-000004', strain: 'Singapore Sling · hybrid', band: 'In stock', price: 1980, priceFrom: '2 Jun 2026',\n lots: [{ tag: '1A4060300016D77000004401', packaged: '28 Jul 2026' }, { tag: '1A4060300016D77000004120', packaged: '20 May 2026' }] },\n { id: 'p9', name: 'Permanent Marker 3.5g Flower', brand: 'urbanXtracts', cat: 'Packaged flower', format: '3.5 g jar, 12 per case', sku: 'UXTB-000009', strain: 'Permanent Marker · hybrid', band: 'Low stock', price: null, priceFrom: null,\n lots: [{ tag: '1A4060300016D77000003988', packaged: '30 Mar 2026' }] },\n { id: 'p10', name: 'Singapore Sling Live Rosin 1g', brand: 'urbanXtracts', cat: 'Concentrate', format: '1 g jar, 20 per case', sku: 'UXTC-000003', strain: 'Singapore Sling · hybrid', band: 'In stock', price: 3600, priceFrom: '15 Jul 2026',\n lots: [{ tag: '1A4060300015C22000002210', packaged: '8 Jul 2026' }] },\n { id: 'p11', name: 'Hudson Valley Kush Badder 1g', brand: 'urbanXtracts', cat: 'Concentrate', format: '1 g jar, 20 per case', sku: 'UXTC-000018', strain: 'Hudson Valley Kush · hybrid', band: 'Low stock', price: 2900, priceFrom: '8 Apr 2026',\n lots: [{ tag: '1A4060300015C22000001845', packaged: '4 Mar 2026' }] },\n { id: 'p12', name: 'Purple Dream Infused Flower 3.5g', brand: 'Bellwether', cat: 'Infused flower', format: '3.5 g jar, 12 per case', sku: 'BLWI-000006', strain: 'Purple Dream · hybrid', band: 'In stock', price: null, priceFrom: null,\n lots: [{ tag: '1A4060300014A55000007731', packaged: '9 Jun 2026' }] }\n];\n\nconst P = id => PRODUCTS.find(x => x.id === id);\n\n/* Lucide icon path data (path-only variants so they inline cleanly) */\nconst ICON = {\n dash: ['M3 3h7v7H3z', 'M14 3h7v7h-7z', 'M14 14h7v7h-7z', 'M3 14h7v7H3z'],\n catalog: ['M12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.83z', 'M2 12a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 12', 'M2 17a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 17'],\n cart: ['M2.05 2.05h2l2.66 12.42a2 2 0 0 0 2 1.58h9.78a2 2 0 0 0 1.95-1.57l1.65-7.43H5.12', 'M7 20a1 1 0 1 0 2 0a1 1 0 1 0-2 0', 'M18 20a1 1 0 1 0 2 0a1 1 0 1 0-2 0'],\n orders: ['M3 6h.01', 'M3 12h.01', 'M3 18h.01', 'M8 6h13', 'M8 12h13', 'M8 18h13'],\n validation: ['M21.73 18l-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3', 'M12 9v4', 'M12 17h.01'],\n accounts: ['M2 7l4.41-4.41A2 2 0 0 1 7.83 2h8.34a2 2 0 0 1 1.42.59L22 7', 'M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8', 'M15 22v-4a2 2 0 0 0-2-2h-2a2 2 0 0 0-2 2v4', 'M2 7h20'],\n qa: ['M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z', 'M9 12l2 2 4-4'],\n economics: ['M16 7h6v6', 'M22 7l-8.5 8.5-5-5L2 17'],\n kiosk: ['M3 7V5a2 2 0 0 1 2-2h2', 'M17 3h2a2 2 0 0 1 2 2v2', 'M21 17v2a2 2 0 0 1-2 2h-2', 'M7 21H5a2 2 0 0 1-2-2v-2', 'M7 12h10'],\n admin: ['M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2', 'M9 3a4 4 0 1 0 0 8a4 4 0 1 0 0-8', 'M22 21v-2a4 4 0 0 0-3-3.87', 'M16 3.13a4 4 0 0 1 0 7.75'],\n audit: ['M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8', 'M3 3v5h5', 'M12 7v5l4 2'],\n bell: ['M10.268 21a2 2 0 0 0 3.464 0', 'M3.262 15.326A1 1 0 0 0 4 17h16a1 1 0 0 0 .74-1.673C19.41 13.956 18 12.499 18 8A6 6 0 0 0 6 8c0 4.499-1.411 5.956-2.738 7.326'],\n access: ['M3 11h18v11H3z', 'M7 11V7a5 5 0 0 1 10 0v4'],\n lineage: ['M6 3v12', 'M18 3a3 3 0 1 0 0 6a3 3 0 1 0 0-6', 'M6 15a3 3 0 1 0 0 6a3 3 0 1 0 0-6', 'M18 9a9 9 0 0 1-9 9'],\n coa: ['M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7z', 'M14 2v4a2 2 0 0 0 2 2h4', 'M16 13H8', 'M16 17H8'],\n compare: ['M3 3h18v18H3z', 'M9 3v18', 'M15 3v18'],\n training: ['M12 7v14', 'M3 18a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h5a4 4 0 0 1 4 4 4 4 0 0 1 4-4h5a1 1 0 0 1 1 1v13a1 1 0 0 1-1 1h-6a3 3 0 0 0-3 3 3 3 0 0 0-3-3z']\n};\n\n/* The signed-in external organisation for this prototype session. */\nconst ACTING_ORG = 'Downtown Provisions';\n\nconst USERS = [\n { name: 'Dana Whitfield', email: 'dana@downtownprovisions.com', org: 'Downtown Provisions', role: 'Store owner', locs: 'All 3 locations', last: '24 Aug 2026' },\n { name: 'Marcus Reyes', email: 'marcus@downtownprovisions.com', org: 'Downtown Provisions', role: 'Buyer', locs: 'Downtown', last: '24 Aug 2026' },\n { name: 'Priya Anand', email: 'priya@downtownprovisions.com', org: 'Downtown Provisions', role: 'Budtender', locs: 'Downtown', last: '22 Aug 2026' },\n { name: 'Floor tablet 01', email: 'device · paired 4 Jun 2026', org: 'Downtown Provisions', role: 'Kiosk device', locs: 'Downtown', last: '24 Aug 2026' },\n { name: 'Sam Okafor', email: 'sam@riversidecollective.com', org: 'Riverside Collective', role: 'Store owner', locs: 'Riverside', last: '19 Aug 2026' },\n { name: 'Toni Alvarez', email: 'toni@urbanxtracts.com', org: 'urbanXtracts', role: 'Account management', locs: 'Assigned accounts', last: '24 Aug 2026' }\n];\n\nconst SEED_AUDIT = [\n { ts: '24 Aug 2026 09:12', actor: 'Toni Alvarez', action: 'Price override requested', subject: 'Purple Dream 3.5g · Riverside', detail: 'Awaiting approval — reps cannot set price' },\n { ts: '23 Aug 2026 16:40', actor: 'System', action: 'Release state changed', subject: 'Tag …4310', detail: 'In progress → available, lab result passed' },\n { ts: '22 Aug 2026 11:05', actor: 'Quality', action: 'COA published', subject: 'Tag …9021', detail: 'Version 1 — first publication' },\n { ts: '21 Aug 2026 08:55', actor: 'Administration', action: 'Permission changed', subject: 'Priya Anand', detail: 'Buyer → Budtender, pricing no longer rendered' },\n { ts: '19 Aug 2026 14:22', actor: 'Administration', action: 'Ownership code changed', subject: 'Inbound lot IL-0442', detail: 'TOLL → SPLIT, internal only' }\n];\n\nconst SESSION_STATES = [\n { title: 'Sign in', kind: 'ENTRY', body: 'Email and password, then the role and organisation come from the account — never from a picker the user controls. No multi-factor step in phase one, recorded as an assumption.', action: 'Sign in' },\n { title: 'Session timed out', kind: 'EXPIRED', body: 'You were signed out after a period of inactivity. Your draft order was saved and nothing was submitted. Signing back in returns you to where you were.', action: 'Sign in again' },\n { title: 'Access denied', kind: 'DENIED', body: 'Your account does not have access to this area. It never says whether the thing behind it exists, and the request was refused by the server, not hidden by the interface.', action: 'Back to dashboard' },\n { title: 'Link expired', kind: 'EXPIRED LINK', body: 'This link is no longer valid. Request a fresh one — the original never discloses what it pointed at, including on a public COA page.', action: 'Request a new link' },\n { title: 'Permissions changed', kind: 'RE-EVALUATED', body: 'Your access changed while you were working. The view has reloaded against your current permissions rather than degrading in place, and anything you could no longer see has been removed.', action: 'Reload' },\n { title: 'Viewing as another user', kind: 'IMPERSONATION', body: 'An administrator is viewing this account read-only. A banner is visible for the whole session, cost is withheld even from an administrator who could otherwise see it, and the event is recorded in this organisation\\u2019s own audit history.', action: 'Understood' }\n];\n\nconst ACCOUNTS_DETAIL = {\n 'OCM-RETL-24-000412': {\n name: 'Downtown Provisions', org: 'Downtown Provisions', state: 'NY', since: 'First order 14 Feb 2025',\n locations: [\n { name: 'Downtown', license: 'OCM-RETL-24-000412', status: 'Active' },\n { name: 'Riverside', license: 'OCM-RETL-24-000518', status: 'Balance past due' },\n { name: 'Northgate', license: 'OCM-RETL-24-000633', status: 'License expired 12 Aug' }\n ],\n contacts: [\n { name: 'Dana Whitfield', role: 'Owner · approves orders', reach: 'dana@downtownprovisions.com' },\n { name: 'Marcus Reyes', role: 'Buyer · Downtown', reach: 'marcus@downtownprovisions.com' },\n { name: 'Priya Anand', role: 'Budtender · Downtown', reach: 'priya@downtownprovisions.com' }\n ],\n mix: [\n { cat: 'Vape cartridge', share: 38 }, { cat: 'Infused edible', share: 26 },\n { cat: 'Concentrate', share: 18 }, { cat: 'Infused pre-roll', share: 12 }, { cat: 'Packaged flower', share: 6 }\n ],\n notes: [\n { when: '19 Aug 2026', who: 'Toni Alvarez', text: 'Store visit — asked about a larger edible pack. Logged as a request, no commitment made.' },\n { when: '2 Aug 2026', who: 'Toni Alvarez', text: 'Northgate renewal in progress with their compliance consultant.' }\n ],\n notOrdered: ['Purple Dream Infused Flower 3.5g', 'Hudson Valley Kush Badder 1g', 'Grape Gas Infused Pre-Roll 5pk']\n }\n};\n\nconst LOCATION_ROWS = [\n { metric: 'Orders, 90 days', a: '9', b: '6', c: '3' },\n { metric: 'Spend, 90 days', a: '$24,180', b: '$16,940', c: '$7,090' },\n { metric: 'Average order value', a: '$2,687', b: '$2,823', c: '$2,363' },\n { metric: 'Reorder interval', a: '19 days', b: '31 days', c: '48 days' },\n { metric: 'Largest category', a: 'Vape cartridge', b: 'Infused edible', c: 'Concentrate' },\n { metric: 'Distinct SKUs ordered', a: '14', b: '9', c: '5' },\n { metric: 'Ordering status', a: 'Open', b: 'Blocked — past due', c: 'Blocked — license' },\n { metric: 'Sell-through', a: 'Not connected', b: 'Not connected', c: 'Not connected' }\n];\n\nconst TESTS = [\n { label: 'REQUIRED TEST', name: 'An expired license blocks ordering at that location only', how: 'Buyer role, switch to Northgate: submission refuses, other locations still submit.', state: 'Enforced' },\n { label: 'REQUIRED TEST', name: 'Unreleased product is not visible', how: 'Catalog renders only packages that are available and lab-passed; failed and in-progress lots have no external presence.', state: 'Enforced' },\n { label: 'REQUIRED TEST', name: 'The receivables gate fires at order entry, not at shipping', how: 'Buyer role, Riverside: submit refuses with a reason and the draft is kept.', state: 'Enforced' },\n { label: 'REQUIRED TEST', name: 'One store cannot see another store\\u2019s orders', how: 'Orders are filtered by the acting location for every external role.', state: 'Enforced' },\n { label: 'REQUIRED TEST', name: 'A blocked user is given a path forward', how: 'Every gate names its reason and who to contact; no block is silent.', state: 'Enforced' },\n { label: 'REQUIRED TEST', name: 'Cost is absent from every external and impersonated view', how: 'Cost and margin is structurally absent from external navigation and from a view-as session.', state: 'Enforced' },\n { label: 'RECOMMENDED ADDITIONAL TEST', name: 'One store cannot see another store\\u2019s users', how: 'Users are scoped to the acting organisation; role changes are refused cross-org and from read-only sessions.', state: 'Enforced after a defect' },\n { label: 'RECOMMENDED ADDITIONAL TEST', name: 'A partial compliance tag never resolves to one lot', how: 'Kiosk lookup returns a candidate list or a refusal; typed entry requires the full tag.', state: 'Enforced' },\n { label: 'RECOMMENDED ADDITIONAL TEST', name: 'No aggregate blends incompatible units', how: 'Grams and units are reported separately; no combined inventory total is offered.', state: 'Enforced' },\n { label: 'RECOMMENDED ADDITIONAL TEST', name: 'Every list-backed screen is scoped by acting organisation', how: 'Orders, users, notifications, drafts, price requests and audit rows all filter on the actor, not the screen.', state: 'To verify per screen' }\n];\n\nconst CAT_HUE = {\n 'Vape cartridge': 'var(--ux-cat-vape)',\n 'Infused edible': 'var(--ux-cat-edible)',\n 'Infused pre-roll': 'var(--ux-cat-preroll)',\n 'Packaged flower': 'var(--ux-cat-flower)',\n 'Concentrate': 'var(--ux-cat-concentrate)',\n 'Infused flower': 'var(--ux-cat-infused)'\n};\nconst catDot = (cat, size) => 'width:' + size + 'px;height:' + Math.round(size * 0.78) + 'px;flex:none;border-radius:' + size + 'px ' + size + 'px 0 0;background:' + (CAT_HUE[cat] || 'var(--ux-faint)');\nconst CATS = ['All', 'Vape cartridge', 'Infused edible', 'Infused pre-roll', 'Packaged flower', 'Concentrate', 'Infused flower'];\n\nconst LOCATIONS = [\n { id: 'l1', name: 'Downtown', license: 'OCM-RETL-24-000412', expired: false },\n { id: 'l2', name: 'Riverside', license: 'OCM-RETL-24-000518', expired: false, pastDue: true },\n { id: 'l3', name: 'Northgate', license: 'OCM-RETL-24-000633', expired: true }\n];\n\nconst FLOW = ['Awaiting approval', 'Placed', 'Approved', 'Shipped', 'Received'];\nconst INTERNAL_STATE = { 'Awaiting approval': 'draft', 'Placed': 'created', 'Approved': 'approved · filled', 'Shipped': 'shipped', 'Received': 'accepted', 'Flagged': 'canceled / returned' };\n\nconst SEED_ORDERS = [\n { id: 'SO-24187', loc: 'l1', placed: '18 Aug 2026', state: 'Placed', history: { 'Placed': '18 Aug 2026' },\n lines: [{ id: 'p1', lot: 0, qty: 12, price: 1850 }, { id: 'p4', lot: 0, qty: 24, price: 1420 }, { id: 'p6', lot: 0, qty: 50, price: 640 }] },\n { id: 'SO-24151', loc: 'l2', placed: '11 Aug 2026', state: 'Approved', history: { 'Placed': '11 Aug 2026', 'Approved': '12 Aug 2026' },\n lines: [{ id: 'p8', lot: 1, qty: 12, price: 1920 }, { id: 'p10', lot: 0, qty: 20, price: 3600 }] },\n { id: 'SO-24098', loc: 'l1', placed: '2 Aug 2026', state: 'Shipped', history: { 'Placed': '2 Aug 2026', 'Approved': '3 Aug 2026', 'Shipped': '6 Aug 2026' },\n lines: [{ id: 'p2', lot: 0, qty: 30, price: 1850 }, { id: 'p5', lot: 0, qty: 24, price: 1380 }] },\n { id: 'SO-24020', loc: 'l2', placed: '21 Jul 2026', state: 'Received', history: { 'Placed': '21 Jul 2026', 'Approved': '22 Jul 2026', 'Shipped': '24 Jul 2026', 'Received': '26 Jul 2026' },\n lines: [{ id: 'p1', lot: 1, qty: 12, price: 1790 }, { id: 'p3', lot: 0, qty: 30, price: 1290 }, { id: 'p7', lot: 0, qty: 20, price: 2450 }] },\n { id: 'SO-23964', loc: 'l3', placed: '9 Jul 2026', state: 'Flagged', history: { 'Placed': '9 Jul 2026', 'Flagged': '12 Jul 2026' },\n lines: [{ id: 'p11', lot: 0, qty: 20, price: 2900 }] }\n];\n\nconst VALIDATION = [\n { cls: 'INFORMATION REQUIRED', closedBy: 'Closed by whoever holds the fact', items: [\n { severity: 'BLOCKING', screen: 'Product detail', question: 'Where do parsed potency, cannabinoid and terpene values live?', detail: 'No structured field for potency or terpenes exists in either reporting domain, and labs are manual. The product detail page therefore shows an explicit pending panel where the largest section of the hero screen should be.', blocks: 'Potency and profile layer; budtender education', owner: 'Quality', evidence: 'A named field or record per lot, and who maintains it' },\n { severity: 'DECIDED', screen: 'Performance panels', question: 'Is there any store sell-through feed, now or planned?', detail: 'Answered on 24 Aug 2026 with the second of its two answers: ship without one. The portal stays semi-internal and does not connect to a retailer point-of-sale. Performance panels report the account\\u2019s own ordering with urbanXtracts, permanently rather than pending, and no velocity claim is made anywhere.', blocks: 'Nothing \\u2014 closed', owner: 'The business', evidence: 'Decision recorded 24 Aug 2026' },\n { severity: 'Record and continue', screen: 'Lot panel', question: 'Which lot and batch identifiers will exist going forward?', detail: 'Most active packages carry no lot identifier and a large share carry no batch number either. The prototype treats the compliance tag as sufficient identity so it works either way.', blocks: 'Nothing today; affects lineage depth', owner: 'Operations', evidence: 'A go-forward identifier rule' }\n ] },\n { cls: 'CANIX VALIDATION REQUIRED', closedBy: 'Closed by a Canix test or a vendor answer', items: [\n { severity: 'BLOCKING', screen: 'Order builder', question: 'Are case quantity, minimum order and lead time readable per item?', detail: 'Case and individual unit identifiers appear in the schema but their values were not confirmed. Case figures on product cards are synthetic, and the order builder accepts quantities as entered rather than enforcing increments.', blocks: 'Minimum and case-increment validation at order entry', owner: 'Operations', evidence: 'A read of the case and individual unit values for a sample of items' },\n { severity: 'Record and continue', screen: 'Catalog', question: 'Is there a per-item price list in Canix?', detail: 'Only realised order-line prices were found. If an item price list exists, it is a better source than deriving from history.', blocks: 'Nothing today; would replace the derived price', owner: 'Sales operations', evidence: 'Confirmation the object exists and is populated' }\n ] },\n { cls: 'TECHNICAL VALIDATION REQUIRED', closedBy: 'Closed by a hands-on test in the target system', items: [\n { severity: 'BLOCKING', screen: 'Catalog · Order builder', question: 'The per-account rate card does not exist as a record. What replaces it?', detail: 'No rate-card board was found. Each price here is derived from the most recent qualifying order line for the account, excluding samples, cancellations, returns and lines where discount exceeded price, with the derivation date shown. Where no qualifying line exists the product shows price on request and cannot be added to an order.', blocks: 'Showing any price; the whole ordering flow depends on it', owner: 'Sales operations', evidence: 'A rate-card record with per-account scoping and approval, or a decision to ship the derived price' },\n { severity: 'BLOCKING', screen: 'Inventory', question: 'How will the deployed portal authenticate to the Canix reporting API?', detail: 'The Canix connector is working and package-level queries are verified. This prototype can reload the latest API snapshot packaged with the deployment, but continuous refresh requires a server-side Canix credential or a private connector binding. A browser credential would expose the source and is not acceptable.', blocks: 'Continuous inventory refresh and live package search in the deployed portal', owner: 'Data engineering', evidence: 'A server-side credential or private connector binding, tested from the deployed inventory endpoint' },\n { severity: 'Record and continue', screen: 'Internal account view', question: 'Can store visit reports be read as the account-notes source?', detail: 'A store visit reports board exists in the workflow system. Its structure was not read, so the account view shows the field as sourced but unverified.', blocks: 'Account notes and follow-up activity', owner: 'Sales operations', evidence: 'A read of the board columns' },\n { severity: 'Record and continue', screen: 'Internal lot view', question: 'Does the co-manufacturing record carry the ownership code?', detail: 'A co-manufacturing board exists. Ownership code is a property of the inbound lot and must never reach a counterparty view, so its source needs to be definite first.', blocks: 'Ownership display on the internal lot view', owner: 'Data contracts', evidence: 'A read of the co-man record structure' },\n { severity: 'Record and continue', screen: 'Account · Orders', question: 'Is the ledger reachable for balances and terms?', detail: 'The ledger connector was not attached for this work. Balance and terms are pending rather than estimated, and a buyer sees only that a gate fired, never a balance.', blocks: 'AR display; invoice access', owner: 'Finance', evidence: 'A connector read of balances for a sample account' },\n { severity: 'Record and continue', screen: 'Admin', question: 'How is an organisation resolved when a chain holds several licenses?', detail: 'License number is the account key. Location switching and organisation-level receivables both assume a reliable mapping from several licenses to one organisation.', blocks: 'Location switching at scale', owner: 'Administration', evidence: 'A test with a multi-license chain' },\n { severity: 'Record and continue', screen: 'Account · API access', question: 'What scope, credential model and rate limits does the store API carry?', detail: 'A read-only endpoint over the store records surface, for chains running their own tooling. It is a second renderer of the same authorization, never a second authorization, and it depends on the unresolved multi-license question above.', blocks: 'The store API', owner: 'Administration', evidence: 'A scope, a credential model and a rate limit' }\n ] },\n { cls: 'FINANCE DECISION', closedBy: 'Closed by the finance owner', items: [\n { severity: 'BLOCKING', screen: 'Cost and margin', question: 'May a margin figure be shown when its cost components are incomplete, and at what coverage?', detail: 'Three quarters of active packages carry no total standard cost and labor cost is effectively unpopulated. Every figure on the cost and margin screen is synthetic and labelled, with a coverage column beside it.', blocks: 'The internal economics view', owner: 'Finance', evidence: 'A coverage threshold, and what displays below it' },\n { severity: 'Record and continue', screen: 'Cost and margin', question: 'Which cost components belong in a unit cost?', detail: 'Cannabis, non-cannabis ingredient and labor components exist as separate fields. Which of them constitute the figure a margin is computed against is an accounting treatment, not a display choice.', blocks: 'Nothing today; defines the figure', owner: 'Finance', evidence: 'A stated treatment per component' },\n { severity: 'Record and continue', screen: 'Onboarding', question: 'May an account be created before payment terms and a credit limit exist?', detail: 'Stage 03 of onboarding is already waiting on the per-account rate card. Whether it is a hard stop or a soft one decides whether an account can exist without terms.', blocks: 'Stage 03 of onboarding', owner: 'Finance', evidence: 'A hard stop, or a stated soft path' }\n ] },\n { cls: 'BUSINESS DECISION', closedBy: 'Closed by the business owner', items: [\n { severity: 'BLOCKING', screen: 'Order builder', question: 'What defines past due for the receivables gate?', detail: 'The gate fires at order entry rather than at shipping, which is settled. The threshold is not. The prototype fires it on the Riverside account so the blocked state can be reviewed.', blocks: 'The AR gate', owner: 'Finance', evidence: 'A threshold, and the ledger field it reads' },\n { severity: 'BLOCKING', screen: 'Order builder', question: 'What order value triggers owner approval?', detail: 'Approval also fires when a buyer submits, when an order exceeds available quantity, and when receivables are past due — all settled. The value threshold is not, so the prototype applies the buyer rule only.', blocks: 'The order-submission gate', owner: 'Sales leadership', evidence: 'A number, and whether it varies by account' },\n { severity: 'Record and continue', screen: 'Exceptions', question: 'Who owns each exception type, and against what clock?', detail: 'A store sees status only, never the owner or the clock, so this is an internal decision that does not change any external screen.', blocks: 'Exception queue routing', owner: 'Operations', evidence: 'An owner and a target per exception type' },\n { severity: 'Record and continue', screen: 'Dashboard \\u00b7 Reorder', question: 'What is the reorder interval basis \\u2014 the account\\u2019s own average, or days since last order?', detail: 'With sell-through decided out of scope this is the only basis there will ever be, so the choice is permanent rather than provisional. The basis is stated on screen either way.', blocks: 'Nothing today; fixes the reorder signal permanently', owner: 'Sales leadership', evidence: 'A stated basis' },\n { severity: 'Record and continue', screen: 'Sign-in', question: 'Multi-factor authentication was left open.', detail: 'This prototype assumes none in phase one, and no personal login at all on shared floor devices.', blocks: 'Nothing today', owner: 'Administration', evidence: 'A policy per role' },\n { severity: 'BLOCKING', screen: 'Order builder · Approvals', question: 'May an urbanXtracts rep hold a store’s ordering approval, and on whose written authority?', detail: 'Asked for on 24 Aug 2026. The seller approving the buyer’s purchase commitment is a segregation-of-duties and contracting question before it is a feature. Designed as a store-granted, time-bounded, revocable delegation that is named on the order and cannot lift any gate.', blocks: 'Rep-held approval', owner: 'Sales leadership, with the CCO and Legal', evidence: 'A written authority, or a decision not to offer it' },\n { severity: 'Record and continue', screen: 'Draft order', question: 'Does a draft hold inventory, for how long, and who wins when two accounts draft the last of a lot?', detail: 'Today a draft holds nothing, which is why the inventory-changed gate exists. The gate stays whichever way the policy lands; the policy has never been written.', blocks: 'Nothing today; the gate handles the symptom', owner: 'Operations with sales operations', evidence: 'A stated hold policy, or a stated refusal to hold' },\n { severity: 'Record and continue', screen: 'Approvals', question: 'How long may an approval sit, and where does it escalate?', detail: 'One named human holds approval with no clock and no delegate, so an owner on holiday stalls every order over the threshold.', blocks: 'Nothing today; approvals can stall silently', owner: 'Sales leadership', evidence: 'A period and an escalation path' },\n { severity: 'Record and continue', screen: 'Order · Received', question: 'How long after delivery may a store raise a short, damaged or refused line, and what evidence is required?', detail: 'Short shipments and damage are raised internally only today, so a store receiving eighteen of twenty-four has one route: the telephone.', blocks: 'The receiving claim', owner: 'Operations', evidence: 'A window and an evidence rule' },\n { severity: 'Record and continue', screen: 'Notifications', question: 'Which channels carry a notification, and which notices may a user never switch off?', detail: 'Four events push. An amended COA and a recall notice are not mutable; the rest may be. Channel set and consent wording are unwritten.', blocks: 'Notification delivery', owner: 'Sales leadership, with the CCO on recall wording', evidence: 'A channel set and consent wording' },\n { severity: 'Record and continue', screen: 'Account · Documents', question: 'Which document types may a store send, how long is each kept, and what happens at wind-down?', detail: 'The license gate has told blocked stores to upload a renewal since the first draft with nowhere to upload it. An untyped, unexpiring document store becomes unusable within a year.', blocks: 'Document exchange', owner: 'Operations, with the CCO on anything a regulator could ask for', evidence: 'A type list and a retention rule' },\n { severity: 'Record and continue', screen: 'Onboarding', question: 'Who qualifies a license against the state record, and how often is it rechecked?', detail: 'We record what the state record says and never assert validity ourselves, so the question is who looks, what evidence is kept, and on what cycle.', blocks: 'Stage 02 of onboarding', owner: 'Quality and compliance', evidence: 'A named owner and a recheck cycle' }\n ] },\n { cls: 'CCO SIGN-OFF REQUIRED', closedBy: 'Closed by the Chief Compliance Officer', items: [\n { severity: 'BLOCKING', screen: 'Public COA page', question: 'What does the public page display, how long does a printed code keep resolving, and is retest history shown?', detail: 'The resolver mechanism is designed for either answer and nothing publishes while this is open. The payload is constrained to lab result, batch and dates, and the portal never interprets a result.', blocks: 'Publication of the public resolver', owner: 'CCO', evidence: 'A written display, retention and retest decision' },\n { severity: 'BLOCKING', screen: 'Public COA page', question: 'What triggers a recall notice, what does it say, and how fast must it appear?', detail: 'Product safety escalates immediately and is itself a recall trigger, so it does not queue behind the normal exception path. The notice wording is not ours to write.', blocks: 'Recall behaviour on published pages', owner: 'CCO', evidence: 'Approved trigger and wording' },\n { severity: 'BLOCKING', screen: 'Public COA page', question: 'May a third party walk the public code space?', detail: 'The page contents were specified without specifying the exposure. A non-sequential code, a per-source rate limit, no listing endpoint, and an unknown code answering identically to an unpublished one all follow from the same decision.', blocks: 'Publication of the public resolver', owner: 'CCO', evidence: 'A code format, a rate limit, and an enumeration position' }\n ] }\n];\n\nconst DOCS = [\n { name: 'Downtown license renewal 2026.pdf', type: 'License renewal', dates: 'Effective 1 Sep 2026 · expires 31 Aug 2027', state: 'Accepted', loc: 'Downtown' },\n { name: 'Northgate license renewal.pdf', type: 'License renewal', dates: 'Submitted 22 Aug 2026', state: 'In review', loc: 'Northgate' },\n { name: 'General liability certificate.pdf', type: 'Insurance certificate', dates: 'Expires 14 Nov 2026', state: 'Accepted', loc: 'All locations' },\n { name: 'Resale certificate 2026.pdf', type: 'Resale or exemption', dates: 'Expires 31 Dec 2026', state: 'Accepted', loc: 'All locations' },\n { name: 'Riverside receiving instructions.pdf', type: 'Receiving instructions', dates: 'Updated 3 Aug 2026', state: 'Accepted', loc: 'Riverside' },\n { name: 'SO-23964 short delivery photo.jpg', type: 'Claim evidence', dates: 'Submitted 12 Jul 2026', state: 'Rejected', loc: 'Northgate' }\n];\n\nconst ONBOARDING = [\n { org: 'Cedar & Pine Provisions', licenses: '2 locations · OCM-RETL-24-000871, OCM-RETL-24-000872', stage: 5, note: 'Owner invited 21 Aug. Waiting on the owner to accept and add their buyers.' },\n { org: 'Harborline Cannabis Co.', licenses: '1 location · OCM-RETL-24-000904', stage: 3, note: 'Held at commercial terms — no per-account rate card record exists, so no price can be assigned.' },\n { org: 'Marigold Street Dispensary', licenses: '4 locations · 3 qualified, 1 pending', stage: 2, note: 'One license failed qualification against the state record. The other three proceed; that location cannot order.' },\n { org: 'Ninth Ward Wellness', licenses: '1 location · license number not yet supplied', stage: 1, note: 'A location without a license number cannot proceed — the license number is the account key.' }\n];\n\n// Section 10: an audit event is store-visible only when the store is a party to it.\n// Anything not named here is internal, and a visit note is the clearest example.\nconst STORE_VISIBLE_AUDIT = [\n 'Data export', 'Receiving claim raised', 'Delivery settings changed',\n 'Approval authority delegated', 'Approval authority withdrawn',\n 'User revoked', 'User invited', 'Permission changed',\n 'API credential revoked', 'API credential issued',\n 'Recall notice issued', 'Recall notice acknowledged',\n 'COA amended', 'Amended COA acknowledged', 'COA published'\n];\n\n// The intake endpoint and its key are supplied at deploy time and are deliberately\n// absent from any public build. Anything a browser can read, a visitor can read, so\n// a key committed here would not be a key. Set both on a host that can hold a\n// secret; until then submissions are recorded and not sent.\nconst PORTAL_CONFIG = (typeof window !== 'undefined' && window.UX_PORTAL_CONFIG) || {};\nconst MAKE_WEBHOOK = PORTAL_CONFIG.intakeUrl || '';\nconst INTAKE_KEY = PORTAL_CONFIG.intakeKey || '';\n\n// Supabase Auth. The project URL and publishable key are designed to be public —\n// they identify the project, they do not grant anything. Row-level security in the\n// database decides what each signed-in user may read, so the browser can hold these\n// safely and there are no credentials in this file at all.\nconst SUPABASE_URL = PORTAL_CONFIG.supabaseUrl || 'https://cbhsavfbtcpdyxcvguay.supabase.co';\nconst SUPABASE_KEY = PORTAL_CONFIG.supabaseKey || 'sb_publishable_aIrwn28FxjXNT-MRFYJPYA_MUoWYXED';\n\nconst REP_BOOK = [\n { name: 'Riverside Collective', license: 'OCM-RETL-24-000518', signal: 'STOPPED',\n interval: 'Usually every 28 days', since: 'Last ordered 62 days ago',\n dropped: 'Dropped Blueberry Chews 10ct and Watermelon Zkittlez 0.5g Cart since June',\n gate: 'Past-due balance blocks submission at order entry',\n contact: 'Sam Okafor \\u00b7 206-555-0188',\n seed: ['12 Aug \\u2014 spoke to Sam, waiting on their bookkeeper to clear the balance.'] },\n { name: 'Northgate Cannabis', license: 'OCM-RETL-24-000633', signal: 'BLOCKED',\n interval: 'Usually every 31 days', since: 'Last ordered 24 days ago',\n dropped: 'No lines dropped \\u2014 the licence is the problem, not the range',\n gate: 'Licence record expired 12 Aug. Ordering paused at this location only',\n contact: 'Priya Anand \\u00b7 206-555-0119',\n seed: ['19 Aug \\u2014 renewal is with the state, they expect it back this week.'] },\n { name: 'Eastside Green', license: 'OCM-RETL-24-000844', signal: 'NEW',\n interval: 'Six orders so far, roughly every 48 days',\n since: 'Last ordered 19 days ago',\n dropped: 'Buying flower only \\u2014 has never tried a cartridge or an edible',\n gate: 'No gate firing',\n contact: 'Dana Whitfield \\u00b7 206-555-0170',\n seed: [] }\n];\n\nconst DELIVERY = {\n l1: { window: 'Weekdays, 9am to 12pm', instructions: 'Rear loading door on Pike Alley. Buzz unit 2. Do not leave a delivery with front-of-house staff.', contact: 'Marcus Reyes \\u00b7 206-555-0142' },\n l2: { window: 'Tuesdays and Thursdays, any time', instructions: 'Street-level delivery only, no loading bay. Ask for the duty manager on arrival.', contact: 'Sam Okafor \\u00b7 206-555-0188' },\n l3: { window: 'Weekdays, 1pm to 5pm', instructions: 'Loading bay is shared with the unit next door \\u2014 call ahead so it is clear.', contact: 'Priya Anand \\u00b7 206-555-0119' }\n};\n\nconst PUBLIC_CODES = [\n { code: 'K7M2-QX48-RB91', tag: '1A4060300019F21000004182', product: 'Apple Motorbreath 1g Cart', packaged: '13 Aug 2026', tested: '11 Aug 2026', result: 'Passed', published: true },\n { code: 'T4J9-LW07-ZC63', tag: '1A4060300019F21000003907', product: 'Apple Motorbreath 1g Cart', packaged: '24 Jul 2026', tested: '22 Jul 2026', result: 'Passed', published: true },\n { code: 'P2R6-HN35-VD82', tag: '1A4060300021C08000001174', product: 'Watermelon Zkittlez 0.5g Cart', packaged: '3 Mar 2026', tested: '1 Mar 2026', result: 'Passed', published: false }\n];\n\nconst PUBLIC_CONTROLS = [\n { control: 'Non-sequential code', detail: 'The printed code is not derived from the compliance tag and carries no order. K7M2-QX48-RB91 tells you nothing about what K7M2-QX48-RB92 might be, and there is no arithmetic from one lot to the next.' },\n { control: 'Full code or nothing', detail: 'The resolver answers a code presented in full. There is no partial match, because a partial match is a search, and a search over this space is the thing we are preventing.' },\n { control: 'No listing, no search', detail: 'There is no endpoint that returns a list of codes, no browse, and no index. The only way in is a code somebody printed on a package.' },\n { control: 'Unknown and unpublished are identical', detail: 'A code that does not exist and a real code whose COA is not published return the same response, word for word. Confirming that something exists is itself a disclosure.' },\n { control: 'Rate limited per source', detail: 'Attempts from one source are throttled, so walking the space costs time that grows. The number is not set yet and is carried as an open item.' },\n { control: 'No account, no price, ever', detail: 'The page never carries who bought the lot, what they paid, cost, margin, or the ownership code. It is a lab result, a batch and dates.' }\n];\n\nconst API_KEYS = [\n { name: 'Inventory sync', mask: 'uxp_\\u2022\\u2022\\u2022\\u2022\\u2022\\u2022\\u2022\\u2022\\u2022\\u2022\\u2022\\u20224f2a', scope: 'All three licenses', created: 'Issued 2 Jul 2026 by Dana Whitfield', used: 'Last read 24 Aug 2026 06:12 \\u00b7 1,204 rows' },\n { name: 'Nightly extract', mask: 'uxp_\\u2022\\u2022\\u2022\\u2022\\u2022\\u2022\\u2022\\u2022\\u2022\\u2022\\u2022\\u20229c17', scope: 'Downtown only', created: 'Issued 19 May 2026 by Dana Whitfield', used: 'Last read 23 Aug 2026 02:00 \\u00b7 486 rows' },\n { name: 'Legacy spreadsheet import', mask: 'uxp_\\u2022\\u2022\\u2022\\u2022\\u2022\\u2022\\u2022\\u2022\\u2022\\u2022\\u2022\\u20221b83', scope: 'All three licenses', created: 'Issued 11 Mar 2026 by Dana Whitfield', used: 'Last read 4 Apr 2026' }\n];\n\nconst API_ENDPOINTS = [\n { path: 'GET /orders', returns: 'Orders and their lines, scoped to your licenses', mirrors: 'Records \\u2192 Orders' },\n { path: 'GET /orders/:id', returns: 'One order with its lines, lots and dates', mirrors: 'Records \\u2192 Orders' },\n { path: 'GET /lots', returns: 'Lots you received, with the COA version current at receipt', mirrors: 'Records \\u2192 Lots received' },\n { path: 'GET /coa/:tag', returns: 'The COA pointer for a lot you received', mirrors: 'Records \\u2192 Lots received' },\n { path: 'GET /licenses', returns: 'Your locations, license numbers and expiry', mirrors: 'Records \\u2192 Licenses' },\n { path: 'GET /claims', returns: 'Claims you raised and their state', mirrors: 'Records \\u2192 Claims' },\n { path: 'GET /notices', returns: 'Notices sent to you, and your acknowledgement', mirrors: 'Records \\u2192 Notices' },\n { path: 'GET /documents', returns: 'Document metadata, expiry and review state', mirrors: 'Documents' }\n];\n\nconst API_WITHHELD = [\n 'Unit cost, in any component',\n 'Margin or contribution, including as a band',\n 'Discount depth against list',\n 'Rate cards, yours or anyone else\\u2019s',\n 'Ownership code and toll relationships',\n 'Exact inventory quantities',\n 'Any part of another organisation'\n];\n\nconst INVOICES = [\n { id: 'INV-24187', loc: 'l1', issued: '18 Aug 2026', due: '17 Sep 2026', amount: 88280, state: 'Open', age: 0 },\n { id: 'INV-24098', loc: 'l1', issued: '6 Aug 2026', due: '5 Sep 2026', amount: 88620, state: 'Open', age: 0 },\n { id: 'INV-23855', loc: 'l1', issued: '14 Jun 2026', due: '14 Jul 2026', amount: 41300, state: 'Paid', age: 0 },\n { id: 'INV-24020', loc: 'l2', issued: '26 Jul 2026', due: '25 Aug 2026', amount: 109180, state: 'Open', age: 0 },\n { id: 'INV-23901', loc: 'l2', issued: '28 Jun 2026', due: '28 Jul 2026', amount: 74250, state: 'Past due', age: 27 },\n { id: 'INV-23840', loc: 'l2', issued: '5 Jun 2026', due: '5 Jul 2026', amount: 38640, state: 'Past due', age: 50 },\n { id: 'INV-23964', loc: 'l3', issued: '12 Jul 2026', due: '11 Aug 2026', amount: 58000, state: 'Paid', age: 0 }\n];\n\nconst TERMS = { 'l1': 'Net 30', 'l2': 'Net 30', 'l3': 'Net 30' };\n\nconst CLAIMS = [\n { id: 'CLM-241', order: 'SO-23964', loc: 'l3', line: 'Hudson Valley Kush Badder 1g', reason: 'Short \\u2014 18 of 20 received', raised: '12 Jul 2026', state: 'Resolved \\u00b7 credited' },\n { id: 'CLM-242', order: 'SO-24020', loc: 'l2', line: 'Grape Gas Infused Pre-Roll 5pk', reason: 'Damaged outer packaging on 4 units', raised: '27 Jul 2026', state: 'Open' }\n];\n\nconst STORE_AUDIT = [\n { ts: '21 Aug 2026 08:55', actor: 'urbanXtracts administration', action: 'Permission changed', subject: 'Priya Anand', detail: 'Buyer to Budtender. Recorded and visible to your organisation.' },\n { ts: '18 Aug 2026 10:31', actor: 'urbanXtracts administration', action: 'Viewed your screen', subject: 'Marcus Reyes', detail: 'Read-only, six minutes, reason: order support. Cost was withheld for the duration.' },\n { ts: '22 Aug 2026 11:05', actor: 'urbanXtracts quality', action: 'COA published', subject: 'Tag \\u20269021', detail: 'Version 1, on a lot your organisation received.' },\n { ts: '2 Aug 2026 09:14', actor: 'Marcus Reyes', action: 'Order submitted', subject: 'SO-24098', detail: 'Sent for approval under the buyer-submitted rule.' }\n];\n\nconst RECALL_TAG = '1A4060300019F21000003907';\n\nconst RECALL_OTHER = [\n { org: 'Marigold Street Dispensary', loc: 'Marigold Street', license: 'OCM-RETL-24-000774', qty: 24, order: 'SO-24041', when: 'Received 28 Jul 2026', signed: 'D. Okafor', state: 'Delivered' },\n { org: 'Harborline Cannabis Co.', loc: 'Harborline', license: 'OCM-RETL-24-000904', qty: 12, order: 'SO-24066', when: 'Received 31 Jul 2026', signed: 'R. Nakamura', state: 'Delivered' },\n { org: 'Cedar & Pine Provisions', loc: 'Cedar Street', license: 'OCM-RETL-24-000871', qty: 18, order: 'SO-24129', when: 'Shipped 14 Aug 2026', signed: '—', state: 'In transit' }\n];\n\nconst RECALL_CUSTODY = [\n { where: 'Finished goods, Facility A', qty: 46, note: 'Never shipped. Quarantine on the spot rather than notify anyone.' },\n { where: 'Allocated, not yet picked', qty: 20, note: 'Held against an unshipped order line; the allocation releases when the lot is withdrawn.' }\n];\n\nconst ONBOARDING_STAGES = ['Intake', 'Qualification', 'Commercial terms', 'Account creation', 'Access', 'Ready to order'];\n\nconst INTEGRITY = [\n { name: 'Available with no lab result', count: '1', effect: 'Excluded from catalog by the release rule; flagged for correction' },\n { name: 'Test failed but package still active', count: '4', effect: 'No external presence at all; appears here with its quarantine reason' },\n { name: 'Package with no lot identifier', count: '1,161', effect: 'Tag is used as lot identity; batch and lot shown as not recorded' },\n { name: 'Not submitted to the state system', count: '2', effect: 'Blocked from release until submitted' },\n { name: 'Available but never marked available', count: '425', effect: 'The marked-available flag is unused, so the release rule ignores it' },\n { name: 'Past use-by or expiry, still available', count: '0 of 195 dated', effect: '1,173 packages carry no expiry date, so this check covers a minority' },\n { name: 'Allocated to an order already shipped', count: '2', effect: 'Held for reconciliation before the next allocation run' }\n];\n\nconst INVENTORY_PACKAGE_COLUMNS = [\n 'package_id', 'tag', 'item_id', 'item_name', 'sku', 'item_category_name', 'item_sub_category_name',\n 'product_name', 'brand_id', 'brand_name', 'owner_id', 'owner_name', 'strain_name', 'strain_type',\n 'quantity_type', 'weight', 'weight_unit_name', 'c_weight_g', 'c_reserved_weight', 'uom_code',\n 'facility_id', 'facility_name', 'facility_license', 'room_id', 'room_name',\n 'status', 'status_category', 'compliance_submitted', 'lab_test_status', 'test_result_status', 'has_coa',\n 'marked_available', 'is_finished_good', 'packaged_date', 'expiration_date', 'use_by_date', 'age_days',\n 'order_item_id', 'cost_object_id', 'sales_order_id', 'sales_order_name', 'sales_order_status',\n 'sales_order_delivery_date', 'source_updated_at'\n];\n\nconst CANIX_INVENTORY_API = PORTAL_CONFIG.canixInventoryUrl\n || SUPABASE_URL + '/functions/v1/canix-inventory';\nconst CANIX_INVENTORY_SNAPSHOT = {\n source: { system: 'Canix', domain: 'inventory', table: 'package_inventory_facts_current',\n grain: 'one row per package', refresh: 'near-real-time CDC',\n latest_updated_at: '2026-08-31 23:26:40', latest_updated_at_timezone: null,\n connection_mode: 'canix_reporting_api_snapshot' },\n scope: { excluded_facility_ids: [4546], excluded_samples: true, active_only: true,\n status_categories: ['available', 'in_progress', 'allocated'],\n quantity_types: ['WeightBased', 'CountBased'] },\n summary: { packages: 978, weight_g: 1663379.9319804, units: 174449 },\n quantity_types: [\n { quantity_type: 'WeightBased', packages: 715, weight_g: 1663379.931980402 },\n { quantity_type: 'CountBased', packages: 263, units: 174449 }\n ],\n statuses: [\n { status_category: 'available', packages: 220, weight_g: 32593.549999999992, units: 167313 },\n { status_category: 'allocated', packages: 47, weight_g: 26725.790000000005, units: 90 },\n { status_category: 'in_progress', packages: 711, weight_g: 1604060.5919804012, units: 7046 }\n ],\n facilities: [\n { facility_id: 4467, facility_name: 'OCM-DIST-24-000069', facility_license: 'OCM-DIST-24-000069-DX2',\n packages: 152, weight_g: null, units: 121209, latest_updated_at: '2026-08-31 19:16:57' },\n { facility_id: 4468, facility_name: 'OCM-CULT-24-000171', facility_license: 'OCM-CULT-24-000171-C1',\n packages: 72, weight_g: 10380, units: 4417, latest_updated_at: '2026-08-03 18:35:07' },\n { facility_id: 4469, facility_name: 'OCM-PROC-24-000083', facility_license: 'OCM-PROC-24-000083-P1',\n packages: 754, weight_g: 1652999.931980402, units: 48823, latest_updated_at: '2026-08-31 21:57:37' }\n ],\n sandbox: { facility_id: 4546, packages: 65, weight_g: 53477.85, units: 7117,\n latest_updated_at: '2026-04-27 17:49:29' },\n checks: { active_inactive_conflicts: 240, zero_quantity_packages: 34,\n failed_lab_packages: 4, not_submitted_packages: 0, expired_packages: 0 },\n package_columns: INVENTORY_PACKAGE_COLUMNS,\n packages: []\n};\n\n// Supabase puts the recovery token in the URL fragment, never the query string,\n// so it never reaches whatever server hosts this file. Read it once, at load.\nfunction readRecovery() {\n const out = { active: false, token: null, err: null };\n if (typeof location === 'undefined' || !location.hash) return out;\n const h = new URLSearchParams(location.hash.slice(1));\n if (h.get('error') || h.get('error_code')) {\n out.active = true;\n out.err = h.get('error_description') || h.get('error_code') || h.get('error');\n return out;\n }\n if (h.get('type') === 'recovery' && h.get('access_token')) {\n out.active = true;\n out.token = h.get('access_token');\n }\n return out;\n}\nconst RECOVERY = readRecovery();\n\n// The portal used to mint SO-24188 on every page load, so two sessions produced\n// the same number for different orders. The authoritative order number is now\n// the Monday item id; this stays only as the reference the store saw on screen,\n// and a per-session tag keeps it from colliding.\nconst SESSION_TAG = (typeof crypto !== 'undefined' && crypto.getRandomValues)\n ? Array.from(crypto.getRandomValues(new Uint8Array(2))).map(b => b.toString(36).toUpperCase().padStart(2, '0')).join('').slice(0, 4)\n : Math.floor(Math.random() * 1679616).toString(36).toUpperCase().padStart(4, '0');\n\n// Leaked-password rejection without Supabase Pro, against the same source its\n// paid feature uses. The browser hashes the password and sends only the first\n// five characters of the SHA-1. The password and its full hash never leave this\n// page, and the endpoint cannot tell which of the returned suffixes was being\n// asked about \\u2014 that k-anonymity property is what the range API exists for.\nfunction sha1Hex(text) {\n return crypto.subtle.digest('SHA-1', new TextEncoder().encode(text)).then(buf =>\n Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2, '0')).join('').toUpperCase());\n}\n\n// Resolves to the number of times the password appears in breach data, 0 if it\n// does not, or -1 if the check could not run at all. It fails open on purpose:\n// a network problem at HaveIBeenPwned should not lock somebody out of their own\n// password reset, and the screen says when the check was skipped.\nfunction pwnedCount(password) {\n if (typeof crypto === 'undefined' || !crypto.subtle) return Promise.resolve(-1);\n return sha1Hex(password).then(hash => {\n const prefix = hash.slice(0, 5), suffix = hash.slice(5);\n // Add-Padding keeps the response size from revealing how many real matches\n // there were. Padded entries come back with a count of zero.\n return fetch('https://api.pwnedpasswords.com/range/' + prefix, { headers: { 'Add-Padding': 'true' } })\n .then(r => { if (!r.ok) throw new Error('range lookup failed'); return r.text(); })\n .then(body => {\n const line = body.split('\\n').find(l => l.slice(0, 35).toUpperCase() === suffix);\n if (!line) return 0;\n const n = parseInt((line.split(':')[1] || '0').trim(), 10);\n return isNaN(n) ? 0 : n;\n });\n }).catch(() => -1);\n}\n\nclass Component extends DCLogic {\n state = {\n deactivated: [], apiRotated: {}, extraUsers: [], auOpen: false, auName: '', auEmail: '', auRole: 'Buyer', auErr: null,\n token: null, auLocs: [], auLocOpen: false, pwNotice: null,\n licType: 'Licence renewal', licLoc: 'l3', licExpiry: '', licNote: '',\n licFileName: '', licFileSize: 0, licFileData: null, licErr: null, licSent: false,\n onbType: 'New store', onbEntity: '', onbDba: '', onbLocs: '',\n onbOwnerName: '', onbOwnerEmail: '', onbOwnerPhone: '',\n onbBuyerName: '', onbBuyerEmail: '', onbBuyerLoc: '',\n onbBud: [{ name: '', email: '', loc: '' }], onbErr: null, onbSent: false,\n outbox: [], loginUser: '', loginPass: '', loginErr: null, loginBusy: false, catQty: {}, authUser: null, role: 'buyer', screen: RECOVERY.active ? 'reset' : 'signin', loc: 'l1', cat: 'All', query: '',\n rstToken: RECOVERY.token, rstErr: RECOVERY.err, rstPass: '', rstPass2: '', rstBusy: false, rstDone: false,\n rstStage: null, rstNote: null,\n fpBusy: false, fpMsg: null,\n product: 'p1', lot: 0, qty: 1, cart: [], delivery: 'Next available',\n orders: SEED_ORDERS.map(o => ({ ...o })), requests: [], seq: 24188,\n order: null, toast: null, toastKind: 'DONE', kioskInput: '', kioskMsg: null, kioskMsgKind: 'NOT FOUND',\n audit: SEED_AUDIT.slice(), impersonating: null, notifRead: false, compare: [],\n vw: 1280, favs: ['p1'], view: 'grid', onlyFavs: false,\n drafts: [{ id: 'D1', name: 'Downtown restock', loc: 'l1', lines: [] }], activeDraft: 'D1',\n account: null, loading: false, stale: false, confirmed: null, invSandbox: false,\n invSearch: '', invFacility: 'all', invStatus: 'all', invQuantity: 'all', invCategory: 'all',\n invBrand: 'all', invLab: 'all', invAge: 'all', invView: 'all', invSortKey: 'updated_at',\n invSortDir: 'desc', invPage: 1, invPageSize: 50, invSelectedPackage: null,\n inventoryData: CANIX_INVENTORY_SNAPSHOT, inventoryLoading: false, inventoryLoaded: false, inventoryError: null,\n recallInput: '', recallTag: null, recallMsg: null, recallMsgKind: 'NOT FOUND', recallIssued: false, recallAcks: [], apiRevoked: ['Legacy spreadsheet import'], delegateTo: null, delegateExpiry: '30 Sep 2026',\n metrics: { t0: 0, sessionStart: 0, firstSubmitMs: null, submits: 0, blocks: { licence: 0, receivables: 0, quantity: 0, readOnly: 0 },\n searches: 0, filters: 0, adds: 0, kioskScan: 0, kioskTyped: 0, kioskAmbiguous: 0, byStore: 0, byRep: 0, calls: 0 },\n repNotes: {}, repNoteFor: null, repNoteText: '',\n deliverySettings: JSON.parse(JSON.stringify(DELIVERY)), acctEditing: null, acctWindow: '', acctInstr: '', acctContact: '',\n coaAmended: false, coaAcks: [], pubInput: '', pubResult: null, pubMsg: null, pubMsgKind: 'NO RESULT', pubAttempts: 0, claims: CLAIMS.slice(), claimFor: null, claimLine: 0, claimKind: 'Short', claimQty: 1, claimReason: '', claimPhoto: false, claimSeq: 3\n };\n\n set(patch) { this.setState(patch); }\n\n recallFind() {\n const raw = (this.state.recallInput || '').trim().toUpperCase();\n if (!raw) { this.set({ recallMsg: 'Enter a compliance tag. This view resolves one lot, never a product.', recallMsgKind: 'NOTHING ENTERED' }); return; }\n const all = [];\n PRODUCTS.forEach(p => p.lots.forEach((l, i) => all.push({ pid: p.id, name: p.name, idx: i, tag: l.tag, packaged: l.packaged })));\n const exact = all.find(x => x.tag === raw);\n if (exact) { this.set({ recallTag: exact.tag, recallMsg: null, recallInput: '' }); return; }\n const partial = all.filter(x => x.tag.includes(raw));\n if (partial.length > 1) {\n this.set({ recallMsg: partial.length + ' packages contain that sequence. A recall resolves to one lot or it resolves to nothing \\u2014 enter the full tag.', recallMsgKind: 'AMBIGUOUS' });\n return;\n }\n if (partial.length === 1) { this.set({ recallTag: partial[0].tag, recallMsg: null, recallInput: '' }); return; }\n this.set({ recallMsg: 'No package carries that tag.', recallMsgKind: 'NOT FOUND' });\n }\n\n recallIssue() {\n if (this.state.recallIssued) return;\n this.logAudit('Recall notice issued', this.state.recallTag || RECALL_TAG, 'Accounts holding the lot notified; acknowledgement recorded per account', 'urbanXtracts quality');\n this.set({ recallIssued: true, toast: 'The notice goes out with the wording the CCO has approved. The portal carries that wording and never composes it \\u2014 and a product-safety issue escalates to the CCO immediately rather than waiting for this screen.', toastKind: 'NOT WIRED' });\n }\n\n requestReset() {\n const email = (this.state.loginUser || '').trim();\n if (!email) { this.set({ fpMsg: 'Put your username in the field above first \\u2014 that is where the link goes.' }); return; }\n this.set({ fpBusy: true, fpMsg: null });\n // redirect_to has to be on the project's redirect allow-list as well. If it\n // is not, Supabase ignores it and falls back to the Site URL instead.\n const back = location.origin + location.pathname;\n fetch(SUPABASE_URL + '/auth/v1/recover?redirect_to=' + encodeURIComponent(back),\n { method: 'POST', headers: { 'Content-Type': 'application/json', 'apikey': SUPABASE_KEY }, body: JSON.stringify({ email }) })\n .then(r => {\n if (!r.ok) throw new Error('rejected');\n // Same answer whether or not the address has an account, so this page\n // cannot be used to work out who is registered.\n this.set({ fpBusy: false, fpMsg: 'If that address has an account, a link is on its way. It is good for one use and expires.' });\n })\n .catch(() => this.set({ fpBusy: false, fpMsg: 'That request never reached Supabase, so no mail was sent.' }));\n }\n\n submitReset() {\n const s = this.state;\n if ((s.rstPass || '').length < 8) { this.set({ rstErr: 'Eight characters or more.' }); return; }\n if (s.rstPass !== s.rstPass2) { this.set({ rstErr: 'Those two do not match.' }); return; }\n this.set({ rstBusy: true, rstErr: null, rstStage: 'Checking against breach data\\u2026', rstNote: null });\n const token = s.rstToken, pass = s.rstPass;\n pwnedCount(pass)\n .then(n => {\n if (n > 0) throw new Error('That password appears in known breach data '\n + n.toLocaleString() + (n === 1 ? ' time' : ' times')\n + '. Credential stuffing tries exactly these, so it is refused. Pick one you have not used anywhere else.');\n this.set({ rstStage: 'Saving\\u2026', rstNote: n === -1\n ? 'The breach check could not reach HaveIBeenPwned, so it was skipped for this password.' : null });\n return fetch(SUPABASE_URL + '/auth/v1/user', { method: 'PUT',\n headers: { 'Content-Type': 'application/json', 'apikey': SUPABASE_KEY, 'Authorization': 'Bearer ' + token },\n body: JSON.stringify({ password: pass }) });\n })\n .then(r => r.json().then(b => ({ ok: r.ok, b: b || {} })))\n .then(({ ok, b }) => {\n if (!ok) throw new Error(b.msg || b.message || b.error_description || 'rejected');\n // Drop the token out of the address bar so it cannot be replayed from history.\n if (typeof history !== 'undefined' && history.replaceState) history.replaceState(null, '', location.pathname);\n this.set({ rstBusy: false, rstStage: null, rstDone: true, rstPass: '', rstPass2: '', rstToken: null });\n })\n .catch(e => this.set({ rstBusy: false, rstStage: null, rstErr: (e && e.message) || 'That did not work.' }));\n }\n\n leaveReset() {\n if (typeof history !== 'undefined' && history.replaceState) history.replaceState(null, '', location.pathname);\n this.set({ screen: 'signin', rstToken: null, rstErr: null, rstDone: false, fpMsg: null });\n }\n\n submitLogin() {\n const s = this.state;\n const email = (s.loginUser || '').trim();\n const password = s.loginPass;\n if (!email || !password) { this.set({ loginErr: 'Both a username and a password are needed.' }); return; }\n this.set({ loginBusy: true, loginErr: null });\n const H = { 'Content-Type': 'application/json', 'apikey': SUPABASE_KEY };\n fetch(SUPABASE_URL + '/auth/v1/token?grant_type=password', { method: 'POST', headers: H, body: JSON.stringify({ email, password }) })\n .then(r => r.json())\n .then(tok => {\n // A weak password is not a failed sign-in. Raising the rules does not\n // invalidate an existing password: Supabase issues the session and\n // reports the weakness alongside it, so a successful response carries\n // access_token and weak_password together. Treating the presence of\n // weak_password as a failure locks out every account whose password\n // predates a rules change. Only a response with no token is a failure.\n if (!tok.access_token) throw new Error(tok.error_code === 'weak_password' ? 'weak' : 'rejected');\n // The role is read from the account, not sent by the browser. Row-level\n // security means a request for somebody else's row simply returns nothing.\n // Row-level security hands an owner their whole organisation and an\n // internal account every row, so \"the first row with a name\" is not the\n // person signing in \\u2014 it is whoever sorts first. Narrow to the account\n // the token belongs to, on the server rather than here.\n const uid = tok.user && tok.user.id;\n if (!uid) throw new Error('rejected');\n return fetch(SUPABASE_URL + '/rest/v1/portal_profile?select=id,full_name,org,role,locations,active&id=eq.' + encodeURIComponent(uid),\n { headers: { 'apikey': SUPABASE_KEY, 'Authorization': 'Bearer ' + tok.access_token } })\n .then(r => r.json())\n .then(rows => {\n const me = (rows || []).find(r => r.id === uid) || null;\n if (!me) throw new Error('no profile');\n if (me.active === false) throw new Error('inactive');\n return { me: me, token: tok.access_token,\n weak: tok.weak_password ? (tok.weak_password.message || 'It no longer meets this portal\\u2019s password rules.') : null };\n });\n })\n .then(({ me, token, weak }) => {\n const roleLabel = { owner: 'Store owner', buyer: 'Buyer', budtender: 'Budtender', internal: 'Account management' }[me.role];\n // Built only from this account's own profile row. Matching the address\n // against the sample directory would hand somebody that record's role\n // and locations instead of their own.\n this.set({ loginBusy: false, loginErr: null, loginUser: '', loginPass: '', token: token, pwNotice: weak });\n this.signIn({ name: me.full_name, email: email, org: me.org, role: roleLabel, locs: me.locations || '', last: 'Now' });\n })\n .catch(e => this.set({ loginBusy: false, loginPass: '',\n loginErr: e && e.message === 'weak'\n ? 'That password is right, but it no longer meets this portal\\u2019s password rules. Use the reset link below to set a new one \\u2014 nothing else about your account changes.'\n : e && e.message === 'inactive'\n ? 'That account is deactivated. Whoever administers your organisation can restore it.'\n : e && e.message === 'no profile'\n ? 'That account exists but has no portal profile yet, so there is no role to sign in with.'\n : 'That username and password do not match an account. Nothing here tells you which half was wrong \\u2014 saying so would help somebody guessing.' }));\n }\n\n signIn(u) {\n const map = { 'Store owner': 'owner', 'Buyer': 'buyer', 'Budtender': 'budtender', 'Account management': 'internal' };\n const role = map[u.role];\n if (!role) { this.set({ toast: 'That account has no portal role, so there is nothing to sign in to.', toastKind: 'NO ROLE' }); return; }\n this.logAudit('Signed in', u.name, u.role + ' at ' + u.org + '. Role comes from the account, not from a choice in the interface.', u.name);\n this.set({ authUser: u, role, screen: role === 'budtender' ? 'kiosk' : 'dash', order: null, loc: 'l1',\n toast: 'Signed in as ' + u.name + '. What you can see and do follows from this account being a ' + u.role.toLowerCase() + ' \\u2014 there is no role switch in the product.', toastKind: 'SIGNED IN' });\n }\n\n signOut() {\n const u = this.state.authUser;\n if (u) this.logAudit('Signed out', u.name, 'Session ended.', u.name);\n this.set({ authUser: null, screen: 'signin', order: null, toast: null });\n }\n\n bump(patch) {\n this.setState(st => ({ metrics: { ...st.metrics, ...(typeof patch === 'function' ? patch(st.metrics) : patch) } }));\n }\n blocked(kind) {\n this.setState(st => ({ metrics: { ...st.metrics, blocks: { ...st.metrics.blocks, [kind]: st.metrics.blocks[kind] + 1 } } }));\n }\n\n repNoteOpen(name) { this.set({ repNoteFor: name, repNoteText: '' }); }\n repNoteCancel() { this.set({ repNoteFor: null, repNoteText: '' }); }\n repNoteSave() {\n const s = this.state;\n if (!s.repNoteText.trim()) { this.set({ toast: 'A visit note needs words. It is the only record of what was said.', toastKind: 'NOTHING ENTERED' }); return; }\n const name = s.repNoteFor;\n this.logAudit('Visit note added', name, 'Recorded by the account rep. Internal only \\u2014 a store never sees a visit note, and this event is not in the store-visible set.', 'urbanXtracts account management');\n this.setState(st => ({\n repNotes: { ...st.repNotes, [name]: (st.repNotes[name] || []).concat([TODAY + ' \\u2014 ' + st.repNoteText.trim()]) },\n repNoteFor: null, repNoteText: '',\n toast: 'Visit note saved against ' + name + '. Internal only \\u2014 it never appears in the store\\u2019s view of their own account.', toastKind: 'DONE'\n }));\n }\n\n acctEdit(id) {\n const d = this.state.deliverySettings[id] || { window: '', instructions: '', contact: '' };\n this.set({ acctEditing: id, acctWindow: d.window, acctInstr: d.instructions, acctContact: d.contact });\n }\n acctCancel() { this.set({ acctEditing: null }); }\n acctSave() {\n const id = this.state.acctEditing;\n if (!id) return;\n const name = this.locName(id);\n this.logAudit('Delivery settings changed', name, 'Window, receiving instructions and contact updated by the owner.');\n this.setState(st => ({\n deliverySettings: { ...st.deliverySettings, [id]: { window: st.acctWindow, instructions: st.acctInstr, contact: st.acctContact } },\n acctEditing: null,\n toast: name + ' delivery settings saved. Fulfilment reads these at pick time, and the change is in your audit history.', toastKind: 'DONE'\n }));\n }\n\n pubResolve() {\n const raw = (this.state.pubInput || '').trim().toUpperCase().replace(/\\s+/g, '');\n if (this.state.pubAttempts >= 5) {\n this.set({ pubResult: null, pubMsg: 'Too many attempts from this source. Further lookups are throttled. A person holding a package is not affected; somebody walking the code space is.', pubMsgKind: 'RATE LIMITED' });\n return;\n }\n this.setState(st => ({ pubAttempts: st.pubAttempts + 1 }));\n if (!raw) { this.set({ pubResult: null, pubMsg: 'Enter the code printed on the package.', pubMsgKind: 'NOTHING ENTERED' }); return; }\n const normalised = raw.replace(/-/g, '');\n if (normalised.length < 12) {\n this.set({ pubResult: null, pubMsg: 'A code is answered in full or not at all. There is no partial match here \\u2014 a partial match would be a search.', pubMsgKind: 'INCOMPLETE CODE' });\n return;\n }\n const hit = PUBLIC_CODES.find(c => c.code.replace(/-/g, '') === normalised);\n if (hit && hit.published) {\n this.set({ pubResult: hit, pubMsg: null, pubInput: '' });\n return;\n }\n this.set({ pubResult: null, pubMsg: 'No published certificate for that code.', pubMsgKind: 'NO RESULT' });\n }\n\n pubReset() { this.set({ pubAttempts: 0, pubResult: null, pubMsg: null, pubInput: '' }); }\n\n amendCoa() {\n if (this.state.coaAmended) return;\n const tag = this.state.recallTag || RECALL_TAG;\n this.logAudit('COA amended', 'Tag ' + tag, 'Version 2 published. Version 1 stays resolvable. Accounts that received this lot are notified.', 'urbanXtracts quality');\n this.set({ coaAmended: true, toast: 'Version 2 is now the current COA for this lot. Version 1 stays resolvable, the accounts that received the lot are notified, and the notice carries the wording the CCO has approved \\u2014 the portal never writes it and never interprets a result.', toastKind: 'NOTICE ISSUED' });\n }\n\n coaAck(org) {\n this.setState(st => ({ coaAcks: st.coaAcks.includes(org) ? st.coaAcks : st.coaAcks.concat([org]) }));\n this.logAudit('Amended COA acknowledged', org, 'Acknowledged by the account, not by a named person');\n }\n\n openClaim(id) { this.set({ claimFor: id, claimLine: 0, claimKind: 'Short', claimQty: 1, claimReason: '', claimPhoto: false }); }\n closeClaim() { this.set({ claimFor: null }); }\n\n submitClaim(od) {\n const s = this.state;\n const line = od.lines[s.claimLine];\n const pp = P(line.id);\n if (!s.claimReason.trim()) {\n this.set({ toast: 'A claim needs a reason in your own words. It is read by a person, not matched against a list.', toastKind: 'NOTHING ENTERED' });\n return;\n }\n if (s.claimQty > line.qty) {\n this.set({ toast: 'Refused: you cannot claim ' + s.claimQty + ' units against a line of ' + line.qty + '. The claim is capped at what was ordered.', toastKind: 'BLOCKED \\u2014 QUANTITY' });\n return;\n }\n const id = 'CLM-' + (240 + s.claimSeq);\n const claim = {\n id, order: od.id, loc: od.loc, line: pp ? pp.name : line.id,\n reason: s.claimKind + ' \\u2014 ' + s.claimQty + ' of ' + line.qty + ' \\u00b7 ' + s.claimReason.trim() + (s.claimPhoto ? ' \\u00b7 photo attached' : ''),\n raised: TODAY, state: 'Open',\n owner: 'Operations \\u2014 fulfilment', clock: 'Target: two working days', internalNote: 'Raised from the order by the store. Owner and clock are internal; the store sees state only.'\n };\n this.logAudit('Receiving claim raised', id + ' on ' + od.id, s.claimKind + ' on ' + (pp ? pp.name : line.id) + '. Visible to your organisation as a state, and to us as an exception.');\n this.setState(st => ({ claims: [claim].concat(st.claims), claimFor: null, claimSeq: st.claimSeq + 1,\n toast: id + ' raised on ' + od.id + '. It joins the exception queue. You will see its state on this order and in Your records \\u2014 never who is working it or against what clock.', toastKind: 'DONE' }));\n }\n\n delegate(name, role) {\n this.logAudit('Approval authority delegated', name, 'Granted by the owner, scoped to the organisation, expires ' + this.state.delegateExpiry + '. Revocable at any time.');\n const warn = role === 'Buyer'\n ? ' Note what you have just done: ' + name + ' submits orders, so orders they submit will now be approved by the person who raised them. The portal will do it, and it will say so on every order.'\n : '';\n this.set({ delegateTo: name, toast: name + ' now holds approval until ' + this.state.delegateExpiry + '. Every order approved under this delegation names the holder on its face, permanently.' + warn, toastKind: 'DONE' });\n }\n\n undelegate() {\n const who = this.state.delegateTo;\n this.logAudit('Approval authority withdrawn', who || '\\u2014', 'Authority returns to the owner immediately.');\n this.set({ delegateTo: null, toast: 'Authority is back with the owner. Withdrawal takes effect at once \\u2014 an approval in flight under the old holder still names them.', toastKind: 'DONE' });\n }\n\n repDelegate() {\n this.set({ toast: 'Refused: rep-held approval is not a decided capability. The seller approving the buyer\\u2019s purchase commitment is a segregation-of-duties and contracting question, open with sales leadership, the CCO and Legal. It also needs a standing written authorisation on the account \\u2014 a document, not a click on this screen.', toastKind: 'BLOCKED \\u2014 NOT DECIDED' });\n }\n\n recallAck(org) {\n this.setState(st => ({ recallAcks: st.recallAcks.includes(org) ? st.recallAcks : st.recallAcks.concat([org]) }));\n this.logAudit('Recall notice acknowledged', org, 'Acknowledged by the account, not by a named person');\n }\n\n inventoryFilter(patch) {\n this.set({ ...patch, invPage: 1, invSelectedPackage: null });\n }\n\n inventoryReset() {\n this.set({ invSearch: '', invFacility: 'all', invStatus: 'all', invQuantity: 'all', invCategory: 'all',\n invBrand: 'all', invLab: 'all', invAge: 'all', invView: 'all', invSortKey: 'updated_at', invSortDir: 'desc',\n invPage: 1, invSelectedPackage: null });\n }\n\n inventorySort(key) {\n this.setState(st => ({ invSortKey: key, invSortDir: st.invSortKey === key && st.invSortDir === 'asc' ? 'desc' : 'asc', invPage: 1 }));\n }\n\n inventoryExport(rows) {\n if (!rows || !rows.length) return;\n const fields = [\n ['Package ID', 'package_id'], ['Compliance tag', 'tag'], ['Item', 'item_name'], ['SKU', 'sku'],\n ['Category', 'item_category_name'], ['Brand', 'brand_name'], ['Owner', 'owner_name'], ['Strain', 'strain_name'],\n ['Quantity type', 'quantity_type'], ['Quantity', '_display_quantity'], ['Unit', '_display_unit'],\n ['UX OS UOM', 'uom_code'], ['Cost Object', 'cost_object_id'],\n ['Facility', 'facility_name'], ['Room', 'room_name'], ['Status', 'status'], ['Status category', 'status_category'],\n ['Lab state', 'lab_test_status'], ['COA on file', 'has_coa'], ['Packaged date', 'packaged_date'],\n ['Age days', 'age_days'], ['Sales order', 'sales_order_name'], ['Order status', 'sales_order_status'], ['Updated at', 'source_updated_at']\n ];\n const cell = value => {\n let out = value === null || value === undefined ? '' : String(value);\n if (/^[=+\\-@]/.test(out)) out = \"'\" + out;\n return '\"' + out.replace(/\"/g, '\"\"') + '\"';\n };\n const body = [fields.map(f => cell(f[0])).join(',')].concat(rows.map(r => fields.map(f => cell(r[f[1]])).join(','))).join('\\n');\n const blob = new Blob([body], { type: 'text/csv;charset=utf-8' });\n const url = URL.createObjectURL(blob);\n const link = document.createElement('a');\n link.href = url;\n link.download = 'urbanxtracts-inventory-' + new Date().toISOString().slice(0, 10) + '.csv';\n document.body.appendChild(link);\n link.click();\n link.remove();\n URL.revokeObjectURL(url);\n this.set({ toast: rows.length + ' filtered package rows exported.', toastKind: 'EXPORT READY' });\n }\n\n loadInventory() {\n if (this.state.inventoryLoading) return;\n this.set({ inventoryLoading: true, inventoryError: null });\n const headers = { 'Accept': 'application/json' };\n if (this.state.token) headers.Authorization = 'Bearer ' + this.state.token;\n fetch(CANIX_INVENTORY_API, { method: 'GET', headers, credentials: 'same-origin' })\n .then(r => {\n if (!r.ok) throw new Error('Inventory endpoint returned ' + r.status + '.');\n return r.json();\n })\n .then(data => {\n const types = data && data.scope && data.scope.quantity_types;\n if (!data || !data.summary || !Array.isArray(data.facilities) || !Array.isArray(data.statuses)\n || !Array.isArray(data.package_columns) || !Array.isArray(data.packages)) {\n throw new Error('Inventory endpoint returned an incomplete Canix response.');\n }\n if (!Array.isArray(types) || types.length !== 2 || !types.includes('WeightBased') || !types.includes('CountBased')) {\n throw new Error('Inventory endpoint returned a quantity scope this portal does not accept.');\n }\n if (data.package_columns.join('|') !== INVENTORY_PACKAGE_COLUMNS.join('|')\n || data.packages.some(row => !Array.isArray(row) || row.length !== data.package_columns.length)) {\n throw new Error('Inventory endpoint returned an unsupported package schema.');\n }\n this.set({ inventoryData: data, inventoryLoading: false, inventoryLoaded: true, inventoryError: null,\n invPage: 1, invSelectedPackage: null });\n })\n .catch(e => this.set({ inventoryLoading: false, inventoryLoaded: false,\n inventoryError: (e && e.message) || 'The inventory endpoint could not be reached.' }));\n }\n\n\n componentDidMount() {\n this.bump({ sessionStart: Date.now() });\n this._onResize = () => this.setState({ vw: window.innerWidth });\n window.addEventListener('resize', this._onResize);\n this._onResize();\n }\n\n componentDidUpdate(prevProps, prevState) {\n if (this.state.role === 'internal' && this.state.screen === 'inventory' && prevState.screen !== 'inventory') {\n this.loadInventory();\n }\n }\n\n componentWillUnmount() { window.removeEventListener('resize', this._onResize); }\n\n toggleFav(id) {\n this.setState(st => ({ favs: st.favs.indexOf(id) === -1 ? st.favs.concat([id]) : st.favs.filter(x => x !== id) }));\n }\n\n activeLines() {\n const d = this.state.drafts.find(x => x.id === this.state.activeDraft);\n return d ? d.lines : [];\n }\n\n MAX_UPLOAD() { return 2 * 1024 * 1024; }\n\n licPickFile(e) {\n const f = e.target.files && e.target.files[0];\n if (!f) return;\n if (f.size > this.MAX_UPLOAD()) {\n this.set({ licErr: f.name + ' is ' + Math.round(f.size / 1024 / 1024 * 10) / 10 + ' MB. This route carries up to 2 MB, because the bytes travel inside the webhook payload. A larger scan needs file storage in front of it \\u2014 email it to your rep for now.', licFileName: '', licFileData: null, licFileSize: 0 });\n return;\n }\n const r = new FileReader();\n r.onload = () => this.set({ licFileName: f.name, licFileSize: f.size, licFileData: String(r.result).split(',').pop(), licErr: null });\n r.onerror = () => this.set({ licErr: 'That file could not be read.', licFileName: '', licFileData: null });\n r.readAsDataURL(f);\n }\n\n licSubmit() {\n const s = this.state;\n if (!s.licFileData) { this.set({ licErr: 'Choose the document first. There is nothing to review without it.' }); return; }\n if (!s.licExpiry.trim()) { this.set({ licErr: 'Give the expiry the renewal carries. It is recorded as claimed, and a reviewer checks it against the state record.' }); return; }\n const loc = LOCATIONS.find(x => x.id === s.licLoc) || {};\n this.logAudit('Document uploaded', s.licType + ' \\u00b7 ' + loc.name, 'Sent for internal review. Visible to your organisation.');\n this.post('license', {\n summary: s.licType + ' \\u00b7 ' + loc.name + ' \\u00b7 ' + s.licFileName,\n board: 'License Verification',\n account: ACTING_ORG, licenceNumber: loc.license, location: loc.name,\n documentType: s.licType, expiryClaimed: s.licExpiry.trim(), note: s.licNote.trim(),\n uploadedBy: s.authUser ? s.authUser.name + ' (' + s.authUser.role + ')' : 'Unknown',\n reviewState: 'Submitted',\n file: { name: s.licFileName, sizeBytes: s.licFileSize, base64: s.licFileData }\n });\n this.set({ licSent: true, licErr: null, licFileName: '', licFileData: null, licFileSize: 0, licExpiry: '', licNote: '',\n toast: 'Sent for review. Nothing about your licence gate has changed yet \\u2014 it reads the licence record, and that moves when a reviewer has looked at this.', toastKind: 'SENT FOR REVIEW' });\n }\n\n fixPassword() {\n const email = this.state.authUser && this.state.authUser.email;\n if (!email) return;\n this.set({ pwNotice: null });\n const back = location.origin + location.pathname;\n fetch(SUPABASE_URL + '/auth/v1/recover?redirect_to=' + encodeURIComponent(back),\n { method: 'POST', headers: { 'Content-Type': 'application/json', 'apikey': SUPABASE_KEY }, body: JSON.stringify({ email }) })\n .then(r => { if (!r.ok) throw new Error('rejected');\n this.set({ toast: 'A reset link is on its way to ' + email + '. It is good for one use and expires.', toastKind: 'LINK SENT' }); })\n .catch(() => this.set({ toast: 'That request never reached Supabase, so no mail was sent.', toastKind: 'NOT SENT' }));\n }\n\n auToggle() { this.setState(st => ({ auOpen: !st.auOpen, auErr: null, auLocOpen: false })); }\n auLocOpenToggle() { this.setState(st => ({ auLocOpen: !st.auLocOpen })); }\n auLocPick(id) {\n this.setState(st => ({ auLocs: st.auLocs.indexOf(id) === -1\n ? st.auLocs.concat([id]) : st.auLocs.filter(x => x !== id) }));\n }\n auLocAll() {\n this.setState(st => ({ auLocs: st.auLocs.length === LOCATIONS.length ? [] : LOCATIONS.map(l => l.id) }));\n }\n auLocText(ids) {\n if (!ids.length) return '';\n if (ids.length === LOCATIONS.length) return 'All ' + LOCATIONS.length + ' locations';\n return LOCATIONS.filter(l => ids.indexOf(l.id) !== -1).map(l => l.name).join(', ');\n }\n auSubmit() {\n const s = this.state;\n if (!s.auName.trim() || !s.auEmail.trim()) { this.set({ auErr: 'A name and an email are both needed. The email is the username they will sign in with.' }); return; }\n const email = s.auEmail.trim().toLowerCase();\n if (email.indexOf('@') < 1) { this.set({ auErr: 'That is not an email address, and the email is the username they sign in with.' }); return; }\n if (!s.auLocs.length) { this.set({ auErr: 'Pick at least one location. A user with no location has nothing to order for.' }); return; }\n const org = s.role === 'internal' && s.auRole === 'Account management' ? 'urbanXtracts' : ACTING_ORG;\n const locs = this.auLocText(s.auLocs);\n const dbRole = { 'Store owner': 'owner', 'Buyer': 'buyer', 'Budtender': 'budtender', 'Account management': 'internal' }[s.auRole];\n const u = { name: s.auName.trim(), email: email, org, role: s.auRole, locs: locs, last: 'Never signed in' };\n this.logAudit('User added', u.name, u.role + ' at ' + org + ', ' + locs + '. Added directly rather than invited; access begins when they set a password.');\n\n // Their profile is written against their address and belongs to them alone.\n // When the account first appears it is provisioned from this row, so the\n // role and locations chosen here are what they get \\u2014 not a default, and\n // not another person's record. The row is consumed on use, so it can never\n // apply to a second account.\n if (this.state.token) {\n fetch(SUPABASE_URL + '/rest/v1/portal_pending_profile', { method: 'POST',\n headers: { 'Content-Type': 'application/json', 'apikey': SUPABASE_KEY,\n 'Authorization': 'Bearer ' + this.state.token, 'Prefer': 'resolution=merge-duplicates' },\n body: JSON.stringify({ email: email, full_name: u.name, org: org, role: dbRole,\n locations: locs, added_by: s.authUser ? s.authUser.name : null }) })\n .then(r => r.ok ? null : r.text().then(t => { throw new Error(t || 'rejected'); }))\n .then(() => this.set({ toast: u.name + ' added as ' + u.role + ' for ' + locs\n + '. Their own profile is held against ' + email + ' and becomes theirs when they set a password.',\n toastKind: 'USER ADDED' }))\n .catch(() => this.set({ toast: u.name + ' is listed here, but the profile did not save \\u2014 so they would arrive with default access instead of '\n + u.role + '. Worth adding them again before they sign in.', toastKind: 'PROFILE NOT SAVED' }));\n } else {\n this.set({ toast: u.name + ' is listed here only. This build has no signed-in session to write a profile with, so nothing was saved for them.', toastKind: 'NOT SAVED' });\n }\n\n this.post('onboarding', {\n summary: 'People change \\u00b7 ' + u.name + ' as ' + u.role,\n board: 'Store Onboarding',\n submissionType: 'Existing store, people change',\n legalEntity: org, locations: u.locs,\n owner: s.auRole === 'Store owner' ? { name: u.name, email: u.email } : { name: '', email: '' },\n buyer: s.auRole === 'Buyer' ? { name: u.name, email: u.email } : { name: '', email: '' },\n budtenders: s.auRole === 'Budtender' ? [{ name: u.name, email: u.email, location: u.locs }] : [],\n budtenderCount: s.auRole === 'Budtender' ? 1 : 0,\n submittedBy: s.authUser ? s.authUser.name : 'Unknown'\n });\n this.setState(st => ({ extraUsers: st.extraUsers.concat([u]), auOpen: false, auName: '', auEmail: '',\n auLocs: [], auLocOpen: false, auErr: null }));\n }\n\n onbSetBud(i, field, v) {\n this.setState(st => ({ onbBud: st.onbBud.map((b, j) => j === i ? { ...b, [field]: v } : b) }));\n }\n onbAddBud() {\n this.setState(st => st.onbBud.length >= 5\n ? { toast: 'Five budtenders per submission. Send another once these are set up \\u2014 the cap is there so a list of thirty names does not arrive as one item.', toastKind: 'AT THE CAP' }\n : { onbBud: st.onbBud.concat([{ name: '', email: '', loc: '' }]) });\n }\n onbRemoveBud(i) {\n this.setState(st => ({ onbBud: st.onbBud.length <= 1 ? st.onbBud : st.onbBud.filter((_, j) => j !== i) }));\n }\n\n onbSubmit() {\n const s = this.state;\n const miss = [];\n if (!s.onbEntity.trim()) miss.push('the legal entity name');\n if (!s.onbLocs.trim()) miss.push('at least one location with its licence number');\n if (!s.onbOwnerName.trim() || !s.onbOwnerEmail.trim()) miss.push('the owner\\u2019s name and email');\n if (miss.length) { this.set({ onbErr: 'Still needed: ' + miss.join(', ') + '. A location without a licence number cannot proceed at all \\u2014 the licence number is the account key.' }); return; }\n const buds = s.onbBud.filter(b => b.name.trim() || b.email.trim());\n this.post('onboarding', {\n summary: s.onbType + ' \\u00b7 ' + s.onbEntity.trim(),\n board: 'Store Onboarding',\n submissionType: s.onbType,\n legalEntity: s.onbEntity.trim(), dba: s.onbDba.trim(),\n locations: s.onbLocs.trim(),\n owner: { name: s.onbOwnerName.trim(), email: s.onbOwnerEmail.trim(), phone: s.onbOwnerPhone.trim() },\n buyer: { name: s.onbBuyerName.trim(), email: s.onbBuyerEmail.trim(), location: s.onbBuyerLoc.trim() },\n budtenders: buds.map(b => ({ name: b.name.trim(), email: b.email.trim(), location: b.loc.trim() })),\n budtenderCount: buds.length,\n stage: '01 Intake',\n submittedBy: s.authUser ? s.authUser.name : (s.onbOwnerEmail.trim() || 'Prospective store')\n });\n this.set({ onbSent: true, onbErr: null,\n toast: 'Submitted. Nothing is approved by sending it \\u2014 a licence is qualified against the state record by us, and terms are set before an account can order.', toastKind: 'SUBMITTED' });\n }\n\n post(kind, payload, onOk) {\n const s = this.state;\n // Everything a static page carries is public, so the published portal cannot\n // hold the Make URL or the shared secret. It posts to the portal's own\n // intake function instead, which holds both server-side and checks who is\n // calling before forwarding. A directly configured Make URL still works for\n // local development.\n const direct = !!MAKE_WEBHOOK && !!INTAKE_KEY;\n const url = direct ? MAKE_WEBHOOK : SUPABASE_URL + '/functions/v1/portal-intake';\n const headers = direct\n ? { 'Content-Type': 'application/json' }\n : { 'Content-Type': 'application/json', 'apikey': SUPABASE_KEY,\n 'Authorization': 'Bearer ' + (s.token || SUPABASE_KEY) };\n const body = direct\n ? { kind, secret: INTAKE_KEY, sentAt: new Date().toISOString(), source: 'UX Store Portal', payload }\n : { kind, payload };\n this.setState(st => ({ outbox: [{ kind, at: TODAY, state: 'Sending', detail: payload.summary || kind }].concat(st.outbox) }));\n const failed = (state, why) => this.setState(st => ({\n outbox: st.outbox.map((o, i) => i === 0 ? { ...o, state: state } : o),\n // A failure used to appear only in the outbox, which is not the pane\n // anybody has open at the moment they submit. Say it where they are.\n toast: 'That did not reach Monday \\u2014 ' + why + '. It is recorded here and on this device only.',\n toastKind: 'NOT SENT'\n }));\n fetch(url, { method: 'POST', headers: headers, body: JSON.stringify(body) })\n .then(r => r.text().then(t => ({ ok: r.ok, status: r.status, t: t })))\n .then(({ ok, status, t }) => {\n if (!ok) {\n let why = 'the intake answered HTTP ' + status;\n try { const j = JSON.parse(t); if (j && j.error) why = j.error; } catch (e) {}\n failed('Rejected: HTTP ' + status, why);\n return;\n }\n this.setState(st => ({ outbox: st.outbox.map((o, i) => i === 0 ? { ...o, state: 'Accepted by Make' } : o) }));\n let reply = null;\n try { reply = JSON.parse(t); } catch (e) {}\n if (onOk) onOk(reply);\n })\n .catch(() => failed('Could not reach the intake', 'the intake could not be reached'));\n return 'sending';\n }\n\n cleanQty(v, fallback) {\n const n = parseInt(String(v).replace(/[^0-9]/g, ''), 10);\n if (!isFinite(n) || n < 1) return fallback === undefined ? 1 : fallback;\n return Math.min(n, 9999);\n }\n\n patchActive(fn) {\n this.setState(st => ({ drafts: st.drafts.map(d => d.id === st.activeDraft ? { ...d, lines: fn(d.lines) } : d) }));\n }\n\n newDraft() {\n const id = 'D' + (this.state.drafts.length + 1);\n const name = this.locName(this.state.loc) + ' draft ' + (this.state.drafts.length + 1);\n this.setState(st => ({ drafts: st.drafts.concat([{ id, name, loc: st.loc, lines: [] }]), activeDraft: id, screen: 'cart', toast: 'New draft started for ' + this.locName(st.loc) + '.', toastKind: 'DRAFT ADDED' }));\n }\n\n deleteDraft(id) {\n this.setState(st => {\n const left = st.drafts.filter(d => d.id !== id);\n const drafts = left.length ? left : [{ id: 'D1', name: 'New draft', loc: st.loc, lines: [] }];\n return { drafts, activeDraft: drafts[0].id, toast: 'Draft discarded.', toastKind: 'DISCARDED' };\n });\n }\n\n swapLine(i, toId) {\n const p = P(toId);\n this.patchActive(lines => lines.map((l, j) => j === i ? { ...l, id: toId, lot: 0, price: p.price } : l));\n this.setState({ toast: 'Line swapped to ' + p.name + ' at its current derived price.', toastKind: 'LINE SWAPPED' });\n }\n\n refresh() {\n this.setState({ loading: true, stale: false });\n setTimeout(() => this.setState({ loading: false, toast: 'Reloaded against the subledger.', toastKind: 'REFRESHED' }), 750);\n }\n\n logAudit(action, subject, detail, actor) {\n this.setState(st => ({\n audit: [{ ts: '24 Aug 2026 ' + String(9 + (st.audit.length % 9)).padStart(2, '0') + ':' + String(10 + (st.audit.length * 7) % 49).padStart(2, '0'),\n actor: actor || (st.impersonating ? 'Administrator (as ' + st.impersonating.name + ')' : 'You'), action, subject, detail }].concat(st.audit)\n }));\n }\n\n impersonate(u) {\n this.setState({ impersonating: u, role: u.role === 'Buyer' ? 'buyer' : 'owner', screen: 'dash', toast: 'Viewing as ' + u.name + '. Read-only, cost withheld, and recorded in ' + u.org + '\\u2019s audit history.', toastKind: 'VIEWING AS USER' });\n this.logAudit('Administrator viewed a user\\u2019s screen', u.name + ' · ' + u.org, 'Read-only session, cost withheld, visible to the organisation', 'Administrator');\n }\n\n stopImpersonating() {\n const u = this.state.impersonating;\n this.setState({ impersonating: null, role: 'internal', screen: 'admin', toast: 'Returned to your own view.', toastKind: 'SESSION ENDED' });\n if (u) this.logAudit('Administrator session ended', u.name + ' · ' + u.org, 'Duration recorded against the audit entry', 'Administrator');\n }\n priced(p) { return p.price !== null; }\n loc() { return LOCATIONS.find(l => l.id === this.state.loc) || LOCATIONS[0]; }\n locName(id) { const l = LOCATIONS.find(x => x.id === id); return l ? l.name : '—'; }\n orderValue(o) { return o.lines.reduce((a, l) => a + l.price * l.qty, 0); }\n orderUnits(o) { return o.lines.reduce((a, l) => a + l.qty, 0); }\n\n addLine(p, qty, lotIdx) {\n const lot = Math.min(lotIdx || 0, p.lots.length - 1);\n this.patchActive(lines => lines.concat([{ id: p.id, lot, qty, price: p.price }]));\n this.bump(m => ({ adds: m.adds + 1 }));\n this.setState({ screen: 'cart', toast: qty + ' × ' + p.name + ' added to your draft.', toastKind: 'ADDED' });\n }\n\n requestPrice(p) {\n this.setState(st => ({\n requests: st.requests.concat([{ name: p.name, loc: this.locName(st.loc), raised: TODAY, state: 'WITH REP' }]),\n screen: 'orders', toast: 'Price requested for ' + p.name + '. Your account manager has been notified and the request is visible to your owner.', toastKind: 'REQUESTED'\n }));\n }\n\n submit() {\n const st = this.state;\n const lines = this.activeLines();\n if (!lines.length) return;\n if (st.impersonating) { this.blocked('readOnly'); this.set({ toast: 'This is a read-only administrator session — an order cannot be submitted from it.', toastKind: 'BLOCKED — READ ONLY' }); return; }\n const l = this.loc();\n if (l.expired) { this.blocked('licence'); this.set({ toast: 'Cannot submit: the license record for ' + l.name + ' has expired. Other locations are unaffected.', toastKind: 'BLOCKED — LICENSE' }); return; }\n if (l.pastDue) { this.blocked('receivables'); this.set({ toast: 'Cannot submit: ' + l.name + ' has a past-due balance. The receivables check runs at order entry, not at shipping — the draft is kept as it is.', toastKind: 'BLOCKED — RECEIVABLES' }); return; }\n const elapsed = st.metrics.sessionStart ? Date.now() - st.metrics.sessionStart : null;\n this.bump(m => ({ submits: m.submits + 1, byStore: m.byStore + 1,\n firstSubmitMs: m.firstSubmitMs === null ? elapsed : m.firstSubmitMs }));\n const isBuyer = st.role === 'buyer';\n const state = isBuyer ? 'Awaiting approval' : 'Placed';\n const id = 'SO-' + SESSION_TAG + '-' + st.seq;\n const order = {\n id, loc: st.loc, placed: TODAY, state,\n history: isBuyer ? { 'Awaiting approval': TODAY } : { 'Placed': TODAY },\n lines: lines.map(l => ({ ...l })), delivery: st.delivery, submittedBy: isBuyer ? 'Buyer' : 'Owner'\n };\n const loc = LOCATIONS.find(x => x.id === st.loc) || {};\n const ds = st.deliverySettings[st.loc] || {};\n // One line per order line, each keeping its own unit. Deliberately not\n // summed into a single quantity: an order can mix grams and each, and those\n // are never blended into one figure.\n // These land in a GraphQL string inside a JSON string, where a raw newline\n // or double quote terminates the literal and the whole mutation fails. The\n // safe place to deal with that is here, not in a chain of escapes.\n const flat = t => String(t == null ? '' : t).replace(/[\\r\\n]+/g, ' ').replace(/\"/g, '\\u2033').trim();\n const detailText = flat(lines.map(l => {\n const pp = P(l.id);\n return pp.name + ' \\u00b7 ' + pp.sku + ' \\u00b7 tag ' + pp.lots[l.lot].tag\n + ' \\u00b7 ' + l.qty + ' each @ ' + MONEY(l.price) + ' = ' + MONEY(l.price * l.qty);\n }).join(' | '));\n const deliveryText = flat([\n 'Window: ' + (ds.window || 'not set'),\n 'Receiving contact: ' + (ds.contact || 'not set'),\n 'Instructions: ' + (ds.instructions || 'none recorded')\n ].join(' | '));\n // The order number is assigned by Monday, so it is not known until the\n // intake answers. Until then the screen shows the portal reference and says\n // the number is still coming, rather than presenting the reference as one.\n const onAccepted = reply => {\n const num = reply && reply.orderNumber;\n if (!num) return;\n this.setState(st => ({\n orders: st.orders.map(o => o.id === id ? { ...o, orderNo: num } : o),\n toast: 'Order number ' + num + ' assigned. That is the number on the board, and the number to quote.',\n toastKind: 'ORDER NUMBER'\n }));\n };\n this.post('order', {\n summary: id + ' \\u00b7 ' + ACTING_ORG + ' \\u00b7 ' + MONEY(lines.reduce((a, l) => a + l.price * l.qty, 0)),\n orderDetailText: detailText,\n deliveryText: deliveryText,\n board: 'Portal Orders',\n orderNumber: id,\n account: ACTING_ORG,\n licenceNumber: loc.license,\n location: loc.name,\n submittedBy: st.authUser ? st.authUser.name + ' (' + st.authUser.role + ')' : 'Unknown',\n submittedVia: st.role === 'internal' ? 'Rep on behalf' : 'Store portal',\n approvalState: isBuyer ? 'Awaiting store approval' : 'Placed by store',\n approvalHeldBy: (st.delegateTo || 'the store owner') + (st.delegateTo ? ' until ' + st.delegateExpiry : ' by default'),\n orderValue: lines.reduce((a, l) => a + l.price * l.qty, 0) / 100,\n lineCount: lines.length,\n lines: lines.map(l => { const pp = P(l.id); return { product: pp.name, sku: pp.sku, tag: pp.lots[l.lot].tag, quantity: l.qty, unit: 'each', unitPriceCents: l.price }; }),\n deliveryWindow: ds.window || '', receivingContact: ds.contact || '', receivingInstructions: ds.instructions || '',\n gatesAtSubmission: 'Licence clear at ' + loc.name + '; receivables clear; available quantity clear.'\n }, onAccepted);\n this.setState({\n orders: [order].concat(st.orders),\n drafts: st.drafts.map(d => d.id === st.activeDraft ? { ...d, lines: [] } : d),\n seq: st.seq + 1, order: id, screen: 'confirm', confirmed: id,\n toast: isBuyer ? id + ' sent to the store owner for approval.' : id + ' submitted.',\n toastKind: isBuyer ? 'SENT FOR APPROVAL' : 'SUBMITTED'\n });\n }\n\n advance(id, to, kind) {\n this.logAudit(to === 'Flagged' ? 'Order declined' : 'Order state changed', id, 'Now ' + to.toLowerCase());\n this.setState(st => ({\n orders: st.orders.map(o => o.id === id ? { ...o, state: to, history: { ...o.history, [to]: TODAY } } : o),\n toast: id + ' is now ' + to.toLowerCase() + '.', toastKind: kind || 'UPDATED'\n }));\n }\n\n reorder(o) {\n this.patchActive(() => o.lines.map(l => ({ ...l, price: P(l.id).price === null ? l.price : P(l.id).price })));\n const gone = o.lines.filter(l => P(l.id).oos).length;\n this.setState({\n screen: 'cart', toast: gone ? 'Draft rebuilt from ' + o.id + '. ' + gone + ' line is no longer stocked — swap it below rather than losing it.' : 'Draft rebuilt from ' + o.id + '. Prices refreshed to your current derived price — check the lines flagged as changed.', toastKind: gone ? 'LINE UNAVAILABLE' : 'REORDER'\n });\n }\n\n kioskFind() {\n this.bump(m => ({ kioskTyped: m.kioskTyped + 1 }));\n const raw = this.state.kioskInput.trim();\n if (!raw) { this.set({ kioskMsg: 'Enter or scan a tag first.', kioskMsgKind: 'NOTHING ENTERED' }); return; }\n const all = [];\n PRODUCTS.forEach(p => p.lots.forEach((l, i) => all.push({ p, i, tag: l.tag })));\n const exact = all.find(x => x.tag.toLowerCase() === raw.toLowerCase());\n if (exact) { this.set({ product: exact.p.id, lot: exact.i, screen: 'product', kioskMsg: null }); return; }\n const partial = all.filter(x => x.tag.toLowerCase().includes(raw.toLowerCase()));\n if (partial.length === 1) {\n this.set({ kioskMsg: 'That looks like part of a tag. One package matches — ' + partial[0].p.name + ' — but a partial tag is never resolved automatically. Enter the full tag to open it.', kioskMsgKind: 'PARTIAL TAG' });\n } else if (partial.length > 1) {\n this.bump(m => ({ kioskAmbiguous: m.kioskAmbiguous + 1 }));\n this.set({ kioskMsg: partial.length + ' packages contain that sequence, so it cannot resolve to one lot. Scan the package or enter the full tag.', kioskMsgKind: 'AMBIGUOUS' });\n } else {\n this.set({ kioskMsg: 'No package matches that tag. Check the digits, or scan the barcode. This never tells you whether a nearby tag exists.', kioskMsgKind: 'NOT FOUND' });\n }\n }\n\n navFor(role) {\n if (role === 'budtender') return [['kiosk', 'Product lookup', 'kiosk'], ['catalog', 'Browse products', 'catalog'], ['compare', 'Compare', 'compare'], ['training', 'Training', 'training'], ['coalookup', 'Public COA page', 'coa'], ['validation', 'Validation queue', 'validation']];\n if (role === 'internal') return [['dash', 'Internal dashboard', 'dash'], ['accounts', 'Retailer accounts', 'accounts'], ['field', 'Field view', 'accounts'], ['measure', 'Measurement', 'economics'], ['onboarding', 'Store onboarding', 'accounts'], ['onboard', 'Onboard a store', 'accounts'], ['recall', 'Recall and lot impact', 'lineage'], ['catalog', 'Catalog', 'catalog'], ['inventory', 'Inventory', 'catalog'], ['lineage', 'Lots and lineage', 'lineage'], ['qa', 'Release and quality', 'qa'], ['economics', 'Cost and margin', 'economics'], ['cart', 'Draft order', 'cart'], ['orders', 'Orders', 'orders'], ['admin', 'Users and roles', 'admin'], ['tests', 'Required tests', 'validation'], ['auditlog', 'Audit history', 'audit'], ['coalookup', 'Public COA page', 'coa'], ['access', 'Access states', 'access'], ['validation', 'Validation queue', 'validation']];\n if (role === 'owner') return [['dash', 'Dashboard', 'dash'], ['locations', 'Compare locations', 'accounts'], ['catalog', 'Catalog', 'catalog'], ['compare', 'Compare', 'compare'], ['cart', 'Draft order', 'cart'], ['orders', 'Orders and approvals', 'orders'], ['notifications', 'Notifications', 'bell'], ['account', 'Account and delivery', 'accounts'], ['approval', 'Approval authority', 'orders'], ['receivables', 'Receivables', 'economics'], ['api', 'API access', 'access'], ['users', 'Your users', 'admin'], ['documents', 'Documents', 'admin'], ['records', 'Your records', 'audit'], ['training', 'Training', 'training'], ['validation', 'Validation queue', 'validation']];\n return [['dash', 'Dashboard', 'dash'], ['catalog', 'Catalog', 'catalog'], ['compare', 'Compare', 'compare'], ['cart', 'Draft order', 'cart'], ['orders', 'Orders', 'orders'], ['notifications', 'Notifications', 'bell'], ['documents', 'Documents', 'admin'], ['records', 'Your records', 'audit'], ['training', 'Training', 'training'], ['validation', 'Validation queue', 'validation']];\n }\n\n renderVals() {\n const s = this.state;\n const role = s.role;\n const invData = s.inventoryData || CANIX_INVENTORY_SNAPSHOT;\n const invNumber = (value, digits) => value === null || value === undefined\n ? '\\u2014'\n : Number(value).toLocaleString('en-US', { minimumFractionDigits: 0, maximumFractionDigits: digits === undefined ? 0 : digits });\n const invWeight = value => value === null || value === undefined ? '\\u2014' : invNumber(value, 3) + ' g';\n const invUnits = value => value === null || value === undefined ? '\\u2014' : invNumber(value, 0) + ' units';\n const invPackageColumns = Array.isArray(invData.package_columns) ? invData.package_columns : INVENTORY_PACKAGE_COLUMNS;\n const invPackages = (Array.isArray(invData.packages) ? invData.packages : []).map(row => {\n const record = Array.isArray(row)\n ? Object.fromEntries(invPackageColumns.map((name, index) => [name, row[index]]))\n : row;\n const isWeight = record.quantity_type === 'WeightBased';\n return { ...record,\n _display_quantity: isWeight ? Number(record.c_weight_g || 0) : Number(record.weight || 0),\n _display_unit: isWeight ? 'g' : 'units' };\n });\n const invTitle = value => String(value || '').replace(/_/g, ' ').replace(/\\b\\w/g, c => c.toUpperCase());\n const invFacet = (key, emptyLabel) => Array.from(new Set(invPackages.map(row => row[key] || emptyLabel).filter(Boolean)))\n .sort((a, b) => String(a).localeCompare(String(b)))\n .map(value => ({ value: String(value), label: invTitle(value) }));\n const invViewMatch = (row, view) => view === 'available' ? row.status_category === 'available'\n : view === 'allocated' ? row.status_category === 'allocated'\n : view === 'failed' ? String(row.lab_test_status || '').indexOf('Failed') !== -1 || String(row.test_result_status || '').indexOf('Failed') !== -1\n : view === 'aged' ? Number(row.age_days) > 90\n : true;\n const invViewCounts = view => invPackages.filter(row => invViewMatch(row, view)).length;\n const invNeedle = String(s.invSearch || '').trim().toLowerCase();\n const invMatchesAge = row => s.invAge === 'all'\n || (s.invAge === '0-30' && Number(row.age_days) <= 30)\n || (s.invAge === '31-60' && Number(row.age_days) >= 31 && Number(row.age_days) <= 60)\n || (s.invAge === '61-90' && Number(row.age_days) >= 61 && Number(row.age_days) <= 90)\n || (s.invAge === '90+' && Number(row.age_days) > 90);\n const invFiltered = invPackages.filter(row => {\n if (!invViewMatch(row, s.invView)) return false;\n if (s.invFacility !== 'all' && String(row.facility_id) !== s.invFacility) return false;\n if (s.invStatus !== 'all' && row.status_category !== s.invStatus) return false;\n if (s.invQuantity !== 'all' && row.quantity_type !== s.invQuantity) return false;\n if (s.invCategory !== 'all' && row.item_category_name !== s.invCategory) return false;\n if (s.invBrand !== 'all' && (row.brand_name || 'Not recorded') !== s.invBrand) return false;\n if (s.invLab !== 'all' && (row.lab_test_status || 'No status') !== s.invLab) return false;\n if (!invMatchesAge(row)) return false;\n if (!invNeedle) return true;\n return [row.package_id, row.tag, row.item_name, row.sku, row.product_name, row.brand_name, row.owner_name,\n row.uom_code, row.cost_object_id, row.strain_name, row.room_name]\n .some(value => String(value || '').toLowerCase().indexOf(invNeedle) !== -1);\n });\n const invSortValue = (row, key) => key === 'quantity' ? row._display_quantity\n : key === 'allocation' ? (row.sales_order_name || row.sales_order_id || '')\n : key === 'updated_at' ? row.source_updated_at\n : row[key];\n const invSorted = invFiltered.slice().sort((a, b) => {\n if (s.invSortKey === 'quantity' && a.quantity_type !== b.quantity_type) {\n return String(a.quantity_type).localeCompare(String(b.quantity_type));\n }\n const av = invSortValue(a, s.invSortKey);\n const bv = invSortValue(b, s.invSortKey);\n let result = 0;\n if (typeof av === 'number' || typeof bv === 'number') result = (Number(av) || 0) - (Number(bv) || 0);\n else result = String(av || '').localeCompare(String(bv || ''), undefined, { numeric: true, sensitivity: 'base' });\n if (result === 0) result = Number(a.package_id) - Number(b.package_id);\n return s.invSortDir === 'asc' ? result : -result;\n });\n const invPageSize = Number(s.invPageSize) || 50;\n const invPageCount = Math.max(1, Math.ceil(invSorted.length / invPageSize));\n const invPage = Math.min(Math.max(1, Number(s.invPage) || 1), invPageCount);\n const invPageStart = (invPage - 1) * invPageSize;\n const invPageRows = invSorted.slice(invPageStart, invPageStart + invPageSize);\n const invFilteredWeightValue = invFiltered.filter(row => row.quantity_type === 'WeightBased').reduce((sum, row) => sum + (Number(row.c_weight_g) || 0), 0);\n const invFilteredUnitsValue = invFiltered.filter(row => row.quantity_type === 'CountBased').reduce((sum, row) => sum + (Number(row.weight) || 0), 0);\n const invSelected = invPackages.find(row => String(row.package_id) === String(s.invSelectedPackage));\n /* Brand geometry: the mark is built from arches, so category markers, image\n frames and empty states repeat that arch rather than inventing a shape. */\n const arch = (size, fill) => 'width:' + size + 'px;height:' + Math.round(size * 0.78) + 'px;flex:none;border-radius:' + size + 'px ' + size + 'px 0 0;background:' + fill;\n const activeDraftObj = s.drafts.find(d => d.id === s.activeDraft) || s.drafts[0];\n const cart = activeDraftObj ? activeDraftObj.lines : [];\n const narrow = s.vw < 820;\n const mid = s.vw < 1120;\n const isInternal = role === 'internal';\n const isKioskRole = role === 'budtender';\n const canOrder = !!s.authUser && (role === 'owner' || role === 'buyer' || role === 'internal');\n const loc = this.loc();\n const licenseBlocked = canOrder && loc.expired;\n const arBlocked = canOrder && !!loc.pastDue;\n const submitBlocked = licenseBlocked || arBlocked;\n\n const visibleOrders = isInternal ? s.orders : s.orders.filter(o => o.loc === s.loc);\n const pendingApproval = s.orders.filter(o => o.state === 'Awaiting approval' && (isInternal || o.loc === s.loc));\n\n const navBtn = (active) => 'display:flex;align-items:center;gap:8px;width:100%;text-align:left;background:' + (active ? 'var(--ux-surface)' : 'transparent') +\n ';border:0;border-left:' + (active ? '3px solid var(--ux-accent)' : '3px solid transparent') +\n ';padding:11px 20px;font:' + (active ? '700' : '500') + ' 13.5px Archivo,sans-serif;color:var(--ux-ink);cursor:pointer';\n\n const badges = { cart: cart.length, orders: pendingApproval.length, notifications: s.notifRead ? 0 : 3 };\n const nav = this.navFor(role).map(([id, label, icon]) => {\n const n = badges[id] || 0;\n return {\n label, icon: ICON[icon] || ICON.dash, style: navBtn(s.screen === id || (id === 'orders' && s.screen === 'order')),\n current: (s.screen === id || (id === 'orders' && s.screen === 'order')) ? 'page' : 'false',\n badge: n > 0 ? String(n) : '',\n badgeStyle: n > 0 ? 'background:var(--ux-ink);color:var(--ux-ground);font:700 10px ui-monospace,monospace;padding:1px 6px' : 'display:none',\n onPick: () => this.set({ screen: id })\n };\n });\n\n const roleTabs = [['owner', 'Owner'], ['buyer', 'Buyer'], ['budtender', 'Budtender'], ['internal', 'Internal']].map(([id, label]) => ({\n label,\n style: 'border:0;padding:8px 12px;font:' + (role === id ? '700' : '500') + ' 12px Archivo,sans-serif;cursor:pointer;background:' +\n (role === id ? 'var(--ux-ink)' : 'transparent') + ';color:' + (role === id ? 'var(--ux-ground)' : 'var(--ux-ink)'),\n pressed: role === id ? 'true' : 'false',\n onPick: () => this.set({ role: id, screen: id === 'budtender' ? 'kiosk' : 'dash', order: null, toast: null })\n }));\n\n const locations = LOCATIONS.map(l => ({\n name: l.name, license: l.license,\n style: 'display:inline-flex;align-items:baseline;gap:6px;white-space:nowrap;background:' + (s.loc === l.id ? '#fff' : 'transparent') +\n ';border:1px solid ' + (s.loc === l.id ? 'var(--ux-ink)' : 'rgba(23,14,11,.25)') +\n ';padding:7px 11px;font:' + (s.loc === l.id ? '700' : '500') + ' 12px Archivo,sans-serif;cursor:pointer',\n onPick: () => this.set({ loc: l.id, order: null })\n }));\n\n const product = P(s.product) || PRODUCTS[0];\n const lot = product.lots[Math.min(s.lot, product.lots.length - 1)];\n const orderedIds = new Set(s.orders.filter(o => isInternal || o.loc === s.loc).flatMap(o => o.lines.map(l => l.id)));\n\n const priceText = (p) => {\n if (isKioskRole) return 'Price not shown on this device';\n if (!this.priced(p)) return 'Price on request';\n return MONEY(p.price) + ' per unit';\n };\n\n const catalogItems = PRODUCTS.filter(p => {\n const q = s.query.trim().toLowerCase();\n if (s.cat !== 'All' && p.cat !== s.cat) return false;\n if (s.onlyFavs && s.favs.indexOf(p.id) === -1) return false;\n if (!q) return true;\n return (p.name + ' ' + p.brand + ' ' + p.sku + ' ' + p.strain + ' ' + p.lots.map(l => l.tag).join(' ')).toLowerCase().includes(q);\n }).map(p => ({\n name: p.name, brand: p.brand, cat: p.cat, format: p.format, sku: p.sku, band: p.band, catDot: catDot(p.cat, 9),\n isFav: s.favs.indexOf(p.id) !== -1,\n favStyle: 'background:transparent;border:1px solid rgba(23,14,11,.25);padding:8px 10px;font:700 12px Archivo,sans-serif;cursor:pointer;color:' + (s.favs.indexOf(p.id) !== -1 ? 'var(--ux-accent-text)' : 'var(--ux-muted)'),\n favLabel: s.favs.indexOf(p.id) !== -1 ? 'Saved' : 'Save',\n onFav: () => this.toggleFav(p.id),\n priceText: priceText(p), priceStyle: this.priced(p) ? 'font-weight:600' : 'font-weight:600;color:var(--ux-signal-text)',\n orderedTag: orderedIds.has(p.id) ? 'ORDERED BEFORE' : '',\n orderedStyle: orderedIds.has(p.id) ? 'font:600 9.5px ui-monospace,monospace;letter-spacing:.05em;padding:3px 6px;background:var(--ux-surface);color:var(--ux-muted-strong)' : 'display:none',\n canAdd: canOrder && this.priced(p) && !licenseBlocked && !p.oos,\n canRequest: canOrder && !this.priced(p),\n onOpen: () => this.set({ product: p.id, lot: 0, qty: 1, screen: 'product' }),\n qty: String(s.catQty[p.id] || 1),\n onQty: e => { const v = this.cleanQty(e.target.value, s.catQty[p.id] || 1); this.setState(st => ({ catQty: { ...st.catQty, [p.id]: v } })); },\n onQtyUp: () => this.setState(st => ({ catQty: { ...st.catQty, [p.id]: (st.catQty[p.id] || 1) + 1 } })),\n onQtyDown: () => this.setState(st => ({ catQty: { ...st.catQty, [p.id]: Math.max(1, (st.catQty[p.id] || 1) - 1) } })),\n onAdd: () => this.addLine(p, s.catQty[p.id] || 1, 0),\n onRequest: () => this.requestPrice(p)\n }));\n\n const cartLines = cart.map((line, i) => {\n const p = P(line.id);\n return {\n name: p.name, brand: p.brand, sku: p.sku, tag: '…' + p.lots[line.lot].tag.slice(-8),\n packaged: p.lots[line.lot].packaged, qty: line.qty, lineTotal: MONEY(line.price * line.qty),\n onInc: () => this.patchActive(lines => lines.map((l, j) => j === i ? { ...l, qty: l.qty + 1 } : l)),\n onDec: () => this.patchActive(lines => lines.map((l, j) => j === i ? { ...l, qty: Math.max(1, l.qty - 1) } : l)),\n onQty: e => { const v = this.cleanQty(e.target.value, line.qty); this.patchActive(lines => lines.map((l, j) => j === i ? { ...l, qty: v } : l)); },\n onRemove: () => this.patchActive(lines => lines.filter((_, j) => j !== i))\n };\n });\n const cartTotalCents = cart.reduce((a, l) => a + l.price * l.qty, 0);\n const changed = cart.filter(l => P(l.id).price !== null && P(l.id).price !== l.price);\n const oosLines = cart.map((l, i) => ({ i, p: P(l.id) })).filter(x => x.p.oos).map(x => ({\n name: x.p.name,\n alternatives: PRODUCTS.filter(alt => alt.cat === x.p.cat && !alt.oos && alt.price !== null && alt.id !== x.p.id).slice(0, 2).map(alt => ({\n label: alt.name + ' · ' + MONEY(alt.price),\n onSwap: () => this.swapLine(x.i, alt.id)\n }))\n }));\n\n const warnings = [];\n if (cart.length) {\n warnings.push({ kind: 'PRICING BASIS', text: 'Each price is derived from the most recent qualifying order line for this account, not from a rate card. Samples, cancellations, returns and lines where discount exceeded price are excluded, and the derivation date is shown on the product.' });\n warnings.push({ kind: 'CASE INCREMENTS NOT ENFORCED', text: 'Case and minimum-order values are not confirmed readable per item, so quantities are accepted as entered.' });\n }\n if (changed.length) warnings.push({ kind: 'PRICE CHANGED SINCE THIS LINE WAS ADDED', text: changed.length + ' line(s) were priced at an earlier derived figure. Submitting confirms the newer price.' });\n if (licenseBlocked) warnings.push({ kind: 'ORDERING PAUSED', text: 'The license record for ' + loc.name + ' has expired, so this location cannot submit an order. Other locations are unaffected.' });\n if (arBlocked && cart.length) warnings.push({ kind: 'RECEIVABLES GATE — SUBMISSION BLOCKED', text: 'This account has a past-due balance, so the check runs here at order entry rather than at shipping. Lines can still be built and saved; the balance itself is shown to the owner, not the buyer. The past-due threshold is still undefined.' });\n if (role === 'buyer' && cart.length) warnings.push({ kind: 'APPROVAL REQUIRED', text: 'Buyer-submitted orders go to the store owner for approval. The value threshold that would also trigger approval has not been set.' });\n\n const tiles = isInternal\n ? [{ label: 'Live orders', value: String(s.orders.length), note: 'Across all accounts' },\n { label: 'Awaiting approval', value: String(pendingApproval.length), note: 'At the store, not with us' },\n { label: 'Released SKUs', value: '32', note: 'Available and lab-passed' },\n { label: 'Integrity exceptions', value: '7', note: 'Checks failing today' }]\n : role === 'owner'\n ? [{ label: 'Orders, this location', value: String(visibleOrders.length), note: 'Placed through the portal' },\n { label: 'Awaiting your approval', value: String(pendingApproval.length), note: 'Buyer-submitted' },\n { label: 'Price requests', value: String(s.requests.length), note: 'With your rep' },\n { label: 'Draft lines', value: String(cart.length), note: 'Unsubmitted' }]\n : [{ label: 'Draft lines', value: String(cart.length), note: 'Yours, unsubmitted' },\n { label: 'Your orders', value: String(visibleOrders.length), note: 'This location' },\n { label: 'Awaiting approval', value: String(pendingApproval.length), note: 'Sent to the owner' },\n { label: 'Released SKUs', value: '32', note: 'Available to this location' }];\n\n const tileStyle = (i, n) => 'padding:18px 20px 18px 0;' + (i < n - 1 ? 'border-right:1px solid rgba(23,14,11,.14)' : '');\n const tilesStyled = tiles.map((t, i) => ({ ...t, style: tileStyle(i, tiles.length) }));\n\n const stateTag = (st) => {\n if (st === 'Received') return 'background:var(--ux-ink);color:var(--ux-ground)';\n if (st === 'Flagged') return 'background:var(--ux-signal);color:#fff';\n if (st === 'Awaiting approval') return 'background:var(--ux-surface);color:var(--ux-ink)';\n return 'background:var(--ux-surface);color:var(--ux-muted-strong)';\n };\n\n const orders = visibleOrders.map(o => ({\n id: o.id, loc: this.locName(o.loc), placed: o.placed, lines: String(o.lines.length),\n value: MONEY(this.orderValue(o)), state: o.state, tagStyle: stateTag(o.state),\n onOpen: () => this.set({ order: o.id, screen: 'order' })\n }));\n\n const dashRows = isInternal\n ? [{ a: 'Riverside Collective', b: 'No order in 62 days', c: 'OCM-RETL-24-000518', tag: 'FOLLOW UP', tagStyle: 'background:var(--ux-signal);color:#fff', onOpen: () => this.set({ screen: 'accounts' }) },\n { a: 'Orders needing movement', b: pendingApproval.length + ' awaiting store approval', c: 'Order queue', tag: 'ORDERS', tagStyle: 'background:var(--ux-surface);color:var(--ux-muted-strong)', onOpen: () => this.set({ screen: 'orders' }) },\n { a: 'Packages with no lot identifier', b: 'Tag used as lot identity', c: '1,161 packages', tag: 'INTEGRITY', tagStyle: 'background:var(--ux-surface);color:var(--ux-muted-strong)', onOpen: () => this.set({ screen: 'qa' }) },\n { a: 'Cost coverage below threshold', b: 'Labor cost effectively unpopulated', c: '75% of packages', tag: 'BLOCKED', tagStyle: 'background:var(--ux-signal);color:#fff', onOpen: () => this.set({ screen: 'economics' }) }]\n : [{ a: 'Apple Motorbreath 1g Cart', b: 'Last ordered 34 days ago', c: 'HLNV-000001', tag: 'REORDER', tagStyle: 'background:var(--ux-surface);color:var(--ux-muted-strong)', onOpen: () => this.set({ product: 'p1', lot: 0, screen: 'product' }) },\n { a: 'New York Sunrise Gummies 10ct', b: 'Last ordered 41 days ago', c: 'WRAE-000007', tag: 'REORDER', tagStyle: 'background:var(--ux-surface);color:var(--ux-muted-strong)', onOpen: () => this.set({ product: 'p4', lot: 0, screen: 'product' }) },\n { a: pendingApproval.length ? pendingApproval[0].id : 'No orders awaiting approval', b: pendingApproval.length ? 'Buyer-submitted, needs the owner' : 'Nothing pending', c: 'Approvals', tag: pendingApproval.length ? 'ACTION' : 'CLEAR', tagStyle: pendingApproval.length ? 'background:var(--ux-signal);color:#fff' : 'background:var(--ux-surface);color:var(--ux-muted-strong)', onOpen: () => this.set({ screen: 'orders' }) },\n { a: 'Northgate license expired', b: 'Ordering paused at that location only', c: '12 Aug 2026', tag: 'ACTION', tagStyle: 'background:var(--ux-signal);color:#fff', onOpen: () => this.set({ loc: 'l3', screen: 'cart' }) }];\n\n const od = s.order ? s.orders.find(o => o.id === s.order) : null;\n let odVals = { isOrder: false };\n if (od && s.screen === 'order') {\n const idx = FLOW.indexOf(od.state);\n const visible = FLOW.filter(f => f !== 'Awaiting approval' || idx === 0);\n const steps = visible.map((f, vi) => {\n const isLast = vi === visible.length - 1;\n const fi = FLOW.indexOf(f);\n const done = idx >= fi;\n const current = idx === fi;\n const good = current && (f === 'Approved' || f === 'Received');\n return {\n label: f, when: od.history[f] || (done ? od.placed : 'Pending'),\n style: 'padding:16px 18px 16px 0;' + (isLast ? '' : 'border-right:1px solid rgba(23,14,11,.14);') +\n (good ? 'background:var(--ux-accent-wash);color:var(--ux-accent-text);font-weight:700;'\n : current ? 'background:var(--ux-surface);color:var(--ux-ink);font-weight:700;'\n : done ? 'color:var(--ux-ink);' : 'color:var(--ux-faint);')\n };\n });\n const actions = [];\n const primary = 'background:var(--ux-accent);color:#fff;border:0;padding:11px 15px;font:700 12.5px Archivo,sans-serif;cursor:pointer';\n const secondary = 'background:transparent;border:1px solid rgba(23,14,11,.35);padding:11px 15px;font:700 12.5px Archivo,sans-serif;cursor:pointer';\n if (od.state === 'Awaiting approval' && role === 'owner') {\n actions.push({ label: 'Approve and place', style: primary, onDo: () => this.advance(od.id, 'Placed', 'APPROVED') });\n actions.push({ label: 'Decline', style: secondary, onDo: () => this.advance(od.id, 'Flagged', 'DECLINED') });\n }\n if (isInternal && od.state === 'Placed') actions.push({ label: 'Confirm order', style: primary, onDo: () => this.advance(od.id, 'Approved', 'CONFIRMED') });\n if (isInternal && od.state === 'Approved') actions.push({ label: 'Mark shipped', style: primary, onDo: () => this.advance(od.id, 'Shipped', 'SHIPPED') });\n if (od.state === 'Shipped') actions.push({ label: 'Confirm received', style: primary, onDo: () => this.advance(od.id, 'Received', 'RECEIVED') });\n if (!isInternal && od.state === 'Placed') {\n actions.push({ label: 'Edit lines', style: secondary, onDo: () => this.set({ toast: 'A placed order stays editable by your organisation until it is approved. Every edit writes a line-level difference both sides can read.', toastKind: 'NOT WIRED' }) });\n actions.push({ label: 'Cancel order', style: secondary, onDo: () => this.set({ toast: 'Cancellation is available on the same window as editing. After approval it becomes a request with a stated reason, not an action.', toastKind: 'NOT WIRED' }) });\n }\n if (!isInternal && (od.state === 'Approved' || od.state === 'Shipped')) {\n actions.push({ label: 'Request a change', style: secondary, onDo: () => this.set({ toast: 'The edit window closed at approval. A change is now a request routed to your account manager, and the order shows that it was requested.', toastKind: 'NOT WIRED' }) });\n }\n if (!isInternal && od.state === 'Received') {\n actions.push({ label: 'Report a problem', style: secondary, onDo: () => this.openClaim(od.id) });\n }\n if (canOrder) actions.push({ label: 'Reorder these lines', style: secondary, onDo: () => this.reorder(od) });\n actions.push({ label: 'Download confirmation', style: secondary, onDo: () => this.set({ toast: 'A PDF confirmation for ' + od.id + ' would download here. Document generation is out of scope for the prototype.', toastKind: 'NOT WIRED' }) });\n\n const notes = [];\n if (od.state === 'Awaiting approval') notes.push({ kind: 'AWAITING APPROVAL', text: 'Submitted by the buyer. Approval is a store-side action — switch to the Owner role to approve or decline it. ' + 'Authority is held by ' + (s.delegateTo || 'the store owner') + (s.delegateTo ? ' under a delegation expiring ' + s.delegateExpiry : ' by default') + ', and whoever approves it is named on the order permanently. ' + 'No clock is set on how long this may wait — that decision is still open.' });\n if (od.state === 'Flagged') notes.push({ kind: 'EXCEPTION', text: 'This order carries an exception. The store sees status only: never the internal owner, the clock, or internal notes.' });\n if (s.recallIssued && od.lines.some(l => { const pp = P(l.id); return pp && pp.lots[l.lot] && pp.lots[l.lot].tag === (s.recallTag || RECALL_TAG); })) notes.push({ kind: 'RECALL NOTICE ON A LOT ON THIS ORDER', text: 'A lot on this order has been withdrawn. The approved wording appears here \\u2014 the portal carries it and never writes it. Acknowledge on behalf of your organisation; we record the account, not the person.' });\n const drift = od.lines.filter(l => P(l.id).price !== null && P(l.id).price !== l.price);\n if (drift.length) notes.push({ kind: 'PRICE ON THIS ORDER DIFFERS FROM TODAY', text: drift.length + ' line(s) were priced at the figure derived when the order was placed. Reordering refreshes them to today\\u2019s derived price.' });\n\n odVals = {\n isOrder: true,\n odStepsStyle: 'display:grid;grid-template-columns:repeat(' + (narrow ? 2 : visible.length) + ',minmax(0,1fr));border-top:2px solid rgba(23,14,11,.4);border-bottom:2px solid rgba(23,14,11,.4);margin-bottom:26px', odId: od.id, odState: od.state, odTagStyle: stateTag(od.state),\n odMeta: this.locName(od.loc) + ' · placed ' + od.placed + ' · ' + (od.delivery || 'Next available') + (isInternal ? ' · subledger state: ' + INTERNAL_STATE[od.state] : ''),\n odSteps: steps, odActions: actions, odHasActions: actions.length > 0,\n odLines: od.lines.map(l => {\n const p = P(l.id);\n return {\n name: p.name, brand: p.brand, sku: p.sku, tag: '…' + p.lots[l.lot].tag.slice(-8),\n packaged: p.lots[l.lot].packaged, qty: String(l.qty), unit: MONEY(l.price), total: MONEY(l.price * l.qty)\n };\n }),\n odUnits: String(this.orderUnits(od)), odValue: MONEY(this.orderValue(od)),\n odNotes: notes, odHasNotes: notes.length > 0,\n odClaims: s.claims.filter(c => c.order === od.id).map(c => ({\n id: c.id || '\\u2014', line: c.line, reason: c.reason, raised: c.raised, state: c.state,\n owner: c.owner || 'Operations \\u2014 fulfilment', clock: c.clock || 'Target: two working days',\n stateStyle: 'display:inline-block;white-space:nowrap;font:600 10px ui-monospace,monospace;letter-spacing:.05em;padding:3px 7px;'\n + (c.state === 'Open' ? 'background:var(--ux-signal);color:#fff' : 'background:var(--ux-surface);color:var(--ux-muted-strong)')\n })),\n odHasClaims: s.claims.some(c => c.order === od.id),\n odInternal: isInternal,\n odClaimOpen: s.claimFor === od.id,\n odClaimLines: od.lines.map((l, i) => {\n const pp = P(l.id);\n return { label: (pp ? pp.name : l.id) + ' \\u00b7 ' + l.qty + ' units', idx: i, selected: s.claimLine === i,\n style: 'padding:8px 11px;font:600 11.5px Archivo,sans-serif;cursor:pointer;border:1px solid rgba(23,14,11,.3);'\n + (s.claimLine === i ? 'background:var(--ux-ink);color:var(--ux-ground)' : 'background:transparent'),\n onPick: () => this.set({ claimLine: i, claimQty: 1 }) };\n }),\n odClaimKinds: ['Short', 'Damaged', 'Refused'].map(k => ({ label: k,\n style: 'padding:8px 11px;font:600 11.5px Archivo,sans-serif;cursor:pointer;border:1px solid rgba(23,14,11,.3);'\n + (s.claimKind === k ? 'background:var(--ux-ink);color:var(--ux-ground)' : 'background:transparent'),\n onPick: () => this.set({ claimKind: k }) })),\n claimQty: String(s.claimQty),\n onClaimQtyDown: () => this.setState(st => ({ claimQty: Math.max(1, st.claimQty - 1) })),\n onClaimQtyUp: () => this.setState(st => ({ claimQty: st.claimQty + 1 })),\n claimReason: s.claimReason,\n onClaimReason: e => this.set({ claimReason: e.target.value }),\n claimPhotoLabel: s.claimPhoto ? 'Photo attached' : 'Attach a photo',\n claimPhotoStyle: 'padding:8px 11px;font:600 11.5px Archivo,sans-serif;cursor:pointer;border:1px solid rgba(23,14,11,.3);'\n + (s.claimPhoto ? 'background:var(--ux-accent);color:#fff' : 'background:transparent'),\n onClaimPhoto: () => this.setState(st => ({ claimPhoto: !st.claimPhoto })),\n onClaimSubmit: () => this.submitClaim(od),\n onClaimCancel: () => this.closeClaim(),\n odFooter: isInternal\n ? 'Internally this order sits at subledger state ' + INTERNAL_STATE[od.state] + '. The store sees one of four collapsed states, so the portal and the subledger can never contradict each other in front of a customer.'\n : 'You see status only. Exception ownership, internal notes and cost never appear on this screen by any route, including export.'\n };\n }\n\n /* Acting organisation. An internal user viewing their own screens sees every\n organisation; anyone else — including an administrator in a view-as session —\n is scoped to one. Rows are filtered server-side in a real build; this mirrors it. */\n const actingOrg = s.impersonating ? s.impersonating.org : (isInternal ? null : ACTING_ORG);\n const users = USERS.concat(s.extraUsers).filter(u => actingOrg === null || u.org === actingOrg).map(u => {\n const ownOrg = actingOrg === null || u.org === actingOrg;\n return {\n ...u,\n canImpersonate: isInternal && !s.impersonating && u.role !== 'Kiosk device' && !s.deactivated.includes(u.name),\n canChangeRole: ownOrg && !s.impersonating && !s.deactivated.includes(u.name),\n canDeactivate: ownOrg && !s.impersonating && u.role !== 'Kiosk device' && !s.deactivated.includes(u.name),\n role: s.deactivated.includes(u.name) ? u.role + ' \\u00b7 deactivated' : u.role,\n onDeactivate: () => {\n this.logAudit('User revoked', u.name, 'Access ended immediately and live sessions closed. Visible to ' + u.org);\n this.setState(st => ({ deactivated: (st.deactivated || []).concat([u.name]),\n toast: u.name + ' is deactivated. Access ended immediately and any live session is closed.', toastKind: 'ACCESS REVOKED' }));\n },\n roleStyle: 'display:inline-block;white-space:nowrap;font:600 10px ui-monospace,monospace;letter-spacing:.05em;padding:3px 7px;background:' + (u.role === 'Kiosk device' ? 'var(--ux-surface)' : 'var(--ux-ink)') + ';color:' + (u.role === 'Kiosk device' ? 'var(--ux-muted-strong)' : 'var(--ux-ground)'),\n onImpersonate: () => this.impersonate(u),\n onRoleChange: () => {\n if (!ownOrg || s.impersonating) {\n this.set({ toast: 'Refused: a role can only be changed inside your own organisation, and never from a read-only session.', toastKind: 'BLOCKED — SCOPE' });\n return;\n }\n this.logAudit('Permission changed', u.name, 'Role change recorded and visible to ' + u.org);\n }\n };\n });\n\n const notifications = [\n ...(s.coaAmended ? [{ kind: 'AMENDED COA \\u00b7 CANNOT BE MUTED', title: 'A COA has been amended on a lot you received',\n body: 'Version 2 is now current for tag \\u2026' + (s.recallTag || RECALL_TAG).slice(-8) + '. Version 1 stays resolvable. The notice carries the wording urbanXtracts compliance has approved; the portal does not interpret a result.',\n when: TODAY, onOpen: () => this.set({ product: 'p1', lot: 1, screen: 'product' }) }] : []),\n ...(s.recallIssued ? [{ kind: 'RECALL NOTICE \\u00b7 CANNOT BE MUTED', title: 'A lot you received has been withdrawn',\n body: 'Tag \\u2026' + (s.recallTag || RECALL_TAG).slice(-8) + '. The approved wording appears on the affected order, where you can acknowledge on behalf of your organisation.',\n when: TODAY, onOpen: () => this.set({ screen: 'records' }) }] : []),\n { kind: 'ORDER STATE', title: 'SO-24098 is on its way', body: 'Shipped 6 Aug to Downtown. Four lines, two lots.', when: '6 Aug 2026', onOpen: () => this.set({ order: 'SO-24098', screen: 'order' }) },\n { kind: 'LICENSE', title: 'Northgate license expires in 14 days', body: 'Ordering pauses at that location when it lapses. Other locations are unaffected.', when: '12 Aug 2026', onOpen: () => this.set({ loc: 'l3', screen: 'cart' }) },\n { kind: 'RECEIVABLES', title: 'Riverside balance is past due', body: 'Submission is blocked at order entry until it clears. The balance is shown to the owner only.', when: '18 Aug 2026', onOpen: () => this.set({ loc: 'l2', screen: 'cart' }) }\n ];\n\n const compareRows = [\n { label: 'Category', a: PRODUCTS[0].cat, b: PRODUCTS[3].cat },\n { label: 'Format', a: PRODUCTS[0].format, b: PRODUCTS[3].format },\n { label: 'SKU', a: PRODUCTS[0].sku, b: PRODUCTS[3].sku },\n { label: 'Release state', a: 'Released', b: 'Released' },\n { label: 'Current lot', a: '…' + PRODUCTS[0].lots[0].tag.slice(-8), b: '…' + PRODUCTS[3].lots[0].tag.slice(-8) },\n { label: 'Packaged', a: PRODUCTS[0].lots[0].packaged, b: PRODUCTS[3].lots[0].packaged },\n { label: 'Cannabinoids', a: 'Awaiting source', b: 'Awaiting source' },\n { label: 'Terpenes', a: 'Awaiting source', b: 'Awaiting source' },\n { label: 'Your price', a: isKioskRole ? 'Not shown here' : MONEY(PRODUCTS[0].price), b: isKioskRole ? 'Not shown here' : MONEY(PRODUCTS[3].price) }\n ];\n\n const trainingCards = [\n { kind: 'HOW TO TALK ABOUT IT', title: 'Reading a lot, not a product', body: 'Potency belongs to the lot in the customer\\u2019s hand, not to the SKU. Scan the tag rather than quoting the shelf label — two jars of the same product can differ.' },\n { kind: 'HOW TO TALK ABOUT IT', title: 'What a COA does and does not say', body: 'A certificate reports what the lab measured on that batch. It is not a claim about how a product will feel, and staff should never extend it into one.' },\n { kind: 'PROCESS', title: 'When a customer asks for something unreleased', body: 'If it is not in the portal, it is not released. There is no back-room list — an unreleased lot has no presence here at all.' },\n { kind: 'PENDING YOUR COPY', title: 'Product-specific talking points', body: 'Written education per SKU needs your words. Nothing is generated here: no effects, benefits or medical language belongs in a portal your staff read from.' }\n ];\n\n const coaStates = [\n { kind: 'VALID', title: 'Released, current version', body: 'Shows lab result, batch and dates only. Nothing about cost, ownership, which brand tolls, or internal batch history reaches this page.' },\n { kind: 'TESTING', title: 'Testing in progress', body: 'The state a consumer hits most often on new product. It says results are not published yet and promises no date.' },\n { kind: 'NOT FOUND', title: 'Code not found', body: 'Fails without revealing whether a nearby code exists, and without hinting at an unreleased batch.' },\n { kind: 'MALFORMED', title: 'Code malformed', body: 'Rejects the input shape without guessing at a correction.' },\n { kind: 'AMENDED', title: 'Result amended', body: 'Resolves to the current version and marks it as amended. History is retained internally and is not public.' },\n { kind: 'RECALLED', title: 'Batch recalled', body: 'Carries the recall notice. Trigger and wording are not ours to write — they need compliance sign-off before anything publishes.' }\n ];\n\n const lineageSteps = [\n { step: 'Inbound lot', id: 'IL-0442', detail: 'Ownership code recorded here, never counterparty-facing', when: '12 Mar 2026' },\n { step: 'Production batch', id: 'PB-1187', detail: 'Present on 59% of packages; absent on the rest', when: '2 Jul 2026' },\n { step: 'Package', id: '…22000002210', detail: 'Compliance tag — the lot identity used externally', when: '8 Jul 2026' },\n { step: 'Release', id: 'available + passed', detail: 'Both conditions met, so the SKU is orderable', when: '9 Jul 2026' },\n { step: 'Allocation', id: 'SO-24151', detail: 'Reserved against an order line', when: '11 Aug 2026' }\n ];\n\n const econRows = [\n { name: 'Vape cartridge 1g', unit: 'per unit', rev: '$18.50', cost: '$7.20', margin: '61%', coverage: '31% of packages', covStyle: 'color:var(--ux-signal-text);font-weight:600' },\n { name: 'Infused edible 10ct', unit: 'per unit', rev: '$14.20', cost: '$5.90', margin: '58%', coverage: '28% of packages', covStyle: 'color:var(--ux-signal-text);font-weight:600' },\n { name: 'Live rosin', unit: 'per gram', rev: '$36.00', cost: '—', margin: 'Not shown', coverage: '11% of packages', covStyle: 'color:var(--ux-signal-text);font-weight:600' },\n { name: 'Packaged flower 3.5g', unit: 'per unit', rev: '$19.80', cost: '$8.40', margin: '58%', coverage: '34% of packages', covStyle: 'color:var(--ux-signal-text);font-weight:600' },\n { name: 'Labor component, all objects', unit: 'per unit', rev: '—', cost: '—', margin: 'Not shown', coverage: '0.4% of packages', covStyle: 'color:#fff;background:var(--ux-signal);font-weight:600;padding:2px 6px' }\n ];\n\n const accounts = [\n { name: 'Downtown Provisions', license: 'OCM-RETL-24-000412', orders: '22', interval: '19 days', status: 'ACTIVE', tagStyle: 'background:var(--ux-surface);color:var(--ux-muted-strong)', open: true },\n { name: 'Riverside Collective', license: 'OCM-RETL-24-000518', orders: '14', interval: '62 days', status: 'PAST DUE', tagStyle: 'background:var(--ux-signal);color:#fff' },\n { name: 'Northgate Cannabis', license: 'OCM-RETL-24-000633', orders: '9', interval: '31 days', status: 'LICENSE', tagStyle: 'background:var(--ux-signal);color:#fff' },\n { name: 'Harbor & Vine', license: 'OCM-RETL-24-000701', orders: '31', interval: '14 days', status: 'ACTIVE', tagStyle: 'background:var(--ux-surface);color:var(--ux-muted-strong)' },\n { name: 'Eastside Green', license: 'OCM-RETL-24-000844', orders: '6', interval: '48 days', status: 'NEW', tagStyle: 'background:var(--ux-surface);color:var(--ux-muted-strong)' }\n ];\n\n const accountsRows = accounts.map(a => ({ ...a, onOpen: () => this.set({ screen: 'account', account: a.license }) }));\n\n const validationGroups = VALIDATION.map(g => ({\n cls: g.cls, closedBy: g.closedBy,\n countLabel: g.items.length + ' item' + (g.items.length === 1 ? '' : 's') + ' · ' + g.items.filter(i => i.severity === 'BLOCKING').length + ' blocking',\n items: g.items.map(i => ({ ...i, sevStyle: i.severity === 'BLOCKING' ? 'background:var(--ux-signal);color:#fff' : i.severity === 'DECIDED' ? 'background:var(--ux-accent-deep);color:#fff' : 'background:var(--ux-surface);color:var(--ux-muted-strong)' }))\n }));\n const totalItems = VALIDATION.reduce((a, g) => a + g.items.length, 0);\n const totalBlocking = VALIDATION.reduce((a, g) => a + g.items.filter(i => i.severity === 'BLOCKING').length, 0);\n\n const perfEmptyText = 'Performance here is this account\\u2019s own ordering with urbanXtracts. We do not receive store sales data \\u2014 that was decided out of scope on 24 Aug 2026, not left pending \\u2014 so nothing is estimated and reorder timing rests on your own order interval alone.';\n\n return {\n roleBadge: !s.authUser ? '' : role === 'owner' ? 'STORE OWNER' : role === 'buyer' ? 'STORE BUYER' : role === 'budtender' ? 'BUDTENDER · KIOSK' : 'INTERNAL',\n roleBadgeStyle: !s.authUser ? 'display:none' : '',\n isLicense: s.screen === 'license' && !!s.authUser,\n isOnboard: s.screen === 'onboard',\n goLicense: () => this.set({ screen: 'license', licSent: false, licErr: null }),\n goOnboard: () => this.set({ screen: 'onboard', onbSent: false, onbErr: null }),\n backFromOnboard: () => this.set({ screen: s.authUser ? 'dash' : 'signin' }),\n licTypes: ['Licence renewal', 'Insurance certificate', 'Resale or exemption', 'Other'].map(t => ({\n label: t, style: 'padding:8px 11px;font:600 11.5px Archivo,sans-serif;cursor:pointer;border:1px solid rgba(23,14,11,.3);'\n + (s.licType === t ? 'background:var(--ux-ink);color:var(--ux-ground)' : 'background:transparent'),\n onPick: () => this.set({ licType: t }) })),\n licLocs: LOCATIONS.map(l => ({\n label: l.name + (l.expired ? ' \\u00b7 expired' : ''),\n style: 'padding:8px 11px;font:600 11.5px Archivo,sans-serif;cursor:pointer;border:1px solid rgba(23,14,11,.3);'\n + (s.licLoc === l.id ? 'background:var(--ux-ink);color:var(--ux-ground)' : 'background:transparent'),\n onPick: () => this.set({ licLoc: l.id }) })),\n licExpiry: s.licExpiry, onLicExpiry: e => this.set({ licExpiry: e.target.value }),\n licNote: s.licNote, onLicNote: e => this.set({ licNote: e.target.value }),\n onLicFile: e => this.licPickFile(e),\n licFileLabel: s.licFileName ? s.licFileName + ' \\u00b7 ' + Math.max(1, Math.round(s.licFileSize / 1024)) + ' KB ready' : 'No document chosen',\n licHasErr: !!s.licErr, licErr: s.licErr || '',\n licSent: s.licSent, onLicSubmit: () => this.licSubmit(),\n onbTypes: ['New store', 'Existing store, people change', 'Existing store, new location'].map(t => ({\n label: t, style: 'padding:9px 12px;font:600 11.5px Archivo,sans-serif;cursor:pointer;border:1px solid rgba(23,14,11,.3);'\n + (s.onbType === t ? 'background:var(--ux-ink);color:var(--ux-ground)' : 'background:transparent'),\n onPick: () => this.set({ onbType: t }) })),\n onbEntity: s.onbEntity, onOnbEntity: e => this.set({ onbEntity: e.target.value }),\n onbDba: s.onbDba, onOnbDba: e => this.set({ onbDba: e.target.value }),\n onbLocs: s.onbLocs, onOnbLocs: e => this.set({ onbLocs: e.target.value }),\n onbOwnerName: s.onbOwnerName, onOnbOwnerName: e => this.set({ onbOwnerName: e.target.value }),\n onbOwnerEmail: s.onbOwnerEmail, onOnbOwnerEmail: e => this.set({ onbOwnerEmail: e.target.value }),\n onbOwnerPhone: s.onbOwnerPhone, onOnbOwnerPhone: e => this.set({ onbOwnerPhone: e.target.value }),\n onbBuyerName: s.onbBuyerName, onOnbBuyerName: e => this.set({ onbBuyerName: e.target.value }),\n onbBuyerEmail: s.onbBuyerEmail, onOnbBuyerEmail: e => this.set({ onbBuyerEmail: e.target.value }),\n onbBuyerLoc: s.onbBuyerLoc, onOnbBuyerLoc: e => this.set({ onbBuyerLoc: e.target.value }),\n onbBuds: s.onbBud.map((b, i) => ({\n n: String(i + 1), name: b.name, email: b.email, loc: b.loc,\n onName: e => this.onbSetBud(i, 'name', e.target.value),\n onEmail: e => this.onbSetBud(i, 'email', e.target.value),\n onLoc: e => this.onbSetBud(i, 'loc', e.target.value),\n canRemove: s.onbBud.length > 1, onRemove: () => this.onbRemoveBud(i) })),\n onbCount: s.onbBud.length + ' of 5',\n onbAtCap: s.onbBud.length >= 5, onbUnderCap: s.onbBud.length < 5,\n onAddBud: () => this.onbAddBud(),\n onbHasErr: !!s.onbErr, onbErr: s.onbErr || '',\n onbSent: s.onbSent, onOnbSubmit: () => this.onbSubmit(),\n pwNoticeShown: !!s.pwNotice && !!s.authUser, pwNotice: s.pwNotice || '',\n onPwFix: () => this.fixPassword(), onPwDismiss: () => this.set({ pwNotice: null }),\n isSignIn: !s.authUser && s.screen !== 'onboard' && s.screen !== 'reset',\n isReset: !s.authUser && s.screen === 'reset', isSignedIn: !!s.authUser,\n loginUser: s.loginUser, onLoginUser: e => this.set({ loginUser: e.target.value }),\n loginPass: s.loginPass, onLoginPass: e => this.set({ loginPass: e.target.value }),\n onLoginSubmit: () => this.submitLogin(),\n loginHasErr: !!s.loginErr, loginErr: s.loginErr || '',\n loginBtnLabel: s.loginBusy ? 'Checking\\u2026' : 'Sign in',\n onForgot: () => this.requestReset(),\n fpBtnLabel: s.fpBusy ? 'Sending\\u2026' : 'Email me a reset link',\n fpHasMsg: !!s.fpMsg, fpMsg: s.fpMsg || '',\n rstPass: s.rstPass, onRstPass: e => this.set({ rstPass: e.target.value }),\n rstPass2: s.rstPass2, onRstPass2: e => this.set({ rstPass2: e.target.value }),\n rstBtnLabel: s.rstBusy ? (s.rstStage || 'Saving\\u2026') : 'Set this password',\n rstHasNote: !!s.rstNote, rstNote: s.rstNote || '',\n rstHasErr: !!s.rstErr, rstErr: s.rstErr || '',\n rstDone: s.rstDone, rstLive: !!s.rstToken && !s.rstDone, rstDead: !s.rstToken && !s.rstDone,\n onRstSubmit: () => this.submitReset(), onLeaveReset: () => this.leaveReset(),\n outbox: s.outbox.map(o => ({ ...o, style: 'display:inline-block;white-space:nowrap;font:600 10px ui-monospace,monospace;letter-spacing:.05em;padding:3px 7px;background:'\n + (o.state === 'Accepted by Make' ? 'var(--ux-accent);color:#fff' : o.state === 'Sending' ? 'var(--ux-surface);color:var(--ux-muted-strong)' : 'var(--ux-signal);color:#fff') })),\n hasOutbox: s.outbox.length > 0,\n signInUsers: USERS.filter(u => u.role !== 'Kiosk device' && (u.org === ACTING_ORG || u.org === 'urbanXtracts')).map(u => ({\n name: u.name, email: u.email, org: u.org, role: u.role,\n derived: u.role === 'Store owner' ? 'Sees its own organisation across every location, holds approval, sees receivables'\n : u.role === 'Buyer' ? 'Sees its own location, builds and submits, never sees a balance'\n : u.role === 'Budtender' ? 'Education and COA lookup only, no price anywhere'\n : 'Internal: cost, margin, lineage, release and the accounts they carry',\n onPick: () => this.signIn(u)\n })),\n authName: s.authUser ? s.authUser.name : '',\n authMeta: s.authUser ? (s.authUser.role + ' \\u00b7 ' + s.authUser.org) : '',\n onSignOut: () => this.signOut(),\n roleTabs, nav: s.authUser ? nav : [], locations, licenseBlocked, showLocationBar: canOrder && !!s.authUser,\n navNote: !s.authUser ? ''\n : isInternal\n ? 'Internal routes are not rendered for any external role — not disabled, not empty. Server-side authorization is the control.'\n : isKioskRole\n ? 'This device is authorised to a location. Ordering, pricing and account routes do not exist on it.'\n : 'You see only your own organisation. Cost, margin, ownership and other retailers are not rendered here by any route.',\n canOrder, cartCount: cart.length, goCart: () => this.set({ screen: 'cart' }),\n goCatalogBtn: () => this.set({ screen: 'catalog' }),\n goOrdersBtn: () => this.set({ screen: 'orders', order: null }),\n goValidation: () => this.set({ screen: 'validation' }),\n\n shellStyle: 'flex:1;display:flex;align-items:stretch;flex-wrap:wrap;flex-direction:' + (narrow ? 'column' : 'row'),\n kioskPairNote: 'A shared floor tablet is paired to a location by an urbanXtracts administrator. It has no personal login, which is why kiosk mode is education-only \\u2014 nothing it can do requires attribution.',\n sidebarStyle: narrow\n ? 'width:100%;border-bottom:2px solid rgba(23,14,11,.4);padding:8px 0;background:var(--ux-ground);display:flex;gap:0;overflow-x:auto'\n : 'width:212px;min-width:180px;border-right:2px solid rgba(23,14,11,.4);padding:22px 0 40px;background:var(--ux-ground)',\n navNoteStyle: (!s.authUser || narrow) ? 'display:none' : 'margin:22px 20px 0;padding-top:16px;border-top:1px solid rgba(23,14,11,.2);font:12px/1.5 Archivo,sans-serif;color:var(--ux-muted)',\n contentStyle: 'flex:1;min-width:0;padding:' + (narrow ? '22px 18px 64px' : '30px 34px 80px') + ';max-width:1180px',\n tileGridStyle: 'display:grid;grid-template-columns:repeat(' + (narrow ? 2 : 4) + ',minmax(0,1fr));gap:0;border-top:2px solid rgba(23,14,11,.4);border-bottom:2px solid rgba(23,14,11,.4)',\n cardGridStyle: 'display:grid;grid-template-columns:repeat(auto-fill,minmax(' + (narrow ? 240 : 300) + 'px,1fr));gap:' + (narrow ? 12 : 18) + 'px',\n isNarrow: narrow, isWide: !narrow,\n viewToggle: [['grid', 'Grid'], ['list', 'List']].map(([v, label]) => ({\n label,\n style: 'background:' + (s.view === v ? 'var(--ux-ink)' : 'transparent') + ';color:' + (s.view === v ? 'var(--ux-ground)' : 'var(--ux-ink)') +\n ';border:1px solid ' + (s.view === v ? 'var(--ux-ink)' : 'rgba(23,14,11,.25)') + ';padding:8px 11px;font:' + (s.view === v ? '700' : '500') + ' 12px Archivo,sans-serif;cursor:pointer',\n onPick: () => this.set({ view: v })\n })),\n isGrid: s.view === 'grid', isList: s.view === 'list',\n favFilterStyle: 'background:' + (s.onlyFavs ? 'var(--ux-ink)' : 'transparent') + ';color:' + (s.onlyFavs ? 'var(--ux-ground)' : 'var(--ux-ink)') +\n ';border:1px solid ' + (s.onlyFavs ? 'var(--ux-ink)' : 'rgba(23,14,11,.25)') + ';padding:8px 11px;font:' + (s.onlyFavs ? '700' : '500') + ' 12px Archivo,sans-serif;cursor:pointer',\n favFilterLabel: s.onlyFavs ? 'Saved only' : 'Saved',\n toggleFavFilter: () => this.set({ onlyFavs: !s.onlyFavs }),\n archMark: arch(30, 'var(--ux-ink)'),\n hasToast: !!s.toast, toast: s.toast || '', toastKind: s.toastKind, clearToast: () => this.set({ toast: null }),\n toastChipStyle: 'font:600 9.5px ui-monospace,monospace;letter-spacing:.08em;padding:3px 7px;color:#fff;background:' + (String(s.toastKind).indexOf('BLOCKED') === 0 || s.toastKind === 'DECLINED' || s.toastKind === 'NOT WIRED' ? 'var(--ux-signal)' : 'var(--ux-accent)'),\n\n isDash: s.screen === 'dash', isCatalog: s.screen === 'catalog', isProduct: s.screen === 'product',\n isInventory: s.screen === 'inventory' && isInternal,\n invConnectionLabel: s.inventoryLoading ? 'REFRESHING API SNAPSHOT' : s.inventoryLoaded ? 'API SNAPSHOT LOADED' : 'VERIFIED API SNAPSHOT',\n invConnectionStyle: 'font:600 9.5px ui-monospace,monospace;letter-spacing:.08em;padding:4px 7px;background:'\n + (s.inventoryLoaded ? 'var(--ux-accent)' : 'rgba(250,247,243,.16)') + ';color:#fff',\n invHasConnectionNote: !!s.inventoryError || !s.inventoryLoaded,\n invConnectionNote: s.inventoryError\n ? 'The portal endpoint could not refresh this response (' + s.inventoryError + '). The last verified Canix API snapshot remains visible; no value was replaced with zero.'\n : 'This is the Canix API response verified on 31 Aug 2026. Continuous refresh in the deployed portal still requires a server-side Canix credential or private connector binding.',\n invSourceUpdated: (invData.source && invData.source.latest_updated_at) || '\\u2014',\n invRefreshLabel: s.inventoryLoading ? 'Refreshing\\u2026' : 'Reload API snapshot',\n onInvRefresh: () => this.loadInventory(),\n invViews: [\n ['all', 'All usable', invViewCounts('all')],\n ['available', 'Available status', invViewCounts('available')],\n ['allocated', 'Allocated', invViewCounts('allocated')],\n ['failed', 'Lab failed', invViewCounts('failed')],\n ['aged', 'Aged 90+ days', invViewCounts('aged')]\n ].map(([id, label, count]) => ({ label, count: invNumber(count), pressed: s.invView === id ? 'true' : 'false',\n style: 'background:' + (s.invView === id ? 'var(--ux-ink)' : 'transparent') + ';color:' + (s.invView === id ? 'var(--ux-ground)' : 'var(--ux-ink)')\n + ';border:1px solid ' + (s.invView === id ? 'var(--ux-ink)' : 'rgba(23,14,11,.25)') + ';padding:7px 9px;font:600 10.5px Archivo,sans-serif;cursor:pointer',\n onPick: () => this.inventoryFilter({ invView: id }) })),\n invFilterGridStyle: 'display:grid;grid-template-columns:repeat(' + (narrow ? 1 : mid ? 2 : 4) + ',minmax(0,1fr));gap:12px',\n invSearchFieldStyle: narrow ? '' : 'grid-column:span 2',\n invSearch: s.invSearch,\n onInvSearch: e => this.inventoryFilter({ invSearch: e.target.value }),\n invFacility: s.invFacility,\n onInvFacility: e => this.inventoryFilter({ invFacility: e.target.value }),\n invFacilityOptions: Array.from(new Map(invPackages.map(row => [String(row.facility_id), row.facility_name])).entries())\n .sort((a, b) => String(a[1]).localeCompare(String(b[1]))).map(([value, label]) => ({ value, label })),\n invStatus: s.invStatus,\n onInvStatus: e => this.inventoryFilter({ invStatus: e.target.value }),\n invStatusOptions: invFacet('status_category'),\n invQuantity: s.invQuantity,\n onInvQuantity: e => this.inventoryFilter({ invQuantity: e.target.value }),\n invQuantityOptions: invFacet('quantity_type').map(option => ({ ...option, label: option.value === 'WeightBased' ? 'Weight based' : 'Count based' })),\n invCategory: s.invCategory,\n onInvCategory: e => this.inventoryFilter({ invCategory: e.target.value }),\n invCategoryOptions: invFacet('item_category_name'),\n invBrand: s.invBrand,\n onInvBrand: e => this.inventoryFilter({ invBrand: e.target.value }),\n invBrandOptions: invFacet('brand_name', 'Not recorded'),\n invLab: s.invLab,\n onInvLab: e => this.inventoryFilter({ invLab: e.target.value }),\n invLabOptions: invFacet('lab_test_status', 'No status'),\n invAge: s.invAge,\n onInvAge: e => this.inventoryFilter({ invAge: e.target.value }),\n onInvReset: () => this.inventoryReset(),\n onInvExport: () => this.inventoryExport(invSorted),\n invExportDisabled: invSorted.length === 0,\n invExportStyle: 'background:var(--ux-ink);color:var(--ux-ground);border:1px solid var(--ux-ink);padding:9px 12px;font:600 11.5px Archivo,sans-serif;cursor:'\n + (invSorted.length ? 'pointer' : 'not-allowed') + ';opacity:' + (invSorted.length ? '1' : '.45'),\n invResultStripStyle: 'display:grid;grid-template-columns:repeat(' + (narrow ? 2 : 4) + ',minmax(0,1fr));gap:1px;background:rgba(23,14,11,.16);border-top:1px solid rgba(23,14,11,.16);border-bottom:1px solid rgba(23,14,11,.16);margin-bottom:12px',\n invFilteredCount: invNumber(invSorted.length),\n invFilteredWeight: invWeight(invFilteredWeightValue),\n invFilteredUnits: invUnits(invFilteredUnitsValue),\n invRangeLabel: invSorted.length ? invNumber(invPageStart + 1) + '\\u2013' + invNumber(Math.min(invPageStart + invPageSize, invSorted.length)) + ' of ' + invNumber(invSorted.length) : '0 of 0',\n invColumns: [\n ['item_name', 'Item / package'], ['facility_name', 'Facility / room'], ['status_category', 'Status'],\n ['quantity', 'On hand'], ['lab_test_status', 'Lab'], ['age_days', 'Age'], ['allocation', 'Allocation'],\n ['updated_at', 'Updated'], ['package_id', 'Detail']\n ].map(([key, label]) => ({ label, arrow: s.invSortKey === key ? (s.invSortDir === 'asc' ? '\\u2191' : '\\u2193') : '',\n ariaSort: s.invSortKey === key ? (s.invSortDir === 'asc' ? 'ascending' : 'descending') : 'none', onSort: () => this.inventorySort(key) })),\n invHasRows: invPageRows.length > 0,\n invNoRows: invPageRows.length === 0,\n invTableRows: invPageRows.map(row => {\n const failed = String(row.lab_test_status || '').indexOf('Failed') !== -1 || String(row.test_result_status || '').indexOf('Failed') !== -1;\n const available = row.status_category === 'available';\n const allocated = row.status_category === 'allocated';\n return {\n item: row.item_name || 'Unnamed item',\n skuTag: (row.sku ? row.sku + ' \\u00b7 ' : '') + (row.tag || 'No compliance tag'),\n facility: row.facility_name || '\\u2014', room: row.room_name || 'No room assigned',\n status: invTitle(row.status_category),\n statusStyle: 'display:inline-block;max-width:100%;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font:600 9.5px ui-monospace,monospace;letter-spacing:.04em;padding:3px 6px;background:'\n + (available ? 'var(--ux-accent)' : allocated ? 'var(--ux-ink)' : 'var(--ux-surface)') + ';color:' + (available || allocated ? '#fff' : 'var(--ux-muted-strong)'),\n quantity: row.quantity_type === 'WeightBased' ? invWeight(row.c_weight_g) : invUnits(row.weight),\n quantityType: row.quantity_type === 'WeightBased' ? 'Weight based' : 'Count based',\n lab: row.lab_test_status || 'No status',\n labStyle: 'display:inline-block;white-space:nowrap;font:600 9px ui-monospace,monospace;letter-spacing:.03em;padding:3px 6px;background:'\n + (failed ? 'var(--ux-signal)' : row.lab_test_status === 'TestPassed' ? 'var(--ux-accent)' : 'var(--ux-surface)') + ';color:' + (failed || row.lab_test_status === 'TestPassed' ? '#fff' : 'var(--ux-muted-strong)'),\n age: invNumber(row.age_days) + ' d',\n allocation: row.sales_order_name || (row.sales_order_id ? 'Order ' + row.sales_order_id : 'Unallocated'),\n updated: String(row.source_updated_at || '\\u2014').replace('T', ' · ').replace('Z', ''),\n rowStyle: String(row.package_id) === String(s.invSelectedPackage) ? 'background:var(--ux-surface)' : '',\n onInspect: () => this.set({ invSelectedPackage: row.package_id })\n };\n }),\n invPageSize: String(invPageSize),\n onInvPageSize: e => this.set({ invPageSize: Number(e.target.value), invPage: 1 }),\n invPageLabel: 'Page ' + invNumber(invPage) + ' of ' + invNumber(invPageCount),\n invPrevDisabled: invPage <= 1,\n invNextDisabled: invPage >= invPageCount,\n invPrevStyle: 'background:transparent;border:1px solid rgba(23,14,11,.3);padding:7px 10px;font:600 11px Archivo,sans-serif;cursor:' + (invPage <= 1 ? 'not-allowed' : 'pointer') + ';opacity:' + (invPage <= 1 ? '.45' : '1'),\n invNextStyle: 'background:transparent;border:1px solid rgba(23,14,11,.3);padding:7px 10px;font:600 11px Archivo,sans-serif;cursor:' + (invPage >= invPageCount ? 'not-allowed' : 'pointer') + ';opacity:' + (invPage >= invPageCount ? '.45' : '1'),\n onInvPrev: () => this.set({ invPage: Math.max(1, invPage - 1), invSelectedPackage: null }),\n onInvNext: () => this.set({ invPage: Math.min(invPageCount, invPage + 1), invSelectedPackage: null }),\n invHasSelection: !!invSelected,\n invSelectedTitle: invSelected ? (invSelected.item_name || 'Unnamed item') : '',\n invSelectedTag: invSelected ? (invSelected.tag || 'No compliance tag') : '',\n onInvCloseDetail: () => this.set({ invSelectedPackage: null }),\n invDetailGridStyle: 'display:grid;grid-template-columns:repeat(' + (narrow ? 1 : mid ? 2 : 4) + ',minmax(0,1fr));gap:15px 18px',\n invDetailRows: invSelected ? [\n ['Package ID', invSelected.package_id], ['SKU', invSelected.sku || 'Not recorded'],\n ['Category', invSelected.item_category_name || 'Not recorded'], ['Brand / strain', [invSelected.brand_name, invSelected.strain_name].filter(Boolean).join(' · ') || 'Not recorded'],\n ['Owner', invSelected.owner_name || ''], ['UX OS UOM', invSelected.uom_code || ''],\n ['Cost Object', invSelected.cost_object_id || ''], ['Raw Canix status', invSelected.status || ''],\n ['Facility / room', [invSelected.facility_name, invSelected.room_name].filter(Boolean).join(' · ') || 'Not recorded'],\n ['Workflow status', invTitle(invSelected.status_category)],\n ['On hand', invSelected.quantity_type === 'WeightBased' ? invWeight(invSelected.c_weight_g) : invUnits(invSelected.weight)],\n ['Reserved', invNumber(invSelected.c_reserved_weight, 3) + ' ' + (invSelected.weight_unit_name || '')],\n ['Lab / result', [invSelected.lab_test_status, invSelected.test_result_status].filter(Boolean).join(' · ') || 'No linked state'],\n ['COA', invSelected.has_coa === true || invSelected.has_coa === 1 ? 'On file' : invSelected.has_coa === false || invSelected.has_coa === 0 ? 'Not on file' : 'No linked result'],\n ['Packaged / age', (invSelected.packaged_date || 'Not recorded') + ' · ' + invNumber(invSelected.age_days) + ' days'],\n ['Expiry / use by', [invSelected.expiration_date, invSelected.use_by_date].filter(Boolean).join(' · ') || 'Not recorded'],\n ['Allocation', invSelected.sales_order_name || (invSelected.sales_order_id ? 'Order ' + invSelected.sales_order_id : 'Unallocated')],\n ['Order status / delivery', [invSelected.sales_order_status, invSelected.sales_order_delivery_date].filter(Boolean).join(' · ') || 'Not applicable'],\n ['Compliance submitted', invSelected.compliance_submitted === true || invSelected.compliance_submitted === 1 ? 'Yes' : 'No'], ['Last updated', invSelected.source_updated_at || 'Not recorded']\n ].map(([label, value]) => ({ label, value: String(value) })) : [],\n invKpiGridStyle: 'display:grid;grid-template-columns:repeat(' + (narrow ? 1 : 3) + ',minmax(0,1fr));border-top:2px solid rgba(23,14,11,.4);border-bottom:2px solid rgba(23,14,11,.4)',\n invKpis: [\n { label: 'Usable packages', value: invNumber(invData.summary.packages), note: 'Active, non-sample, production packages in the three usable status categories.' },\n { label: 'WeightBased', value: invWeight(invData.summary.weight_g), note: invNumber((invData.quantity_types.find(x => x.quantity_type === 'WeightBased') || {}).packages) + ' packages.' },\n { label: 'CountBased', value: invUnits(invData.summary.units), note: invNumber((invData.quantity_types.find(x => x.quantity_type === 'CountBased') || {}).packages) + ' packages.' }\n ].map((k, i, arr) => ({ ...k, valueStyle: k.valueStyle || '', style: 'padding:16px;border-right:' + (i < arr.length - 1 ? '1px solid rgba(23,14,11,.18)' : '0') })),\n invRuleGridStyle: 'display:grid;grid-template-columns:repeat(' + (narrow ? 1 : 4) + ',minmax(0,1fr));gap:' + (narrow ? 16 : 20) + 'px',\n invRuleRows: [\n { label: 'ACTIVE', value: 'is_active = 1' },\n { label: 'STATUS', value: \"available, in_progress or allocated\" },\n { label: 'SAMPLES', value: 'is_sample = 0' },\n { label: 'FACILITY', value: 'canix:facility:4546 excluded' }\n ],\n invStatusRows: invData.statuses.map(r => ({\n status: r.status_category === 'in_progress' ? 'In progress' : r.status_category.charAt(0).toUpperCase() + r.status_category.slice(1),\n packages: invNumber(r.packages), weight: invWeight(r.weight_g), units: invUnits(r.units),\n style: 'display:inline-block;white-space:nowrap;font:600 10px ui-monospace,monospace;letter-spacing:.05em;padding:3px 7px;background:var(--ux-ink);color:var(--ux-ground)' })),\n invSandboxShown: s.invSandbox,\n invSandboxPressed: s.invSandbox ? 'true' : 'false',\n invSandboxButtonLabel: s.invSandbox ? 'Hide sandbox' : 'Include sandbox context',\n invSandboxButtonStyle: 'background:' + (s.invSandbox ? 'var(--ux-ink)' : 'transparent') + ';color:' + (s.invSandbox ? 'var(--ux-ground)' : 'var(--ux-ink)') + ';border:1px solid rgba(23,14,11,.35);padding:9px 12px;font:600 11.5px Archivo,sans-serif;cursor:pointer',\n onInvSandbox: () => this.set({ invSandbox: !s.invSandbox }),\n invFacilities: invData.facilities.map(f => ({\n name: f.facility_id === 4467 ? 'Distribution' : f.facility_id === 4468 ? 'Cultivation' : f.facility_id === 4469 ? 'Processing' : f.facility_name,\n identity: 'canix:facility:' + f.facility_id, licence: f.facility_license,\n packages: invNumber(f.packages), weight: invWeight(f.weight_g), units: invUnits(f.units), rowStyle: '' })),\n invSandboxPackages: invNumber(invData.sandbox.packages),\n invSandboxWeight: invWeight(invData.sandbox.weight_g),\n invSandboxUnits: invUnits(invData.sandbox.units),\n invIssues: [\n { kind: 'SOURCE CONSISTENCY', title: invNumber(invData.checks.active_inactive_conflicts) + ' active / inactive conflicts', detail: 'These rows have is_active = 1 and status_category = inactive. The documented Canix rule excludes them because both clauses must pass.' },\n { kind: 'QUANTITY COVERAGE', title: invNumber(invData.checks.zero_quantity_packages) + ' usable packages carry zero quantity', detail: 'They remain package records and contribute nothing to the weight or count totals. The portal does not delete or impute them.' },\n { kind: 'RELEASE SEPARATION', title: invNumber(invData.checks.failed_lab_packages) + ' usable packages have a failed lab state', detail: 'Usable inventory is not the same as retailer release. The catalog still requires both an available package status and a passed lab result.' }\n ],\n isCoa: s.screen === 'coa', isCart: s.screen === 'cart', isOrders: s.screen === 'orders',\n isKiosk: s.screen === 'kiosk', isEconomics: s.screen === 'economics', isQa: s.screen === 'qa',\n ...(() => {\n const chip = 'display:inline-block;white-space:nowrap;font:600 10px ui-monospace,monospace;letter-spacing:.05em;padding:3px 7px;';\n const tag = s.recallTag || RECALL_TAG;\n let prod = null, idx = -1;\n PRODUCTS.forEach(p => p.lots.forEach((l, i) => { if (l.tag === tag) { prod = p; idx = i; } }));\n const holders = [];\n s.orders.forEach(o => o.lines.forEach(l => {\n if (prod && l.id === prod.id && l.lot === idx && o.state === 'Received' && !holders.includes(ACTING_ORG)) holders.push(ACTING_ORG);\n }));\n RECALL_OTHER.filter(r => r.state === 'Delivered').forEach(r => { if (!holders.includes(r.org)) holders.push(r.org); });\n return {\n coaTag: tag,\n coaLotName: prod ? prod.name : '\\u2014',\n coaVersionNow: s.coaAmended ? 'Version 2' : 'Version 1',\n coaVersionStyle: chip + (s.coaAmended ? 'background:var(--ux-signal);color:#fff' : 'background:var(--ux-accent);color:#fff'),\n coaAmended: s.coaAmended, coaNotAmended: !s.coaAmended,\n coaHolderCount: String(holders.length),\n onAmendCoa: () => this.amendCoa(),\n coaHolders: holders.map(org => ({\n org,\n ackText: s.coaAcks.includes(org) ? 'Acknowledged 24 Aug 2026' : 'Notified, awaiting acknowledgement',\n ackStyle: chip + (s.coaAcks.includes(org) ? 'background:var(--ux-accent);color:#fff' : 'background:var(--ux-surface);color:var(--ux-muted-strong)'),\n canAck: !s.coaAcks.includes(org),\n onAck: () => this.coaAck(org)\n }))\n };\n })(),\n isAccounts: s.screen === 'accounts', isValidation: s.screen === 'validation',\n\n dashTitle: isInternal ? 'Internal dashboard' : role === 'owner' ? 'Store overview' : 'Your ordering',\n dashSub: isInternal\n ? 'Orders, release state and integrity across all accounts. Cost and margin sit behind their own route.'\n : 'Counts are live in this prototype — place an order and they move. Structure and release state are measured from live reads.',\n tiles: tilesStyled, listTitle: isInternal ? 'Needs attention' : 'Your next actions', dashRows,\n showPerfEmpty: !isInternal, perfEmptyText,\n\n catalogSub: canOrder\n ? 'Released finished goods available to this location, with your account price.'\n : isInternal ? 'All items including bulk material.' : 'Product information only. No price appears on this device.',\n query: s.query, onQuery: e => { if (!s.query && e.target.value) this.bump(m => ({ searches: m.searches + 1 })); this.set({ query: e.target.value }); },\n catFilters: CATS.map(c => ({\n label: c,\n style: 'background:' + (s.cat === c ? 'var(--ux-ink)' : 'transparent') + ';color:' + (s.cat === c ? 'var(--ux-ground)' : 'var(--ux-ink)') +\n ';border:1px solid ' + (s.cat === c ? 'var(--ux-ink)' : 'rgba(23,14,11,.25)') + ';padding:8px 11px;font:' + (s.cat === c ? '700' : '500') + ' 12px Archivo,sans-serif;cursor:pointer',\n pressed: s.cat === c ? 'true' : 'false',\n onPick: () => { if (c !== 'All' && s.cat !== c) this.bump(m => ({ filters: m.filters + 1 })); this.set({ cat: c }); }\n })),\n catalogItems, resultCount: catalogItems.length + ' products', noResults: catalogItems.length === 0,\n\n pdName: product.name, pdBrand: product.brand, pdCat: product.cat, pdCatDot: catDot(product.cat, 9), pdFormat: product.format,\n pdSku: product.sku, pdStrain: product.strain, pdBand: product.band,\n pdDesc: 'Category, format, brand, SKU, unit type and release state are read from the operations subledger. The written description is a placeholder — no effects, benefit or medical language belongs in it.',\n pdPriceText: isKioskRole ? 'Not shown on this device' : this.priced(product) ? MONEY(product.price) : 'Price on request',\n pdPriceStyle: this.priced(product) ? '' : 'color:var(--ux-signal-text);font-size:19px',\n pdPriceNote: this.priced(product)\n ? 'Derived from your most recent qualifying order line, ' + product.priceFrom + '. Not a rate card.'\n : 'No qualifying order line for this account yet. Requesting a price notifies your account manager.',\n pdPerfText: perfEmptyText,\n qty: s.qty,\n qtyInput: String(s.qty),\n onQtyInput: e => { const v = this.cleanQty(e.target.value, s.qty); this.setState({ qty: v }); },\n incQty: () => this.setState(st => ({ qty: st.qty + 1 })),\n decQty: () => this.setState(st => ({ qty: Math.max(1, st.qty - 1) })),\n addSelected: () => licenseBlocked ? this.set({ screen: 'cart' }) : this.priced(product) ? this.addLine(product, s.qty, s.lot) : this.requestPrice(product),\n addBtnLabel: licenseBlocked ? 'Ordering paused' : this.priced(product) ? 'Add to draft' : 'Request a price',\n addBtnStyle: 'background:' + (licenseBlocked ? 'var(--ux-faint)' : this.priced(product) ? 'var(--ux-accent)' : 'var(--ux-ink)') +\n ';color:#fff;border:0;padding:11px 16px;font:700 13px Archivo,sans-serif;cursor:pointer',\n caseNote: 'Case configuration shown here is synthetic — case and minimum-order values are not confirmed readable per item, so increments are not enforced.',\n\n pdLots: product.lots.map((l, i) => ({\n tag: '…' + l.tag.slice(-10), packaged: l.packaged,\n style: 'text-align:left;background:' + (s.lot === i ? '#fff' : 'transparent') + ';border:1px solid ' + (s.lot === i ? 'var(--ux-ink)' : 'rgba(23,14,11,.25)') +\n ';border-left:3px solid ' + (s.lot === i ? 'var(--ux-accent)' : 'transparent') + ';padding:11px 14px;cursor:pointer',\n onPick: () => this.set({ lot: i })\n })),\n lotTag: lot.tag, lotPackaged: lot.packaged,\n lotBatch: 'Not recorded for this package — the compliance tag is the lot identity',\n lotTest: 'Passed', lotRelease: 'Released — available and lab-passed',\n coaNote: 'Current published version. Amendments notify accounts that bought this lot.',\n coaVersionText: (s.coaAmended && lot.tag === (s.recallTag || RECALL_TAG))\n ? 'Version 2 — amended 24 Aug 2026. Version 1 stays resolvable and is what you received.'\n : 'Version resolution comes from the operating record — pending connector confirmation',\n coaAmendedHere: s.coaAmended && lot.tag === (s.recallTag || RECALL_TAG),\n openCoa: () => this.set({ screen: 'coa' }),\n backToCatalog: () => this.set({ screen: 'catalog' }),\n backToProduct: () => this.set({ screen: 'product' }),\n\n cartSub: licenseBlocked ? 'This location cannot submit while its license record is expired.' : arBlocked ? 'This location cannot submit while its balance is past due — the check runs here, not at shipping.' : 'Prices are derived, not rate-carded. Review before submitting.',\n cartEmpty: cart.length === 0, cartHasLines: cart.length > 0,\n cartLines, cartTotal: MONEY(cartTotalCents), cartWarnings: warnings,\n oosLines, hasOos: oosLines.length > 0,\n cartUnits: String(cart.reduce((a, l) => a + l.qty, 0)),\n cartTotalNote: 'Excludes tax and any line shown as price on request. A derived price can move between drafting and submitting; submitting confirms the newer figure.',\n deliveryOptions: ['Next available', 'Within 7 days', 'Specific date'].map(d => ({\n label: d,\n style: 'background:' + (s.delivery === d ? 'var(--ux-ink)' : 'transparent') + ';color:' + (s.delivery === d ? 'var(--ux-ground)' : 'var(--ux-ink)') +\n ';border:1px solid ' + (s.delivery === d ? 'var(--ux-ink)' : 'rgba(23,14,11,.25)') + ';padding:8px 11px;font:' + (s.delivery === d ? '700' : '500') + ' 12px Archivo,sans-serif;cursor:pointer',\n onPick: () => this.set({ delivery: d })\n })),\n submitLabel: licenseBlocked ? 'Blocked — license expired' : arBlocked ? 'Blocked — balance past due' : role === 'buyer' ? 'Send for owner approval' : 'Submit order',\n submitStyle: 'width:100%;background:' + (submitBlocked ? 'var(--ux-faint)' : 'var(--ux-accent)') + ';color:#fff;border:0;padding:12px 14px;font:700 13px Archivo,sans-serif;text-align:left;cursor:' + (submitBlocked ? 'not-allowed' : 'pointer'),\n submitNote: licenseBlocked\n ? 'Upload the renewal for this location, or contact your account manager. Ordering at your other locations is unaffected.'\n : arBlocked\n ? 'Clearing the balance releases submission. Your draft is kept exactly as it is in the meantime — nothing is discarded.'\n : 'Receivables are checked at submission, not at shipping. The past-due definition is still open.',\n submitOrder: () => this.submit(),\n\n ordersTitle: isInternal ? 'Orders' : role === 'owner' ? 'Orders and approvals' : 'Your orders',\n ordersSub: isInternal\n ? 'All accounts. Advance an order through confirm and ship to see both state sets move together.'\n : 'Placed, Approved, Shipped, Received. Open any order to track it, approve it, or rebuild it as a draft.',\n orders, noOrders: orders.length === 0,\n hasRequests: s.requests.length > 0,\n requests: s.requests.map(r => ({ ...r, tagStyle: 'background:var(--ux-signal-tint);color:var(--ux-signal-text)' })),\n ordersNote: isInternal\n ? 'Internally the ten subledger states are kept as-is — created, draft, approved, filled, shipped, accepted, canceled, returned, partially returned, archived — so the portal and the subledger can never contradict each other in front of a customer.'\n : 'You see status only. Exception ownership and internal notes are not rendered here.',\n ...odVals,\n\n kioskScan: () => { this.bump(m => ({ kioskScan: m.kioskScan + 1 })); this.set({ product: 'p1', lot: 0, screen: 'product', kioskMsg: null }); },\n kioskInput: s.kioskInput, onKioskInput: e => this.set({ kioskInput: e.target.value }),\n kioskLookup: () => this.kioskFind(),\n kioskHasMsg: !!s.kioskMsg, kioskMsg: s.kioskMsg || '', kioskMsgKind: s.kioskMsgKind,\n kioskItems: PRODUCTS.slice(0, 4).map(p => ({\n name: p.name, brand: p.brand, cat: p.cat,\n onOpen: () => this.set({ product: p.id, lot: 0, screen: 'product' })\n })),\n\n isAdmin: s.screen === 'admin' || s.screen === 'users', isAuditLog: s.screen === 'auditlog',\n isNotifications: s.screen === 'notifications', isCompare: s.screen === 'compare',\n isDocuments: s.screen === 'documents',\n docs: DOCS.map(d => ({ ...d, stateStyle: 'display:inline-block;white-space:nowrap;font:600 10px ui-monospace,monospace;letter-spacing:.05em;padding:3px 7px;background:'\n + (d.state === 'Accepted' ? 'var(--ux-accent);color:#fff' : d.state === 'Rejected' ? 'var(--ux-signal);color:#fff' : 'var(--ux-surface);color:var(--ux-muted-strong)'),\n onOpen: () => this.set({ toast: 'Opening ' + d.name + ' would render the stored document. File storage is out of scope for the prototype.', toastKind: 'NOT WIRED' }) })),\n isMeasure: s.screen === 'measure' && isInternal,\n ...(() => {\n const m = s.metrics;\n const secs = v => v === null ? null : (v / 1000).toFixed(1) + 's';\n const b = m.blocks;\n const totalBlocks = b.licence + b.receivables + b.quantity + b.readOnly;\n const rows = [\n { q: 'Does the hero path work?', counted: 'Sign-in to first submitted order',\n value: m.firstSubmitMs === null ? 'Not yet in this session' : secs(m.firstSubmitMs),\n why: 'The target is under thirty seconds. One session is one observation \\u2014 a median and a ninetieth percentile need a population, and this screen cannot give you one.' },\n { q: 'Which gate costs most?', counted: 'Blocks per gate',\n value: totalBlocks === 0 ? 'No gate has fired' : (b.licence + ' licence \\u00b7 ' + b.receivables + ' receivables \\u00b7 ' + b.quantity + ' quantity \\u00b7 ' + b.readOnly + ' read-only'),\n why: 'A gate that fires often and ends in a phone call is a policy problem, not a copy problem. What the user did in the ten minutes after is the half this prototype cannot see.' },\n { q: 'Is the catalog findable?', counted: 'Searches and filters against adds',\n value: m.searches + ' searches \\u00b7 ' + m.filters + ' filters \\u00b7 ' + m.adds + ' adds',\n why: 'Sessions that search and never add separate a findability problem from a pricing one.' },\n { q: 'Is the kiosk used?', counted: 'Lookups per device, scanned against typed',\n value: m.kioskScan + ' scanned \\u00b7 ' + m.kioskTyped + ' typed \\u00b7 ' + m.kioskAmbiguous + ' refused as ambiguous',\n why: 'Decides whether the device-framed pass earns its place. A high refusal count says the printed tag is the problem, not the screen.' },\n { q: 'Is the phone call gone?', counted: 'Store-placed against rep-placed',\n value: m.byStore + ' by the store \\u00b7 ' + m.byRep + ' by a rep \\u00b7 ' + m.calls + ' calls started',\n why: 'The single number that says whether any of this worked. If reps still place everything, the portal is a catalogue and not a channel.' }\n ];\n return {\n measureRows: rows.map(r => ({ ...r, valueStyle: 'font:800 20px/1.15 Archivo,sans-serif;letter-spacing:-.01em' })),\n measureSession: m.sessionStart ? 'Session started ' + Math.round((Date.now() - m.sessionStart) / 1000) + 's ago' : 'Session clock not started',\n onMeasureReset: () => this.bump({ sessionStart: Date.now(), firstSubmitMs: null, submits: 0,\n blocks: { licence: 0, receivables: 0, quantity: 0, readOnly: 0 },\n searches: 0, filters: 0, adds: 0, kioskScan: 0, kioskTyped: 0, kioskAmbiguous: 0, byStore: 0, byRep: 0, calls: 0 })\n };\n })(),\n isField: s.screen === 'field' && isInternal,\n repBook: REP_BOOK.map(a => {\n const chip = 'display:inline-block;white-space:nowrap;font:600 10px ui-monospace,monospace;letter-spacing:.05em;padding:3px 7px;';\n const notes = (s.repNotes[a.name] || []).slice().reverse().concat(a.seed);\n return { ...a,\n signalStyle: chip + (a.signal === 'NEW' ? 'background:var(--ux-surface);color:var(--ux-muted-strong)' : 'background:var(--ux-signal);color:#fff'),\n gateShown: a.gate !== 'No gate firing',\n notes: notes.map(n => ({ text: n })), hasNotes: notes.length > 0, noNotes: notes.length === 0,\n noteOpen: s.repNoteFor === a.name, noteClosed: s.repNoteFor !== a.name,\n onNote: () => this.repNoteOpen(a.name),\n onPrice: () => this.set({ toast: 'A price request goes to sales operations with the account and the SKU. A rep never sets a price \\u2014 that is why this is a request and not a field.', toastKind: 'NOT WIRED' }),\n onOrder: () => { this.bump(m => ({ byRep: m.byRep + 1 })); this.set({ screen: 'catalog', toast: 'Ordering on behalf of ' + a.name + '. Price is read-only to you, and approval stays with the store.', toastKind: 'ON BEHALF' }); },\n onCall: () => { this.bump(m => ({ calls: m.calls + 1 })); const num = String(a.contact).split('\\u00b7').pop().trim(); if (typeof window !== 'undefined') window.location.href = 'tel:' + num.replace(/[^0-9+]/g, ''); this.set({ toast: 'Calling ' + a.contact + '.', toastKind: 'DIALLING' }); }\n };\n }),\n repNoteText: s.repNoteText, onRepNoteText: e => this.set({ repNoteText: e.target.value }),\n onRepNoteSave: () => this.repNoteSave(),\n onRepNoteCancel: () => this.repNoteCancel(),\n isOnboarding: s.screen === 'onboarding',\n isRecall: s.screen === 'recall',\n isRecords: s.screen === 'records',\n isReceivables: s.screen === 'receivables' && role === 'owner',\n isApi: s.screen === 'api' && role === 'owner',\n isApproval: s.screen === 'approval' && role === 'owner',\n isAccountSettings: s.screen === 'account' && role === 'owner',\n acctLocs: LOCATIONS.map(l => {\n const d = s.deliverySettings[l.id] || { window: '', instructions: '', contact: '' };\n const chip = 'display:inline-block;white-space:nowrap;font:600 10px ui-monospace,monospace;letter-spacing:.05em;padding:3px 7px;';\n return {\n name: l.name, license: l.license,\n licState: l.expired ? 'Licence expired 12 Aug 2026' : 'Licence active',\n licStyle: chip + (l.expired ? 'background:var(--ux-signal);color:#fff' : 'background:var(--ux-accent);color:#fff'),\n window: d.window, instructions: d.instructions, contact: d.contact,\n editing: s.acctEditing === l.id,\n notEditing: s.acctEditing !== l.id,\n onEdit: () => this.acctEdit(l.id)\n };\n }),\n acctWindow: s.acctWindow, onAcctWindow: e => this.set({ acctWindow: e.target.value }),\n acctInstr: s.acctInstr, onAcctInstr: e => this.set({ acctInstr: e.target.value }),\n acctContact: s.acctContact, onAcctContact: e => this.set({ acctContact: e.target.value }),\n onAcctSave: () => this.acctSave(),\n onAcctCancel: () => this.acctCancel(),\n ...(() => {\n const chip = 'display:inline-block;white-space:nowrap;font:600 10px ui-monospace,monospace;letter-spacing:.05em;padding:3px 7px;';\n const held = s.delegateTo || 'Dana Whitfield';\n const delegated = !!s.delegateTo;\n const holderRole = delegated ? (USERS.find(u => u.name === s.delegateTo) || {}).role : 'Store owner';\n const candidates = USERS.filter(u => u.org === ACTING_ORG && u.role !== 'Kiosk device' && u.role !== 'Store owner');\n return {\n apHolder: held, apHolderRole: holderRole || '\\u2014',\n apDelegated: delegated, apNotDelegated: !delegated,\n apScope: 'All three locations',\n apUntil: delegated ? 'Until ' + s.delegateExpiry + ', then it returns to the owner on its own' : 'No expiry \\u2014 the owner holds it by default',\n apHowGranted: delegated ? 'Granted by the owner in this portal, revocable in one action, and recorded in your audit history' : 'Held by default; nothing was granted',\n apStateStyle: chip + (delegated ? 'background:var(--ux-surface);color:var(--ux-muted-strong)' : 'background:var(--ux-accent);color:#fff'),\n apStateText: delegated ? 'Delegated' : 'Held by the owner',\n apCandidates: candidates.map(u => ({\n name: u.name, role: u.role, locs: u.locs,\n isHolder: s.delegateTo === u.name,\n canGrant: s.delegateTo !== u.name,\n warn: u.role === 'Buyer' ? 'Submits orders \\u2014 delegating here means self-approval, and every such order will say so.' : '',\n onGrant: () => this.delegate(u.name, u.role)\n })),\n onUndelegate: () => this.undelegate(),\n onRepDelegate: () => this.repDelegate()\n };\n })(),\n apiKeys: API_KEYS.map(k0 => {\n const k = { ...k0, mask: s.apiRotated[k0.name] || k0.mask };\n const revoked = s.apiRevoked.includes(k.name);\n const chip = 'display:inline-block;white-space:nowrap;font:600 10px ui-monospace,monospace;letter-spacing:.05em;padding:3px 7px;';\n return { ...k,\n state: revoked ? 'Revoked' : 'Active',\n stateStyle: chip + (revoked ? 'background:var(--ux-signal);color:#fff' : 'background:var(--ux-accent);color:#fff'),\n live: !revoked,\n onRotate: () => { this.setState(st => ({ apiRotated: { ...(st.apiRotated || {}), [k.name]: 'uxp_\\u2022\\u2022\\u2022\\u2022\\u2022\\u2022\\u2022\\u2022\\u2022\\u2022\\u2022\\u2022' + Math.floor(1000 + (st.outbox.length * 7919) % 8999) } })); this.logAudit('API credential rotated', k.name, 'New secret issued; the previous one retires after a grace period.'); this.set({ toast: 'Rotated. The new secret is shown once, now, and never again \\u2014 we keep a hash rather than the key.', toastKind: 'ROTATED' }); },\n onRevoke: () => {\n this.logAudit('API credential revoked', k.name, 'Scope: ' + k.scope + '. Reads with this credential stop immediately.');\n this.setState(st => ({ apiRevoked: st.apiRevoked.concat([k.name]),\n toast: k.name + ' is revoked. Reads stop immediately, and the revocation is in your own audit history under Your records.', toastKind: 'DONE' }));\n }\n };\n }),\n apiEndpoints: API_ENDPOINTS,\n apiWithheld: API_WITHHELD.map(w => ({ text: w })),\n onApiIssue: () => this.set({ toast: 'Issuing asks for a name and a licence scope, then shows the secret once. Nothing here can write \\u2014 there is no endpoint that places an order, so no path reaches the order gates from outside them.', toastKind: 'NOT WIRED' }),\n ...(() => {\n const chip = 'display:inline-block;white-space:nowrap;font:600 10px ui-monospace,monospace;letter-spacing:.05em;padding:3px 7px;';\n const open = INVOICES.filter(i => i.state !== 'Paid');\n const byLoc = LOCATIONS.map(l => {\n const mine = INVOICES.filter(i => i.loc === l.id);\n const outstanding = mine.filter(i => i.state !== 'Paid').reduce((a, i) => a + i.amount, 0);\n const overdue = mine.filter(i => i.state === 'Past due').reduce((a, i) => a + i.amount, 0);\n const oldest = mine.filter(i => i.state === 'Past due').reduce((a, i) => Math.max(a, i.age), 0);\n return {\n name: l.name, license: l.license, terms: TERMS[l.id] || '\\u2014',\n outstanding: MONEY(outstanding), overdue: MONEY(overdue),\n oldest: oldest ? oldest + ' days past due' : 'Nothing overdue',\n gate: l.pastDue ? 'Blocked at entry' : 'Clear',\n gateStyle: chip + (l.pastDue ? 'background:var(--ux-signal);color:#fff' : 'background:var(--ux-accent);color:#fff'),\n otherGate: l.expired ? 'Ordering is paused here anyway \\u2014 the license record expired. A different gate, and clearing this balance would not lift it.' : ''\n };\n });\n return {\n arRows: byLoc,\n arInvoices: INVOICES.map(i => ({\n id: i.id, loc: this.locName(i.loc), issued: i.issued, due: i.due, amount: MONEY(i.amount),\n state: i.state, ageText: i.age ? i.age + ' days' : '\\u2014',\n stateStyle: chip + (i.state === 'Past due' ? 'background:var(--ux-signal);color:#fff'\n : i.state === 'Paid' ? 'background:var(--ux-accent);color:#fff' : 'background:var(--ux-surface);color:var(--ux-muted-strong)')\n })),\n arOutstanding: MONEY(open.reduce((a, i) => a + i.amount, 0)),\n arOverdue: MONEY(INVOICES.filter(i => i.state === 'Past due').reduce((a, i) => a + i.amount, 0)),\n arBlockedCount: String(LOCATIONS.filter(l => l.pastDue).length),\n arOpenCount: String(open.length)\n };\n })(),\n ...(() => {\n const chip = 'display:inline-block;white-space:nowrap;font:600 10px ui-monospace,monospace;letter-spacing:.05em;padding:3px 7px;';\n const wholeOrg = role === 'owner' || isInternal;\n const locIds = wholeOrg ? LOCATIONS.map(l => l.id) : [s.loc];\n const scopeText = wholeOrg ? 'Your organisation \\u2014 all three locations' : this.locName(s.loc) + ' only, because a buyer is scoped to their own location';\n const mine = s.orders.filter(o => locIds.includes(o.loc));\n const recOrders = mine.map(o => ({\n id: o.id, loc: this.locName(o.loc), placed: o.placed, lines: String(o.lines.length),\n value: MONEY(this.orderValue(o)), state: o.state,\n stateStyle: chip + (o.state === 'Flagged' ? 'background:var(--ux-signal);color:#fff' : o.state === 'Received' ? 'background:var(--ux-accent);color:#fff' : 'background:var(--ux-surface);color:var(--ux-muted-strong)')\n }));\n const lots = [];\n mine.filter(o => o.state === 'Received').forEach(o => o.lines.forEach(l => {\n const pp = P(l.id); if (!pp) return;\n const lot = pp.lots[l.lot] || {};\n lots.push({ name: pp.name, tag: lot.tag || '\\u2014', qty: l.qty, order: o.id,\n when: (o.history && o.history['Received']) || o.placed, coa: 'Version 1, pinned at receipt' });\n }));\n const lic = LOCATIONS.filter(l => locIds.includes(l.id)).map(l => ({\n name: l.name, license: l.license,\n state: l.expired ? 'Expired 12 Aug 2026' : 'Active',\n stateStyle: chip + (l.expired ? 'background:var(--ux-signal);color:#fff' : 'background:var(--ux-accent);color:#fff')\n }));\n const claims = s.claims.filter(c => locIds.includes(c.loc)).map(c => ({ ...c, locName: this.locName(c.loc), stateStyle: chip + (c.state === 'Open' ? 'background:var(--ux-signal);color:#fff' : 'background:var(--ux-surface);color:var(--ux-muted-strong)') }));\n const notices = [];\n if (s.coaAmended) notices.push({ kind: 'Amended COA', subject: 'Tag ' + (s.recallTag || RECALL_TAG), when: '24 Aug 2026',\n state: s.coaAcks.includes(ACTING_ORG) ? 'Acknowledged by your organisation' : 'Awaiting your acknowledgement',\n stateStyle: chip + (s.coaAcks.includes(ACTING_ORG) ? 'background:var(--ux-accent);color:#fff' : 'background:var(--ux-signal);color:#fff') });\n const recallNotice = s.recallIssued\n ? [{ kind: 'Recall or withdrawal', subject: 'Tag ' + (s.recallTag || RECALL_TAG), when: '24 Aug 2026',\n state: s.recallAcks.includes(ACTING_ORG) ? 'Acknowledged by your organisation' : 'Awaiting your acknowledgement',\n stateStyle: chip + (s.recallAcks.includes(ACTING_ORG) ? 'background:var(--ux-accent);color:#fff' : 'background:var(--ux-signal);color:#fff') }]\n : [];\n recallNotice.forEach(n => notices.push(n));\n const live = s.audit.filter(a => STORE_VISIBLE_AUDIT.some(k => a.action.indexOf(k) === 0))\n .map(a => ({ ...a, actor: a.actor === 'You' ? 'Your organisation' : a.actor }));\n const storeAudit = live.concat(STORE_AUDIT);\n return {\n recScope: scopeText,\n recOrders, recLots: lots, recLicenses: lic, recClaims: claims,\n recHasClaims: claims.length > 0, recNoClaims: claims.length === 0,\n recNoLots: lots.length === 0,\n recNotices: notices, recHasNotices: notices.length > 0, recNoNotices: notices.length === 0,\n recDocs: DOCS.length, recAudit: storeAudit,\n recCounts: recOrders.length + ' orders \\u00b7 ' + lots.length + ' lots received \\u00b7 ' + lic.length + ' licenses \\u00b7 ' + claims.length + ' claims \\u00b7 ' + DOCS.length + ' documents \\u00b7 ' + notices.length + ' notices \\u00b7 ' + storeAudit.length + ' audit entries',\n onRecExport: () => {\n const rows = recOrders.length + lots.length + lic.length + claims.length + DOCS.length + notices.length + storeAudit.length;\n this.logAudit('Data export', 'Your records', 'Scope: ' + scopeText + '. ' + rows + ' rows. Same field filtering as the screen.');\n this.set({ toast: 'The export carries exactly what this screen carries \\u2014 ' + rows + ' rows, and the same withheld fields. Scope, row count and field list land in the audit trail, which you can read below. Cost, margin, ownership and rate cards are absent here and absent from the file.', toastKind: 'EXPORT PREPARED' });\n }\n };\n })(),\n ...(() => {\n const tag = s.recallTag || RECALL_TAG;\n let prod = null, idx = -1;\n PRODUCTS.forEach(p => p.lots.forEach((l, i) => { if (l.tag === tag) { prod = p; idx = i; } }));\n const chip = 'display:inline-block;white-space:nowrap;font:600 10px ui-monospace,monospace;letter-spacing:.05em;padding:3px 7px;';\n const rows = [];\n s.orders.forEach(o => o.lines.forEach(l => {\n if (prod && l.id === prod.id && l.lot === idx) {\n const loc = LOCATIONS.find(x => x.id === o.loc) || { name: o.loc, license: '' };\n const delivered = o.state === 'Received';\n rows.push({ org: ACTING_ORG, loc: loc.name, license: loc.license, qty: l.qty, order: o.id,\n when: (o.history && o.history[o.state] ? o.state + ' ' + o.history[o.state] : o.state), signed: delivered ? 'M. Reyes' : '\\u2014',\n state: delivered ? 'Delivered' : 'Not yet delivered' });\n }\n }));\n RECALL_OTHER.forEach(r => rows.push({ ...r }));\n const draftHold = [];\n s.drafts.forEach(d => d.lines.forEach(l => {\n if (prod && l.id === prod.id && l.lot === idx) draftHold.push({ where: 'Open draft \\u2014 ' + d.name, qty: l.qty, note: 'Blocked rather than silently dropped when the lot is withdrawn.' });\n }));\n const stoppable = draftHold\n .concat(rows.filter(r => r.state !== 'Delivered').map(r => ({ where: r.state === 'In transit' ? 'In transit \\u2014 ' + r.order : 'Allocated, unshipped \\u2014 ' + r.order, qty: r.qty, note: 'Stopped at our end, not at theirs.' })))\n .concat(RECALL_CUSTODY.map(c => ({ where: c.where, qty: c.qty, note: c.note })));\n const orgs = [];\n rows.forEach(r => { if (!orgs.some(o => o.org === r.org)) orgs.push({ org: r.org, qty: 0, locs: [] }); });\n rows.forEach(r => { const o = orgs.find(x => x.org === r.org); o.qty += r.qty; if (!o.locs.includes(r.loc)) o.locs.push(r.loc); });\n const delivered = rows.filter(r => r.state === 'Delivered').reduce((a, r) => a + r.qty, 0);\n const inflight = rows.filter(r => r.state !== 'Delivered').reduce((a, r) => a + r.qty, 0);\n return {\n recallTagText: tag,\n recallLotName: prod ? prod.name : 'Unresolved',\n recallLotMeta: prod ? (prod.sku + ' \\u00b7 packaged ' + prod.lots[idx].packaged + ' \\u00b7 ' + prod.brand) : '',\n recallInput: s.recallInput,\n onRecallInput: e => this.set({ recallInput: e.target.value }),\n recallLookup: () => this.recallFind(),\n recallHasMsg: !!s.recallMsg, recallMsg: s.recallMsg || '', recallMsgKind: s.recallMsgKind,\n recallRows: rows.map(r => ({ ...r, stateStyle: chip + (r.state === 'Delivered' ? 'background:var(--ux-signal);color:#fff' : 'background:var(--ux-surface);color:var(--ux-muted-strong)') })),\n recallStoppable: stoppable,\n recallDeliveredQty: delivered, recallInflightQty: inflight,\n recallCustodyQty: RECALL_CUSTODY.reduce((a, c) => a + c.qty, 0),\n recallDraftQty: draftHold.reduce((a, c) => a + c.qty, 0),\n recallAccountCount: orgs.length,\n recallIssued: s.recallIssued,\n recallNoticeText: s.recallIssued ? 'Issued to ' + orgs.length + ' accounts' : 'Not issued',\n recallNoticeStyle: chip + (s.recallIssued ? 'background:var(--ux-accent);color:#fff' : 'background:var(--ux-surface);color:var(--ux-muted-strong)'),\n onRecallIssue: () => this.recallIssue(),\n recallAccounts: orgs.map(o => ({\n org: o.org, qty: o.qty, locs: o.locs.join(', '),\n ackText: !s.recallIssued ? 'Notice not issued' : (s.recallAcks.includes(o.org) ? 'Acknowledged 24 Aug 2026' : 'Awaiting acknowledgement'),\n ackStyle: chip + (s.recallAcks.includes(o.org) ? 'background:var(--ux-accent);color:#fff' : s.recallIssued ? 'background:var(--ux-signal);color:#fff' : 'background:var(--ux-surface);color:var(--ux-muted-strong)'),\n canAck: s.recallIssued && !s.recallAcks.includes(o.org),\n onAck: () => this.recallAck(o.org)\n }))\n };\n })(),\n onboarding: ONBOARDING.map(o => ({ ...o,\n stageLabel: String(o.stage).padStart(2, '0') + ' \\u00b7 ' + ONBOARDING_STAGES[o.stage - 1],\n stageStyle: 'display:inline-block;white-space:nowrap;font:600 10px ui-monospace,monospace;letter-spacing:.05em;padding:3px 7px;background:'\n + (o.stage >= 5 ? 'var(--ux-accent);color:#fff' : o.stage <= 2 ? 'var(--ux-signal);color:#fff' : 'var(--ux-surface);color:var(--ux-muted-strong)') })),\n isTraining: s.screen === 'training', isCoaLookup: s.screen === 'coalookup',\n pubInput: s.pubInput,\n onPubInput: e => this.set({ pubInput: e.target.value }),\n onPubResolve: () => this.pubResolve(),\n onPubReset: () => this.pubReset(),\n pubAttemptsText: s.pubAttempts + ' of 5 attempts used from this source',\n pubHasMsg: !!s.pubMsg, pubMsg: s.pubMsg || '', pubMsgKind: s.pubMsgKind,\n pubHasResult: !!s.pubResult,\n pubProduct: s.pubResult ? s.pubResult.product : '',\n pubTag: s.pubResult ? s.pubResult.tag : '',\n pubResult: s.pubResult ? s.pubResult.result : '',\n pubPackaged: s.pubResult ? s.pubResult.packaged : '',\n pubTested: s.pubResult ? s.pubResult.tested : '',\n pubAmended: !!(s.pubResult && s.coaAmended && s.pubResult.tag === (s.recallTag || RECALL_TAG)),\n pubControls: PUBLIC_CONTROLS,\n isAccess: s.screen === 'access', isLineage: s.screen === 'lineage',\n users, adminTitle: isInternal ? 'Users and roles' : 'Your users',\n auOpen: s.auOpen, auClosed: !s.auOpen,\n onAuToggle: () => this.auToggle(),\n auName: s.auName, onAuName: e => this.set({ auName: e.target.value }),\n auEmail: s.auEmail, onAuEmail: e => this.set({ auEmail: e.target.value }),\n auLocOpen: s.auLocOpen,\n onAuLocOpen: () => this.auLocOpenToggle(),\n auLocLabel: s.auLocs.length ? this.auLocText(s.auLocs) : 'Choose locations',\n auLocCount: s.auLocs.length + ' of ' + LOCATIONS.length,\n auLocAllLabel: s.auLocs.length === LOCATIONS.length ? 'Clear all locations' : 'Select all locations',\n onAuLocAll: () => this.auLocAll(),\n auLocOptions: LOCATIONS.map(l => {\n const on = s.auLocs.indexOf(l.id) !== -1;\n return { id: l.id, name: l.name, mark: on ? '\\u2713' : '',\n aria: (on ? 'Remove ' : 'Add ') + l.name,\n style: 'display:flex;align-items:center;gap:9px;width:100%;text-align:left;border:0;'\n + 'border-bottom:1px solid rgba(23,14,11,.12);padding:9px 11px;cursor:pointer;background:'\n + (on ? 'var(--ux-surface)' : '#fff') + ';font:' + (on ? '600' : '400') + ' 12.5px Archivo,sans-serif',\n boxStyle: 'flex:0 0 auto;width:14px;height:14px;border:1px solid rgba(23,14,11,.45);display:flex;'\n + 'align-items:center;justify-content:center;font:700 10px Archivo,sans-serif;'\n + (on ? 'background:var(--ux-ink);color:var(--ux-ground)' : 'background:#fff'),\n onPick: () => this.auLocPick(l.id) };\n }),\n auRoles: (isInternal ? ['Store owner', 'Buyer', 'Budtender', 'Account management'] : ['Store owner', 'Buyer', 'Budtender']).map(r => ({\n label: r, style: 'padding:8px 11px;font:600 11.5px Archivo,sans-serif;cursor:pointer;border:1px solid rgba(23,14,11,.3);'\n + (s.auRole === r ? 'background:var(--ux-ink);color:var(--ux-ground)' : 'background:transparent'),\n onPick: () => this.set({ auRole: r }) })),\n auHasErr: !!s.auErr, auErr: s.auErr || '',\n onAuSubmit: () => this.auSubmit(),\n adminSub: isInternal\n ? 'Every organisation\\u2019s users. Viewing as a user is read-only, withholds cost, and is recorded in that organisation\\u2019s own audit history.'\n : 'Users in your organisation. You assign buyer and budtender access; kiosk devices are paired by urbanXtracts.',\n auditRows: s.audit, notifications,\n notifSub: 'Three events push to a store user: an order state change, an approaching license expiry, and a past-due balance. Everything else waits to be found.',\n markNotifRead: () => this.set({ notifRead: true, toast: 'Notifications marked as read.', toastKind: 'DONE' }),\n compareA: PRODUCTS[0].name, compareB: PRODUCTS[3].name, compareRows,\n trainingCards, coaStates, lineageSteps,\n sessionStates: SESSION_STATES.map(x => ({ ...x, chipStyle: 'font:600 9.5px ui-monospace,monospace;letter-spacing:.06em;padding:3px 7px;background:' + (x.kind === 'ENTRY' ? 'var(--ux-ink)' : 'var(--ux-signal)') + ';color:#fff' })),\n impersonating: !!s.impersonating,\n impersonatingLabel: s.impersonating ? s.impersonating.name + ' · ' + s.impersonating.org : '',\n stopImpersonating: () => this.stopImpersonating(),\n goAccountsBtn: () => this.set({ screen: 'accounts' }),\n acctName: ACCOUNTS_DETAIL['OCM-RETL-24-000412'].name,\n acctMeta: 'License OCM-RETL-24-000412 · 3 locations · ' + ACCOUNTS_DETAIL['OCM-RETL-24-000412'].since + ' · assigned to Toni Alvarez',\n isAccount: s.screen === 'account', isLocations: s.screen === 'locations',\n isTests: s.screen === 'tests', isConfirm: s.screen === 'confirm',\n loading: s.loading, notLoading: !s.loading, stale: s.stale,\n onRefresh: () => this.refresh(),\n onStale: () => this.set({ stale: true, toast: 'Showing data as of 10:14 — the subledger has moved since.', toastKind: 'STALE DATA' }),\n dataStamp: s.stale ? 'Showing data as of 24 Aug 2026 10:14 — newer figures exist' : 'Live as of 24 Aug 2026, read from the subledger',\n skeletonRows: [1, 2, 3, 4],\n\n draftTabs: s.drafts.map(d => ({\n label: d.name + ' · ' + d.lines.length,\n style: 'display:inline-flex;align-items:center;gap:8px;white-space:nowrap;background:' + (d.id === s.activeDraft ? 'var(--ux-ink)' : 'transparent') +\n ';color:' + (d.id === s.activeDraft ? 'var(--ux-ground)' : 'var(--ux-ink)') + ';border:1px solid ' + (d.id === s.activeDraft ? 'var(--ux-ink)' : 'rgba(23,14,11,.25)') +\n ';padding:8px 12px;font:' + (d.id === s.activeDraft ? '700' : '500') + ' 12px Archivo,sans-serif;cursor:pointer',\n onPick: () => this.set({ activeDraft: d.id, loc: d.loc }),\n onDelete: () => this.deleteDraft(d.id)\n })),\n onNewDraft: () => this.newDraft(),\n activeDraftName: activeDraftObj ? activeDraftObj.name : '',\n\n accountDetail: ACCOUNTS_DETAIL['OCM-RETL-24-000412'],\n acctLocations: ACCOUNTS_DETAIL['OCM-RETL-24-000412'].locations.map(l => ({\n ...l,\n style: 'font:600 9.5px ui-monospace,monospace;letter-spacing:.05em;padding:3px 7px;display:inline-block;white-space:nowrap;background:' +\n (l.status === 'Active' ? 'var(--ux-surface)' : 'var(--ux-signal)') + ';color:' + (l.status === 'Active' ? 'var(--ux-muted-strong)' : '#fff')\n })),\n acctContacts: ACCOUNTS_DETAIL['OCM-RETL-24-000412'].contacts,\n acctMix: ACCOUNTS_DETAIL['OCM-RETL-24-000412'].mix.map(m => ({\n cat: m.cat, share: m.share + '%',\n barStyle: 'height:9px;width:' + m.share + '%;background:' + (CAT_HUE[m.cat] || 'var(--ux-faint)')\n })),\n acctNotes: ACCOUNTS_DETAIL['OCM-RETL-24-000412'].notes,\n acctNotOrdered: ACCOUNTS_DETAIL['OCM-RETL-24-000412'].notOrdered,\n openAccount: () => this.set({ screen: 'account' }),\n\n locationRows: LOCATION_ROWS,\n tests: TESTS.map(t => ({\n ...t,\n labelStyle: 'display:inline-block;white-space:nowrap;font:600 9.5px ui-monospace,monospace;letter-spacing:.05em;padding:3px 7px;background:' +\n (t.label === 'REQUIRED TEST' ? 'var(--ux-ink)' : 'var(--ux-surface)') + ';color:' + (t.label === 'REQUIRED TEST' ? 'var(--ux-ground)' : 'var(--ux-muted-strong)'),\n stateStyle: 'font:600 9.5px ui-monospace,monospace;letter-spacing:.05em;color:' + (t.state === 'To verify per screen' ? 'var(--ux-signal-text)' : 'var(--ux-accent-text)')\n })),\n\n confirmId: (() => {\n const o = s.orders.find(x => x.id === s.confirmed);\n return (o && o.orderNo) ? o.orderNo : (s.confirmed || '');\n })(),\n confirmRef: (() => {\n const o = s.orders.find(x => x.id === s.confirmed);\n return (o && o.orderNo)\n ? 'Order number, assigned by the order board. Portal reference ' + o.id + '.'\n : 'Portal reference. The order number is assigned when the board accepts it \\u2014 quote this until then.';\n })(),\n confirmState: (() => {\n const o = s.orders.find(x => x.id === s.confirmed);\n return o ? (o.state === 'Awaiting approval' ? 'SENT FOR APPROVAL' : 'ORDER ' + o.state.toUpperCase()) : 'ORDER SUBMITTED';\n })(),\n confirmChipStyle: (() => {\n const o = s.orders.find(x => x.id === s.confirmed);\n const done = o && (o.state === 'Received' || o.state === 'Approved');\n return 'font:600 9.5px ui-monospace,monospace;letter-spacing:.06em;padding:4px 8px;background:' + (done ? 'var(--ux-accent)' : 'var(--ux-ink)') + ';color:' + (done ? '#fff' : 'var(--ux-ground)');\n })(),\n ...(() => {\n const o = s.orders.find(x => x.id === s.confirmed);\n if (!o) return { mondayRows: [] };\n const loc = LOCATIONS.find(x => x.id === o.loc) || { name: o.loc, license: '' };\n const who = s.authUser ? s.authUser.name + ' (' + s.authUser.role + ')' : 'Unknown';\n const rows = [\n ['Item name', o.id + ' \\u00b7 ' + ACTING_ORG + ' (' + loc.name + ')'],\n ['Order number', o.id],\n ['Account', ACTING_ORG + ' \\u2014 linked to Licensed Retailers, which is where the rep comes from'],\n ['Rep', 'Mirrored from Account Owner on the account record, not typed here'],\n ['Location', loc.name + ' \\u00b7 ' + loc.license],\n ['Submitted by', who],\n ['Submitted via', 'Store portal'],\n ['Approval state', o.state === 'Awaiting approval' ? 'Awaiting store approval' : 'Placed by store'],\n ['Approval held by', (s.delegateTo || 'the store owner') + (s.delegateTo ? ' under a delegation expiring ' + s.delegateExpiry : ' by default')],\n ['Order value', MONEY(this.orderValue(o))],\n ['Lines', String(o.lines.length) + ' \\u2014 a line count, not a unit total, because an order can mix grams and each'],\n ['Intake status', 'Needs acknowledgement \\u2014 this is what alerts the team'],\n ['Notify', 'The account rep and the fulfilment owner, by automation']\n ];\n return { mondayRows: rows.map(([k, v]) => ({ k, v })) };\n })(),\n confirmLines: (s.orders.find(o => o.id === s.confirmed) || { lines: [] }).lines.map(l => {\n const p = P(l.id);\n return { name: p.name, brand: p.brand, sku: p.sku, tag: p.lots[l.lot].tag, packaged: p.lots[l.lot].packaged, qty: String(l.qty), unit: MONEY(l.price), total: MONEY(l.price * l.qty) };\n }),\n confirmMeta: (() => {\n const o = s.orders.find(x => x.id === s.confirmed);\n return o ? this.locName(o.loc) + ' · placed ' + o.placed + ' · ' + (o.delivery || 'Next available') + ' · ' + o.state : '';\n })(),\n confirmValue: (() => {\n const o = s.orders.find(x => x.id === s.confirmed);\n return o ? MONEY(this.orderValue(o)) : '$0.00';\n })(),\n onPrint: () => window.print(),\n econRows, accounts: accountsRows,\n qaTiles: [\n { label: 'Released SKUs', value: '32', note: 'Available and lab-passed' },\n { label: 'Awaiting result', value: '11', note: 'Submitted for testing' },\n { label: 'Failed, active', value: '4', note: 'No external presence' },\n { label: 'Available, no result', value: '1', note: 'Excluded by the release rule' }\n ].map((q, i, arr) => ({ ...q, style: tileStyle(i, arr.length) })),\n integrityRows: INTEGRITY.map(i => ({ ...i, countStyle: i.count === '0 of 195 dated' ? 'color:var(--ux-muted)' : 'font-weight:700' })),\n\n validationGroups,\n validationSummary: totalItems + ' open items across six validation types · ' + totalBlocking + ' of them blocking · Canix inventory API verified 2026-08-31'\n };\n }\n}\n<\u002Fscript>\n\n\n<\u002Fbody><\u002Fhtml>"