// ============================================================================
// LEON Operations Hub — data model, constants, and seed data (mock, in-browser)
// ============================================================================

// Seeded from wall-clock time (not a fixed 1000) so ids stay unique across
// page reloads — a fixed start value collides with ids already sitting in
// persisted state from an earlier session the moment the counter climbs
// back through the same range, silently aliasing two unrelated records
// onto one id (discovered via a Window Schedule dependency-graph node
// landing on an existing node's id after a reload, corrupting its DAG).
let __uidCounter = Date.now();
function uid(prefix) {
  __uidCounter += 1;
  return `${prefix}-${__uidCounter}`;
}

// ---------------------------------------------------------------------------
// Departments (Windows / Interiors)
// ---------------------------------------------------------------------------
// The company runs as two operating departments. This is a DATA-SCOPE axis,
// deliberately kept orthogonal to SECURITY_ROLES below (which is the
// CAPABILITY axis): one 'Project Coordinator' role definition serves both
// departments, and each person is simply marked as covering Windows,
// Interiors, or both. Creating per-department copies of every role was
// considered and rejected — it doubles the role matrix and every future
// permission change then has to be made twice and kept in sync.
//
// A department is carried by a scope-library FAMILY (families are what
// classify work), stamped onto each scope at creation time so a scope's
// department is stable even if the family is later re-classified, and
// derived upward for a project (a project belongs to whichever departments
// its scopes do — a project with both is "Mixed" and is visible to
// single-department users with its other department's data filtered out).
const DEPARTMENTS = ['Windows', 'Interiors'];
// Sentinel used by the header department selector for "show me everything"
// — only offered to a person who actually covers both departments.
const ALL_DEPARTMENTS = 'All';

// Fallback for anything that predates the department field. Interiors is the
// safe default: it's the larger department, and the only window-classified
// family ships flagged, so nothing lands in Windows by accident.
const DEFAULT_DEPARTMENT = 'Interiors';

function normalizeDepartments(list) {
  const clean = (list || []).filter(d => DEPARTMENTS.includes(d));
  // Never leave a person with zero departments — that would silently lock
  // them out of every project in the app.
  return clean.length ? clean : [...DEPARTMENTS];
}
// Can this person see this department's data at all?
function personCoversDepartment(person, department) {
  if (!person) return false;
  if (!department || department === ALL_DEPARTMENTS) return true;
  return normalizeDepartments(person.departments).includes(department);
}
// Departments a person may actually choose between in the header selector.
// The three roles that run the whole company are LOCKED to both departments —
// instructed 2026-09-06. An Admin, Accounting or the General Manager whose
// departments had been narrowed would silently stop seeing half the jobs, half
// the money and half the people, with nothing on screen to say why. Their
// coverage is therefore not a per-person setting at all.
const ALL_DEPARTMENT_ROLES = ['Admin', 'Accounting', 'General Manager'];
function departmentsLockedFor(person) {
  return !!person && ALL_DEPARTMENT_ROLES.indexOf(person.securityRole) >= 0;
}
function selectableDepartments(person) {
  if (departmentsLockedFor(person)) return DEPARTMENTS.slice();
  return normalizeDepartments(person && person.departments);
}

// ---------------------------------------------------------------------------
// Security roles (system roles — who is logged in / permission tiering)
// These are the company's actual job titles, used directly as login/permission
// roles (replacing the earlier generic functional-role list).
// ---------------------------------------------------------------------------
const SECURITY_ROLES = [
  'Admin',
  'Accounting',
  'General Manager',
  'Project Coordinator',
  'Production Director',
  'Senior Associate',
  'Associates',
  'Junior Associate',
  'Export Manager',
  'Logistic Manager',
  'Installation Team & Field Foreman',
  'Subcontractor',
  // Internal team members (not a separate company/registration collection
  // like Subcontractor) who log into the Delivery Driver Hub — see their
  // own assigned deliveries and run the on-site proof-of-delivery flow.
  'Delivery Driver',
  // External, view-only login for a client contact — same idea as
  // Subcontractor (a teamDirectory entry is the login, linked back to the
  // real record — accountId here instead of subcontractorId) but routed to
  // ClientPortal instead of the normal app shell.
  'Client',
  // Quality control is sometimes ours and sometimes a third party's. A third-
  // party inspector gets a login of their own, routed to QCPortal, where they
  // see only the inspections booked in their name and record the result. An
  // in-house inspection is the same record with an internal person on it — one
  // shape either way, so a job's QC history reads the same however it was done.
  'QC Inspector',
];
// The four EXTERNAL logins. They are in SECURITY_ROLES because they are real
// accounts, but they are not staff — they get a portal, not the app shell — and
// several rules turn on exactly this line (who appears in the internal share
// list, who may contribute to the company document library).
// Defined HERE, next to the list it partitions, because the capability table
// further down reads it at module-evaluation time: leaving it near the bottom
// of the file put it in the temporal dead zone and blanked the whole app.
const PORTAL_ROLES = ['Client', 'Subcontractor', 'Delivery Driver', 'QC Inspector'];

// Roles allowed to see financial figures anywhere in the system (§2, §3) —
// Quotes & Contracts, Financials, Change Orders/Back Charges. Originally
// Admin/Accounting only; General Manager was added when AIA Billing
// certification (Admin/Accounting/General Manager) needed a way to actually
// reach the Financials tab that holds it — GM gaining AP/Profitability
// visibility as a side effect was confirmed explicitly, not incidental.
// Narrowed on the client's instruction, 2026-09-06. It had been every internal
// role by default (the module rows were permissive and only this capability was
// tight); it is now the four functions that need the numbers — the company's
// officers, and sales, who quote the work and are measured on the margin.
// The three associate ranks ARE the sales function here.
const SALES_ROLES = ['Senior Associate', 'Associates', 'Junior Associate'];
const FINANCIAL_ROLES = ['Admin', 'Accounting', 'General Manager'].concat(SALES_ROLES);

function canSeeFinancials(role) {
  return roleHasCapability(role, 'financials.view');
}
// A vendor's Price List is more sensitive than its Catalog (open to
// everyone) — restricted to Admin, Accounting, and General Manager (the
// role standing in for "Sales" per explicit instruction, since the login
// role vocabulary has no literal Sales role).
const VENDOR_PRICING_ROLES = ['Admin', 'Accounting', 'General Manager'];
function canSeeVendorPricing(role) {
  return roleHasCapability(role, 'vendorPricing.view');
}

// Per-module edit permission (lightweight approximation of a full security
// matrix — financials tiering above is enforced strictly; the rest is a
// reasonable default per job title for a prototype).
const MODULE_EDIT_RIGHTS = {
  Admin: '*',
  Accounting: ['financials', 'billing', 'quotes', 'documents', 'changelog', 'vendors', 'users', 'profitability', 'accountsPayable', 'logistics'],
  'General Manager': ['overview', 'contacts', 'scopes', 'selections', 'documents', 'tasks', 'issues', 'procurement', 'vendors', 'drawingSets', 'takeOffs', 'renders', 'delivery', 'export', 'installation', 'production', 'accountsPayable', 'logistics'],
  'Project Coordinator': ['overview', 'contacts', 'scopes', 'selections', 'documents', 'tasks', 'issues', 'procurement', 'vendors', 'drawingSets', 'takeOffs', 'renders', 'delivery', 'export', 'installation', 'production', 'logistics'],
  'Production Director': ['scopes', 'documents', 'tasks', 'procurement', 'vendors', 'takeOffs', 'renders', 'installation', 'production'],
  'Senior Associate': ['overview', 'contacts', 'selections', 'documents', 'tasks', 'issues', 'drawingSets', 'takeOffs', 'renders'],
  Associates: ['overview', 'contacts', 'selections', 'documents', 'tasks', 'issues', 'drawingSets', 'takeOffs', 'renders'],
  'Junior Associate': ['selections', 'documents', 'tasks', 'drawingSets', 'takeOffs'],
  'Export Manager': ['export', 'documents', 'tasks', 'logistics'],
  'Logistic Manager': ['delivery', 'documents', 'tasks', 'warehouse', 'logistics'],
  // Narrowed to Installation only, per explicit instruction — this is the
  // full extent of what Installation Team/Field Foreman/Fieldman and
  // Subcontractors should be able to do in the system.
  'Installation Team & Field Foreman': ['installation'],
  Subcontractor: ['installation'],
  // Scheduling and managing quality control belongs to the Production Director
  // and the associates, per explicit instruction; everyone else can look.
  'QC Inspector': [],
};
// Quality Control is edited by the Production Director and the associates.
// It is added to the seed rights above rather than written into each list, so
// the intent stays readable: these roles run QC, everyone else can only view.
['Production Director', 'Senior Associate', 'Associates', 'Junior Associate'].forEach(r => {
  if (Array.isArray(MODULE_EDIT_RIGHTS[r])) MODULE_EDIT_RIGHTS[r].push('qc');
});
// Closeout is run by whoever ran the job — the coordinators and the managers.
// Everyone else can read it, because "was this job actually finished, and what
// did we learn" is not privileged information.
['Project Coordinator', 'Production Director', 'General Manager', 'Senior Associate', 'Associates'].forEach(r => {
  if (Array.isArray(MODULE_EDIT_RIGHTS[r])) MODULE_EDIT_RIGHTS[r].push('closeout');
});
// The specialty tools are technical work: the people who configure a door, lay
// out a slab or build a cut list. Everyone else can look — a schedule is not
// privileged — but only these roles change one.
['Project Coordinator', 'Production Director', 'General Manager', 'Senior Associate', 'Associates', 'Junior Associate'].forEach(r => {
  if (Array.isArray(MODULE_EDIT_RIGHTS[r])) MODULE_EDIT_RIGHTS[r].push('softwares');
});

// person (optional) — a per-user permissionOverrides map wins over the role
// default for that one module (Phase 10): 'edit' grants it outright, 'view'
// and 'none' both deny edit (view-only is still not edit), regardless of
// what the role would normally allow.
// Every module key canEditModule/canViewModule recognize, for the
// per-user permission-override editor (Phase 10, UsersView).
const ALL_MODULE_KEYS = [
  { key: 'overview', label: 'Project Information' }, { key: 'contacts', label: 'Contacts' }, { key: 'scopes', label: 'Scopes & Schedule' },
  { key: 'selections', label: 'Selection Hub' }, { key: 'documents', label: 'Shop Drawing Hub' }, { key: 'tasks', label: 'Tasks' }, { key: 'issues', label: 'Issues' },
  { key: 'procurement', label: 'Procurement Hub' }, { key: 'drawingSets', label: 'Drawing Sets' }, { key: 'takeOffs', label: 'Take-offs Hub' }, { key: 'renders', label: 'Renders' },
  { key: 'delivery', label: 'Delivery and Dispatch Hub' }, { key: 'export', label: 'Export Hub' }, { key: 'installation', label: 'Installation Hub' }, { key: 'production', label: 'Production Hub' },
  { key: 'financials', label: 'Financial Hub' }, { key: 'billing', label: 'AIA Billing' }, { key: 'quotes', label: 'Sales Hub' }, { key: 'changelog', label: 'Change Log' },
  { key: 'vendors', label: 'Vendors' }, { key: 'users', label: 'Users' }, { key: 'profitability', label: 'Profitability' }, { key: 'accountsPayable', label: 'Accounts Payable' },
  { key: 'logistics', label: 'Logistics' }, { key: 'warehouse', label: 'Inventory' },
  { key: 'qc', label: 'Quality Control' },
  { key: 'closeout', label: 'Closeout' },
  // A JOB'S OWN REPORTS TAB. PROJECT_TABS has always gated it on a module
  // called 'reports' and no such module existed — which cost nothing while
  // canViewModule defaulted to ALLOW, and hid the tab from EVERY role the day
  // module view went default-deny. The fix is the module, not a special case:
  // a tab gated on the matrix has to name something the matrix knows about.
  { key: 'reports', label: 'Project Reports' },
  // LEON Softwares as one permission. The six tools share a body of technical
  // work — profiles, slabs, layouts, cut lists — and whoever runs one usually
  // runs the others; six separate keys would be six things to keep in step.
  { key: 'softwares', label: 'LEON Softwares' },
];
function canEditModule(role, moduleKey, person) {
  const override = person && person.permissionOverrides && person.permissionOverrides[moduleKey];
  if (override) return override === 'edit';
  // Role defaults now come from the editable map (Users -> Role Permissions),
  // seeded from MODULE_EDIT_RIGHTS so behaviour is unchanged until an admin
  // actually edits something.
  return roleModuleLevel(role, moduleKey) === 'edit';
}

// Tabs/modules some roles must not even see at all (view-level, stricter than
// edit rights above) — per explicit client instruction.
// The three questions the Logistics Dashboard's figures answer. Bands, rather
// than one undifferentiated grid, because "what is moving" and "what is it
// costing" are read by different people for different reasons.
const LOGISTICS_KPI_BANDS = [
  { key: 'moving',    label: 'On the move', icon: '🚢', hint: 'what is in flight right now',
    stripe: 'bg-[var(--leon-brown)]/50', tint: 'bg-[var(--leon-cream)]' },
  { key: 'attention', label: 'Needs attention', icon: '⚠️', hint: 'somebody has to act',
    stripe: 'bg-amber-400', tint: 'bg-amber-50' },
  { key: 'money',     label: 'What it is costing', icon: '💵', hint: 'value held and spent',
    stripe: 'bg-emerald-400', tint: 'bg-emerald-50' },
];

const MODULE_VIEW_BLOCKLIST = {
  'Export Manager': ['installation', 'financials', 'profitability'],
  // Money is for the officers and for sales. These four run the work and do not
  // need the figures to do it, so the Financial Hub and Profitability are
  // hidden outright rather than shown empty — instructed 2026-09-06.
  // Note this hides the HUB; the assigned Sales Person on a job still reaches
  // that job's Job Costs through isProjectSalesPerson, which is a per-project
  // grant and deliberately not a role.
  'Project Coordinator': ['financials', 'profitability'],
  'Production Director': ['financials', 'profitability'],
  'Logistic Manager': ['financials', 'profitability'],
};
// Some roles are restricted to ONLY the listed modules (everything else
// hidden), rather than merely blocked from a few.
// Every module each role may SEE. This is now the whole answer — view is
// DEFAULT-DENY, on the client's instruction (2026-09-06). It used to be
// default-ALLOW with a blocklist, which meant a module key added in a later
// version was visible to every role until someone remembered to block it. That
// is exactly how the Delivery Driver ended up holding view on all 28 modules.
//
// The lists below were GENERATED from what the old allow/block logic actually
// computed on the day of the change, so nobody's access moved by one module.
// From here on a NEW module is invisible until it is named here.
const MODULE_VIEW_GRANTS = {
  Admin: ['overview', 'contacts', 'scopes', 'selections', 'documents', 'tasks', 'issues', 'procurement', 'drawingSets', 'takeOffs', 'renders', 'delivery', 'export', 'installation', 'production', 'financials', 'billing', 'quotes', 'changelog', 'vendors', 'users', 'profitability', 'accountsPayable', 'logistics', 'warehouse', 'qc', 'closeout', 'softwares', 'reports'],
  Accounting: ['overview', 'contacts', 'scopes', 'selections', 'documents', 'tasks', 'issues', 'procurement', 'drawingSets', 'takeOffs', 'renders', 'delivery', 'export', 'installation', 'production', 'financials', 'billing', 'quotes', 'changelog', 'vendors', 'users', 'profitability', 'accountsPayable', 'logistics', 'warehouse', 'qc', 'closeout', 'softwares', 'reports'],
  'General Manager': ['overview', 'contacts', 'scopes', 'selections', 'documents', 'tasks', 'issues', 'procurement', 'drawingSets', 'takeOffs', 'renders', 'delivery', 'export', 'installation', 'production', 'financials', 'billing', 'quotes', 'changelog', 'vendors', 'users', 'profitability', 'accountsPayable', 'logistics', 'warehouse', 'qc', 'closeout', 'softwares', 'reports'],
  'Project Coordinator': ['overview', 'contacts', 'scopes', 'selections', 'documents', 'tasks', 'issues', 'procurement', 'drawingSets', 'takeOffs', 'renders', 'delivery', 'export', 'installation', 'production', 'billing', 'quotes', 'changelog', 'vendors', 'users', 'accountsPayable', 'logistics', 'warehouse', 'qc', 'closeout', 'softwares', 'reports'],
  'Production Director': ['overview', 'contacts', 'scopes', 'selections', 'documents', 'tasks', 'issues', 'procurement', 'drawingSets', 'takeOffs', 'renders', 'delivery', 'export', 'installation', 'production', 'billing', 'quotes', 'changelog', 'vendors', 'users', 'accountsPayable', 'logistics', 'warehouse', 'qc', 'closeout', 'softwares', 'reports'],
  'Senior Associate': ['overview', 'contacts', 'scopes', 'selections', 'documents', 'tasks', 'issues', 'procurement', 'drawingSets', 'takeOffs', 'renders', 'delivery', 'export', 'installation', 'production', 'financials', 'billing', 'quotes', 'changelog', 'vendors', 'users', 'profitability', 'accountsPayable', 'logistics', 'warehouse', 'qc', 'closeout', 'softwares', 'reports'],
  Associates: ['overview', 'contacts', 'scopes', 'selections', 'documents', 'tasks', 'issues', 'procurement', 'drawingSets', 'takeOffs', 'renders', 'delivery', 'export', 'installation', 'production', 'financials', 'billing', 'quotes', 'changelog', 'vendors', 'users', 'profitability', 'accountsPayable', 'logistics', 'warehouse', 'qc', 'closeout', 'softwares', 'reports'],
  'Junior Associate': ['overview', 'contacts', 'scopes', 'selections', 'documents', 'tasks', 'issues', 'procurement', 'drawingSets', 'takeOffs', 'renders', 'delivery', 'export', 'installation', 'production', 'financials', 'billing', 'quotes', 'changelog', 'vendors', 'users', 'profitability', 'accountsPayable', 'logistics', 'warehouse', 'qc', 'closeout', 'softwares', 'reports'],
  'Export Manager': ['overview', 'contacts', 'scopes', 'selections', 'documents', 'tasks', 'issues', 'procurement', 'drawingSets', 'takeOffs', 'renders', 'delivery', 'export', 'production', 'billing', 'quotes', 'changelog', 'vendors', 'users', 'accountsPayable', 'logistics', 'warehouse', 'qc', 'closeout', 'softwares', 'reports'],
  'Logistic Manager': ['overview', 'contacts', 'scopes', 'selections', 'documents', 'tasks', 'issues', 'procurement', 'drawingSets', 'takeOffs', 'renders', 'delivery', 'export', 'installation', 'production', 'billing', 'quotes', 'changelog', 'vendors', 'users', 'accountsPayable', 'logistics', 'warehouse', 'qc', 'closeout', 'softwares', 'reports'],
  'Installation Team & Field Foreman': ['overview', 'installation'],
  Subcontractor: [],
  'Delivery Driver': [],
  Client: [],
  'QC Inspector': [],
};

const MODULE_VIEW_ALLOWLIST = {
  // Narrowed to Installation only, per explicit instruction — the full
  // extent of what this role should be able to see is the Installation tab
  // (Installation Records, Daily Field Reports, Field Issues, Material
  // Receiving, Punch List) on whichever projects they're relevant to.
  'Installation Team & Field Foreman': ['overview', 'installation'],
  // Subcontractors never see the normal app shell at all — the App root
  // renders the dedicated SubcontractorPortal instead — this entry is
  // defense-in-depth in case any canView() check runs before that gate.
  Subcontractor: [],
  // Same defense-in-depth as Subcontractor above — Client always renders
  // ClientPortal instead of the normal app shell.
  Client: [],
  // Routed to QCPortal, never the normal shell.
  'QC Inspector': [],
  // Routed to the driver portal — and this entry was MISSING, which mattered
  // because canViewModule defaults to ALLOW. A role absent from this map does
  // not get "nothing", it gets EVERYTHING: the Delivery Driver was holding view
  // on all 28 modules including the Financial Hub, Profitability, Accounts
  // Payable and Users, while the other three portal roles were correctly at
  // none. Any new role must be added here at the same time it is added to
  // SECURITY_ROLES.
  'Delivery Driver': [],
};
// Only Admin/Accounting can see AP financials; only Admin (or an explicitly
// authorized manager) can move an invoice from Pending Approval to Approved.
function canApproveInvoices(role) {
  return roleHasCapability(role, 'invoices.approve');
}
// person (optional) — a per-user permissionOverrides map wins over the role
// default for that one module (Phase 10): 'none' hides it outright even if
// the role would normally show it; 'view' or 'edit' shows it even if the
// role wouldn't.
function canViewModule(role, moduleKey, person) {
  const override = person && person.permissionOverrides && person.permissionOverrides[moduleKey];
  if (override) return override !== 'none';
  return roleModuleLevel(role, moduleKey) !== 'none';
}

// Only Admin/Accounting can approve a freight estimate into a PO, and only
// Export Manager (not Assistant) may give the export-side approval.
function canApproveFreightExport(role) {
  return roleHasCapability(role, 'freight.approveExport');
}
function canApproveFreightAdmin(role) {
  return roleHasCapability(role, 'freight.approveAdmin');
}
// A delivery request must be approved by the Logistic Manager (Admin can
// also approve, as a fallback super-user, matching the freight approval
// pattern above).
function canApproveDelivery(role) {
  return roleHasCapability(role, 'delivery.approve');
}
// Directly overriding a delivery's status (or editing its other fields
// after the fact) is a more sensitive action than the normal
// request/approve/generate-packing-list/confirm workflow, so it's narrowed
// to the same short list the user specified rather than everyone who can
// edit the Delivery tab.
const DELIVERY_STATUS_OVERRIDE_ROLES = ['Admin', 'Accounting', 'Logistic Manager'];
function canEditDeliveryStatus(role) {
  return roleHasCapability(role, 'delivery.overrideStatus');
}
// Who sees the Logistics Dashboard / Reports nav item and category —
// narrowed to Admin, Accounting, and Logistic Manager per explicit
// instruction (Phase 8) — the full extent of who should reach Logistics now.
const LOGISTICS_ROLES = ['Admin', 'Accounting', 'Logistic Manager'];
// Same pairing as canSeeChangeLog above, and the same reasoning.
function canSeeLogistics(role) {
  return roleHasCapability(role, 'logistics.view') && roleModuleLevel(role, 'logistics') !== 'none';
}
// Cross-project Production Timeline nav tab — same production-adjacent
// audience as the per-project Production tab's edit rights (data.jsx
// MODULE_EDIT_RIGHTS), just widened to view-only for everyone who'd
// reasonably want the cross-project view.
const PRODUCTION_TIMELINE_ROLES = ['Admin', 'General Manager', 'Project Coordinator', 'Production Director', 'Accounting'];
function canSeeProductionTimeline(role) {
  return roleHasCapability(role, 'productionTimeline.view');
}
// Employee Workload & Timeline nav tab — restricted to management roles who
// need visibility into who's overloaded and when, per explicit instruction.
// "Production Manager" in that instruction maps to Production Director,
// since Production Manager is only a per-project team ROLE (TEAM_ROLES,
// data.jsx) — not a login/security role — and Production Director is the
// production department's actual login role.
const WORKLOAD_ROLES = ['Admin', 'Production Director', 'Accounting', 'Project Coordinator', 'General Manager'];
function canSeeWorkload(role) {
  return roleHasCapability(role, 'workload.view');
}
// Change Log visibility (project tab, and the Trade Compliance & Tariffs
// module's structured revision logs) is restricted to Admin only, per
// explicit instruction — stricter than the module edit-rights matrix, which
// already governs who can add a manual change-log note but not who can see
// the log at all.
// The Accounting hub — company-wide cash planning. Restricted to the people who
// actually own the money: Accounting and Admin. Deliberately NOT tied to
// canSeeFinancials, which is broader (it includes General Manager and governs
// per-project figures, not company cash).
const ACCOUNTING_HUB_ROLES = ['Admin', 'Accounting'];
function canSeeAccountingHub(role) {
  return roleHasCapability(role, 'accounting.hub');
}
// LEON Collection holds the standing data every project inherits — scope
// families, supplier finishes, materials, lead times. Was Admin-only in code.
function canManageCollection(role) {
  return roleHasCapability(role, 'collection.manage');
}
function canApproveStockConversion(role) {
  return roleHasCapability(role, 'inventory.approveStockConversion');
}
// The door LIBRARY — opening rules, frames, trims, leaf designs, hardware and
// door types. These are company standards that every future door inherits, so
// changing one is a different authority from drawing a door with them. It is a
// capability rather than a hardcoded list, so an admin can move it without a
// code change — the same treatment collection.manage got.
// The catalog is a flat list; these are the two ways it is read.
function leonDoorModel(code) {
  if (!code || typeof LEON_DOOR_MODELS === 'undefined') return null;
  return LEON_DOOR_MODELS.find(m => m.code === code) || null;
}
function leonDoorModelsByStyle() {
  if (typeof LEON_DOOR_MODELS === 'undefined') return [];
  const out = [];
  LEON_DOOR_MODELS.forEach(m => {
    let g = out.find(x => x.style === m.style);
    if (!g) { g = { style: m.style, items: [] }; out.push(g); }
    g.items.push(m);
  });
  return out;
}
function canEditDoorLibrary(role) {
  return roleHasCapability(role, 'doors.library');
}
// The casework equivalent — cabinet modules, construction defaults, components
// and hardware. A sibling rather than a shared key, because the two libraries
// are run by the same people today and may not be tomorrow, and one capability
// covering both could not express that.
function canEditCaseworkLibrary(role) {
  return roleHasCapability(role, 'casework.library');
}

// The capability AND the module row. Both exist in the editable matrix, but only
// the capability was ever read — so an admin who set Change Log to "none" for a
// role saw no effect, which is the worst kind of permission control. AND-ing can
// only tighten, and it tightens nothing today (the capability is already the
// binding constraint for every role), so this makes the module row work without
// moving anyone's access.
function canSeeChangeLog(role) {
  return roleHasCapability(role, 'changeLog.view') && roleModuleLevel(role, 'changelog') !== 'none';
}

// The company document library (§ new request) is a shared resource for all
// employees — every role can contribute to it, not just module-gated editors.
function canEditDocLibrary(role) {
  return roleHasCapability(role, 'docLibrary.edit');
}
const DOCUMENT_LIBRARY_CATEGORIES = [
  'HR & Policies', 'Templates & Forms', 'Standards & Specifications', 'Safety', 'Training', 'Other',
];

// Placeholder entry points for LEON's own suite of specialty take-off/spec
// tools — not yet built. `url: null` means "Coming Soon"; dropping in a real
// URL later is the only change needed to make a card live.
// LEON's own tools, built INTO the Hub rather than linked out to. A software
// with `key` opens as a workspace here; one without is still on the roadmap and
// says so plainly rather than pretending to be a link.
const LEON_SOFTWARE_LINKS = [
  { name: 'LEON Sign', key: 'sign', icon: '\u270D\uFE0F',
    blurb: 'Send a drawing or a contract for signature, track who is holding it up, and file the executed copy back on the job.' },
  { name: 'LEON Take-off', key: 'takeoff', icon: '📐',
    blurb: 'Drawing intelligence and take-off — drawing log, sheets, quantities, revision impact.' },
  { name: 'LEON Casework & Millwork', key: 'casework', icon: '🪵',
    blurb: 'Casework configurator and cut lists.' },
  { name: 'LEON Doors', key: 'doors', icon: '🚪',
    blurb: 'Door records, opening rules, schedule and elevations — one record behind all of them.' },
  // One software, not two. Drawing a kitchen and buying the slab it is cut from
  // are the same job — the drawing produces the pieces, the slab side consumes
  // them — so they share a shell and a name.
  { name: 'LEON Countertop', key: 'stone', icon: '🪨',
    blurb: 'Draw it, price it, then cut it — quotes and drawings through to slabs, remnants, costing and the cut list.' },
  { name: 'LEON Tiles and Flooring', key: 'surfaces', icon: '🧱',
    blurb: 'Surface layout and finish designer — connected room surfaces and unit-type inheritance.' },
  { name: 'LEON Picture-shop', key: 'studio', icon: '🎨',
    blurb: 'Architectural image editing — swap a real LEON material onto a surface, in perspective, without repainting round the cabinets.' },
  { name: 'LEON Windows', key: 'fenestration', icon: '🪟',
    blurb: 'Fenestration and façade — manufacturer profile geometry, assemblies, schedules.' },
  { name: 'LEON Logistics', key: 'logistics', icon: '📦',
    blurb: 'Live warehouse — packages with printed QR labels, bins, receiving, picks and movements, on the stock the Hub already holds.' },
  { name: 'LEONCAD', key: 'cad', icon: '✏️',
    blurb: 'Two-dimensional drafting — layers, snaps, a command line and real geometry, for the details the parametric tools do not cover.' },
];

// ── Warehouse packages ─────────────────────────────────────────────────────
// A PACKAGE is the physical thing on the rack: a crate, a bundle, a carton. The
// Hub tracked MATERIALS — how many of a thing exist — and never the thing
// itself, which is what a warehouse actually picks, moves and loses. Its code
// is what goes on the printed label and into the QR.
const WAREHOUSE_PACKAGE_STATUSES = ['Expected', 'Received', 'In Stock', 'Allocated',
  'Picked', 'Staged', 'Loaded', 'Delivered', 'Returned', 'Damaged', 'Written Off'];
// The ones that mean it is physically here and countable.
const WAREHOUSE_ON_HAND_STATUSES = ['Received', 'In Stock', 'Allocated', 'Picked', 'Staged'];
const WAREHOUSE_PACKAGE_KINDS = ['Crate', 'Pallet', 'Carton', 'Bundle', 'Roll', 'Loose item', 'Container'];

function makeWarehousePackage(data, createdBy) {
  const d = data || {};
  return {
    id: d.id || uid('whpkg'),
    // The human code. It is on the label, in the QR and on the paperwork, so it
    // is short, unambiguous and never reused.
    code: d.code || '',
    name: d.name || '', kind: d.kind || 'Crate',
    status: d.status || 'Expected',
    warehouseId: d.warehouseId || null,
    zone: d.zone || '', rack: d.rack || '', bin: d.bin || '',
    projectId: d.projectId || null, scopeId: d.scopeId || null,
    vendorId: d.vendorId || null, containerId: d.containerId || null,
    poNumber: d.poNumber || '',
    // What is in it. Each line points at a warehouse material where there is
    // one, so a package and the stock count cannot drift; a line with no
    // material is a described item, which is honest for a one-off.
    lines: Array.isArray(d.lines) ? cloneDeep(d.lines) : [],
    qty: d.qty === undefined ? 1 : d.qty,
    weightKg: d.weightKg === undefined ? null : d.weightKg,
    lengthMm: d.lengthMm === undefined ? null : d.lengthMm,
    widthMm: d.widthMm === undefined ? null : d.widthMm,
    heightMm: d.heightMm === undefined ? null : d.heightMm,
    receivedDate: d.receivedDate || null, deliveredDate: d.deliveredDate || null,
    notes: d.notes || '',
    // Every move, so a package can answer where it has been rather than only
    // where it is. This is the log a warehouse argument is settled from.
    movements: Array.isArray(d.movements) ? cloneDeep(d.movements) : [],
    photos: Array.isArray(d.photos) ? d.photos.slice() : [],
    createdBy: createdBy || d.createdBy || '', createdDate: d.createdDate || todayISO(),
  };
}
function makeWarehouseMovement(data) {
  const d = data || {};
  return {
    id: d.id || uid('whmov'), date: d.date || todayISO(), at: d.at || new Date().toISOString(),
    from: d.from || '', to: d.to || '', status: d.status || '',
    by: d.by || '', note: d.note || '',
  };
}
// Where a package is, written the way it is spoken.
function warehousePackageLocation(p, warehouses) {
  const w = (warehouses || []).find(x => x.id === (p && p.warehouseId));
  return [w ? w.name : null, p && p.zone, p && p.rack, p && p.bin].filter(Boolean).join(' · ') || 'No location set';
}
// The payload the label's QR carries. A code alone scans into nothing useful on
// a phone, so it is a deep link into this app — and the code is in it plainly,
// so a scanner that only reads text still gives the picker something to type.
function warehousePackageQr(p, origin) {
  const base = origin || (typeof location !== 'undefined' ? location.origin + location.pathname : '');
  return `${base}?pkg=${encodeURIComponent(p && p.code ? p.code : '')}`;
}

// ---------------------------------------------------------------------------
// Team assignment roles (§9) — one named person per process role, per project
// ---------------------------------------------------------------------------
// Sourced directly from the client's "Team .xlsx" (Scopes & Schedules /
// Team Assignment sections) — Quotation Drafter is retired (no stage maps to
// it anymore) and Account Executive is new. Take-Off Drafter and Lead
// Dispatcher are kept even though the source file's own Team Assignment list
// omits them, because two stages below (Take-Off, Lead Review respectively)
// are explicitly assigned to them — otherwise those roles could never
// resolve to a real person.
const TEAM_ROLES = [
  'Account Executive',
  'Sales Person',
  'Lead Dispatcher',
  'Take-Off Drafter',
  'Project Coordinator',
  'Project Manager',
  'Production Manager',
  'Export Manager',
  'Logistic Manager',
  'Accounting Manager',
];

// ---------------------------------------------------------------------------
// Pipeline / complexity / health
// ---------------------------------------------------------------------------
const PIPELINE_STATUSES = ['Lead', 'Active Quotation', 'Active Job', 'Completed Job', 'Lost Job'];

// How much longer a job of this kind takes than the base durations in the
// schedule template. The three below are the seed; the live list is editable
// and extendable under LEON Collection -> Lead Times, because "high-end" is a
// judgement about a market, not a constant.
const COMPLEXITY_LEVELS = {
  Basic: 1.0,
  Medium: 1.5,
  'High-End': 2.0,
};
function makeComplexityLevel(data) {
  data = data || {};
  return { id: uid('cx'), name: data.name || '', multiplier: Number(data.multiplier) || 1, active: true };
}
const DEFAULT_COMPLEXITY_LEVELS = Object.keys(COMPLEXITY_LEVELS)
  .map(name => makeComplexityLevel({ name, multiplier: COMPLEXITY_LEVELS[name] }));
// instantiateStages and startDateForJobsiteDate are pure functions with no
// access to React state, and they are called from hundreds of places — same
// situation as role permissions and supplier overrides, so the same module-level
// registry, re-pointed by App() on every render.
let __activeComplexityLevels = null;
function setActiveComplexityLevels(list) { __activeComplexityLevels = (list && list.length) ? list : null; }
function complexityLevelList() { return __activeComplexityLevels || DEFAULT_COMPLEXITY_LEVELS; }
function complexityMultiplier(name) {
  const lv = complexityLevelList().find(l => l.name === name);
  if (lv) return Number(lv.multiplier) || 1;
  return COMPLEXITY_LEVELS[name] || 1.0;
}
// A level a project already uses is never dropped from the picker, even when
// deactivated — otherwise the project would silently read as something else.
function complexityOptions(current) {
  const list = complexityLevelList().filter(l => l.active !== false || l.name === current);
  return list.length ? list : DEFAULT_COMPLEXITY_LEVELS;
}
function mergeNewComplexityLevels(persisted) {
  const have = new Set((persisted || []).map(l => l.name));
  const missing = DEFAULT_COMPLEXITY_LEVELS.filter(l => !have.has(l.name));
  return missing.length ? [...(persisted || []), ...missing] : (persisted || DEFAULT_COMPLEXITY_LEVELS);
}

const PROJECT_TYPES = ['Residential', 'Commercial', 'Mixed-Use', 'Hotel'];
const LABOR_TYPES = ['Union', 'Standard', 'Prevailing Wage'];
const UNIT_TYPES = ['Units', 'Rooms', 'Keys', 'Linear Ft', 'Sq Ft', 'Each'];
// Alias kept so the existing project-level department tag (project.
// companyDepartment) and its UI keep working unchanged — it is the SAME two
// departments as DEPARTMENTS above, not a second vocabulary. companyDepartment
// is the DECLARED department(s) of a project (set at lead/creation time,
// before any scope exists); the departments actually derived from its scopes
// are the operational truth. projectDepartments unions the two.
const COMPANY_DEPARTMENTS = DEPARTMENTS;

// The 11-step export paperwork workflow, in order.
const EXPORT_WORKFLOW_STEPS = [
  { key: 'po_contract', order: 1, name: 'PO / Contract' },
  { key: 'commercial_invoice', order: 2, name: 'Commercial Invoice' },
  { key: 'packing_list', order: 3, name: 'Packing List' },
  { key: 'certificate_of_origin', order: 4, name: 'Certificate of Origin' },
  { key: 'hts_customs', order: 5, name: 'HTS & Customs Sheet' },
  { key: 'isf', order: 6, name: 'ISF' },
  { key: 'bl_awb', order: 7, name: 'B/L or AWB' },
  { key: 'compliance_certificates', order: 8, name: 'Compliance Certificates (Lacey Act if needed)' },
  { key: 'freight_insurance', order: 9, name: 'Freight & Insurance' },
  { key: 'customs_entry', order: 10, name: 'Customs Entry / Duty Documents' },
  { key: 'delivery_pod', order: 11, name: 'Delivery / POD' },
];
// Steps 10-11 (Customs Entry/Duty Documents, Delivery/POD) genuinely can't
// exist until the container's been received, so they're logged exclusively
// from ReceiveContainerModal (app.jsx) at that point — never shown, and
// never required, on the container's own Documents checklist.
const EXPORT_DOC_CHECKLIST_STEPS = EXPORT_WORKFLOW_STEPS.filter(s => s.key !== 'customs_entry' && s.key !== 'delivery_pod');
// A document step counts as satisfied either by having a real file on it, or
// by being explicitly marked "Not Required" for this particular shipment
// (§ export document N/A request) — e.g. Customs Entry/Duty Documents and
// Delivery/POD (steps 10-11) genuinely can't exist until after the
// container's already been received, so Export marks them N/A to get past
// the hand-off gate, and Logistics fills in the real document later — see
// ReceiveContainerModal, app.jsx.
function docStepSatisfied(doc) {
  return !!(doc && (doc.fileUrl || doc.notRequired));
}

// Shipping Info: one entity per ocean/air container, carrying its own copy
// of the 11-step export document checklist (documents keyed by step key —
// {fileUrl,file,date,note}) — separate from the older per-scope
// exportDocuments log above, which is left in place rather than migrated so
// nothing already on file appears to vanish.
const EXPORT_CONTAINER_STATUSES = ['Planning', 'Booked', 'Loaded', 'In Transit', 'Arrived', 'Delivered'];
const CONTAINER_TYPES = ['20ft Standard', '40ft Standard', '40ft High Cube', 'LCL (Consolidated)', 'Air Freight', 'Domestic Truck'];
const SHIPMENT_TYPES = ['Ocean FCL', 'Ocean LCL', 'Air Freight', 'Domestic Truck'];
const CUSTOMS_STATUSES = ['Not Started', 'Documents Submitted', 'In Review', 'Cleared', 'Held / Inspection', 'Rejected'];
// Actual (incurred) logistics cost buckets for a shipment/container — mirrors
// PROFIT_COST_FIELDS' freight/tariff/duty buckets but at the granularity a
// real freight invoice arrives in, so the Freight Cost / Landed Cost /
// Estimated-vs-Actual reports can break a shipment down the way accounting
// actually sees the bill, not just the two lump sums the profitability tab
// tracks.
const LOGISTICS_COST_FIELDS = [
  { key: 'oceanFreight', label: 'Ocean Freight' },
  { key: 'airFreight', label: 'Air Freight' },
  { key: 'inlandTrucking', label: 'Inland Trucking' },
  { key: 'drayage', label: 'Drayage' },
  { key: 'customsBrokerage', label: 'Customs Brokerage' },
  { key: 'duties', label: 'Duties' },
  { key: 'tariffs', label: 'Tariffs' },
  { key: 'portFees', label: 'Port Fees' },
  { key: 'storage', label: 'Storage' },
  { key: 'demurrage', label: 'Demurrage' },
  { key: 'detention', label: 'Detention' },
  { key: 'warehouseHandling', label: 'Warehouse Handling' },
  { key: 'insurance', label: 'Insurance' },
  { key: 'other', label: 'Other Logistics Costs' },
];
function blankLogisticsCosts() {
  const out = {};
  LOGISTICS_COST_FIELDS.forEach(f => { out[f.key] = 0; });
  return out;
}
function logisticsCostTotal(costs) {
  if (!costs) return 0;
  return LOGISTICS_COST_FIELDS.reduce((sum, f) => sum + (Number(costs[f.key]) || 0), 0);
}

// ---------------------------------------------------------------------------
// Trade Compliance & Tariffs
// Two collections, both top-level (cross-project, like materialAllocations):
//   tariffLibrary — versioned rate classifications (HS/HTS + origin + destination).
//     Rate changes NEVER overwrite — addTariffVersion (app.jsx) closes out the
//     prior version's effectiveUntil and appends a new one, so a historical
//     tariffLine's locked-in rate always resolves against the version that
//     was actually in force when it was created.
//   tariffLines — the actual Project → Scope → Vendor → Material → Country →
//     HTS → Rate join, one per material/classification on a shipment. A line
//     snapshots its rate at creation time (never silently follows later
//     library edits) and carries both estimated (PO/PI stage) and actual
//     (post-clearance) customs cost, kept as two permanently separate fields
//     — never overwritten into one another — so estimating accuracy stays
//     analyzable.
// ---------------------------------------------------------------------------
const TARIFF_STATUSES = ['Active', 'Pending', 'Suspended', 'Expired'];
const TARIFF_LINE_STATUSES = ['Estimated', 'Filed', 'Cleared'];
// The four rate components that sum to a classification's total estimated
// duty % — kept separate (not one blended number) because each can change
// independently (e.g. a Section 301 action layers on top of the base rate).
const TARIFF_RATE_COMPONENTS = [
  { key: 'baseDutyPct', label: 'Base Duty %' },
  { key: 'additionalTariffPct', label: 'Additional Tariff %' },
  { key: 'section301Pct', label: 'Section 301 / Other Additional Duties %' },
  { key: 'antidumpingCvdPct', label: 'Antidumping / CVD %' },
];
function tariffVersionTotalPct(v) {
  if (!v) return 0;
  return TARIFF_RATE_COMPONENTS.reduce((s, f) => s + (Number(v[f.key]) || 0), 0);
}
function makeTariffVersion(data, createdBy) {
  const v = {
    id: uid('tver'), revision: data.revision || 1,
    baseDutyPct: Number(data.baseDutyPct) || 0, additionalTariffPct: Number(data.additionalTariffPct) || 0,
    section301Pct: Number(data.section301Pct) || 0, antidumpingCvdPct: Number(data.antidumpingCvdPct) || 0,
    effectiveFrom: data.effectiveFrom || todayISO(), effectiveUntil: data.effectiveUntil || null,
    status: data.status || 'Active',
    source: data.source || '', notes: data.notes || '',
    lastVerifiedDate: data.lastVerifiedDate || todayISO(), verifiedBy: data.verifiedBy || createdBy,
    attachments: data.attachments || [],
    createdBy, createdDate: todayISO(),
  };
  v.totalEstimatedDutyPct = tariffVersionTotalPct(v);
  return v;
}
// Resolves the version actually in force on a given date (defaults to
// today) — brackets on effectiveFrom/effectiveUntil, falling back to the
// most recent version starting on or before that date if none brackets it
// exactly (covers an open-ended "current" version with no effectiveUntil).
function tariffVersionForDate(classification, dateStr) {
  const d = dateStr || todayISO();
  const versions = classification.versions || [];
  const bracketed = versions.find(v => v.effectiveFrom <= d && (!v.effectiveUntil || v.effectiveUntil >= d));
  if (bracketed) return bracketed;
  const priorOnes = versions.filter(v => v.effectiveFrom <= d).sort((a, b) => (a.effectiveFrom < b.effectiveFrom ? 1 : -1));
  return priorOnes[0] || versions[versions.length - 1] || null;
}
function currentTariffVersion(classification) {
  return tariffVersionForDate(classification, todayISO());
}
function makeTariffClassification(data, createdBy) {
  return {
    id: uid('tariffcls'),
    scopeFamily: data.scopeFamily || '', materialCategory: data.materialCategory || '',
    productDescription: data.productDescription || '', hsCode: data.hsCode || '', htsCode: data.htsCode || '',
    countryOfOrigin: data.countryOfOrigin || '', destinationCountry: data.destinationCountry || 'USA',
    notes: data.notes || '', attachments: data.attachments || [],
    versions: [makeTariffVersion(data, createdBy)],
    changeLog: [],
    createdBy, createdDate: todayISO(),
  };
}
// Structured change-log entry — previous/new value, user, date/time, reason,
// and effective date, exactly as specified — attached to the classification
// (or tariff line) it describes, since neither is scoped to one project and
// so can't live in a single project's changeLog. A human-readable summary is
// ALSO pushed into the relevant project's changeLog wherever a tariff LINE
// (project-scoped) changes, so it still surfaces there per the existing
// convention — see logTariffLineChange, app.jsx.
function makeTariffChangeEntry(data, user) {
  return {
    id: uid('tchg'), date: new Date().toISOString(), user, field: data.field,
    previousValue: data.previousValue, newValue: data.newValue,
    reason: data.reason || '', effectiveDate: data.effectiveDate || todayISO(),
  };
}
function makeTariffLine(data, createdBy) {
  const customsValue = Number(data.customsValue) || 0;
  const applicableTariffPct = Number(data.applicableTariffPct) || 0;
  return {
    id: uid('tline'),
    projectId: data.projectId, scopeId: data.scopeId || null, vendorId: data.vendorId || null,
    exportContainerId: data.exportContainerId || null, tariffClassificationId: data.tariffClassificationId || null,
    productDescription: data.productDescription || '', countryOfOrigin: data.countryOfOrigin || '',
    hsCode: data.hsCode || '', htsCode: data.htsCode || '',
    customsValue, applicableTariffPct,
    estimatedTariff: Math.round(customsValue * applicableTariffPct) / 100,
    status: 'Estimated',
    actual: null, // set via recordTariffActuals (app.jsx) once customs clears
    actualTariff: null, actualFees: null, varianceAmount: null, variancePct: null,
    notes: data.notes || '', attachments: data.attachments || [],
    changeLog: [],
    createdBy, createdDate: todayISO(),
  };
}
function makeTariffActuals(data) {
  const dutyTotal = (Number(data.baseDuty) || 0) + (Number(data.additionalTariff) || 0) + (Number(data.section301) || 0) + (Number(data.antidumpingCvd) || 0);
  const feesTotal = (Number(data.merchandiseProcessingFee) || 0) + (Number(data.harborMaintenanceFee) || 0) + (Number(data.brokerageFees) || 0) + (Number(data.otherCharges) || 0);
  return {
    customsEntryNumber: data.customsEntryNumber || '', brokerId: data.brokerId || null, entryDate: data.entryDate || todayISO(),
    declaredHtsCode: data.declaredHtsCode || '', enteredValue: Number(data.enteredValue) || 0,
    baseDuty: Number(data.baseDuty) || 0, additionalTariff: Number(data.additionalTariff) || 0,
    section301: Number(data.section301) || 0, antidumpingCvd: Number(data.antidumpingCvd) || 0,
    merchandiseProcessingFee: Number(data.merchandiseProcessingFee) || 0, harborMaintenanceFee: Number(data.harborMaintenanceFee) || 0,
    brokerageFees: Number(data.brokerageFees) || 0, otherCharges: Number(data.otherCharges) || 0,
    totalCustomsCost: dutyTotal + feesTotal,
    brokerInvoiceFile: data.brokerInvoiceFile || null, brokerInvoiceFileUrl: data.brokerInvoiceFileUrl || null,
    entrySummaryFile: data.entrySummaryFile || null, entrySummaryFileUrl: data.entrySummaryFileUrl || null,
    _dutyTotal: dutyTotal, _feesTotal: feesTotal,
  };
}
function tariffLineVariance(line) {
  if (line.actualTariff === null || line.actualTariff === undefined) return { amount: null, pct: null };
  const amount = line.actualTariff - line.estimatedTariff;
  const pct = line.estimatedTariff ? (amount / line.estimatedTariff) * 100 : null;
  return { amount, pct };
}
function normalizeTariffClassification(c) {
  if (!c.versions) c.versions = [];
  if (!c.changeLog) c.changeLog = [];
  if (!c.attachments) c.attachments = [];
  c.versions.forEach(v => { if (v.totalEstimatedDutyPct === undefined) v.totalEstimatedDutyPct = tariffVersionTotalPct(v); });
  return c;
}
function normalizeTariffLine(l) {
  if (l.actual === undefined) l.actual = null;
  if (l.actualTariff === undefined) l.actualTariff = null;
  if (l.actualFees === undefined) l.actualFees = null;
  if (l.varianceAmount === undefined) l.varianceAmount = null;
  if (l.variancePct === undefined) l.variancePct = null;
  if (!l.changeLog) l.changeLog = [];
  if (!l.attachments) l.attachments = [];
  if (l.status === undefined) l.status = 'Estimated';
  return l;
}
// Who may create/edit tariff classifications (the rate library itself) and
// financial fields on a tariff line — Admin/Accounting/Logistic Manager/
// Export Manager, per explicit instruction ("Export Manager" maps to the
// actual login role Export Manager — see SECURITY_ROLES).
const TARIFF_CLASSIFICATION_EDIT_ROLES = ['Admin', 'Accounting', 'Logistic Manager', 'Export Manager'];
function canEditTariffClassification(role) {
  return roleHasCapability(role, 'tariff.editClassification');
}
// Logistics Team may enter shipment/customs-related info (customs entry #,
// broker, entry date, attachments) on an EXISTING line, but not create
// classifications or edit rates/financial totals — enforced by which form
// fields are exposed, same defense-in-depth pattern as the vendor-catalog
// admin-only-delete gate.
const TARIFF_SHIPMENT_INFO_EDIT_ROLES = ['Admin', 'Accounting', 'Logistic Manager', 'Export Manager'];
function canEditTariffShipmentInfo(role) {
  return roleHasCapability(role, 'tariff.editShipmentInfo');
}
// Sales and Project Coordinator get view-only access per explicit
// instruction — added alongside the edit roles for nav visibility.
const TRADE_COMPLIANCE_VIEW_ROLES = ['Admin', 'Accounting', 'Logistic Manager', 'Export Manager', 'General Manager', 'Project Coordinator', 'Senior Associate', 'Associates'];
function canSeeTradeCompliance(role) {
  return roleHasCapability(role, 'tradeCompliance.view');
}

function makeContainerMaterialLine(data) {
  return {
    id: uid('cml'), piId: data.piId, piLineId: data.piLineId, materialId: data.materialId || null,
    // A container can carry several projects' material at once, so each line
    // records which project and scope it belongs to. Without this the loading
    // list was one flat run of descriptions with no way to see whose material
    // was whose.
    projectId: data.projectId || null, scopeId: data.scopeId || null,
    description: data.description || '', quantity: Number(data.quantity) || 0, unit: data.unit || 'Units',
  };
}
// Groups container/delivery lines into project -> scope -> items for display.
// Falls back to an "Unassigned" bucket rather than hiding anything that
// predates the projectId/scopeId fields.
function groupLinesByProjectScope(lines, projects) {
  const out = [];
  (lines || []).forEach(l => {
    const proj = projects.find(p => p.id === l.projectId) || null;
    const scope = proj ? (proj.scopes || []).find(s => s.id === l.scopeId) : null;
    const pKey = proj ? proj.id : 'unassigned';
    const sKey = scope ? scope.id : 'unassigned';
    let pg = out.find(g => g.key === pKey);
    if (!pg) { pg = { key: pKey, name: proj ? proj.name : 'Unassigned', number: proj ? proj.projectNumber : '', scopes: [] }; out.push(pg); }
    let sg = pg.scopes.find(g => g.key === sKey);
    if (!sg) { sg = { key: sKey, name: scope ? scope.name : 'No scope', lines: [] }; pg.scopes.push(sg); }
    sg.lines.push(l);
  });
  return out;
}
function makeExportContainer(data) {
  return {
    id: uid('cont'), containerNumber: data.containerNumber,
    // A container is a top-level record, not nested in any one project — it
    // can carry material for several projects at once, and several scopes
    // within each, via one shipment leg per project (§ multi-project
    // containers).
    shipments: data.shipments || [],
    // Which PI material lines are riding in this container, and how much of
    // each (§ container material selection request) — always a reference
    // back to a PI line (piId/piLineId), never a freehand duplicate, so a
    // container's contents can never drift from what was actually ordered.
    materialLines: data.materialLines || [],
    blNumber: data.blNumber || '', freightCompanyId: data.freightCompanyId || null, brokerCompanyId: data.brokerCompanyId || null,
    inlandFreightCompany: data.inlandFreightCompany || '',
    etl: data.etl || null, etd: data.etd || null, eta: data.eta || null,
    fromPort: data.fromPort || '', toPort: data.toPort || '',
    // Multiple people can be assigned to one container (§ export request) —
    // accepts either the new assigneeIds array or a single legacy assigneeId
    // so existing call sites keep working.
    assigneeIds: data.assigneeIds || (data.assigneeId ? [data.assigneeId] : []),
    status: 'Planning', notes: data.notes || '',
    documents: {}, createdDate: todayISO(),
    // Logistics module fields — kept optional/blank by default so existing
    // containers (and the simpler Add Container form) keep working unchanged.
    countryOfOrigin: data.countryOfOrigin || '', shipmentType: data.shipmentType || 'Ocean FCL', containerType: data.containerType || '',
    carrier: data.carrier || '', bookingNumber: data.bookingNumber || '', trackingLink: data.trackingLink || '',
    actualDeparture: data.actualDeparture || null, actualArrival: data.actualArrival || null,
    customsStatus: data.customsStatus || 'Not Started',
    warehouseDestination: data.warehouseDestination || '', jobsiteDestination: data.jobsiteDestination || '',
    freeTimeExpiration: data.freeTimeExpiration || null, pickupDeadline: data.pickupDeadline || null, emptyReturnDeadline: data.emptyReturnDeadline || null,
    actualPickupDate: data.actualPickupDate || null, actualReturnDate: data.actualReturnDate || null,
    costs: data.costs || blankLogisticsCosts(),
    // Hand-off — while in planning/transit the container is the Export
    // team's responsibility; once every document is on file and it's
    // actually dispatched, Export explicitly hands it to Logistics, who can
    // then receive it at the warehouse. Not automatic on status change —
    // Export has to say so.
    responsibleParty: data.responsibleParty || 'Export', handoffDate: data.handoffDate || null, handoffBy: data.handoffBy || null,
  };
}
// Backfill for containers created before the Logistics module existed, so
// persisted state from earlier sessions gets the new fields without wiping
// anything already entered — same pattern as the Delivery/Meeting backfills.
function normalizeExportContainer(c) {
  if (!c.shipments) {
    // Pre-migration shape nested a container inside one project, with an
    // implicit projectId (from the nesting) and a single scopeIds array —
    // callers doing the actual project-lift migration (app.jsx, at load)
    // pass shipments in directly; this is just a defensive fallback for any
    // row that somehow reaches here without it.
    c.shipments = c.projectId ? [{ projectId: c.projectId, scopeIds: c.scopeIds || [] }] : [];
  }
  delete c.projectId;
  delete c.scopeIds;
  if (!c.assigneeIds) {
    c.assigneeIds = c.assigneeId ? [c.assigneeId] : [];
    delete c.assigneeId;
  }
  if (!c.materialLines) c.materialLines = [];
  if (c.countryOfOrigin === undefined) c.countryOfOrigin = '';
  if (c.shipmentType === undefined) c.shipmentType = 'Ocean FCL';
  if (c.containerType === undefined) c.containerType = '';
  if (c.carrier === undefined) c.carrier = '';
  if (c.bookingNumber === undefined) c.bookingNumber = '';
  if (c.trackingLink === undefined) c.trackingLink = '';
  if (c.actualDeparture === undefined) c.actualDeparture = null;
  if (c.actualArrival === undefined) c.actualArrival = null;
  if (c.customsStatus === undefined) c.customsStatus = 'Not Started';
  if (c.warehouseDestination === undefined) c.warehouseDestination = '';
  if (c.jobsiteDestination === undefined) c.jobsiteDestination = '';
  if (c.freeTimeExpiration === undefined) c.freeTimeExpiration = null;
  if (c.pickupDeadline === undefined) c.pickupDeadline = null;
  if (c.emptyReturnDeadline === undefined) c.emptyReturnDeadline = null;
  if (c.actualPickupDate === undefined) c.actualPickupDate = null;
  if (c.actualReturnDate === undefined) c.actualReturnDate = null;
  if (!c.costs) c.costs = blankLogisticsCosts();
  else LOGISTICS_COST_FIELDS.forEach(f => { if (c.costs[f.key] === undefined) c.costs[f.key] = 0; });
  // Hand-off (Export -> Logistics) — existing containers default to
  // 'Export' so nothing already past receiving is retroactively blocked.
  if (c.responsibleParty === undefined) c.responsibleParty = 'Export';
  if (c.handoffDate === undefined) c.handoffDate = null;
  if (c.handoffBy === undefined) c.handoffBy = null;
  return c;
}
// Canonical ways to ask "does this container touch project X / scope Y" —
// every per-project container view (Export Hub, In-Transit mirror, reports)
// goes through these instead of re-deriving the same shipment lookup ad hoc.
function containerScopeIdsForProject(container, projectId) {
  const shipment = (container.shipments || []).find(s => s.projectId === projectId);
  return shipment ? shipment.scopeIds : [];
}
function containerTouchesProject(container, projectId) {
  return (container.shipments || []).some(s => s.projectId === projectId);
}
function containerTouchesScope(container, projectId, scopeId) {
  return containerScopeIdsForProject(container, projectId).includes(scopeId);
}
function containersForProject(exportContainers, projectId) {
  return exportContainers.filter(c => containerTouchesProject(c, projectId));
}
function containersForProjectScope(exportContainers, projectId, scopeId) {
  return exportContainers.filter(c => containerTouchesScope(c, projectId, scopeId));
}
// Flattens the global container collection into one row per shipment leg,
// each carrying that leg's own projectId/projectName/scopeIds/scopeNames —
// the shape every existing "containers across every project" consumer
// (Logistics Dashboard, Calendar Hub, reports) already expects, so a
// container spanning two projects simply appears once under each rather
// than needing a special case at every call site.
function containerShipmentRows(exportContainers, projects) {
  return exportContainers.flatMap(c => (c.shipments || []).map(sh => {
    const project = projects.find(p => p.id === sh.projectId);
    return {
      ...c, projectId: sh.projectId, projectName: project ? project.name : '—',
      scopeIds: sh.scopeIds || [],
      scopeNames: project ? (sh.scopeIds || []).map(id => (project.scopes.find(s => s.id === id) || {}).name).filter(Boolean).join(', ') : '',
    };
  }));
}
// A shipment/container's plain-language operational risk, driven off actual
// vs. planned dates — same five states the Master Logistics Status Report,
// Dashboard KPIs, and Delayed/At-Risk Report all key off of, computed once
// here so every surface agrees.
function containerRiskStatus(c) {
  if (c.status === 'Delivered') return 'Completed';
  const today = todayISO();
  if (c.eta && !c.actualArrival && c.eta < today) return 'Delayed';
  if (c.etd && !c.actualDeparture && c.etd < today && c.status === 'Planning') return 'Delayed';
  if (c.eta && daysBetween(today, c.eta) <= 3 && c.status !== 'Arrived') return 'Attention Required';
  if (c.customsStatus === 'Held / Inspection' || c.customsStatus === 'Rejected') return 'At Risk';
  return 'On Track';
}
function containerDaysDelayed(c) {
  const today = todayISO();
  if (c.actualArrival && c.eta) return Math.max(0, daysBetween(c.eta, c.actualArrival));
  if (!c.actualArrival && c.eta && c.eta < today) return daysBetween(c.eta, today);
  // Not yet departed at all — still overdue against ETD, even though ETA
  // itself hasn't passed yet (mirrors the "Delayed" branch in
  // containerRiskStatus, so the two never disagree about a container).
  if (!c.actualDeparture && c.etd && c.etd < today && c.status === 'Planning') return daysBetween(c.etd, today);
  return 0;
}

// Vendor estimate / PO category — why this estimate exists
const VENDOR_ESTIMATE_CATEGORIES = [
  'Original Order',
  'Change Order',
  'LEON Mistake',
  'Damaged Item',
  'Vendor Mistake',
  'Samples',
  'No Fault - No Charge',
  'Stock Material',
  'Credit from Vendor',
];

// ---------------------------------------------------------------------------
// AIA-style billing (Schedule of Values + Payment Applications)
// G702/G703 progress-billing system (§ AIA Billing redesign). The Schedule
// of Values is no longer hand-typed on this screen — every line traces back
// to a source record (a scope's contract value, or an approved Change
// Order) via sourceType/sourceId, imported through syncSovFromContract
// (lib.jsx) rather than invented here, so it can never drift from the
// executed Original Contract. Not in scope: a licensed AIA PDF template,
// the Massachusetts lien waiver template, drag-positioned signatures/seals,
// the portfolio dashboard/aging analytics, email/mailto delivery, and a
// real test runner (this is a zero-build static app — verification is a
// live-browser scenario checklist instead, per explicit instruction).
// ---------------------------------------------------------------------------
const APPLICATION_PAYMENT_STATUSES = ['Unpaid', 'Partially Paid', 'Paid'];
const CURRENT_INPUT_TYPES = ['Amount', 'Percent', 'Formula'];
// Draft -> Ready for Review -> Submitted -> Approved/Certified is the normal
// path; Revised and Voided are branches off Submitted+ (never a silent edit
// of certified numbers) and Partially Paid/Paid come from AR payment
// activity against a Certified application, not from this status field
// directly (see recordAiaApplicationPayment).
const AIA_APPLICATION_STATUSES = [
  'Draft', 'Ready for Review', 'Submitted', 'Revised', 'Approved/Certified',
  'Partially Paid', 'Paid', 'Voided',
];
// Once an application reaches any of these, its lines/coLines/header are
// locked in the UI — further change requires Revise (snapshots the current
// state to history[] first) or Void (requires a reason), never a direct edit.
const AIA_LOCKED_STATUSES = ['Submitted', 'Approved/Certified', 'Partially Paid', 'Paid'];
// Only these roles may Submit/Approve/Reopen/Void/Revise (Draft editing
// stays gated by the existing MODULE_EDIT_RIGHTS.billing right) — mirrors
// canApproveFreightAdmin below.
const AIA_BILLING_APPROVE_ROLES = ['Admin', 'Accounting', 'General Manager'];
function canApproveAiaApplication(role) { return roleHasCapability(role, 'aia.approve'); }

function makeSovItem(description, scheduledValue, sourceType, sourceId) {
  return { id: uid('sovi'), description, scheduledValue, sourceType: sourceType || null, sourceId: sourceId || null };
}
function makeSovCategory(name, items) {
  return { id: uid('sovc'), name, items };
}
// Manual, project-level G702 header fields that don't exist anywhere else in
// the data model (Distribution To, Contract For, an invoice/reference
// prefix) — persisted once per job and copied into each new application's
// frozen header snapshot at creation, so editing these later never rewrites
// an application that's already been created.
// Field set matches the client's actual "Application for Payment" template
// (LEON Trading LLC layout) exactly — Construction Manager and Work Cat No
// have no home anywhere else in the data model, and the certification block
// (state/county/notary) is specific to how this company executes AIA
// applications, so all of it lives here as manual, carry-forward fields.
function makeAiaHeaderDefaults() {
  return {
    constructionManager: '', workCatNo: '',
    certState: '', certCounty: '', notaryName: '', notaryDay: '', notaryMonth: '', notaryExpiration: '',
  };
}
// The AIA print package's Contractor block is LEON's own company info —
// one shared record, not duplicated per project.
// The company's own record. Everyone can read it (it is where the team looks
// up our address, our licence numbers, who to send a certificate of insurance
// to); only Accounting and Admin edit it. Anything a stranger shouldn't see —
// tax id, banking — is gated separately at the view, not stored differently.
const OFFICE_TYPES = ['Office', 'Showroom', 'Warehouse', 'Factory', 'Workshop', 'Sales Office', 'Other'];
function makeCompanyOffice(data) {
  data = data || {};
  return {
    id: uid('office'), name: data.name || '', type: OFFICE_TYPES.includes(data.type) ? data.type : 'Office',
    addressLine1: data.addressLine1 || '', addressLine2: data.addressLine2 || '',
    country: data.country || '', phone: data.phone || '', email: data.email || '',
    contactName: data.contactName || '', notes: data.notes || '', active: true,
  };
}
function makeCompanyProfile() {
  return {
    // Identity
    name: 'LEON Trading LLC', tradeName: 'LEON Integra', tagline: '', logoUrl: null,
    foundedYear: '', entityType: 'LLC', about: '',
    // Contact + main office
    phone: '617-559-0122', email: '', website: '',
    addressLine1: '210 Highland Ave', addressLine2: 'Needham, MA 02494', country: 'US',
    // Second location (warehouse / shop)
    warehouseName: '', warehouseAddressLine1: '', warehouseAddressLine2: '',
    // Every other location the company works out of. A list rather than more
    // fixed fields, because "how many offices" is not something the schema
    // should have an opinion about.
    offices: [],
    // Legal / compliance
    ein: '', stateOfIncorporation: '', duns: '', licenses: '',
    // Insurance — the numbers everyone gets asked for on a new jobsite
    glCarrier: '', glPolicyNumber: '', glExpiry: '',
    wcCarrier: '', wcPolicyNumber: '', wcExpiry: '',
    // Remittance — Accounting/Admin only
    bankName: '', bankAccountName: '', bankAccountLast4: '', bankRoutingLast4: '',
    remittanceEmail: '', paymentInstructions: '',
  };
}
// Fixed company letterhead line — added to every printable form regardless
// of whether that particular print template has ctx/companyProfile threaded
// into it (most don't), so this is guaranteed to show everywhere rather
// than depending on a wide, risky refactor of every print call site.
const COMPANY_PRINT_ADDRESS = 'LEON Trading LLC · 210 Highland Ave, Needham, MA 02494 · 617-559-0122';

// ---------------------------------------------------------------------------
// Production (§ new request) — Project → Scope → Vendor → Production Record.
// Drawings, photos, and QC reports are each append-only revision histories:
// uploading never overwrites — it always adds a new revision and marks the
// prior one Superseded.
// ---------------------------------------------------------------------------
const PRODUCTION_STATUSES = ['Not Started', 'In Production', 'Quality Check', 'Complete'];
const PRODUCTION_DOC_STATUSES = ['Current', 'Superseded'];
const QC_RESULTS = ['Pass', 'Pass with Comments', 'Fail'];
const QC_APPROVAL_STATUSES = ['Pending', 'Approved', 'Not Approved'];

// No stored date range here — the Production Timeline reads a record's
// start/end live from its scope's "Production" stage in Scopes & Schedule
// (productionDateRange, lib.jsx), one database for both instead of a second
// independently-editable copy that could drift out of sync.
function makeProductionRecord(scopeId, vendorId, vendorName, createdBy) {
  return {
    id: uid('prod'), scopeId, vendorId: vendorId || null, vendorName,
    status: 'Not Started', notes: '',
    // Auto-stamped on status transition (setProductionRecordStatus) — lets a
    // Window Schedule Factory node mirror real production dates instead of
    // relying on manual entry (§ Window Schedule template integration).
    startedDate: null, completedDate: null,
    drawings: [], photos: [], qcReports: [],
    createdDate: todayISO(), createdBy,
  };
}

// ---------------------------------------------------------------------------
// Field / Installation Ops (§ field team request)
// ---------------------------------------------------------------------------
const INSTALLATION_STATUS_FLOW = [
  'Not Ready', 'Ready', 'Material On Site', 'Installation Started',
  'Installation Complete', 'QC Required', 'Punch', 'Approved/Closed',
];
const FIELD_ISSUE_TYPES = [
  'Missing Material', 'Damaged Material', 'Incorrect Material', 'Site Not Ready',
  'Dimension Conflict', 'Drawing Conflict', 'Installation Problem', 'GC/Trade Conflict',
  'Change Required', 'Safety', 'Other',
];
const FIELD_ISSUE_URGENCY = ['Low', 'Medium', 'High', 'Critical'];
const MATERIAL_RECEIVING_OUTCOMES = ['Received', 'Missing', 'Damaged', 'Wrong Item', 'Unable to Verify'];
const PUNCH_STATUSES = ['Open', 'Completed – Awaiting Verification', 'Closed'];
const PUNCH_PRIORITIES = ['Low', 'Medium', 'High'];
// The field response a subcontractor/installation team member gives when
// working a punch item — distinct from the overall admin-facing lifecycle
// status above. A "Completed" response moves the item to Awaiting
// Verification automatically; the other three keep it Open with the latest
// field update visible.
const PUNCH_RESPONSE_STATUSES = ['Completed', 'Pending', 'Additional Material Needed', 'Not Done'];
function makePunchPhoto(file, fileUrl) { return { id: uid('punchphoto'), file, fileUrl }; }
// What a field measurement was taken against. A template technician measuring
// a ROUGH OPENING is recording something materially different from one
// measuring FINISHED WALLS — the allowance for finishes is either still to
// come off or already gone — and getting the two confused is a re-fabrication.
const MEASUREMENT_BASIS = [
  { key: 'Rough Opening', hint: 'Framing / structural opening, before finishes' },
  { key: 'Finished Walls', hint: 'Final finished surfaces, ready for install' },
  { key: 'Mixed / See Notes', hint: 'Some of each — explain in the notes' },
];

// A field service (measurement, installation, punch) is REQUESTED by someone in
// the office and PERFORMED by a field crew or subcontractor. It is not finished
// until the requester confirms the work — this is the close-out step, and it is
// what stops "done" meaning "the sub says it is done".
const FIELD_CLOSEOUT_STATUSES = [
  'Pending Field Work',      // requested, not yet performed
  'Submitted for Close-Out', // field crew says it is done; waiting on the requester
  'Revision Requested',      // requester sent it back
  'Closed Out',              // requester approved the work
];

const PHOTO_MILESTONES = ['Before Installation', 'During Installation', 'Completed Installation', 'Punch/Problem', 'Punch Corrected'];

// ---------------------------------------------------------------------------
// Field Measurement Report (§ field measurement request) — one thread per
// scope + location, append-only revisions per site visit (never overwrite a
// prior visit's numbers — this mirrors the submittal-revision pattern).
// ---------------------------------------------------------------------------
const FIELD_MEASUREMENT_STATUSES = [
  'Not Started', 'Measurement Scheduled', 'Measurement in Progress', 'Completed',
  'Issue Found', 'Awaiting Clarification', 'Revision Required',
  'Approved for Production', 'Approved for Installation', 'Field Condition Requires Review',
];
const FIELD_MEASUREMENT_LABELS = [
  'Width', 'Height', 'Depth', 'Ceiling Height', 'Wall-to-Wall Dimension',
  'Opening Width', 'Opening Height', 'Finished Floor Elevation',
  'Plumbing Location', 'Electrical Location', 'Appliance Opening', 'Door Opening',
  'Countertop Dimension', 'Other',
];
const FIELD_NOTE_CATEGORIES = [
  'Wall Is Not Straight', 'Floor Is Out of Level', 'Opening Differs From Shop Drawing',
  'Plumbing Location Is Incorrect', 'Electrical Outlet Needs Relocation',
  'Appliance Dimensions Differ From Approved Specification', 'Site Is Not Ready',
  'Material Cannot Be Installed', 'Additional Trim Required', 'Field Modification Required', 'Other',
];
const FIELD_NOTE_PRIORITIES = ['Low', 'Medium', 'High'];
function makeFieldMeasurementThread(scopeId, building, floor, unit, room, createdBy, requestedById) {
  return {
    id: uid('fmr'), scopeId, building: building || '', floor: floor || '', unit: unit || '', room: room || '',
    status: 'Not Started', revisions: [], createdDate: todayISO(), createdBy,
    // Close-out: whoever requested the measurement signs off that it is usable.
    requestedById: requestedById || null,
    closeoutStatus: 'Pending Field Work', closedOutBy: null, closedOutDate: null, closeoutNote: '',
  };
}

const HEALTH = {
  Green: { label: 'On Track', color: '#3a7d44' },
  Yellow: { label: 'Attention Required', color: '#c99a2e' },
  Red: { label: 'Critical / Delayed', color: '#b83b3b' },
};

const DELAY_REASON_CATEGORIES = [
  'Client Decision Pending',
  'Vendor/Supplier Delay',
  'Design Revision',
  'Material Availability',
  'Site Readiness',
  'Weather',
  'Internal Capacity',
  'Other',
];

// ---------------------------------------------------------------------------
// 20-stage workflow standard (base duration in business days @ Basic complexity)
// ---------------------------------------------------------------------------
// Sourced directly from the client's "Team .xlsx" (Scopes & Schedules
// section) — stage list, order, and the "Assigned To" role for each are
// taken verbatim from that sheet. `role` is now a real TEAM_ROLES value
// (not a separate vocabulary needing a lookup table), so a stage's default
// assignee resolves straight from the project's Team Assignments.
// Lead Review / Take-Off / Quote Prep / Quote Revision are project-level,
// not scoped — "usually for full project and all scopes" (explicit
// request), so they're tracked ONCE per project (project.chronology, see
// instantiateChronology below) instead of being duplicated once per scope
// the way STAGE_DEFS stages below still are. Contract-onward stays
// per-scope exactly as before.
const PROJECT_CHRONOLOGY_STAGE_DEFS = [
  { key: 'lead_review', name: 'Lead Review', baseDays: 3, role: 'Lead Dispatcher' },
  { key: 'take_off', name: 'Take-Off', baseDays: 5, role: 'Take-Off Drafter' },
  { key: 'quote_prep', name: 'Quote Preparation', baseDays: 5, role: 'Sales Person' },
  { key: 'quote_revision', name: 'Quote Revision', baseDays: 3, role: 'Sales Person' },
];
const STAGE_DEFS = [
  { key: 'selections', name: 'Selections', baseDays: 7, role: 'Sales Person' },
  { key: 'contract', name: 'Contract', baseDays: 5, role: 'Sales Person' },
  { key: 'contract_deposit', name: 'Client Contract Deposit', baseDays: 3, role: 'Accounting Manager' },
  { key: 'shop_drawings', name: 'Shop Drawings', baseDays: 10, role: 'Project Manager' },
  { key: 'shop_drawing_revision', name: 'Shop Drawing Revision', baseDays: 5, role: 'Project Manager' },
  { key: 'production_drawings', name: 'Production Drawings', baseDays: 7, role: 'Project Manager' },
  { key: 'pi_po', name: 'PI/PO', baseDays: 3, role: 'Sales Person' },
  { key: 'pi_payment', name: 'Vendor PI Payment', baseDays: 5, role: 'Accounting Manager' },
  { key: 'production', name: 'Production', baseDays: 20, role: 'Production Manager' },
  { key: 'production_completion_payment', name: 'Client — Production Completion Payment', baseDays: 3, role: 'Accounting Manager' },
  { key: 'shipping_warehouse', name: 'Shipping to Warehouse', baseDays: 7, role: 'Export Manager' },
  { key: 'warehouse_receiving', name: 'Warehouse Receiving', baseDays: 2, role: 'Logistic Manager' },
  { key: 'delivery_jobsite', name: 'Delivery to Jobsite', baseDays: 3, role: 'Logistic Manager' },
  { key: 'delivery_jobsite_payment', name: 'Delivery to Jobsite Payment', baseDays: 3, role: 'Accounting Manager' },
  { key: 'installation', name: 'Installation', baseDays: 10, role: 'Project Coordinator' },
  { key: 'punch_list', name: 'Punch List Completion', baseDays: 5, role: 'Project Coordinator' },
  { key: 'final_payment', name: 'Final Payment', baseDays: 5, role: 'Accounting Manager' },
  { key: 'closeout', name: 'Closeout', baseDays: 2, role: 'Sales Person' },
];

// Stages that exist ONLY on a countertop scope, because a countertop is the one
// thing that is fabricated to a measurement taken after the rest of the job is
// built: the slab ships to a fabrication shop rather than the warehouse, gets
// templated on site, and is cut to that template.
const COUNTERTOP_STAGE_DEFS = [
  { key: 'shipping_fab_shop', name: 'Shipping to Fabrication Shop', baseDays: 7, role: 'Export Manager' },
  { key: 'fab_shop_receiving', name: 'Fabrication Shop Receiving', baseDays: 2, role: 'Logistic Manager' },
  { key: 'template', name: 'Template', baseDays: 3, role: 'Project Coordinator' },
  { key: 'fabrication', name: 'Fabrication', baseDays: 10, role: 'Production Manager' },
];
// The catalogue every template draws from. One definition per stage — its key,
// its default duration and the role that owns it — so a stage means the same
// thing, and carries the same lead time, whichever running order it appears in.
const ALL_STAGE_DEFS = [...STAGE_DEFS, ...COUNTERTOP_STAGE_DEFS];
function stageDefByKey(key) { return ALL_STAGE_DEFS.find(d => d.key === key) || null; }

// SUPPLY AND LABOUR ARE SEPARATE SCOPES. Even when LEON does both on the same
// casework, they are contracted separately and billed separately, so they are
// two scopes with two schedules — not one scope with a longer stage list. The
// picker offers exactly those two for every family:
//   Supply Only  — no installation or punch list; the scope is finished when the
//                  material is delivered, and it never reaches the Installation Hub.
//   Labor Only   — the install trades (casework, doors, baseboards, tile, carpet,
//                  flooring). No submittal, no PI, no production, no shipping,
//                  because none of those steps belong to this scope.
// Countertops are the one exception — one contract covers buying the slab,
// templating it and installing it, so that family gets a single combined
// package instead ('Supply & Install', on the fabrication-shop template).
// 'Supply & Install' also remains the stored value on every scope created
// before the split, which is why it stays in the vocabulary.
const SCOPE_TYPES = ['Supply Only', 'Labor Only', 'Supply & Install'];
const COMBINED_SCOPE_TYPE = 'Supply & Install';
const DEFAULT_SCOPE_TYPE = 'Supply Only';
// What this family may actually be sold as.
function scopeTypeOptions(familyName, scopeLibrary) {
  const fam = (scopeLibrary || DEFAULT_SCOPE_LIBRARY).find(f => f.name === familyName);
  // A supply-only operation is not offered a labour option it does not sell.
  // This beats a validation message, which only tells you afterwards.
  if (fam && fam.supplyOnly) return ['Supply Only'];
  return isCountertopFamily(familyName, scopeLibrary) ? [COMBINED_SCOPE_TYPE] : ['Supply Only', 'Labor Only'];
}
function scopeTypeLabel(familyName, scopeLibrary, type) {
  if (type === COMBINED_SCOPE_TYPE) {
    return isCountertopFamily(familyName, scopeLibrary) ? 'Supply & Labor — one package' : 'Supply & Install';
  }
  return type;
}
// Stages that only exist because LEON is doing the installation.
const INSTALL_ONLY_STAGE_KEYS = ['installation', 'punch_list'];
// How a scope is ACTUALLY sold, which is not always the string stored on it.
// A family sold as exactly one package decides for itself — a countertop is
// Supply & Install however it was saved — and reading the raw field behind
// that rule is what hid the Installation Hub on a job LEON is installing.
// Anything else keeps what was chosen on the scope, INCLUDING the legacy
// 'Supply & Install' every pre-split scope still carries: resolving those
// through effectiveScopeType would demote them to Supply Only, because that
// value is no longer in a non-countertop family's allowed list.
// scopeLibrary is optional; without it scopeTypeOptions reads the default.
function scopeSoldAs(scope, scopeLibrary) {
  if (!scope) return null;
  const allowed = scopeTypeOptions(scope.familyName, scopeLibrary);
  return allowed.length === 1 ? allowed[0] : (scope.scopeType || DEFAULT_SCOPE_TYPE);
}
function scopeIsSupplyOnly(scope, scopeLibrary) { return scopeSoldAs(scope, scopeLibrary) === 'Supply Only'; }
function scopeIsLaborOnly(scope, scopeLibrary) { return scopeSoldAs(scope, scopeLibrary) === 'Labor Only'; }

// A revision is raised when it is needed, not scheduled in advance — the
// "+ Add Revision" button on the Shop Drawings / Submittal row inserts it and
// shifts everything downstream (insertRevisionStage, lib.jsx). Carrying one in
// every new scope's stage list meant every schedule showed a revision round
// nobody had asked for yet, and every projected completion assumed it.
const OPTIONAL_STAGE_KEYS = ['shop_drawing_revision'];

// The running orders. Each is a list of keys into the catalogue above, plus any
// display names that differ in that context — a countertop's drawing round is
// called a Submittal, and its vendor order is a PI with no PO behind it.
const LABOR_STAGE_KEYS = ['selections', 'contract', 'contract_deposit', 'installation', 'punch_list', 'final_payment', 'closeout'];
const COUNTERTOP_STAGE_KEYS = [
  'selections', 'contract', 'contract_deposit', 'shop_drawings',
  'pi_po', 'pi_payment', 'production', 'production_completion_payment',
  'shipping_fab_shop', 'fab_shop_receiving', 'template', 'fabrication',
  'installation', 'punch_list', 'final_payment', 'closeout',
];
const COUNTERTOP_STAGE_NAMES = {
  shop_drawings: 'Submittal',
  shop_drawing_revision: 'Submittal Revision',
  pi_po: 'PI',
};
// Ordered key list for a template, before the scope type trims it.
function templateStageKeys(templateKey) {
  if (templateKey === 'countertop') return COUNTERTOP_STAGE_KEYS;
  if (templateKey === 'laborOnly') return LABOR_STAGE_KEYS;
  if (templateKey === 'window') return WINDOW_STAGE_DEFS.map(d => d.key).filter(k => !OPTIONAL_STAGE_KEYS.includes(k));
  return STAGE_DEFS.map(d => d.key).filter(k => !OPTIONAL_STAGE_KEYS.includes(k));
}
function templateStageName(templateKey, key, fallback) {
  if (templateKey === 'countertop' && COUNTERTOP_STAGE_NAMES[key]) return COUNTERTOP_STAGE_NAMES[key];
  return fallback;
}
function stageDefsForScopeType(defs, scopeType) {
  return scopeType === 'Supply Only' ? defs.filter(d => !INSTALL_ONLY_STAGE_KEYS.includes(d.key)) : defs;
}

// Stages a WINDOW / EXTERIOR DOOR SYSTEM scope does NOT get. Windows run
// their material flow through the Window Schedule dependency graph
// (scope.windowSchedule — profile/glass/fabrication-drawing approvals,
// factory production, shipping), so carrying the generic interiors drawing
// and production stages alongside it duplicated the same milestones in two
// places and let them disagree. Everything commercial (contract, payments)
// and everything after the material lands (warehouse receiving, delivery,
// installation, punch, closeout) still runs on the normal stage engine.
const WINDOW_EXCLUDED_STAGE_KEYS = [
  'shop_drawings',            // -> windowSchedule fab_drawing_approval
  'shop_drawing_revision',    // -> windowSchedule revisions log
  'production_drawings',      // -> windowSchedule profile/glass approvals
  'production',               // -> windowSchedule factory_full_production
  'shipping_warehouse',       // -> windowSchedule shipping
];
const WINDOW_STAGE_DEFS = STAGE_DEFS.filter(d => !WINDOW_EXCLUDED_STAGE_KEYS.includes(d.key));

// What a response to an issue actually DOES — an issue often needs a comment
// or a hand-off long before anyone can close it, and forcing every response to
// resolve it was losing that middle ground.
const ISSUE_RESPONSE_OUTCOMES = [
  { key: 'Update', label: 'Post an update', hint: 'Records what was found or done. The issue stays open.' },
  { key: 'Needs Follow-Up', label: 'Needs follow-up', hint: 'Stays open and is handed to someone, with a due date.' },
  { key: 'Resolved', label: 'Resolve the issue', hint: 'Closes the issue, recording how it was handled.' },
];

// Where a delivery is being sent.
const DELIVERY_DESTINATIONS = ['Jobsite', 'Subcontractor', 'Warehouse', 'Client / Other'];

// ---------------------------------------------------------------------------
// Cash flow planning (Financial Calendar)
// ---------------------------------------------------------------------------
// The point of this is PLANNING, not bookkeeping: what is expected to come in
// and go out, week by week, so Accounting can see a shape and build a budget.
// It is not a general ledger — no double entry, no reconciliation — and this
// app should not become the system of record for company finances.
//
// Cash events are DERIVED from the records that already exist (payment terms,
// AP invoices, AIA applications) rather than re-entered, so a changed PO or a
// slipped schedule moves the forecast automatically instead of drifting. The
// one exception is company overhead — rent, insurance, payroll, utilities —
// which has no job record behind it and is entered directly (makeCashEntry).
//
// Every event is either PLANNED (soft, movable, shown lighter) or ACTUAL
// (locked to the date the money really moved).

// A payment term says WHEN by naming a trigger, not a date. Each trigger maps
// to the scope stage that satisfies it, so the expected date can be derived
// from that stage's planned completion and follow the schedule as it moves.
const PAYMENT_TRIGGER_STAGE = {
  'Contract Signing': 'contract',
  'Contract Deposit': 'contract_deposit',
  'Production Release': 'production',
  'Production Complete': 'production_completion_payment',
  'Shipping': 'shipping_warehouse',
  'Delivery to Jobsite': 'delivery_jobsite',
  'Installation Complete': 'installation',
  'Punch List Complete': 'punch_list',
  'Final': 'final_payment',
  'Closeout': 'closeout',
};

// Non-job money. Deliberately a small, recognisable list rather than a chart
// of accounts — this is for planning, and a long list nobody maintains is
// worse than a short one everybody uses.
const CASH_CATEGORIES_OUT = [
  'Rent', 'Payroll', 'Insurance', 'Utilities', 'Vehicles & Fuel', 'Software & Subscriptions',
  'Professional Services', 'Taxes', 'Loan / Finance', 'Marketing', 'Office & Supplies', 'Other Expense',
];
const CASH_CATEGORIES_IN = ['Owner Contribution', 'Loan / Financing', 'Interest', 'Refund / Rebate', 'Other Income'];
const CASH_RECURRENCE = ['One-off', 'Weekly', 'Monthly', 'Quarterly', 'Annually'];

// A manually-entered cash event. `recurrence` expands into occurrences at read
// time (expandCashEntry, lib.jsx) rather than writing N rows, so changing the
// rent amount changes every future occurrence and none of the past ones.
// Cash position anchor. A running total that starts at zero is a shape, not a
// balance — this is what turns it into a real forecast of the bank position.
// `weekOverrides` lets Accounting reset the balance at any week (they've just
// reconciled, or the forecast has drifted) without rewriting history: the weeks
// before it are untouched, and the weeks after carry on from the new figure.
function makeCashSettings() {
  return { openingBalance: 0, openingDate: todayISO(), weekOverrides: {} };
}

// A company credit card. Vendor invoices and overheads can be charged to one,
// which changes WHEN cash actually leaves: not on the invoice due date, but
// when the card's statement is paid. Modelling that is the whole point.
function makeCreditCard(data) {
  return {
    id: uid('cc'), name: data.name || '', issuer: data.issuer || '', last4: data.last4 || '',
    // Statement closes on this day of the month; the bill is due on dueDay of
    // the following month (or the same month if dueDay > statementDay).
    statementDay: Number(data.statementDay) || 25,
    dueDay: Number(data.dueDay) || 15,
    creditLimit: Number(data.creditLimit) || 0,
    active: true, notes: data.notes || '',
  };
}

// ---------------------------------------------------------------------------
// Notifications
// ---------------------------------------------------------------------------
// Two different things live under one word, and keeping them apart is what
// makes this work in a browser-only app:
//   * EVENTS are things that happened — someone assigned you a task, a date
//     moved, an approval landed on you. They are recorded when the action is
//     taken, by whoever took it, and they persist.
//   * ALERTS are things that are true right now — this is due in two days,
//     that release is late. Nothing "fires" them; they are recomputed from the
//     data every time the inbox renders, so they can never go stale and need
//     no server ticking in the background.
// Only events are stored. See buildDueAlerts() in lib.jsx for the other half.
const NOTIFICATION_EVENTS = [
  { key: 'assignment.new', label: 'Assigned to me', group: 'My work',
    hint: 'A task, installation, punch item or approval is put in my name', email: true },
  { key: 'assignment.changed', label: 'My assignment changed', group: 'My work',
    hint: 'Something already assigned to me is edited, rescheduled or reassigned', email: true },
  { key: 'sign.requested', label: 'Signature requested of me', group: 'Approvals',
    hint: 'A document has been routed to me to sign or approve in LEON Sign', email: true },
  { key: 'sign.completed', label: 'An envelope I sent completed', group: 'Approvals',
    hint: 'Everyone routed a document I sent has now signed it', email: true },
  { key: 'approval.pending', label: 'Waiting on my approval', group: 'Approvals',
    hint: 'An invoice, delivery or requisition needs my decision', email: true },
  { key: 'approval.decided', label: 'My submission was decided', group: 'Approvals',
    hint: 'Something I submitted was approved, rejected or sent back', email: true },
  { key: 'schedule.moved', label: 'A date moved', group: 'Schedule',
    hint: 'A stage, delivery or installation date on my project changed', email: false },
  { key: 'issue.new', label: 'Issue raised', group: 'Field',
    hint: 'A field or financial issue is opened on my project', email: true },
  { key: 'issue.response', label: 'Issue answered', group: 'Field',
    hint: 'Someone responds on an issue I raised or follow up on', email: true },
  { key: 'payment.received', label: 'Client payment recorded', group: 'Money',
    hint: 'A receipt is entered against one of my projects', email: false },
  { key: 'payment.moved', label: 'A payment date moved', group: 'Money',
    hint: 'An expected receipt, vendor due date or bank release is postponed', email: false },
  { key: 'mention', label: 'Follow-up assigned to me', group: 'My work',
    hint: 'Someone hands me a follow-up on an issue or a thread', email: true },
  { key: 'share.received', label: 'Something shared with me', group: 'My work',
    hint: 'A colleague shares a report, a project or a document with me', email: true },
  { key: 'submittal.clientResponse', label: 'Client answered a submittal', group: 'Approvals',
    hint: 'The client approves, notes or rejects a shop drawing from their portal', email: true },
];
function notificationEvent(key) { return NOTIFICATION_EVENTS.find(e => e.key === key) || null; }
const NOTIFICATION_GROUPS = ['My work', 'Approvals', 'Schedule', 'Field', 'Money'];

// A data: URI is roughly 4/3 the size of the file it encodes. Good enough to
// decide delivery: small files can ride along as attachments, large ones have
// to be a link — mail servers reject big attachments and mailboxes fill up.
const EMAIL_ATTACHMENT_LIMIT_BYTES = 8 * 1024 * 1024;   // 8 MB, a safe common ceiling
function approxFileBytes(dataUrl) {
  if (!dataUrl || typeof dataUrl !== 'string') return 0;
  const i = dataUrl.indexOf(',');
  if (i < 0) return 0;
  return Math.round((dataUrl.length - i - 1) * 0.75);
}
function fmtBytes(n) {
  if (!n) return '';
  if (n < 1024) return `${n} B`;
  if (n < 1024 * 1024) return `${Math.round(n / 1024)} KB`;
  return `${(n / (1024 * 1024)).toFixed(1)} MB`;
}
// How a file will actually reach the recipient.
function emailDelivery(bytes) { return bytes > EMAIL_ATTACHMENT_LIMIT_BYTES ? 'link' : 'attachment'; }

// ---------------------------------------------------------------------------
// Sign-in log
// ---------------------------------------------------------------------------
// Who signed in and when, plus the failures. Failures matter more than the
// successes: repeated bad passwords against one username is the only signal
// this app can give that someone is trying accounts that are not theirs.
// This is a prototype-only record — the browser reports its own time and its
// own user agent, and nothing here is verified server-side. See
// docs/production-audit for what real authentication logging requires.
function makeLoginEvent(data) {
  return {
    id: uid('login'),
    at: new Date().toISOString(),
    date: todayISO(),
    userId: data.userId || null,
    name: data.name || '',
    username: data.username || '',
    role: data.role || '',
    outcome: data.outcome || 'Signed in',   // 'Signed in' | 'Failed' | 'Signed out'
    agent: (typeof navigator !== 'undefined' && navigator.userAgent) ? navigator.userAgent.slice(0, 180) : '',
  };
}
// The log is capped — it is an operational record, not an audit archive, and
// it lives in the same localStorage budget as everything else.
const LOGIN_LOG_LIMIT = 500;
// The permission trail. Smaller than the sign-in log because a permission is
// changed rarely and each entry matters more — 300 covers years of ordinary
// administration, and the oldest entry being dropped is not a loss anyone
// would notice.
const PERMISSION_LOG_LIMIT = 300;

// ---------------------------------------------------------------------------
// LEON Sign — envelopes, recipients, fields and the audit trail
// ---------------------------------------------------------------------------
// Shaped from Leon's own DocuSign account (docs/leon-sign-study.md): 23
// envelopes, of which 17 are SHOP DRAWING APPROVALS at a revision, routed to an
// external party and then a Leon counter-signer, and 6 are contracts.
//
// BE PRECISE ABOUT WHAT THIS IS. A page with no backend cannot produce what
// makes a DocuSign signature hold up: identity verified server-side, a
// tamper-evident chain, and a timestamped certificate from a party with no
// stake in the document. So an envelope declares its OWN weight:
//   'approval'  — an internal record that someone approved a drawing. That is
//                 what a shop drawing sign-off actually is, and it is honest.
//   'contract'  — needs a real e-signature service. The Hub prepares, tracks
//                 and files it; the signing itself is handed over, and until a
//                 service is connected the screen says so rather than implying
//                 the signature is binding.
// The split is not cosmetic — `signIsBinding()` is read wherever the app would
// otherwise be tempted to call an approval a signature.
const SIGN_WEIGHTS = [
  { key: 'approval', label: 'Approval record',
    hint: 'Signed inside the Hub. Proves who approved what and when, for our own record.' },
  { key: 'contract', label: 'Contract — needs e-signature',
    hint: 'Prepared and tracked here; the binding signature is taken elsewhere.' },
];
function signIsBinding(env) { return !!(env && env.weight === 'contract' && env.externalProvider && env.externalCompletedId); }

// The lifecycle, in the order it happens. Taken from the account rather than
// invented: Draft / In Progress / Completed / Voided are what its own filters
// show, with Declined kept because a recipient refusing is a real outcome that
// is not the same as us voiding it.
const SIGN_STATUSES = ['Draft', 'Sent', 'Viewed', 'Partially signed', 'Completed', 'Declined', 'Voided'];
const SIGN_OPEN_STATUSES = ['Draft', 'Sent', 'Viewed', 'Partially signed'];
// What a recipient is there to do. DocuSign's own four, because they are the
// four that exist: two of them never sign anything.
const SIGN_RECIPIENT_ROLES = [
  { key: 'signer',   label: 'Needs to sign',    signs: true },
  { key: 'approver', label: 'Needs to approve', signs: true },
  { key: 'copy',     label: 'Receives a copy',  signs: false },
  { key: 'viewer',   label: 'Needs to view',    signs: false },
];
function signRoleSigns(role) {
  const r = SIGN_RECIPIENT_ROLES.find(x => x.key === role);
  return !!(r && r.signs);
}
// The field types actually used on a drawing approval or a contract. Deliberately
// short: a palette nobody uses is a palette nobody can find anything in.
const SIGN_FIELD_TYPES = [
  { key: 'signature', label: 'Signature',   w: 22, h: 8,   forSigner: true },
  { key: 'initial',   label: 'Initials',    w: 9,  h: 8,   forSigner: true },
  { key: 'dateSigned',label: 'Date signed', w: 18, h: 5,   auto: true },
  { key: 'name',      label: 'Name',        w: 24, h: 5,   auto: true },
  { key: 'title',     label: 'Title',       w: 24, h: 5 },
  { key: 'company',   label: 'Company',     w: 24, h: 5,   auto: true },
  { key: 'text',      label: 'Text',        w: 24, h: 5 },
  { key: 'checkbox',  label: 'Checkbox',    w: 5,  h: 5 },
];

// A recipient. `order` is the routing step — everyone on the same number is
// asked at once, and a higher number is not asked until the lower one is done.
// Leon's own envelopes are always external first, then a Leon counter-signer,
// which is orders 1 and 2.
function makeSignRecipient(data) {
  const d = data || {};
  return {
    id: d.id || uid('sgr'),
    name: d.name || '', email: d.email || '',
    role: d.role || 'signer',
    order: d.order != null ? d.order : 1,
    userId: d.userId || null,          // a Leon person, where it is one of ours
    party: d.party || 'external',      // 'internal' | 'external'
    status: d.status || 'Pending',     // Pending | Sent | Viewed | Signed | Declined
    sentDate: d.sentDate || null, viewedDate: d.viewedDate || null,
    signedDate: d.signedDate || null,
    declinedReason: d.declinedReason || '',
    // The drawn or typed mark, as a data URI. Kept on the RECIPIENT rather than
    // on each field so one person signing three pages signs once.
    signatureImage: d.signatureImage || null,
    signatureTyped: d.signatureTyped || '',
    note: d.note || '',
  };
}

// A field placed on a page. Geometry is in PAGE PERCENTAGES, the same choice
// Leon PDF makes and for the same reason: a field lands on the same spot at any
// zoom, on any screen.
function makeSignField(data) {
  const d = data || {};
  return {
    id: d.id || uid('sgf'),
    recipientId: d.recipientId || null,
    docId: d.docId || null,
    page: d.page != null ? d.page : 1,
    type: d.type || 'signature',
    x: d.x != null ? d.x : 10, y: d.y != null ? d.y : 80,
    w: d.w != null ? d.w : 22, h: d.h != null ? d.h : 8,
    required: d.required !== false,
    label: d.label || '',
    value: d.value || '',
  };
}

// A document inside the envelope. It REFERENCES what the Hub already produced
// wherever it can — a submittal revision, a shop drawing, a quotation — rather
// than holding a second copy that can drift from it.
function makeSignDocument(data) {
  const d = data || {};
  return {
    id: d.id || uid('sgd'),
    name: d.name || '', url: d.url || '', pages: d.pages || 1,
    source: d.source || null,        // { kind, projectId, scopeId, refId, revision }
    sha256: d.sha256 || null,        // what the bytes were when it was sent
    bytes: d.bytes || 0,
  };
}

// Every consequential thing that happened, in order. This is the closest a
// browser can get to an audit trail, and the limit is worth stating plainly:
// the CLOCK is the signer's own machine and nothing here is independently
// witnessed. The content HASH is real — a changed document is detectable — but
// a hash proves the bytes, not the hour.
function makeSignEvent(data) {
  const d = data || {};
  return {
    id: d.id || uid('sge'),
    date: d.date || new Date().toISOString(),
    kind: d.kind || 'note',
    by: d.by || '', byId: d.byId || null,
    recipientId: d.recipientId || null,
    detail: d.detail || '',
    sha256: d.sha256 || null,
  };
}

function makeSignEnvelope(data) {
  const d = data || {};
  return {
    id: d.id || uid('sgenv'),
    number: d.number || '',
    subject: d.subject || '', message: d.message || '',
    weight: d.weight || 'approval',
    status: d.status || 'Draft',
    projectId: d.projectId || null, scopeId: d.scopeId || null,
    // Where this envelope came FROM, so the executed copy can be filed back on
    // it without anyone choosing a destination twice.
    source: d.source ? Object.assign({}, d.source) : null,
    documents: d.documents ? d.documents.map(makeSignDocument) : [],
    recipients: d.recipients ? d.recipients.map(makeSignRecipient) : [],
    fields: d.fields ? d.fields.map(makeSignField) : [],
    events: d.events ? d.events.map(makeSignEvent) : [],
    createdBy: d.createdBy || '', createdDate: d.createdDate || todayISO(),
    sentDate: d.sentDate || null, completedDate: d.completedDate || null,
    voidedReason: d.voidedReason || '',
    expiresDate: d.expiresDate || null,
    reminderDays: d.reminderDays != null ? d.reminderDays : 3,
    // Set only when a real e-signature service handled the signing.
    externalProvider: d.externalProvider || '', externalEnvelopeId: d.externalEnvelopeId || '',
    externalCompletedId: d.externalCompletedId || '',
  };
}

// ONE NAME, GENERATED. Their own account carries three conventions in the same
// month — "Leon I 116 Winter St I Kitchen Shop drawing I Rev01 I 04.28.2026",
// "116 Winter - Stairs Shop Drawing - REV 05 - 05-27-2026" and
// "Leon-144 Worcester-Mirror Shop Drawings-Rev02-05132026" — because a person
// types it each time. The Hub knows the job, the scope, the kind and the
// revision, so it can write it the same way every time.
function signDocumentName(o) {
  const p = o || {};
  const bits = [p.project, p.scope, p.kind].filter(Boolean);
  const rev = (p.revision != null && p.revision !== '') ? `REV ${String(p.revision).padStart(2, '0')}` : '';
  const date = (p.date || todayISO()).slice(0, 10).split('-').reverse().join('-');
  return [bits.join(' - '), rev, date].filter(Boolean).join(' - ');
}

// ---------------------------------------------------------------------------
// AI-generated take-offs
// ---------------------------------------------------------------------------
// A request to have a take-off produced from a drawing set. The app itself has
// no AI backend — nothing here calls a model — so a request is RAISED against a
// drawing set and stays Pending until a result is uploaded against it. That is
// the honest shape today, and it is also exactly the record a real integration
// would fill in automatically: same fields, filled by a service instead of a
// person. Admin-only for now.
const AI_TAKEOFF_STATUSES = ['Requested', 'In Progress', 'Delivered', 'Rejected'];
// What we ask for, not just take-offs. The collection is still called
// aiTakeoffRequests because that is what is already persisted on every
// project — renaming it would orphan existing records for no gain.
// The take-off column contract, shared by the Excel template, the AI brief and
// any future importer, so a take-off produced by hand, by Claude or by an
// import all arrive in the same shape. The COUNTING columns are identical on
// every scope — that is what lets one summary total all of them — while the six
// SPEC columns are named in each trade's own language.
// The standing operational notice on the sign-in screen. It lives here as data
// rather than inside the JSX so the wording is changed in one place — and so it
// can be moved under Admin Settings later without hunting for a string. One
// LoginScreen serves staff and every portal, so this reaches everyone.
const SIGNIN_NOTICE = {
  title: 'Weekend maintenance',
  icon: '🔧',
  lines: [
    'The Hub is maintained and fixed on Saturdays and Sundays, so it is not available over the weekend.',
    'Please report any issues or concerns by Friday.',
  ],
};

// LEON Studio's tools each carry a TRIAL VERSION badge; this says what that
// badge means and what to do about it. Stated once, at the top of the hub,
// rather than repeated on every tile.
const STUDIO_NOTICE = {
  title: 'Still in development',
  icon: '🚧',
  lines: [
    'These tools are still being built, so expect to find problems with them.',
    'Please use them anyway — and report anything that breaks, or any feature you need that is missing.',
  ],
};

// What a take-off is filed WITH — the drawings and schedules it was measured
// from. Seeded from what LEON actually attaches. The KEY is what gets stored on
// an attachment, so relabelling one here never orphans a file, and a kind that
// is later removed still resolves through takeoffDocKind() below.
// Ordered the way a set is read: the schedules that cover the whole job, then
// doors, then the trades that are specified and set out together, then the two
// rooms that get their own elevations.
const TAKEOFF_DOC_KINDS = [
  { key: 'finishSchedule', label: 'Finish Schedule', icon: '🎨' },
  { key: 'clientDoorSchedule', label: 'Client Door Schedule', icon: '🚪' },
  { key: 'doorFinishes', label: 'Door Finishes & Hardware', icon: '🔩' },
  { key: 'doorElevations', label: 'Door Elevations', icon: '📐' },
  { key: 'tileSpecLayout', label: 'Tile Specification & Layout', icon: '🧱' },
  { key: 'flooringSpecLayout', label: 'Flooring Specification & Layout', icon: '🪵' },
  { key: 'baseSpecLayout', label: 'Base Specification & Layout', icon: '📏' },
  // Relabelled from "Kitchen Layouts" / "Bathroom Layouts" — elevations are
  // filed with the plan, not apart from it. Safe to rename: an attachment
  // stores the KEY, so anything already filed still resolves.
  { key: 'kitchenLayouts', label: 'Kitchen Layout & Elevations', icon: '🍳' },
  { key: 'bathroomLayouts', label: 'Bathroom Layout & Elevations', icon: '🛁' },
  { key: 'other', label: 'Other', icon: '📎' },
];
function takeoffDocKind(key) {
  return TAKEOFF_DOC_KINDS.find(k => k.key === key)
    || { key: key || 'other', label: key || 'Other', icon: '📎' };
}
// Pictures are downscaled on the way in and documents are capped, because every
// one of these is a base64 string in localStorage and the whole budget is about
// 13 MB. A phone photo is 2-5 MB untouched, so three of them would exceed the
// entire app's storage. Supabase Storage is what actually removes this limit;
// until then the panel states the weight rather than letting someone find out.
const TAKEOFF_PICTURE_MAX_PX = 1400;
const TAKEOFF_FILE_LIMIT_BYTES = 2 * 1024 * 1024;

function makeTakeoffAttachment(data, by) {
  const d = data || {};
  return Object.assign({
    kind: 'other',
    name: '',
    url: null,
    caption: '',        // optional — blank shows nothing at all, as on the deck
    picture: false,
    w: 0, h: 0, bytes: 0,
    addedBy: by || '',
    date: todayISO(),
  }, d, { id: d.id || uid('toa') });   // id stamped LAST: a caller cloning with
                                       // id:undefined must get a NEW one, not null
}

const TAKEOFF_AREAS = ['Residences', 'Amenities', 'Corridors & Common', 'Back of House',
  'Retail', 'Exterior / Site', 'Model Unit'];

// Area and Category are two axes. Area is which part of the building; Category
// is which room or assembly within the scope. Kept apart so a quote can be cut
// by phase OR by rate — Residences kitchens and amenities never carry the same
// number, and summing them is what hides a margin problem until site.
const TAKEOFF_FLOOR_CATS = ['Living / Dining', 'Bedroom', 'Kitchen', 'Bathroom',
  'Powder Room', 'Closet', 'Laundry', 'Foyer / Entry', 'Hallway', 'Corridor',
  'Stairs & Landings', 'Balcony / Terrace', 'Lobby', 'Lounge', 'Fitness',
  'Business Center', 'Mail Room', 'Pool / Wet Area', 'Back of House', 'Other'];

const TAKEOFF_COUNTING_COLUMNS = [
  'Area', 'Category', 'Location / Room', 'Item Tag', 'Description',
  'spec1', 'spec2', 'spec3', 'spec4', 'spec5', 'spec6',
  'Unit Type', 'Qty per Unit', 'Unit Qty', 'Subtotal', 'Waste %',
  'Waste Qty', 'TOTAL QTY', 'UoM', 'Drawing Ref', 'Measured By',
  'Date', 'Status', 'Notes',
];

// std / hi are the published trade allowances the template ships with. Once the
// Hub holds enough finished jobs these should be set from what was actually
// consumed — a real history beats any published figure.
const TAKEOFF_SCOPES = [
  { key: 'Casework & Mirrors', tab: 'Casework',
    cats: ['Kitchen','Kitchen Island','Bathroom Vanity','Powder Room Vanity','Closet','Laundry','Media / Built-In','Bar','Office / Study','Mudroom / Entry','Pantry','Reception / Front Desk','Lounge Millwork','Fitness','Mail Room','Package Room','Other'], uom: 'Set', std: 0.00, hi: 0.02,
    specs: ['Item Type', 'Box Material / Thickness', 'Door Material', 'Door Design', 'Door Finish', 'Hardware'] },
  { key: 'Countertop / Stone', tab: 'Countertop',
    cats: ['Kitchen','Kitchen Island','Bathroom Vanity','Powder Room Vanity','Bar','Laundry','Pantry','Window Sill','Shower Curb / Bench','Reception / Front Desk','Lounge / Bar','Fitness','Other'], uom: 'Sq. Ft.', std: 0.15, hi: 0.25,
    specs: ['Material', 'Colour', 'Thickness', 'Finish', 'Edge Profile', 'Backsplash'] },
  { key: 'Doors', tab: 'Doors',
    cats: ['Entry Door','Interior Door','Closet Door','Bathroom Door','Pocket Door','Barn / Sliding Door','Bi-Fold Door','Balcony / Terrace Door','Stair / Fire Door','Utility / Mech Door','Amenity Door','Back of House Door','Other'], uom: 'Set', std: 0.00, hi: 0.02,
    specs: ['Type / Swing', 'Design', 'Core', 'Thickness', 'Width x Height', 'Fire Rating'] },
  { key: 'Engineered Wood Flooring', tab: 'Eng Wood',
    cats: TAKEOFF_FLOOR_CATS, uom: 'Sq. Ft.', std: 0.05, hi: 0.10,
    specs: ['Species / Colour', 'Width', 'Overall Thickness', 'Wear Layer', 'Grade', 'Finish'] },
  { key: 'SPC Flooring', tab: 'SPC',
    cats: TAKEOFF_FLOOR_CATS, uom: 'Sq. Ft.', std: 0.05, hi: 0.10,
    specs: ['Colour', 'Width', 'Overall Thickness', 'Wear Layer', 'Length', 'Finish'] },
  { key: 'LVT Flooring', tab: 'LVT',
    cats: TAKEOFF_FLOOR_CATS, uom: 'Sq. Ft.', std: 0.05, hi: 0.10,
    specs: ['Colour', 'Width', 'Overall Thickness', 'Wear Layer', 'Format', 'Finish'] },
  { key: 'Rubber Flooring', tab: 'Rubber',
    cats: ['Fitness','Stairs & Landings','Back of House','Mech / Electrical','Corridor','Pool / Wet Area','Pet Area','Bike Room','Trash / Loading','Other'], uom: 'Sq. Ft.', std: 0.10, hi: 0.15,
    specs: ['Type', 'Colour', 'Thickness', 'Size / Format', 'Surface', 'Backing'] },
  { key: 'Carpet', tab: 'Carpet',
    cats: TAKEOFF_FLOOR_CATS, uom: 'Sq. Ft.', std: 0.10, hi: 0.15,
    specs: ['Style', 'Colour', 'Construction', 'Backing', 'Roll Width', 'Pattern Repeat'] },
  { key: 'Tile', tab: 'Tile',
    cats: ['Bathroom Floor','Bathroom Wall','Shower Floor','Shower Wall','Tub Surround','Powder Room','Kitchen Backsplash','Kitchen Floor','Laundry','Foyer / Entry','Corridor','Lobby','Lounge','Fitness','Pool / Wet Area','Accent Wall','Balcony / Terrace','Back of House','Other'], uom: 'Sq. Ft.', std: 0.10, hi: 0.15,
    specs: ['Series / Colour', 'Size', 'Finish', 'Application', 'Pattern', 'Grout'] },
  { key: 'Baseboard / Trim', tab: 'Baseboard',
    cats: TAKEOFF_FLOOR_CATS, uom: 'Linear Ft.', std: 0.10, hi: 0.15,
    specs: ['Profile', 'Height', 'Thickness', 'Material', 'Finish', 'Stock Length'] },
  { key: 'Hardware', tab: 'Hardware',
    cats: ['Kitchen Cabinet','Bath Cabinet','Closet','Entry Door','Interior Door','Bath Accessory','Amenity','Back of House','Other'], uom: 'Each', std: 0.00, hi: 0.02,
    specs: ['Type', 'Manufacturer', 'Model', 'Finish', 'Size', 'Notes'] },
  { key: 'Other', tab: 'Other',
    cats: ['Residences','Amenities','Corridors & Common','Back of House','Exterior / Site','Other'], uom: 'Each', std: 0.00, hi: 0.00,
    specs: ['Spec 1', 'Spec 2', 'Spec 3', 'Spec 4', 'Spec 5', 'Spec 6'] },
];

// Match a scope to its take-off tab by family name, then scope name. Returns
// null rather than guessing — a wrong tab would put linear feet under sq ft.
function takeoffScopeFor(familyName, scopeName) {
  const hay = `${familyName || ''} ${scopeName || ''}`.toLowerCase();
  const hit = k => hay.includes(k);
  if (hit('countertop') || hit('stone') || hit('quartz') || hit('granite')) return TAKEOFF_SCOPES[1];
  if (hit('door') && !hit('hardware')) return TAKEOFF_SCOPES[2];
  if (hit('engineered') || hit('eng wood') || hit('hardwood')) return TAKEOFF_SCOPES[3];
  if (hit('spc')) return TAKEOFF_SCOPES[4];
  if (hit('lvt') || hit('luxury vinyl')) return TAKEOFF_SCOPES[5];
  if (hit('rubber')) return TAKEOFF_SCOPES[6];
  if (hit('carpet')) return TAKEOFF_SCOPES[7];
  if (hit('tile') || hit('ceramic') || hit('porcelain')) return TAKEOFF_SCOPES[8];
  if (hit('baseboard') || hit('trim') || hit('molding') || hit('moulding')) return TAKEOFF_SCOPES[9];
  if (hit('hardware')) return TAKEOFF_SCOPES[10];
  if (hit('casework') || hit('cabinet') || hit('millwork') || hit('mirror') || hit('vanity')) return TAKEOFF_SCOPES[0];
  return null;
}

// ── Quote Analysis (Interiors) ────────────────────────────────────────────
// A draft priced off a take-off. Deliberately self-contained: it hangs off the
// project like every other collection, so it persists with `projects` and adds
// no new top-level state, and nothing outside the Sales Hub reads it. Until it
// has been used on a real bid it should stay that way — a quote analysis that
// silently drove the contract value would be very hard to unpick.
const QUOTE_ANALYSIS_STATUSES = ['Draft', 'Under Review', 'Issued', 'Superseded'];
// The two a person may CHOOSE. `Issued` is reached by issuing — which stamps
// the date and the person — and `Superseded` by raising the next revision;
// offering either as a value let a revision claim to be issued with no record
// of when or by whom.
const QUOTE_ANALYSIS_DRAFT_STATUSES = ['Draft', 'Under Review'];

// Two ways to price, because the words are used interchangeably and mean
// different numbers. Markup is on cost; margin is on the sell price. A 35%
// markup on $100 sells at $135 and earns 25.9%; a 35% margin sells at $153.85.
// The screen always shows the resulting margin so the answer is never implied.
// Freight and duty are almost never one percentage across a whole job. A slab
// ships by volume, a door by the piece, and an amenity package might carry one
// negotiated lump sum; duty follows the item's own tariff code, so two lines in
// the same scope can sit at different rates. Each line therefore chooses HOW it
// is charged, not just how much — with the scope, then the analysis, providing
// the default so the common case is still one entry.
// How the Revere Street workbook actually charges freight, and the one basis the
// model was missing. A container is billed as a FRACTION — 17 casework modules
// against a 100-module container is 0.17 of one — and ocean, inland and broker
// are three separate rates against that same fraction. Rounding the fraction up
// per line would charge a full container eight times on one job; the job-level
// check is where a short load gets noticed, not the line.
const QUOTE_FREIGHT_CONTAINER = { key: 'container', label: 'By the container',
  hint: 'Driver / capacity = container fraction, then ocean + inland + broker rates against it.' };

// What a scope counts. Casework is bought by the module, stone by the square
// foot, base by the stick — and the container fraction is measured against
// whichever of those it is, not against a universal unit.
const QUOTE_COST_DRIVERS = [
  { key: 'qty', label: 'The take-off quantity', hint: 'Square feet, each, linear feet — whatever the line is measured in.' },
  { key: 'module', label: 'Module count', hint: 'Casework: a kitchen is priced by its module count, upper plus base.' },
  { key: 'piece', label: 'Pieces', hint: 'Base and trim: linear feet converted into sticks of a fixed length.' },
];
// A second way to buy material, alongside a rate per unit: stone is bought by
// the SLAB. Square feet plus waste, divided by what one slab actually yields.
const QUOTE_MATERIAL_BASES = [
  { key: 'unit', label: 'Rate per unit', hint: 'Quantity x a rate.' },
  { key: 'slab', label: 'By the slab', hint: '(Quantity + waste) / yield per slab, x the slab rate.' },
];

const QUOTE_FREIGHT_BASES = [
  { key: 'pct', label: '% of material', hint: 'Freight as a percentage of the material cost.' },
  { key: 'perUnit', label: '$ per unit', hint: 'A rate against the take-off quantity.' },
  { key: 'volume', label: 'By volume (CBM)', hint: 'Volume per unit x a rate per cubic metre — how a container is actually charged.' },
  { key: 'lump', label: 'Lump sum', hint: 'One agreed figure for the whole line.' },
  QUOTE_FREIGHT_CONTAINER,
];
const QUOTE_DUTY_BASES = [
  { key: 'pct', label: '% of material', hint: 'The tariff rate for this item\u2019s HTS code.' },
  { key: 'perUnit', label: '$ per unit', hint: 'A specific duty charged per unit.' },
  { key: 'lump', label: 'Lump sum', hint: 'One agreed figure for the whole line.' },
];

// The margin a job is quoted at, by how the job is pitched. The client's own
// three tiers — a job is basic, medium or high-end, and that is what decides
// the margin rather than each quote inventing a number. Chosen on the analysis;
// any scope or line may still differ, and the screen shows when one does.
const QUOTE_MARGIN_TIERS = [
  { key: 'basic',   label: 'Basic',    rate: 0.30, hint: 'Standard work, competitive bid.' },
  { key: 'medium',  label: 'Medium',   rate: 0.40, hint: 'The usual middle of the range.' },
  { key: 'high',    label: 'High-end', rate: 0.50, hint: 'High specification, bespoke detailing.' },
];
function quoteMarginTierFor(rate) {
  const r = Number(rate);
  return QUOTE_MARGIN_TIERS.find(t => Math.abs(t.rate - r) < 0.0005) || null;
}
// Commission is 3% of the client price everywhere EXCEPT countertops, which are
// 6%. Confirmed with the client rather than inferred: it is the countertop
// scope specifically, not "any scope carrying installation".
const QUOTE_COMMISSION_DEFAULT = 0.03;
const QUOTE_COMMISSION_COUNTERTOP = 0.06;
function quoteCommissionForScope(name) {
  return quoteScopeIsCombined(name) ? QUOTE_COMMISSION_COUNTERTOP : QUOTE_COMMISSION_DEFAULT;
}

// The cost recipe each scope actually uses, read out of the client's own
// workbook: what it counts, how many fit a container, and what the three
// freight legs cost. These are DEFAULTS — every one of them is a field on the
// section and can be changed on the quote, which is the whole point of lifting
// them out of the formulas they were buried in.
const QUOTE_SCOPE_RECIPES = {
  casework:   { costDriver: 'module', containerCapacity: 100,   freightPerContainer: 4500, inlandPerContainer: 1750, brokerPerContainer: 225, overheadPct: 0.10, uom: 'Module' },
  countertop: { costDriver: 'qty', matBasis: 'slab', slabYield: 45, slabWastePct: 0.15,
                freightPerContainer: 6000, inlandPerContainer: 665, brokerPerContainer: 0, uom: 'Sq. Ft.' },
  doors:      { costDriver: 'qty', containerCapacity: 175,   freightPerContainer: 4500, inlandPerContainer: 1750, brokerPerContainer: 225, overheadPct: 0.10, uom: 'Each' },
  spc:        { costDriver: 'qty', containerCapacity: 25000, freightPerContainer: 6000, inlandPerContainer: 1000, brokerPerContainer: 165, overheadPct: 0.10, uom: 'Sq. Ft.' },
  wood:       { costDriver: 'qty', containerCapacity: 26000, freightPerContainer: 4500, inlandPerContainer: 1750, brokerPerContainer: 225, overheadPct: 0.10, uom: 'Sq. Ft.' },
  tile:       { costDriver: 'qty', containerCapacity: 10000, freightPerContainer: 4500, inlandPerContainer: 1000, brokerPerContainer: 165, overheadPct: 0.10, uom: 'Sq. Ft.' },
  carpet:     { costDriver: 'qty', containerCapacity: 35000, freightPerContainer: 4500, inlandPerContainer: 1750, brokerPerContainer: 225, overheadPct: 0.10, uom: 'Sq. Ft.' },
  base:       { costDriver: 'piece', pieceLengthIn: 96, containerCapacity: 35000, freightPerContainer: 4500, inlandPerContainer: 1750, brokerPerContainer: 225, overheadPct: 0.10, uom: 'Linear ft.' },
};
// SPC and Tile charge inland as two legs of 500 in the workbook; one field of
// 1000 is the same money and one fewer thing to keep in step.
// The company's own quote defaults, editable under Admin Settings and persisted
// as `quoteRecipes`. `quoteRecipeFor` is PURE and is called from makeQuoteSection
// with no route to React state, so it reads a module-level registry that App()
// re-points on every render — the same pattern as role permissions, complexity
// levels and software settings.
let __activeQuoteRecipes = null;
function setActiveQuoteRecipes(map) { __activeQuoteRecipes = map || null; }
function quoteRecipeSeed() { return QUOTE_SCOPE_RECIPES; }
// Forward-merge: a scope added to the seed later still reaches a library the
// team has already edited, and a value they changed stays changed.
function mergeNewQuoteRecipes(persisted) {
  const out = {};
  Object.keys(QUOTE_SCOPE_RECIPES).forEach(k => {
    out[k] = Object.assign({}, QUOTE_SCOPE_RECIPES[k], (persisted && persisted[k]) || {});
  });
  Object.keys(persisted || {}).forEach(k => { if (!out[k]) out[k] = persisted[k]; });
  return out;
}
// Which specification fields a quoted scope asks for. Read from TAKEOFF_SCOPES
// so the quote and the take-off ask for the SAME six things about a trade —
// two different lists would be two different definitions of the product.
// What a QUOTE line of each trade has to say about the thing being sold.
// It falls back to the take-off's own six questions, so the quote and the
// take-off keep asking the same things for every trade until the team has said
// otherwise — but where a trade has been specified here, this wins.
//
// Casework is the first one the client wrote out, and it is deliberately finer
// than the take-off's version: the take-off asks "Door Design" once, while a
// quote has to price the UPPER and the BASE separately, because they are
// routinely different doors at different rates. Box material and thickness are
// split for the same reason — thickness is a price, not a note about the
// material.
//
// Every other trade is a DRAFT, written from the take-off's own six questions
// plus the things that actually move a price in that trade and were being
// carried as loose text: a countertop's cutouts, a door's hardware set and
// whether it is supplied pre-hung, a floor's underlay. They are seeds, not
// decisions — the list is editable per scope under Admin Settings, so the team
// corrects a trade without a code change and a corrected list survives a seed
// that grows later.
const QUOTE_SCOPE_SPECS = {
  // Casework, as the client wrote it, then completed down the rest of what a
  // shop actually needs told: the box, the fronts and the drawers. The parts
  // these name are the same parts `cwComputeParts` cuts, so a quote and a cut
  // list describe one cabinet.
  // NO HARDWARE — hinges, slides and pulls came off on the client's
  // instruction (2026-09-06): casework hardware is quoted as its own scope, so
  // asking for it on every casework line is a column that is always blank.
  // Door hardware is a different matter and stays: their own door schedule
  // carries a HW SET column against every door.
  casework: ['Box Material', 'Box Thickness',
             'Door Material',
             'Upper Door Design', 'Upper Door Finish',
             'Base Door Design', 'Base Door Finish',
             'Handle Type', 'LED'],
  // Everything that is cut, mitred or drilled is a price, which is why they are
  // fields rather than a note: a cutout never reduces the square footage it
  // comes out of, and a miter bills both pieces.
  countertop: ['Material', 'Colour', 'Thickness', 'Finish', 'Edge Profile',
               'Backsplash', 'Backsplash Height',
               'Sink Cutouts', 'Cooktop Cutouts', 'Faucet Holes',
               'Waterfall Ends', 'Mitred Edges', 'Seams',
               'Support / Brackets', 'Template Required'],
  // Read off the reviewed 55 India schedule, which carries the leaf, the frame
  // and the rough opening as three separate sets of dimensions because three
  // trades read them.
  doors: ['Type / Swing', 'Handing', 'Leaf Design', 'Core',
          'Leaf Size (W x H)', 'Leaf Thickness', 'Leaf Material / Skin', 'Finish',
          'Frame Profile', 'Frame Material', 'Frame Finish', 'Rough Opening',
          'Hardware Set', 'Hinges', 'Lockset',
          'Fire Rating', 'Louver / Vision Lite', 'Undercut',
          'Supplied As', 'Installation Type'],
  // NO UNDERLAY on any floor — the client's instruction (2026-09-06): it is
  // never LEON's supply, so a field for it would be blank on every line.
  tile: ['Series / Colour', 'Size', 'Thickness', 'Finish', 'Application',
         'Pattern / Layout', 'Trim / Edge Profile',
         'Grout', 'Grout Colour', 'Grout Joint',
         'Waterproofing / Membrane', 'Setting Material', 'Movement Joints'],
  spc: ['Colour', 'Width', 'Length', 'Overall Thickness', 'Wear Layer',
        'Core', 'Finish', 'Edge / Bevel', 'Locking System',
        'Installation Pattern', 'Transition Profiles', 'Skirting / Trim'],
  lvt: ['Colour', 'Width', 'Length', 'Overall Thickness', 'Wear Layer',
        'Format', 'Finish', 'Edge / Bevel', 'Locking System',
        'Installation Pattern', 'Transition Profiles', 'Skirting / Trim'],
  wood: ['Species / Colour', 'Grade', 'Width', 'Length', 'Overall Thickness',
         'Wear Layer', 'Core Construction', 'Finish', 'Gloss Level', 'Edge Profile',
         'Installation Method', 'Transition Profiles'],
  carpet: ['Style', 'Colour', 'Fibre', 'Construction', 'Pile Weight', 'Pile Height',
           'Backing', 'Roll Width', 'Pattern Repeat',
           'Installation Method', 'Edge / Binding'],
  rubber: ['Type', 'Colour', 'Thickness', 'Size / Format', 'Surface', 'Backing',
           'Adhesive', 'Edge / Trim', 'Installation Pattern'],
  base: ['Profile', 'Height', 'Thickness', 'Material', 'Finish', 'Stock Length',
         'Corner Treatment', 'Mounting'],
};
// A quoted scope's name carries its sold-as suffix ("Casework — Supply") and the
// team's own wording ("Countertops", "Baseboards / Trims"), so the key is
// resolved rather than matched. Same aliases the cost recipes resolve, kept
// beside them on purpose: a scope that finds its recipe must find its
// specification too.
const QUOTE_SPEC_ALIASES = {
  casework: 'casework', millwork: 'casework',
  countertop: 'countertop', countertops: 'countertop', stone: 'countertop',
  door: 'doors', doors: 'doors',
  tile: 'tile', tiles: 'tile',
  spc: 'spc', lvt: 'lvt', rubber: 'rubber', carpet: 'carpet',
  wood: 'wood', 'engineered wood': 'wood', 'engineered wood flooring': 'wood',
  base: 'base', baseboard: 'base', baseboards: 'base', trim: 'base',
};
function quoteScopeSpecKey(name) {
  const k = String(name || '').trim().toLowerCase().replace(/\s+—.*$/, '').trim();
  if (QUOTE_SPEC_ALIASES[k]) return QUOTE_SPEC_ALIASES[k];
  // Fall back to the first word, which catches "Baseboard / Trim" and
  // "Countertop / Stone" without listing every punctuation variant.
  const first = k.split(/[^a-z]+/)[0];
  return QUOTE_SPEC_ALIASES[first] || k;
}

// Editable, the same way the cost recipes are. `quoteSpecFieldsFor` is pure and
// is called from render with no route to React state, so it reads a
// module-level registry that App() re-points every render.
// How a scope key is written when it is shown to someone. The keys are
// lowercase because they are looked up; "Spc" and "Lvt" are not how anyone
// writes them.
// ── Drafting scales and paper sizes ─────────────────────────────────────────
// The number is the DENOMINATOR: at 1:20, twenty millimetres of the thing is
// one millimetre of paper. The imperial three are the scales a US shop drawing
// is actually issued at, expressed the same way so one code path serves both.
// A DETAIL is drawn at a detail scale. An edge profile at 1:5 is 6 mm of paper
// for 30 mm of stone — a hairline, and the one thing about a countertop the
// client actually looks at. These are the scales a joinery detail is issued at
// and they belong on the ladder a detail view searches; they are deliberately
// NOT offered for a whole plan, which is why the sheet pickers read
// SHEET_SCALES and a detail reads SHEET_DETAIL_SCALES.
const SHEET_DETAIL_SCALES = [
  { key: '1:1', denom: 1, label: 'FULL SIZE 1:1' },
  { key: '1:2', denom: 2, label: '1:2' },
  { key: '1:5', denom: 5, label: '1:5' },
  { key: '1:10', denom: 10, label: '1:10' },
];
const SHEET_SCALES = [
  { key: '1:5',  denom: 5,  label: '1:5' },
  { key: '1:10', denom: 10, label: '1:10' },
  { key: '1:20', denom: 20, label: '1:20' },
  { key: '1:25', denom: 25, label: '1:25' },
  { key: '1:50', denom: 50, label: '1:50' },
  { key: 'half', denom: 24, label: '1/2" = 1\'-0"' },
  { key: 'threeEighth', denom: 32, label: '3/8" = 1\'-0"' },
  { key: 'quarter', denom: 48, label: '1/4" = 1\'-0"' },
];
// ISO and ANSI sheets, in millimetres, landscape.
const SHEET_SIZES = [
  { key: 'A3', label: 'A3 (420 x 297)', w: 420, h: 297 },
  { key: 'A2', label: 'A2 (594 x 420)', w: 594, h: 420 },
  { key: 'A1', label: 'A1 (841 x 594)', w: 841, h: 594 },
  { key: 'ARCH_D', label: 'ARCH D (36 x 24 in)', w: 914.4, h: 609.6 },
  { key: 'TABLOID', label: 'Tabloid (17 x 11 in)', w: 431.8, h: 279.4 },
];
function sheetSize(key) { return SHEET_SIZES.find(x => x.key === key) || SHEET_SIZES[0]; }
function sheetDenom(key) { const s = SHEET_SCALES.find(x => x.key === key); return s ? s.denom : 20; }
function sheetScaleLabel(denom) {
  const s = SHEET_SCALES.find(x => x.denom === denom);
  return s ? s.label : `1:${denom}`;
}

const QUOTE_SCOPE_LABELS = {
  casework: 'Casework', countertop: 'Countertop / Stone', doors: 'Doors',
  tile: 'Tile', spc: 'SPC', lvt: 'LVT', wood: 'Engineered Wood',
  carpet: 'Carpet', rubber: 'Rubber', base: 'Baseboard / Trim',
};
function quoteScopeLabel(k) {
  return QUOTE_SCOPE_LABELS[k] || String(k || '').charAt(0).toUpperCase() + String(k || '').slice(1);
}

let __activeQuoteSpecs = null;
function setActiveQuoteSpecs(map) { __activeQuoteSpecs = map || null; }

// ─── The quotation deck ──────────────────────────────────────────────────────
// Leon's issued quotation is a slide deck, and the one supplied as the model
// (3-4 Seagrave REV 00) is 82 slides built from only SEVEN repeating slide
// types, applied by hand once per scope. So the template is not being designed
// here — it is being extracted from what the team already produces.
//
// Two faults in that deck are what this exists to fix, and neither is cosmetic:
// the Bid Summary and the Table of Contents were PASTED PICTURES (a 1.9 MB
// Windows metafile of a spreadsheet), so the page the client decides on could
// not be checked and went stale the moment a price moved; and the file was
// 533 MB, which nobody can email.
const QUOTE_DECK_ARCHETYPES = [
  { key: 'cover',    label: 'Cover',                 pictures: 1, perScope: false },
  { key: 'about',    label: 'About us',              pictures: 1, perScope: false },
  { key: 'refs',     label: 'References',            pictures: 0, perScope: false },
  { key: 'toc',      label: 'Table of contents',     pictures: 0, perScope: false },
  { key: 'plans',    label: 'Project plans',         pictures: 2, perScope: false },
  // NO picture slot. A section divider always uses LEON's own standard artwork
  // — the same kitchen behind KITCHEN CASEWORK on every quotation — so offering
  // it as something to fill in per job is offering work that should never be
  // done. Changing one is a decision about the STANDARD, made once under
  // Quote Settings, not a slot on every deck.
  { key: 'divider',  label: 'Section divider',       pictures: 0, perScope: true  },
  // ONE picture — their design-intent pages carry a single image, not a pair.
  { key: 'intent',   label: 'Design intent',         pictures: 1, perScope: true  },
  { key: 'spec',     label: 'Specification',         pictures: 1, perScope: true  },
  { key: 'colors',   label: 'Colour options',        pictures: 0, perScope: true  },
  { key: 'gallery',  label: 'Gallery',               pictures: 4, perScope: true  },
  { key: 'product',  label: 'Product detail',        pictures: 3, perScope: true  },
  { key: 'breakdown',label: 'Quantities by area',    pictures: 0, perScope: true  },
  { key: 'disclaim', label: 'Disclaimer',            pictures: 1, perScope: true  },
  // No picture. The estimate is where the client reads the numbers, and it
  // states the line items instead — so it declares no slot to fill.
  { key: 'estimate', label: 'Scope estimate',        pictures: 0, perScope: true  },
  { key: 'install',  label: 'Installation package',  pictures: 1, perScope: true  },
  { key: 'summary',  label: 'Bid summary',           pictures: 0, perScope: false },
  { key: 'terms',    label: 'Terms & conditions',    pictures: 0, perScope: false },
  // The back cover is the mark, centred, with nothing behind it.
  { key: 'back',     label: 'Back cover',            pictures: 0, perScope: false },
];
// Archetypes a person may insert by hand. The generated ones (cover, estimate,
// summary, terms) are not offered: they are the quotation stating itself, and a
// deck with two bid summaries or none is not a variation, it is a mistake.
// A specification slide is often SEVERAL slides. Every issued deck splits
// casework into a BASE CABINET page and an UPPER CABINET page — the same
// questions asked twice with different answers — and the Quote Lines list
// already carries that split in the field names ("Base Door Finish", "Upper
// Door Finish"). So the split is derived from the fields rather than declared
// twice and left to drift.
//
// A field carrying no part prefix (Box Material, Handle Type, LED) is COMMON
// and appears on every part slide, which is how the real decks read: both the
// base and the upper page list the door material and the box material.
const QUOTE_SPEC_PARTS = {
  casework: [
    { label: 'Base Cabinet', prefix: 'Base' },
    { label: 'Upper Cabinet', prefix: 'Upper' },
  ],
};
function quoteSpecPartsFor(scopeKey) {
  return QUOTE_SPEC_PARTS[quoteScopeSpecKey(scopeKey)] || null;
}

// Trades whose AREAS each get their own break page and design intent.
//
// Casework is the case: every issued deck opens KITCHEN CASEWORK, specifies it,
// then opens BATHROOM CASEWORK and specifies that — two sections of the deck
// for one priced scope, because they are two different products to look at even
// though they are one line of the quotation. The area name and the scope name
// together are also exactly how LEON's standard artwork is filed
// ("kitchen-casework", "bathroom-casework"), so the pictures follow with no
// second mapping to keep in step.
const QUOTE_DECK_AREA_BREAKS = { casework: true };
function quoteDeckAreaBreaks(scopeKey) {
  return !!QUOTE_DECK_AREA_BREAKS[quoteScopeSpecKey(scopeKey)];
}

const QUOTE_DECK_INSERTABLE = ['divider', 'intent', 'spec', 'product', 'breakdown',
                               'gallery', 'colors', 'disclaim', 'plans', 'refs', 'about'];

// The quantities disclaimer, which appears verbatim on a slide of its own in
// every issued deck we have seen.
const QUOTE_DECK_DISCLAIMER =
  'The square footage and quantities provided in this quotation have been estimated by LEON Integra ' +
  'based on the drawing sets provided at the time of quotation. The General Contractor or Developer ' +
  'is responsible for verifying all quantities required to complete the project.';
// Which supplier catalog a trade's colour board is drawn from. Only trades
// that actually HAVE a board appear — a scope with no entry simply gets no
// colour slides, which is the right answer for casework or doors.
const QUOTE_DECK_COLOUR_SUPPLIERS = {
  countertop: 'vicrown',
};
function quoteDeckArchetype(key) {
  return QUOTE_DECK_ARCHETYPES.find(a => a.key === key) || null;
}

// The lead times an estimate slide states. These are Leon's own standard, read
// off their issued deck — shop drawing 2 weeks, a revision round 1, production
// 8, freight 6 — and they are a DEFAULT, not a rule: a quotation may carry its
// own, and a scope may differ again. null at either level means follow the one
// above, which is the same inherit rule every figure on a quotation uses.
const QUOTE_DECK_LEADTIME_DEFAULT = { shopDrawingWeeks: 2, revisionWeeks: 1, productionWeeks: 8, freightWeeks: 6 };
const QUOTE_DECK_LEADTIME_FIELDS = [
  { key: 'shopDrawingWeeks', label: 'Shop drawing — 1st issue' },
  { key: 'revisionWeeks',    label: 'Shop drawing — revisions' },
  { key: 'productionWeeks',  label: 'Production' },
  { key: 'freightWeeks',     label: 'Freight' },
];
function quoteDeckLeadTimes(qa, sec) {
  const out = {};
  QUOTE_DECK_LEADTIME_FIELDS.forEach(f => {
    const fromLine = sec && sec.leadTimes ? sec.leadTimes[f.key] : null;
    const fromJob  = qa && qa.leadTimes ? qa.leadTimes[f.key] : null;
    // null inherits; 0 is a real answer meaning "none", so only null falls through.
    out[f.key] = fromLine != null ? fromLine
      : (fromJob != null ? fromJob : QUOTE_DECK_LEADTIME_DEFAULT[f.key]);
  });
  return out;
}

// A picture SLOT. The deck's pictures are the half a generator cannot invent,
// so each slide declares the slots it has and the team fills them — from the
// Render Library, a supplier finish, a project photo, or an upload. An unfilled
// slot is not silently dropped: it renders as a placeholder and is counted, so
// a deck cannot be issued with holes in it.
const QUOTE_DECK_PICTURE_SOURCES = [
  { key: 'render',  label: 'Render Library' },
  { key: 'finish',  label: 'Supplier finish' },
  { key: 'project', label: 'Project photo' },
  { key: 'upload',  label: 'Upload' },
];
function makeQuoteDeckPicture(data) {
  return Object.assign({
    slot: '', source: null, url: null, name: '', caption: '',
  }, data || {});
}

// The 15 clauses of Leon's standard terms, transcribed VERBATIM from the
// issued deck (3-4 Seagrave REV 00, slides 75-81). The wording is theirs and
// is not edited here; only the block structure (paragraph / sub-heading /
// list item) is inferred, because the source marks none — every line in that
// deck is a plain paragraph. Anything mis-classified is fixed on the screen,
// which is why the set is editable rather than a constant.
const QUOTE_TERMS_SEED = [
  { n: "1", title: "Validity of Proposal", body: [{ t: "p", s: "This quotation is valid for a period of thirty (30) days from the date of issuance. Pricing is subject to change after this period due to fluctuations in material costs, tariffs, freight, labor, or market conditions." }] },
  { n: "2", title: "Scope & Revisions", body: [{ t: "p", s: "This proposal is based on the specifications, quantities, and materials outlined herein. Any changes to scope, design, quantities, finishes, specifications, or project conditions shall require a revised quotation and may result in adjustments to pricing, production schedules, and delivery timelines." }] },
  { n: "3", title: "Scope of Work", body: [{ t: "h", s: "3.1 Supply Only (Unless Otherwise Stated)" }, { t: "p", s: "Unless explicitly stated in writing, LEON acts strictly as a material supplier. LEON shall not be responsible for:" }, { t: "li", s: "Installation, field labor, supervision, or site coordination" }, { t: "li", s: "Tools, fasteners, adhesives, sealants, blocking, or sundry materials" }, { t: "li", s: "Substrate preparation or site readiness" }, { t: "li", s: "Field measurements or verification" }, { t: "li", s: "Plumbing, electrical, or structural coordination" }] },
  { n: "4", title: "Casework, Countertops, Doors & Material Disclaimer", body: [{ t: "p", s: "All casework, countertops, and doors are manufactured strictly in accordance with approved shop drawings and specifications." }, { t: "p", s: "LEON is not responsible for discrepancies between drawings and actual site conditions, including but not limited to framing variations, wall conditions, floor leveling, ceiling alignment, or rough opening inconsistencies." }, { t: "p", s: "The Buyer/Client is fully responsible for:" }, { t: "li", s: "Verifying all field dimensions, rough openings, and site conditions prior to production" }, { t: "li", s: "Ensuring that all openings are plumb, level, square, and ready for installation" }, { t: "li", s: "Coordinating all required blocking, backing, and structural supports" }, { t: "li", s: "Confirming all clearances for appliances, plumbing, electrical, HVAC, and other trades" }, { t: "li", s: "Ensuring proper door frame preparation, hardware coordination, and tolerance allowances" }, { t: "p", s: "Field adjustments, including but not limited to scribing, trimming, cutting, re-drilling, hinge adjustments, or alignment corrections, are not included and shall be performed by others unless specifically stated." }, { t: "p", s: "LEON shall not be responsible for:" }, { t: "li", s: "Misalignment or improper operation caused by site conditions or improper installation by others" }, { t: "li", s: "Variations in gaps, reveals, or tolerances due to field conditions" }, { t: "li", s: "Hardware installation, adjustments, or compatibility unless specifically included" }, { t: "p", s: "Natural and engineered materials (including wood doors, veneers, stone, tile, and other finishes) may exhibit variations in color, grain, veining, and texture, which are inherent characteristics and shall not be considered defects." }, { t: "p", s: "LEON is not responsible for:" }, { t: "li", s: "Damage caused by improper handling, unloading, storage, or installation by others" }, { t: "li", s: "Exposure to moisture, humidity, temperature fluctuations, or job site conditions outside recommended standards" }, { t: "li", s: "Warping, expansion, contraction, or movement of materials due to environmental conditions" }, { t: "p", s: "Any modifications required due to incorrect site conditions, coordination issues, or changes requested after production shall result in additional costs and potential delays." }] },
  { n: "5", title: "Delivery, Mobilization & Hoisting", body: [{ t: "p", s: "Delivery is based on standard ground-level access." }, { t: "p", s: "Hoisting, craning, forklift use, rigging, or specialized equipment is excluded unless explicitly included in writing." }, { t: "p", s: "Mobilization, staging, site logistics, and coordination are excluded unless otherwise stated." }, { t: "p", s: "Any site constraints or special requirements not disclosed prior to execution may result in additional charges." }] },
  { n: "6", title: "Shipping & Delivery Terms", body: [{ t: "p", s: "Delivery shall be made to the project site unless otherwise agreed." }, { t: "p", s: "Any changes to delivery location, delays in acceptance, or missed delivery schedules may result in additional charges, including storage, re-delivery, detention, and demurrage, which shall be borne by the Buyer." }, { t: "p", s: "Any additional costs related to oversized containers or special shipping requirements shall be passed through to the Buyer." }, { t: "p", s: "Unloading Responsibility" }, { t: "p", s: "The Buyer is responsible for unloading all materials and providing necessary labor and equipment." }, { t: "p", s: "Unloading must be completed within the carrier’s allocated time." }, { t: "p", s: "Any additional charges due to delays shall be the Buyer’s responsibility." }] },
  { n: "7", title: "Shop Drawings & Production", body: [{ t: "p", s: "Shop drawings will be issued after contract execution." }, { t: "p", s: "Production will commence only upon:" }, { t: "li", s: "Receipt of approved, signed, and stamped shop drawings, and" }, { t: "li", s: "Receipt of required deposit payment" }, { t: "li", s: "The Buyer is solely responsible for verifying all:" }, { t: "li", s: "Dimensions" }, { t: "li", s: "Quantities" }, { t: "li", s: "Layouts" }, { t: "li", s: "Material selections" }, { t: "p", s: "Any changes after approval will result in additional costs and potential delays." }] },
  { n: "8", title: "Payment Terms", body: [{ t: "p", s: "50% – Deposit to commence production" }, { t: "p", s: "40% – Prior to shipment from manufacturer" }, { t: "p", s: "10% – Upon delivery to job site" }] },
  { n: "9", title: "Storage & Warehousing", body: [{ t: "p", s: "In case of project delays, LEON may provide storage at additional cost:" }, { t: "li", s: "Sharon, MA:" }, { t: "li", s: "$750.00 – First month (per container)" }, { t: "li", s: "$500.00/month – 40’ container" }, { t: "li", s: "$400.00/month – 20’ container" }, { t: "p", s: "Rates apply per container, per month." }, { t: "p", s: "Inland freight is not included when warehousing is required and will be charged based on market rates at time of delivery." }] },
  { n: "10", title: "Installation Terms (If Applicable)", body: [{ t: "p", s: "If LEON is contracted to perform installation services, the following shall apply:" }, { t: "h", s: "10.1 Installation Scope" }, { t: "p", s: "Installation is limited strictly to the agreed contractual scope and shall be performed in accordance with industry standards and approved shop drawings." }, { t: "h", s: "10.2 Site Conditions" }, { t: "p", s: "LEON is not responsible for:" }, { t: "li", s: "Improper or incomplete site preparation" }, { t: "li", s: "Structural, plumbing, or electrical conflicts" }, { t: "li", s: "Field conditions differing from approved drawings" }] },
  { n: "11", title: "Warranty & Post-Completion Modifications", body: [{ t: "h", s: "11.1 Third-Party Modifications" }, { t: "p", s: "Upon completion of LEON’s installation, any modification, adjustment, removal, reinstallation, or alteration performed by any third party—including Owner, General Contractor, or other trades—shall immediately void all warranties and responsibilities of LEON for the affected scope." }, { t: "p", s: "This applies to, but is not limited to:" }, { t: "li", s: "Casework" }, { t: "li", s: "Countertops" }, { t: "li", s: "Tile installations" }, { t: "li", s: "Doors and frames" }, { t: "li", s: "Flooring (SPC, engineered wood, carpet, etc.)" }, { t: "li", s: "Baseboards and trim" }, { t: "li", s: "Any materials supplied and/or installed by LEON" }, { t: "h", s: "11.2 Warranty Limitations" }, { t: "p", s: "LEON shall not be responsible for:" }, { t: "li", s: "Damages caused by third-party work after installation" }, { t: "li", s: "Improper use, maintenance, or cleaning" }, { t: "li", s: "Building movement, settlement, or structural conditions" }, { t: "li", s: "Coordination issues caused by other trades" }, { t: "li", s: "Concealed conditions not visible at time of installation" }] },
  { n: "12", title: "Acceptance of Work", body: [{ t: "p", s: "Work shall be deemed accepted upon:" }, { t: "li", s: "Substantial completion of LEON’s scope, or" }, { t: "li", s: "Occupancy or use of the installed areas" }, { t: "p", s: "Any claims must be submitted in writing prior to third-party intervention. Failure to do so releases LEON from further responsibility." }] },
  { n: "13", title: "Force Majeure", body: [{ t: "p", s: "LEON shall not be liable for delays or failure to perform due to events beyond its control, including but not limited to acts of God, war, civil unrest, strikes, pandemics, governmental actions, fire, theft, or damage caused by others. LEON reserves the right to extend timelines accordingly." }] },
  { n: "14", title: "Limitation of Liability", body: [{ t: "p", s: "LEON’s liability is strictly limited to the supply and/or installation scope defined herein. LEON shall not be liable for:" }, { t: "li", s: "Indirect, incidental, or consequential damages" }, { t: "li", s: "Loss of profits, project delays, or labor costs" }, { t: "li", s: "Damages occurring after delivery or installation" }] },
  { n: "15", title: "Entire Agreement", body: [{ t: "p", s: "These Terms & Conditions constitute the entire agreement and supersede any prior communications unless otherwise agreed in writing." }] },
];

// A terms SET is versioned, because an issued quotation must keep the terms it
// went out with. Editing the standard raises a new version; a quotation stamps
// the version it used, so re-opening Rev 00 next year still shows what the
// client actually agreed to rather than today's wording.
function makeQuoteTermsSet(data) {
  return Object.assign({
    id: uid('qterms'),
    name: 'Standard Terms & Conditions',
    version: 1,
    createdDate: todayISO(),
    createdBy: '',
    note: '',
    active: true,
    clauses: QUOTE_TERMS_SEED.map(c => ({ n: c.n, title: c.title, body: c.body.map(b => ({ t: b.t, s: b.s })) })),
  }, data || {});
}
function quoteTermsCurrent(sets) {
  const live = (sets || []).filter(s => s.active !== false);
  if (!live.length) return null;
  return live.slice().sort((a, b) => qnum(b.version) - qnum(a.version))[0];
}

function quoteSpecSeed() { return QUOTE_SCOPE_SPECS; }
// The map is SPARSE: it holds only the trades whose list actually differs from
// the standard. That matters because App() persists whatever this returns, so a
// map filled in for every trade would mark all ten as "edited" on the first
// load and no improvement to the standard could ever reach anyone again — which
// is exactly what happened. Same rule as softwareSettings: a value equal to the
// default is not stored.
//
// A list that IS an override is kept ENTIRE rather than merged with the seed:
// these are lists, not field maps, and appending the seed's fields would put
// back the ones someone deliberately removed.
function mergeNewQuoteSpecs(persisted) {
  const out = {};
  Object.keys(persisted || {}).forEach(k => {
    const v = persisted[k];
    if (!Array.isArray(v)) return;
    const seed = QUOTE_SCOPE_SPECS[k];
    if (seed && seed.length === v.length && seed.every((f, i) => f === v[i])) return;  // same as standard
    out[k] = v.slice();
  });
  return out;
}
// The job-level dials a quotation is priced on, as one object. Named in one
// place so the wizard step, the read-only strip and the write-back cannot drift
// apart — a field added here reaches all three.
const QUOTE_JOB_FIELDS = ['rateBasis', 'defaultRatePct', 'overheadPct',
  'freightBasis', 'freightPct', 'freightPerUnit', 'freightPerCbm', 'freightLump',
  'containerCapacity', 'freightPerContainer', 'inlandPerContainer', 'brokerPerContainer',
  'dutyBasis', 'dutyPct', 'dutyPerUnit', 'dutyLump',
  'commissionPct', 'bonusPct', 'referralPct', 'referralBasis', 'referralAmt', 'referralTo', 'unitCount', 'taxRatePct'];
function quoteJobFields(qa) {
  const out = {};
  QUOTE_JOB_FIELDS.forEach(k => { out[k] = qa ? qa[k] : null; });
  return out;
}
// A priced row, as opposed to an area heading or a note. Every place that adds
// money, counts lines or checks for an unpriced one asks this first.
function quoteRowIsItem(line) { return !line || !line.rowKind || line.rowKind === 'item'; }
const QUOTE_ROW_KINDS = [
  { key: 'area', label: 'area', icon: '\u25A6', hint: 'A heading that separates the lines beneath it.' },
  { key: 'note', label: 'note', icon: '\u270E', hint: 'Something to say about the lines around it.' },
];
// Which of a scope's specification fields carry their PICTURE to the client
// quote and the presentation deck. Absent means every field that has a finish
// picked — the useful default, and what makes this work with no setup. An
// array is a deliberate curation and an empty one means none, which is why the
// two cases must not be collapsed.
// THE SPECIFICATION BELONGS TO THE AREA.
// A quotation is written area by area — "Level 4, Units 401-412" and then the
// cabinets in it — and every item in an area is almost always the same
// specification. Asking it per line meant typing one answer forty times.
// So an AREA row carries the specification and the items beneath it inherit it,
// field by field. A line may still override one field without disturbing the
// rest, which is why this merges per key rather than choosing one whole map.
//
// `lines` is ordered and area rows live in it, which is what makes "beneath"
// mean something: the owning area is the nearest area row ABOVE the line.
function quoteAreaRowFor(section, line) {
  const rows = (section && section.lines) || [];
  const i = rows.indexOf(line);
  if (i < 0) return null;
  for (let k = i - 1; k >= 0; k--) {
    if (rows[k] && rows[k].rowKind === 'area') return rows[k];
  }
  return null;
}
// The specification actually in force on a line: the area's answers with the
// line's own written over them. Returns both halves so a screen can show what
// is inherited and what was overridden — a value you cannot tell apart from an
// inherited one is how a scope ends up quoted on somebody else's finish.
function quoteResolvedSpecs(section, line) {
  const area = quoteAreaRowFor(section, line);
  const aSpecs = (area && area.specs) || {};
  const aRefs = (area && area.specRefs) || {};
  const lSpecs = (line && line.specs) || {};
  const lRefs = (line && line.specRefs) || {};
  const specs = Object.assign({}, aSpecs, lSpecs);
  const refs = Object.assign({}, aRefs, lRefs);
  // A blank on the line is not an override, it is silence — strip it so the
  // area's answer shows through.
  Object.keys(lSpecs).forEach(k => { if (!lSpecs[k]) { if (aSpecs[k]) specs[k] = aSpecs[k]; } });
  Object.keys(lRefs).forEach(k => { if (!lRefs[k] && aRefs[k]) refs[k] = aRefs[k]; });
  return { specs, refs, area, from: k => (lSpecs[k] ? 'line' : (aSpecs[k] ? 'area' : null)) };
}

function quoteSpecImageFieldsFor(scopeKey, map, allFields) {
  const key = quoteScopeSpecKey(scopeKey);
  const m = map || {};
  const chosen = m[key];
  if (!Array.isArray(chosen)) return (allFields || []).slice();
  return (allFields || []).filter(f => chosen.indexOf(f) >= 0);
}

function quoteSpecFieldsFor(name) {
  const k = quoteScopeSpecKey(name);
  // The team's override first, then the standard, then the take-off's own
  // questions for a trade neither has been written for.
  const over = __activeQuoteSpecs && __activeQuoteSpecs[k];
  if (over) return over;
  const own = QUOTE_SCOPE_SPECS[k];
  if (own) return own;
  const raw = String(name || '').trim().toLowerCase();
  const hit = TAKEOFF_SCOPES.find(sc => {
    const key = String(sc.key || '').toLowerCase();
    const tab = String(sc.tab || '').toLowerCase();
    return key === raw || tab === raw || key.indexOf(raw) === 0 || raw.indexOf(tab) === 0;
  });
  return (hit && hit.specs) || [];
}
function quoteRecipeFor(name) {
  const k = String(name || '').trim().toLowerCase();
  const table = __activeQuoteRecipes || QUOTE_SCOPE_RECIPES;
  if (table[k]) return table[k];
  // "Countertops", "Engineered Wood", "Baseboard" and the like.
  const alias = { countertops: 'countertop', 'countertop / stone': 'countertop', stone: 'countertop',
                  'engineered wood': 'wood', lvt: 'spc', tiles: 'tile', baseboard: 'base',
                  'baseboards / trims': 'base', door: 'doors' };
  return table[alias[k]] || null;
}

const QUOTE_RATE_BASES = [
  { key: 'margin', label: 'Margin on sell', hint: 'Sell = Cost / (1 - rate)' },
  { key: 'markup', label: 'Markup on cost', hint: 'Sell = Cost x (1 + rate)' },
];

// The scopes quotes are actually written against. Offered when a section is
// added by hand; an imported workbook brings its own tab names.
const QUOTE_STANDARD_SCOPES = ['Casework', 'Countertops', 'Doors', 'SPC',
  'Engineered Wood', 'LVT', 'Rubber', 'Tiles', 'Carpet', 'Baseboard',
  'Hardware', 'Mirrors', 'Other'];

// One priced line. Everything above `vendorId` is carried from the take-off and
// is not re-derived here — if a quantity is wrong it is wrong in the take-off,
// which is the only place it should be corrected.
function makeQuoteLine(data) {
  return {
    id: uid('qln'),
    // What KIND of row this is. A quotation is not only priced items: it needs
    // a heading to say which area the next few lines are for, and a note to say
    // something about them. Both live in `lines` so they sit where they were
    // put and move with the work; neither is priced, and neither counts as an
    // unpriced line. Default 'item', so everything saved before this reads as
    // an item without a migration.
    rowKind: 'item',
    area: '', category: '', location: '', itemTag: '', description: '',
    // How a line is actually described on the client's own sheet. The two
    // quantities are the important part and were being collapsed into one:
    //   qtyPerItem  — how many of the counted thing in ONE item (17 modules in
    //                 this kitchen, 78.9 sq ft in this top)
    //   itemCount   — how many identical items of this type (5 such kitchens)
    // The workbook does exactly this (module count in J, the multiplier in L)
    // and the total is the product. Storing only the product loses the fact
    // that five kitchens are five of the same thing, which is what makes a
    // schedule of quantities readable and what a client asks about.
    itemName: '', itemType: '', proposedFinishes: '',
    qtyPerItem: null, itemCount: null,
    specs: {}, unitType: '', qtyPerUnit: null, unitQty: null,
    wastePct: 0, qty: 0, uom: '', drawingRef: '', takeoffStatus: '', takeoffNote: '',
    // the cost build-up — the part sales actually works in.
    // null on a rate means "follow the analysis default", which is different
    // from 0: a line that should genuinely carry no freight says 0.
    vendorId: null, matUnit: null, laborUnit: null, installUnit: null,
    // Freight and duty each carry a basis and the figure that basis needs.
    // null on the basis means "however this scope charges it".
    freightBasis: null, freightPct: null, freightPerUnit: null,
    cbmPerUnit: null, freightPerCbm: null, freightLump: null,
    // By-the-container freight. Capacity and the three rates inherit from the
    // scope; the DRIVER is the line's own, because a container fraction is
    // measured against what this line actually ships.
    containerCapacity: null, freightPerContainer: null,
    inlandPerContainer: null, brokerPerContainer: null,
    // Overhead is charged on material, at a rate that inherits.
    overheadPct: null,
    // Material bought by the slab rather than by the unit.
    matBasis: null, slabYield: null, slabWastePct: null, slabRate: null,
    // What this line is OFFERING — real supplier finishes, not their names
    // retyped. Each is the same ~196-byte reference the Selection Hub stores, so
    // a quote and the order placed from it cannot describe two different
    // products. A LIST, because one line is routinely a core plus a laminate
    // plus an edge; `finishRef` is the old singular field, read for anything
    // saved before this and never written again.
    finishRefs: [],
    finishRef: null,
    // The trade's OWN specification, keyed by field name. Every scope asks for
    // six different things — a countertop wants thickness and edge profile, a
    // door wants core and fire rating, carpet wants backing and pattern repeat
    // — and those lists already exist on TAKEOFF_SCOPES. This is where the
    // answers live, so a quote line says what is actually being bought rather
    // than only what it costs.
    specs: {},
    // The workbook's Mark-Up column: a dollar amount added to the client's
    // price that is NOT margin — it is money owed onward, so it comes out of
    // profit rather than adding to it.
    markupAmt: null,
    // Commission and bonus inherit; a line may differ.
    commissionPct: null, bonusPct: null, referralPct: null,
    referralBasis: null, referralAmt: null,
    dutyBasis: null, dutyPct: null, dutyPerUnit: null, dutyLump: null, htsCode: '',
    // Which classification this rate came from, so a quote can be traced back
    // to the library entry rather than carrying an orphan percentage.
    tariffClassificationId: null, countryOfOrigin: '',
    // How this ONE item is priced, when it differs from its scope.
    // null = follow the scope. 'rate' builds the price up from cost;
    // 'unitPrice' quotes a set rate per unit and lets the margin fall out.
    pricingMethod: null, ratePct: null, rateBasis: null, unitPrice: null,
    sellOverride: null, excluded: false, note: '',
    ...data,
    // Minted AFTER the spread: callers clone a line with `{ ...l, id: undefined }`
    // to mean "this copy needs its own", and a spread undefined was overwriting
    // the generated id with null — leaving every line in a wizard-built draft
    // sharing one id, so editing one edited the first.
    id: (data && data.id) || uid('qln'),
  };
}

// One scope's worth of lines. Mirrors a tab of the take-off template.
// Supply and labour are contracted and billed separately, so they are separate
// scopes in the quote as well — the one exception being countertops, where a
// single contract buys the slab, templates it and installs it. That is the same
// rule COMBINED_SCOPE_TYPE encodes for a live job; the quote follows it rather
// than inventing a second convention.
const QUOTE_SECTION_KINDS = [
  { key: 'supply', label: 'Supply', hint: 'Material delivered. Carries freight and duty.' },
  { key: 'labor', label: 'Labor', hint: 'Installation only. No freight, no duty.' },
  { key: 'combined', label: 'Supply & Install', hint: 'One contract for both — countertops.' },
];
// The scopes sold as one package rather than split.
const QUOTE_COMBINED_SCOPES = ['countertop', 'countertops', 'countertop / stone', 'stone'];
function quoteScopeIsCombined(name) {
  return QUOTE_COMBINED_SCOPES.includes(String(name || '').trim().toLowerCase());
}

// A scope's worth of lines, and how that scope is priced. This is the level
// the decision is normally made at — a casework package priced on target
// margin, a flooring scope quoted at a known rate per square foot — with any
// single item free to differ. null on any rate means "follow the analysis".
function makeQuoteSection(data) {
  // Countertops carry 6% commission and everything else 3%, so a section picks
  // that up from its own name rather than needing to be told.
  // scopeKey FIRST: `name` carries the sold-as suffix — "Casework — Supply",
  // "Countertops — Supply & Install" — which matches no recipe and no commission
  // rule. scopeKey is the clean scope name, which is what both look up. Reading
  // `name` first silently gave every hand-added scope no recipe and 3%
  // commission, countertops included.
  const named = (data && (data.scopeKey || data.name)) || '';
  return {
    // The id is stamped AFTER the spread below, because callers clone a section
    // with `{ ...sec, id: undefined }` to mean "give this copy a new one" — and
    // a spread `id: undefined` silently overwrote the generated id with null.
    // Every scope and every line the quote wizard built therefore shared one id,
    // so editing any line edited the first and removing any scope removed the
    // first. Written last, a supplied id is honoured and a blank one is minted.
    id: uid('qsec'), name: '', scopeKey: '', scopeId: null, uom: '', note: '',
    kind: 'supply',
    pricingMethod: 'rate', ratePct: null, unitPrice: null,
    // Whether that percentage is read on the SELL price or on cost. null follows
    // the job, which is margin-on-sell — LEON's standard. A scope genuinely
    // bought cost-plus can still be quoted as a markup without dragging the rest
    // of the quotation onto the wrong convention.
    rateBasis: null,
    freightBasis: null, freightPct: null, freightPerUnit: null, freightPerCbm: null, freightLump: null,
    dutyBasis: null, dutyPct: null, dutyPerUnit: null, dutyLump: null,
    // The scope's cost recipe — what it counts, how its material is bought, how
    // its container is filled and what the freight legs cost. This is the level
    // the Revere Street workbook set these at, one block at a time, except that
    // there they were typed inside formulas rather than shown.
    costDriver: 'qty', pieceLengthIn: null,
    matBasis: 'unit', slabYield: null, slabWastePct: null, slabRate: null,
    overheadPct: null,
    containerCapacity: null, freightPerContainer: null,
    inlandPerContainer: null, brokerPerContainer: null,
    bonusPct: null, referralPct: null,
    // A referral is either a share of the price or a flat sum agreed for the
    // introduction. 'pct' reads referralPct, 'amount' reads referralAmt.
    referralBasis: null, referralAmt: null,
    // After the other defaults so they are not overwritten by them, and before
    // ...data so a section created with explicit values still keeps them.
    commissionPct: named ? quoteCommissionForScope(named) : null,
    ...(named ? (quoteRecipeFor(named) || {}) : {}),
    lines: [], ...data,
    // Last word on the id, for the reason set out above.
    id: (data && data.id) || uid('qsec'),
  };
}

function makeQuoteAnalysis(data, byName) {
  return {
    id: uid('qa'),
    name: 'Quote Analysis',
    revision: 1,
    status: 'Draft',
    department: 'Interiors',
    source: 'manual',          // 'takeoff' | 'excel' | 'manual'
    sourceRef: '',             // the take-off record or file it was generated from
    preparedBy: byName || '',
    createdDate: todayISO(),
    currency: 'USD',
    // How a percentage is read, everywhere in this analysis. Separate from the
    // per-scope choice of WHETHER to price by percentage at all.
    rateBasis: 'margin',       // 'margin' (on sell) | 'markup' (on cost)
    defaultRatePct: 0.35,
    // Freight by the CONTAINER is the standard: it is how the goods actually
    // move, and the per-scope recipes already carry the capacity and the three
    // legs. A flat percentage was the old default and was nobody's real answer.
    freightBasis: 'container', freightPct: 0.08, freightPerUnit: null, freightPerCbm: null, freightLump: null,
    dutyBasis: 'pct', dutyPct: 0, dutyPerUnit: null, dutyLump: null,
    // Job-wide defaults for the rest of the spine. A scope or a line may differ,
    // and where it does the screen says so rather than leaving you to find it.
    overheadPct: 0,
    containerCapacity: null, freightPerContainer: null,
    inlandPerContainer: null, brokerPerContainer: null,
    // Sales cost, which the workbook charges against the CLIENT PRICE, not cost.
    // Commission is the salesperson's; the bonus is the GM's. They are two
    // different people's money and the workbook charges both against the client
    // price, not against cost.
    commissionPct: 0.03, bonusPct: 0.005,
    // A referral fee is owed to whoever brought the job in — an architect, a
    // past client, a broker. Zero by default because most jobs owe none, and it
    // comes out of PROFIT rather than adding to the price: the client is not
    // charged extra because we were introduced.
    referralPct: 0, referralTo: '', referralBasis: 'pct', referralAmt: 0,
    salesPersonId: null,
    // null means "follow the job", which is different from 0 — a quotation
    // deliberately zero-rated says 0, and the screen shows which it is.
    taxRatePct: null, unitCount: null,
    assumptions: '',
    exclusions: '',
    sections: [],
    warnings: [],              // what the import could not resolve, kept visible
    history: [],
    ...data,
  };
}

// A fully worked example, so the screen can be understood without pricing a
// real bid first. Deliberately mixes BOTH pricing methods — cost + margin on
// the made-to-order scopes, a set unit price on the ones that are really
// quoted as a rate — because that contrast is the point of the design.
// Named EXAMPLE so it can never be mistaken for live pricing.
function buildDemoQuoteAnalysis(byName) {
  const L = (tag, desc, area, cat, qty, uom, mat, inst, extra) => makeQuoteLine({
    itemTag: tag, description: desc, area, category: cat, qty, uom,
    matUnit: mat, installUnit: inst, ...(extra || {}),
  });
  const S = (name, uom, method, opts, lines) => makeQuoteSection({
    name, scopeKey: name, uom, pricingMethod: method, ...(opts || {}), lines,
  });

  return makeQuoteAnalysis({
    name: 'EXAMPLE — Quote Analysis (18-unit mid-rise)',
    status: 'Draft', source: 'manual', sourceRef: 'Worked example',
    rateBasis: 'margin', defaultRatePct: 0.35, freightPct: 0.08, dutyPct: 0.05,
    assumptions:
      'Priced off Take-Off R1 against Architectural Set Rev B.\n'
      + 'Quantities include the standard waste allowance for each scope.\n'
      + 'Freight at 8% and duty at 5% of material, imported from Vietnam.\n'
      + 'Install assumes a clear, dust-free site with power and a working hoist.\n'
      + 'Prices held for 30 days.',
    exclusions:
      'Demolition and removal of existing finishes.\n'
      + 'Substrate levelling or moisture mitigation.\n'
      + 'Appliances, plumbing fixtures and light fittings.\n'
      + 'Painting other than the factory finish on our own product.\n'
      + 'Permits, and any work outside normal hours.',
    sections: [
      // Made to order and counted as sets — priced from cost.
      S('Casework', 'Set', 'rate', { ratePct: 0.32 }, [
        L('KIT-1', 'Kitchen cabinetry — full unit set', 'Residences', 'Kitchen', 18, 'Set', 3850, 620),
        L('VAN-1', 'Bathroom vanity, 36" wall-hung', 'Residences', 'Bathroom Vanity', 36, 'Set', 890, 180),
        L('VAN-2', 'Powder room vanity, 24"', 'Residences', 'Powder Room Vanity', 18, 'Set', 640, 150),
        L('LOB-1', 'Reception desk and back millwork', 'Amenities', 'Reception / Front Desk', 1, 'Set', 14500, 3200),
      ]),
      S('Countertops', 'Sq. Ft.', 'rate', { ratePct: 0.35 }, [
        L('CT-1', 'Quartz, 3cm, polished — kitchen', 'Residences', 'Kitchen', 507.15, 'Sq. Ft.', 42, 14),
        L('CT-2', 'Quartz, 2cm, polished — vanity tops', 'Residences', 'Bathroom Vanity', 186.30, 'Sq. Ft.', 38, 12),
        L('CT-3', 'Granite, 3cm, honed — lounge bar', 'Amenities', 'Lounge / Bar', 69.00, 'Sq. Ft.', 58, 18),
      ]),
      S('Doors', 'Set', 'rate', { ratePct: 0.30 }, [
        L('D-1', 'Unit entry door — 20 min rated, painted', 'Residences', 'Entry Door', 18, 'Set', 680, 145),
        L('D-2', 'Interior door — flat slab, white', 'Residences', 'Interior Door', 92, 'Set', 310, 95),
        L('D-3', 'Closet door — bypass', 'Residences', 'Closet Door', 46, 'Set', 265, 80),
        L('D-4', 'Amenity doors — natural wood', 'Amenities', 'Amenity Door', 8, 'Set', 745, 165),
      ]),
      // Quoted as a rate per square foot — the margin is whatever it earns.
      S('SPC', 'Sq. Ft.', 'unitPrice', { unitPrice: 6.95 }, [
        L('SPC-1', 'SPC plank 6.5mm / 20 mil — living, dining, bedrooms', 'Residences', 'Living / Dining', 14820, 'Sq. Ft.', 2.85, 1.40),
      ]),
      S('Engineered Wood', 'Sq. Ft.', 'rate', { ratePct: 0.34 }, [
        L('EW-1', 'European oak, 7½" x 5/8", wire-brushed', 'Amenities', 'Lobby', 1460, 'Sq. Ft.', 7.20, 2.60),
        L('EW-2', 'European oak — lounge', 'Amenities', 'Lounge', 780, 'Sq. Ft.', 7.20, 2.60),
      ]),
      S('Tiles', 'Sq. Ft.', 'rate', { ratePct: 0.36 }, [
        L('TL-1', 'Porcelain 12x24, matte — bathroom floors', 'Residences', 'Bathroom Floor', 1640, 'Sq. Ft.', 4.10, 5.50),
        L('TL-2', 'Porcelain 12x24, matte — shower walls', 'Residences', 'Shower Wall', 2180, 'Sq. Ft.', 5.30, 7.20),
        L('TL-3', 'Mosaic — shower floors', 'Residences', 'Shower Floor', 190, 'Sq. Ft.', 11.50, 9.80),
        L('TL-4', 'Pool deck tile', 'Amenities', 'Pool / Wet Area', 1250, 'Sq. Ft.', 6.40, 6.90,
          { excluded: true, takeoffStatus: 'Not In Scope', note: 'By the pool contractor — carried for reference only.' }),
      ]),
      S('Carpet', 'Sq. Ft.', 'unitPrice', { unitPrice: 4.25 }, [
        L('CP-1', 'Broadloom, cut pile — corridors', 'Corridors & Common', 'Corridor', 3900, 'Sq. Ft.', 1.95, 1.10),
      ]),
      S('Baseboard', 'Linear Ft.', 'unitPrice', { unitPrice: 6.50 }, [
        L('BB-1', '5¼" MDF, primed and painted', 'Residences', 'Living / Dining', 2376, 'Linear Ft.', 3.10, 1.85),
        L('BB-2', '7" MDF, painted — amenity spaces', 'Amenities', 'Lobby', 640, 'Linear Ft.', 3.40, 2.00),
      ]),
    ],
  }, byName);
}

// ── Project closeout ──────────────────────────────────────────────────────
// The end of a job is a real step, not just the last stage going green: there
// are things that must be true before it can be closed, documents the client is
// owed, photographs worth keeping, and a warranty clock that starts. All of it
// hangs off the project, so it persists with `projects` and adds no new
// top-level state.
//
// The checklist is a seeded default rather than a fixed list — a job can add
// its own items, and an item that genuinely does not apply is marked N/A rather
// than ticked, so "complete" never quietly means "we ignored three of them".
const CLOSEOUT_STATUSES = ['Not Started', 'In Progress', 'Ready to Close', 'Closed'];
const CLOSEOUT_ITEM_STATES = ['Open', 'Done', 'N/A'];

const CLOSEOUT_CHECKLIST_SEED = [
  { key: 'punch_clear', group: 'Site', label: 'Punch list cleared and signed off' },
  { key: 'final_qc', group: 'Site', label: 'Final QC inspection passed' },
  { key: 'client_walk', group: 'Site', label: 'Client walkthrough completed' },
  { key: 'site_clean', group: 'Site', label: 'Site cleaned and protection removed' },
  { key: 'keys_returned', group: 'Site', label: 'Keys, badges and site access returned' },
  { key: 'substantial_completion', group: 'Documents', label: 'Certificate of Substantial Completion issued' },
  { key: 'as_builts', group: 'Documents', label: 'As-built / final drawings issued' },
  { key: 'warranty_docs', group: 'Documents', label: 'Warranty documents issued to the client' },
  { key: 'om_manual', group: 'Documents', label: 'Care & maintenance / O&M handover' },
  { key: 'attic_stock', group: 'Documents', label: 'Attic stock delivered and receipted' },
  { key: 'final_invoice', group: 'Money', label: 'Final invoice issued' },
  { key: 'retainage', group: 'Money', label: 'Retainage released' },
  { key: 'ap_settled', group: 'Money', label: 'All vendor and subcontractor invoices settled' },
  { key: 'lien_waivers', group: 'Money', label: 'Lien waivers collected' },
  { key: 'back_charges', group: 'Money', label: 'Back-charges resolved' },
  { key: 'leftover_stock', group: 'Materials', label: 'Leftover material returned to stock or disposed' },
  { key: 'containers_closed', group: 'Materials', label: 'Containers and logistics costs reconciled' },
  { key: 'photos_taken', group: 'Record', label: 'Finished photographs taken' },
  { key: 'lessons_logged', group: 'Record', label: 'Lessons learned recorded' },
];
const CLOSEOUT_GROUPS = ['Site', 'Documents', 'Money', 'Materials', 'Record'];

const CLOSEOUT_DOC_TYPES = ['Certificate of Substantial Completion', 'Final Punch Sign-Off',
  'Warranty Letter', 'Care & Maintenance Manual', 'As-Built Drawings', 'O&M Manual',
  'Lien Waiver', 'Attic Stock Receipt', 'Final Inspection Report', 'Client Sign-Off', 'Other'];

const LESSON_CATEGORIES = ['Schedule', 'Cost', 'Quality', 'Client', 'Vendor / Supplier',
  'Subcontractor', 'Logistics', 'Design / Drawings', 'Process', 'Other'];
const LESSON_KINDS = ['Went well', 'Went badly', 'Would do differently'];

function makeCloseoutItem(seed) {
  return { id: uid('cl'), key: seed.key, group: seed.group || 'Record', label: seed.label,
           state: 'Open', note: '', by: '', date: null, file: null, fileUrl: null, custom: !seed.key };
}
function makeCloseoutPhoto(file, fileUrl, data) {
  return { id: uid('clphoto'), file, fileUrl, caption: '', room: '', scopeId: null,
           credit: '', featured: false, addedBy: '', addedDate: todayISO(), ...(data || {}) };
}
function makeCloseoutDoc(data, by) {
  return { id: uid('cldoc'), type: CLOSEOUT_DOC_TYPES[0], name: '', file: null, fileUrl: null,
           date: todayISO(), addedBy: by || '', note: '', ...data };
}
function makeLesson(data, by) {
  return { id: uid('lesson'), category: 'Process', kind: 'Went badly', what: '', impact: '',
           action: '', loggedBy: by || '', date: todayISO(), ...data };
}
function makeCloseout() {
  return {
    status: 'Not Started',
    substantialCompletionDate: null, finalCompletionDate: null,
    closedDate: null, closedBy: '',
    // The warranty clock starts at substantial completion, and knowing when it
    // ENDS is the whole reason to record it.
    warrantyStartDate: null, warrantyMonths: 12, warrantyNotes: '',
    checklist: CLOSEOUT_CHECKLIST_SEED.map(makeCloseoutItem),
    photos: [], documents: [], lessons: [],
    clientSignOffName: '', clientSignOffDate: null, clientSignOffFile: null, clientSignOffUrl: null,
    summary: '',
    // Publishing to the Finished Projects library is a separate, deliberate act
    // — a job being closed is not the same as it being fit to show a client.
    portfolio: { published: false, title: '', blurb: '', coverPhotoId: null, tags: [],
                 hideClient: false, publishedBy: '', publishedDate: null },
  };
}
// A closeout is only as done as its checklist, and an N/A counts as settled —
// it was considered and ruled out, which is different from being ignored.
function closeoutProgress(co) {
  if (!co || !co.checklist) return { done: 0, total: 0, pct: 0, open: 0 };
  const total = co.checklist.length;
  const done = co.checklist.filter(i => i.state === 'Done' || i.state === 'N/A').length;
  return { done, total, open: total - done, pct: total ? done / total : 0 };
}
function warrantyEndDate(co) {
  if (!co || !co.warrantyStartDate || !co.warrantyMonths) return null;
  const d = new Date(co.warrantyStartDate + 'T00:00:00');
  d.setMonth(d.getMonth() + Number(co.warrantyMonths || 0));
  return toISO(d);
}

// ═══════════════════════════════════════════════════════ LEON Doors
// A door is a database object, not a drawing. Everything — the schedule, the
// elevation, the hardware, production, the submittal — reads this one record,
// so changing a width in the schedule and changing it in the designer are the
// same act. Drawings are GENERATED from these parameters and never stored as
// the source of truth.
//
// Dimensions are held in MILLIMETRES as the single canonical unit and formatted
// on the way out. Storing "36" and a unit flag means every comparison has to
// know which system it is in; storing mm means the engine never has to ask.
const DOOR_UNIT_SYSTEMS = ['Imperial', 'Metric'];
const MM_PER_INCH = 25.4;

// Accepts 3'-0", 3' 0", 3'0", 36", 36 1/2", 36.5, 914, 914mm, 2438 mm.
// Returns millimetres, or null when it cannot be read — never a guess.
function parseDim(text, system) {
  if (text === null || text === undefined) return null;
  if (typeof text === 'number') return system === 'Metric' ? text : text * MM_PER_INCH;
  let s = String(text).trim().toLowerCase().replace(/[–—]/g, '-');
  if (!s) return null;
  const mm = s.match(/^([\d.]+)\s*(mm|millimet(?:er|re)s?)$/);
  if (mm) return parseFloat(mm[1]);
  const cm = s.match(/^([\d.]+)\s*cm$/);
  if (cm) return parseFloat(cm[1]) * 10;
  const m = s.match(/^([\d.]+)\s*m$/);
  if (m) return parseFloat(m[1]) * 1000;
  // feet and inches, with or without a fraction
  const fi = s.match(/^(\d+)\s*(?:'|ft|feet|-)\s*-?\s*(\d+)?\s*(?:(\d+)\s*\/\s*(\d+))?\s*(?:"|in|inch(?:es)?)?$/);
  if (fi && (fi[2] !== undefined || fi[3] !== undefined || /'|ft|feet/.test(s))) {
    const ft = parseFloat(fi[1]) || 0;
    const inch = parseFloat(fi[2] || 0);
    const frac = fi[3] ? parseFloat(fi[3]) / parseFloat(fi[4]) : 0;
    return (ft * 12 + inch + frac) * MM_PER_INCH;
  }
  const inFrac = s.match(/^(\d+)\s+(\d+)\s*\/\s*(\d+)\s*(?:"|in|inch(?:es)?)?$/);
  if (inFrac) return (parseFloat(inFrac[1]) + parseFloat(inFrac[2]) / parseFloat(inFrac[3])) * MM_PER_INCH;
  const justFrac = s.match(/^(\d+)\s*\/\s*(\d+)\s*(?:"|in)?$/);
  if (justFrac) return (parseFloat(justFrac[1]) / parseFloat(justFrac[2])) * MM_PER_INCH;
  const inches = s.match(/^([\d.]+)\s*(?:"|in|inch(?:es)?)$/);
  if (inches) return parseFloat(inches[1]) * MM_PER_INCH;
  const bare = s.match(/^([\d.]+)$/);
  if (bare) {
    const n = parseFloat(bare[1]);
    // A bare number is read in the system the user is working in. In imperial a
    // value that large is plainly already millimetres — 300" is not a door.
    if (system === 'Metric') return n;
    return n > 200 ? n : n * MM_PER_INCH;
  }
  return null;
}

const DIM_FRACTION = 16;   // imperial output is rounded to the nearest 1/16"
function fmtDim(mmVal, system, opts) {
  if (mmVal === null || mmVal === undefined || !isFinite(mmVal)) return '—';
  const o = opts || {};
  if (system === 'Metric') return `${Math.round(mmVal)}${o.bare ? '' : ' mm'}`;
  const totalIn = mmVal / MM_PER_INCH;
  let whole = Math.floor(totalIn);
  let num = Math.round((totalIn - whole) * DIMENSION_DEN());
  if (num === DIMENSION_DEN()) { whole += 1; num = 0; }
  let den = DIMENSION_DEN();
  while (num && num % 2 === 0) { num /= 2; den /= 2; }
  const ft = Math.floor(whole / 12);
  const inch = whole % 12;
  const fracTxt = num ? ` ${num}/${den}` : '';
  // A dimension under an inch is written 1/2", never 0 1/2" — a leading zero
  // reads as a mistake on a drawing, and every groove width, reveal and
  // undercut in the app lands here.
  if ((o.inchesOnly || ft === 0) && whole === 0 && num) return `${num}/${den}"`;
  if (o.inchesOnly || ft === 0) return `${whole}${fracTxt}"`;
  return `${ft}'-${inch}${fracTxt}"`;
}
function DIMENSION_DEN() { return DIM_FRACTION; }

// ── Opening rules ─────────────────────────────────────────────────────────
// The relationship between rough opening, frame and leaf is NOT one formula.
// It belongs to the frame system, so it lives in the library as data and every
// value is editable. A rule says, per edge, how much bigger the next thing out
// has to be — which is how the trade actually specifies it, and it inverts
// cleanly so the same rule works from either end.
function makeOpeningRule(data) {
  return {
    id: uid('orule'), name: 'New opening rule', active: true,
    frameSystem: '', manufacturer: '', notes: '', source: '',
    // leaf  ->  frame overall
    leafToFrameJambEach: 25.4,      // per jamb
    leafToFrameHead: 50.8,
    leafToFrameSill: 0,
    // frame overall  ->  rough opening
    frameToRoJambEach: 25.4,        // per side, for shimming
    frameToRoHead: 50.8,
    frameToRoSill: 0,
    // the rest of the stack-up, kept separate so each is visible and editable
    // Handle height above finished floor. It belongs to the RULE because it is
    // a standard the shop works to rather than a per-door decision, and it is
    // on the drawing because the installer sets the lock out from it.
    // 914.4 mm = 36", which is what LEON's own issued sheets dimension to, NOT
    // the 40" general default. Read off their side elevation. ADA wants 34-48"
    // to the operable part, so 36" sits inside it.
    handleHeight: 914.4,
    // The distance from the LEAF'S LOCK EDGE to the centre of the spindle. A
    // handle height alone does not locate a handle — the installer needs both,
    // and the lock's own backset is what the leaf is machined to. 60 mm is the
    // standard LEON's locksets are supplied at.
    handleBackset: 60,
    // CLEAR OPENING is not the leaf width: the leaf stands proud of the jamb on
    // its hinge side and the stop takes the rest, so the usable width is about
    // 1 1/2" less. It varies with the hinge, which is why it is a rule the shop
    // can edit rather than a constant in the drawing code.
    clearOpeningDeduction: 38.1,
    undercut: 19.05,                // 3/4" — clearance under the leaf
    thresholdAllowance: 0,
    floorFinishAllowance: 0,
    astragal: 0,                    // added between leaves on a pair
    maxLeafWidth: 1219.2, maxLeafHeight: 2743.2,   // manufacturer limits
    ...data,
  };
}

// How a door is supplied. A pre-hung door arrives as a leaf already hung in its
// frame; a slab is the leaf alone, to be hung on site. It changes the price, the
// packing and what the installer is expecting, and the client's own schedule
// carries it as its own column.
// The four cores a wood door is actually built with, in the order they get
// heavier and dearer. Drawn differently in the side section, because the whole
// point of that view is showing what is inside the leaf.
const DOOR_CORE_TYPES = [
  { key: 'honeycomb', label: 'Honeycomb', note: 'Honeycomb paper core — economical interior doors.' },
  { key: 'tubular',   label: 'Tubular solid', note: 'Rows of tubular particleboard. Strong and still light.' },
  { key: 'hSemi',     label: 'H semi solid', note: 'Particleboard stiles and rails with hollow bays.' },
  { key: 'solid',     label: 'Solid', note: 'Solid particleboard or wood throughout. Heaviest, most durable.' },
];
function doorCoreLabel(k) {
  const c = DOOR_CORE_TYPES.find(x => x.key === k);
  return c ? c.label : (k || 'Solid');
}
// Trim is what the client sees where the frame meets the wall, and it was the
// one part of the assembly the record could not describe.
const DOOR_TRIM_TYPES = ['None', 'Square / Flat Casing', 'Stepped Casing', 'Colonial Casing',
  'Reveal / Shadow Gap', 'Architrave', 'Plaster-In / Trimless', 'Custom'];
// Every LEON door is gasketted; what varies is which one.
const DOOR_GASKET_TYPES = ['Acoustic Gasket', 'Smoke Seal', 'Intumescent (fire)',
  'Brush Seal', 'Bulb / Q-Lon', 'None'];
const DOOR_HINGE_TYPES = ['Butt Hinge', 'Concealed / Invisible', 'Pivot',
  'Continuous / Piano', 'Spring Hinge', 'Barn Track'];

const DOOR_INSTALLATION_TYPES = ['Pre-Hung', 'Slab'];

const DOOR_HANDINGS = [
  { key: 'LH',  label: 'Left Hand',            hint: 'Hinges left, swings away from you' },
  { key: 'RH',  label: 'Right Hand',           hint: 'Hinges right, swings away from you' },
  { key: 'LHR', label: 'Left Hand Reverse',    hint: 'Hinges left, swings toward you' },
  { key: 'RHR', label: 'Right Hand Reverse',   hint: 'Hinges right, swings toward you' },
  { key: 'PAIR', label: 'Pair — equal leaves', hint: 'Two leaves of the same width' },
  { key: 'PAIR_UNEQUAL', label: 'Pair — unequal', hint: 'Active and inactive leaf differ' },
];
// HOW THE DOOR MOVES. This existed on the door TYPE and had no field anywhere,
// so every door in the app was a swing whatever it really was — and the
// operation diagram could only ever draw an arc.
const DOOR_OPERATIONS = ['Swing', 'Double Swing', 'Sliding', 'Bypass', 'Pocket', 'Barn',
  'Bifold', 'Pivot', 'Fixed'];
// Which of these is a pair of leaves, and which slides rather than swings —
// asked in one place so the designer, the schedule and the diagram agree.
const DOOR_PAIR_OPERATIONS = ['Double Swing', 'Bypass', 'Bifold'];
const DOOR_SLIDING_OPERATIONS = ['Sliding', 'Bypass', 'Pocket', 'Barn'];
function doorIsPair(operation, handing) {
  return DOOR_PAIR_OPERATIONS.indexOf(operation) >= 0
    || handing === 'PAIR' || handing === 'PAIR_UNEQUAL';
}
function doorSlides(operation) { return DOOR_SLIDING_OPERATIONS.indexOf(operation) >= 0; }
// A pair swings one way, the other, or both. A single leaf's direction is
// already in its handing (Reverse = towards you); a pair needs saying outright.
const DOOR_PAIR_SWINGS = ['Inward', 'Outward', 'Double acting'];
const DOOR_ACTIVE_LEAF = ['Left', 'Right'];
const DOOR_FIRE_RATINGS = ['None', '20 Min', '30 Min', '45 Min', '60 Min', '90 Min', '3 Hour', 'Custom'];
// A rating that has been ASKED for is not a rating that has been certified.
// Keeping them apart is the whole point — the app must never imply a
// certification it has no document for.
const DOOR_RATING_STATES = ['Requested', 'Specified', 'Certified'];
const DOOR_STATUSES = ['Draft', 'In Design', 'Internal Review', 'Submitted',
  'Approved', 'Approved as Noted', 'Revise & Resubmit', 'Released for Production',
  'In Production', 'Delivered', 'Installed', 'Superseded'];

// ── Door library objects ──────────────────────────────────────────────────
// One state key holds every reusable door definition, rather than six separate
// collections that would each need persisting, merging and remembering.
const DOOR_DESIGN_KINDS = ['Flat Slab', 'Wood Veneer Flat Slab', 'Painted Flat Slab',
  'Shaker', 'Wood Shaker', '1 Vertical Groove', '2 Vertical Grooves',
  '3 Horizontal Grooves', 'Custom Groove Pattern', 'Custom Panel Pattern',
  'Hollow Metal', 'Flush Fire-Rated', 'Louvered', 'Custom'];
const FRAME_KINDS = ['Wood Frame', 'Hollow Metal', 'Aluminum', 'Frameless / Invisible',
  'Wrap-Around', 'Wrapped Jamb', 'Split Jamb', 'Knockdown', 'Custom'];
const WALL_KINDS = ['Metal Stud', 'Wood Stud', 'Masonry', 'Concrete', 'Existing', 'Other'];
const LITE_KINDS = ['None', 'Full Lite', 'Half Lite', 'Narrow Lite', 'Custom Glass Opening',
  'Louver', 'Custom Opening'];
const HARDWARE_CATEGORIES = ['Hinge', 'Concealed Hinge', 'Pivot', 'Lock', 'Mortise Lock',
  'Cylindrical Lock', 'Cylinder', 'Lever', 'Pull Handle', 'Closer', 'Floor Closer',
  'Stop', 'Overhead Stop', 'Catch', 'Kick Plate', 'Flush Bolt', 'Coordinator',
  'Panic Device', 'Threshold', 'Sweep', 'Gasket', 'Drop Seal', 'Viewer',
  'Access Control', 'Electric Strike', 'Magnetic Lock', 'Silencer', 'Other'];
// The categories a shop drawing shows on the leaf, and roughly where. A hinge
// is on the hanging edge; a viewer is at eye height; a stop is at the floor
// unless it is overhead. Anything not listed is scheduled but not drawn — a
// symbol nobody can place is worse than a line in the list.
const DOOR_HW_DRAWN = {
  Lever: 'handle', 'Pull Handle': 'handle',
  Lock: 'lock', 'Mortise Lock': 'lock', 'Cylindrical Lock': 'lock',
  Hinge: 'hinge', 'Concealed Hinge': 'hinge', Pivot: 'hinge',
  Stop: 'stop', 'Overhead Stop': 'overheadStop',
  Catch: 'catch', Viewer: 'viewer', Closer: 'closer',
};

// A leaf design is parametric — the elevation is drawn FROM these numbers, so a
// groove count change redraws rather than needing a new picture.
function makeDoorDesign(data) {
  return {
    id: uid('ddes'), name: 'New design', kind: 'Flat Slab', active: true,
    grooveCount: 0, grooveOrientation: 'Vertical', grooveWidth: 12.7,
    grooveDepth: 6, grooveSpacing: 0,            // 0 = distribute evenly
    railTop: 127, railBottom: 203, railMid: 0, stile: 127,
    panelRows: 0, panelCols: 0,
    // The moulding round each panel — its face width. A panelled door is a
    // count AND a profile; without the second the drawing can say how many
    // panels there are and not what frames them.
    panelProfile: 19,
    // A vision panel or a louver can belong to the DESIGN rather than to each
    // door. The 55 India schedule lists "Louvered" as a leaf style beside "Flat
    // Slab" — it is what the leaf IS, not something decided door by door — so a
    // design carries it and a door still overrides where one genuinely differs.
    // null means "the door decides", which is how every door behaved before.
    liteKind: null, liteW: null, liteH: null, liteSill: null,
    thumbnail: null, renderItemId: null,          // ties to the Render Library
    notes: '', ...data,
  };
}

// A trim (casing/architrave) is a profile the shop buys or mills, exactly like a
// frame — so it lives in the library rather than as two loose numbers on a
// door. Its design, thickness and width are what get ordered and what get drawn
// in section.
function makeTrimProfile(data) {
  return {
    id: uid('dtrim'), name: 'New trim', active: true,
    // WHAT IT IS MADE OF comes from Supplier Finishes, the same catalog the
    // selections, the hardware and the order read — so a trim on a drawing and
    // the trim on the purchase order are the same product. `design` stays
    // because the SECTION still has to be drawn, and a catalog record does not
    // carry a profile shape.
    finishRef: null,
    design: 'Square / Flat',
    width: 63.5,        // face width — what you see, 2 1/2"
    thickness: 19,      // how far it stands off the wall, 3/4"
    // How the head meets the legs. It is not decoration: a mitred corner is cut
    // at 45° and shows the profile turning, a butt joint runs the head over the
    // legs square. It changes what the shop cuts and what the corner looks like,
    // and a moulded profile can really only be mitred.
    joint: 'Mitered',
    // The lip that returns into the jamb. It is a thin tongue — a quarter inch,
    // not the casing's own thickness — and drawing it at the full thickness
    // made the return read as a second piece of casing.
    legThickness: 6.35,
    // How far the return runs into the jamb, and how far in from the frame's
    // own face it sits. Both are real set-out figures the shop machines to:
    // 1 1/2" long, 1/2" in from the frame.
    legLength: 38.1,
    legOffset: 12.7,
    img: '',            // an optional photograph of the real profile
    reveal: 6.35,       // set-back from the frame edge, 1/4"
    material: '', finish: '', manufacturer: '', profile: '', notes: '',
    ...data,
  };
}
// What a wrapped jamb is made of, layer by layer. Short lists on purpose: these
// are the build-ups actually bought, not every material that exists.
const JAMB_CORE_MATERIALS = ['Finger Joint', 'Solid Timber', 'LVL', 'MDF',
  'Moisture Resistant MDF', 'Particleboard', 'Steel', 'Aluminium Extrusion', 'None'];
const JAMB_SKIN_MATERIALS = ['Moisture Resistant MDF', 'MDF', 'Plywood',
  'HDF', 'Steel Sheet', 'None'];
const JAMB_FACE_MATERIALS = ['Melamine', 'Laminate', 'Hi-Tech Veneer',
  'Natural Veneer', 'PVC Foil', 'Primed for Paint', 'Powder Coat', 'Anodized'];
// Trim is bought in standard sizes, so they are picked rather than typed — a
// free number invites 2.4" and nothing mills that. Millimetres, from the
// imperial sizes the shop actually orders.
const TRIM_WIDTHS = [38.1, 44.45, 50.8, 57.15, 63.5, 69.85, 76.2, 88.9, 101.6];
const TRIM_THICKNESSES = [12.7, 15.875, 19, 22.225, 25.4];
const TRIM_JOINTS = ['Mitered', 'Butt joint'];
const TRIM_DESIGNS = ['Square / Flat', 'Stepped', 'Colonial', 'Ogee', 'Bullnose',
  'Chamfered', 'Reveal / Shadow Gap', 'Architrave', 'Custom'];
const DEFAULT_TRIM_PROFILES = [
  makeTrimProfile({ id: 'dtrm-sq25', name: 'SQ-01 — Square 2 1/2"', design: 'Square / Flat',
    width: 63.5, thickness: 19, reveal: 6.35, material: 'MDF', finish: 'Primed, paint' }),
  makeTrimProfile({ id: 'dtrm-sq35', name: 'SQ-02 — Square 3 1/2"', design: 'Square / Flat',
    width: 88.9, thickness: 19, reveal: 6.35, material: 'MDF', finish: 'Primed, paint' }),
  makeTrimProfile({ id: 'dtrm-step', name: 'ST-01 — Stepped 3"', design: 'Stepped',
    width: 76.2, thickness: 22.2, reveal: 6.35, material: 'MDF', finish: 'Primed, paint' }),
  makeTrimProfile({ id: 'dtrm-shadow', name: 'SG-01 — Shadow gap', design: 'Reveal / Shadow Gap',
    width: 12.7, thickness: 12.7, reveal: 0, material: 'Aluminium', finish: 'Anodised' }),
];

function makeFrameProfile(data) {
  return {
    id: uid('dfrm'), name: 'New frame', kind: 'Hollow Metal', active: true,
    manufacturer: '', profile: '',
    jambWidth: 50.8, headWidth: 50.8, frameDepth: 133.35,
    stop: 15.9, rabbet: 44.5, casing: 0, shadowGap: 0,
    // ── How the jamb is BUILT ────────────────────────────────────────────
    // A wrapped jamb is not a solid block: it is a core, a skin over it and a
    // face on the skin, and a section that draws it as one poché tells the
    // shop nothing about what to make. Read off An Cuong's own UHT20 cutaway.
    coreMaterial: '', skinMaterial: '', faceMaterial: '',
    skinThickness: 0,                 // the MDF wrap, drawn as its own band
    // Whether the casing is PART of the jamb — one moulded piece returning onto
    // the wall — or a separate trim applied to it. It changes what is ordered
    // and what the section draws: an integral casing has no joint to show.
    integralCasing: false,
    sealGroove: false,                // the stop carries a groove for a bulb seal
    anchoring: '', material: '', finish: '',
    wallMin: 82.55, wallMax: 152.4,
    openingRuleId: null,                          // how this frame sizes an opening
    notes: '', ...data,
  };
}


// ── The standard hardware lines every door is specified against ────────────
// A door is not a free-form shopping list: the same questions are asked of
// every opening, and the answer to each is either a model from the library or
// "not applicable here". Ticking items out of one long catalog list left no way
// to tell a door that genuinely has no closer from a door nobody had got to
// yet — which is the difference between a checked drawing and an unchecked one.
//
// `cats` are the library categories offered for that line first; the picker
// still lets any item be chosen, because a catalog does not always file things
// the way a schedule reads them.
// What a vision panel is glazed in, and what a louver's blades are made of.
// Both are specified separately from the leaf's own finish: the glass is a
// performance product and the blades are usually a different material from the
// door face — so neither can be read off the door's finish selection.
const DOOR_GLASS_TYPES = ['Clear Tempered', 'Frosted / Acid-Etched', 'Sandblasted',
  'Laminated', 'Laminated Acoustic', 'Fire-Rated Ceramic', 'Wired', 'Reeded / Fluted',
  'Low-Iron Clear', 'Tinted Grey', 'Tinted Bronze', 'Mirror', 'Obscure Pattern'];
const DOOR_LOUVER_MATERIALS = ['Solid Wood', 'MDF Primed', 'Veneered MDF',
  'Extruded Aluminium', 'Powder-Coated Aluminium', 'Steel', 'PVC'];

const DOOR_HARDWARE_SLOTS = [
  { key: 'lever',     label: 'Lever / Handle Set', cats: ['Lever', 'Pull Handle'], qty: 1, drawn: true },
  { key: 'lock',      label: 'Lockset', cats: ['Mortise Lock', 'Cylindrical Lock', 'Lock'], qty: 1, drawn: true },
  { key: 'cylinder',  label: 'Cylinder / Keying', cats: ['Cylinder'], qty: 1 },
  { key: 'hinges',    label: 'Hinges', cats: ['Hinge', 'Concealed Hinge', 'Pivot'], qty: 3, drawn: true,
    note: '3 per single leaf, 6 per pair' },
  { key: 'stop',      label: 'Door Stop', cats: ['Stop'], qty: 1, drawn: true },
  { key: 'overhead',  label: 'Overhead Stop', cats: ['Overhead Stop'], qty: 1 },
  { key: 'catch',     label: 'Magnetic Catch', cats: ['Catch'], qty: 1 },
  { key: 'viewer',    label: 'Door Viewer (peephole)', cats: ['Viewer'], qty: 1, drawn: true },
  { key: 'closer',    label: 'Door Closer', cats: ['Closer', 'Floor Closer'], qty: 1 },
  { key: 'flushBolt', label: 'Flush Bolts', cats: ['Flush Bolt'], qty: 2, note: 'Pairs only' },
  { key: 'seals',     label: 'Perimeter Seals / Gasket', cats: ['Gasket', 'Sweep'], qty: 1 },
  { key: 'dropSeal',  label: 'Drop Seal / Threshold', cats: ['Drop Seal', 'Threshold'], qty: 1 },
  { key: 'silencer',  label: 'Silencers', cats: ['Silencer'], qty: 3 },
  { key: 'kickPlate', label: 'Kick Plate', cats: ['Kick Plate'], qty: 1 },
  { key: 'track',     label: 'Pocket / Sliding Track', cats: ['Other'], qty: 1, note: 'Pocket and sliding doors' },
  { key: 'access',    label: 'Access Control', cats: ['Access Control', 'Electric Strike', 'Magnetic Lock'], qty: 1 },
];
const doorSlot = key => DOOR_HARDWARE_SLOTS.find(x => x.key === key) || null;
// What is actually specified on a door, line by line: the chosen model, the
// quantity, and whether the line was deliberately ruled out. A line with no
// entry at all has simply not been answered yet, and the drawing says nothing
// about it — it does NOT guess.
function doorSlotState(door, key) {
  const na = Array.isArray(door && door.hardwareNa) ? door.hardwareNa : [];
  if (na.indexOf(key) >= 0) return { na: true, line: null };
  const line = (Array.isArray(door && door.hardware) ? door.hardware : []).find(l => l.slot === key) || null;
  return { na: false, line };
}

function makeHardwareItem(data) {
  return {
    id: uid('dhw'), name: '', category: 'Lever', active: true,
    manufacturer: '', model: '', productNumber: '', finish: '',
    dimensions: '', installHeight: null,          // mm AFF
    prep: '', cutout: '', techSheet: null, techSheetUrl: null,
    img: '', cost: null, vendorId: null,
    supplierKey: '', supplierId: '',              // where it came from in a catalog
    notes: '', ...data,
  };
}

// A set is a recipe: assigning it fills the door's hardware in one act.
function makeHardwareSet(data) {
  return {
    id: uid('dhws'), code: 'HW-00', name: 'New hardware set', active: true,
    // What the set DOES — privacy, passage, dummy — which is how a hardware
    // schedule is actually organised and how someone picks the right one.
    fn: '',
    // The rooms it is written for, as the schedule words it.
    appliesTo: '',
    lines: [],            // [{ itemId, qty, note }]
    notes: '', ...data,
  };
}

// A TYPE is the thing that is designed; a DOOR is the thing that is installed.
// Types carry the configuration, marks carry location and any override — which
// is what lets one change reach forty doors, and one door still differ.
function makeDoorType(data, by) {
  return {
    id: uid('dtype'), code: 'TYPE A', name: 'New door type', active: true,
    scopeId: null, global: false,
    category: '', operation: 'Swing', designId: null, frameId: null,
    openingRuleId: null,
    sizeMethod: 'leaf',
    leafW: 914.4, leafH: 2032, leafThickness: 44.45,
    roW: null, roH: null,
    leafCount: 1, handing: 'RH',
    material: '', finishRef: null, finishNotes: '',
    fireRating: 'None', ratingState: 'Requested', ratingDocUrl: null, ratingDocName: '',
    acousticRating: '', acousticState: 'Requested',
    liteKind: 'None', liteW: null, liteH: null, liteSillHeight: null,
    hardwareSetId: null,
    wallKind: 'Metal Stud', wallThickness: 133.35,
    manufacturer: '', vendorId: '',
    notes: '', attachments: [],
    createdBy: by || '', createdDate: todayISO(),
    ...data,
  };
}

// The door record itself. Everything else in the module points at this.
function makeDoor(data, by) {
  return {
    id: uid('door'), mark: '', typeId: null, scopeId: null,
    location: '', fromRoom: '', toRoom: '', level: '', unit: '',
    qty: 1,
    // Anything left null follows the type. An explicit value is an override,
    // and the screen says so — a door that silently differs from its type is
    // how the wrong one gets built.
    sizeMethod: null, leafW: null, leafH: null, leafThickness: null,
    roW: null, roH: null, handing: null,
    // How it moves, and — for a pair — which way and which leaf is the active
    // one. null on `operation` means "follow the type", like every other field.
    operation: null, pairSwing: null, activeLeaf: null,
    designId: null, frameId: null, openingRuleId: null,
    // The LEON Collection model this door is taken from, by its catalog code
    // (FND-001 …). It is a REFERENCE, not geometry — the catalog carries a
    // description and no dimensions — so it names what was specified without
    // pretending to drive the drawing.
    modelCode: null,
    finishRef: null, fireRating: null, ratingState: null,
    acousticRating: null, hardwareSetId: null,
    wallKind: null, wallThickness: null,
    // What the leaf is made of, what surrounds it, and what hangs it. All null
    // means "follow the type", the same rule as every other field here.
    // The hardware actually specified for THIS door, as {itemId, qty}. A set is
    // a recipe and stays the starting point; this is what was chosen, so a door
    // that needs a viewer and an overhead stop can say so without inventing a
    // new set for one opening. Empty means "whatever the set says".
    hardware: [],
    // Lines deliberately ruled out for THIS door, by slot key. A slot that is
    // neither filled nor listed here has not been answered yet — which is a
    // different thing from "this door has no closer", and the drawing and the
    // schedule both need to be able to tell them apart.
    hardwareNa: [],
    // Overrides the opening rule's standard backset, for a door whose lock is
    // supplied at something else.
    handleBackset: null,
    // Three finishes, each a real supplier finish rather than a typed name:
    // what the leaf is faced in, what the core edge is, and what the ironmongery
    // is plated in. They are separate because they are separately specified and
    // separately approved.
    leafFinishRef: null, coreFinishRef: null, hardwareFinishRef: null,
    coreType: null,                 // one of DOOR_CORE_TYPES
    trimProfileId: null,            // a real profile from the library
    trimType: null, trimSize: null, // kept for anything specified before the library existed
    // A door may take a profile from the library AND differ from it — a
    // different material off the catalog, a wider face, a butt joint instead of
    // a mitre. Null means "follow the library profile", which is the common
    // case; a value here is an override and the designer says so.
    trimFinishRef: null, trimWidth: null, trimThickness: null, trimJoint: null,
    jambThickness: null,            // the jamb's own material thickness, drawn in section
    // A louver or a vision panel is a hole with a SIZE. Drawing one without its
    // dimensions tells the shop it is there and not how big to cut it.
    liteKind: null, liteW: null, liteH: null, liteSill: null,
    // And a hole has to be filled with something: glass is a performance
    // product and a louver's blades are usually not the door's own material.
    glassType: null, louverMaterial: null,
    // A CUSTOM LAYOUT on this door. The design is the standard; a door that
    // differs in how many panels or grooves it carries, or how wide the
    // moulding round them is, says so here. null means "follow the design",
    // which is what every door did before these existed.
    panelRows: null, panelCols: null, panelProfile: null,
    grooveCount: null, grooveWidth: null, grooveOrientation: null,
    // The gap between the finished floor and the BOTTOM OF THE LEAF — the air
    // path a room is ventilated through, and a different number from the
    // opening rule's undercut, which is about how the leaf clears its frame.
    // Conflating them is how a door gets hung tight to the floor and the
    // bathroom extract stops working.
    leafUndercut: null,
    gasketType: null,               // every LEON door has one; which varies
    hingeType: null, hingeCount: null,
    overrides: {},               // manual dimension overrides, with a reason
    overrideReason: '',
    // How it is supplied. A pre-hung door is a leaf already hung in its frame;
    // a slab is the leaf alone. It changes the price, the packing and what the
    // installer is expecting on the truck, and the client's own schedule
    // carries it as its own column.
    installationType: 'Pre-Hung',
    // The hardware set code the schedule quotes (U1B, U1C, U1F). The Hub links
    // the real set through `hardwareSetId`; this is the ARCHITECT's label for
    // it, which is what the reviewer and the hardware submittal both cite.
    hwSetCode: '',
    // The leaf code on the schedule — 'A', 'B' — grouping leaves of identical
    // construction across different marks.
    leafCode: '',
    status: 'Draft', revision: 0,
    approvalStatus: '', submittalThreadId: null,
    productionRecordId: null, installStatus: '',
    notes: '', history: [],
    createdBy: by || '', createdDate: todayISO(),
    ...data,
  };
}

// A door reads through to its type for anything it has not overridden.
function resolveDoor(door, type) {
  const t = type || {};
  const pick = (k, fb) => {
    const v = door[k];
    return (v === null || v === undefined || v === '') ? (t[k] !== undefined ? t[k] : fb) : v;
  };
  return {
    ...t, ...door,
    sizeMethod: pick('sizeMethod', 'leaf'),
    leafW: pick('leafW', 0), leafH: pick('leafH', 0), leafThickness: pick('leafThickness', 44.45),
    roW: pick('roW', null), roH: pick('roH', null),
    handing: pick('handing', 'RH'),
    operation: pick('operation', 'Swing'),
    pairSwing: pick('pairSwing', 'Inward'), activeLeaf: pick('activeLeaf', 'Right'),
    designId: pick('designId', null), frameId: pick('frameId', null),
    openingRuleId: pick('openingRuleId', null),
    finishRef: pick('finishRef', null),
    fireRating: pick('fireRating', 'None'), ratingState: pick('ratingState', 'Requested'),
    acousticRating: pick('acousticRating', ''),
    hardwareSetId: pick('hardwareSetId', null),
    modelCode: pick('modelCode', null),
    // LEON'S STANDARDS, given by the client. A default is what most doors on
    // most jobs actually are, so these are the figures a new door starts from
    // and the ones the drawing falls back to when nothing is set.
    wallKind: pick('wallKind', 'Wood Stud'), wallThickness: pick('wallThickness', 114.3),
    // The leaf's make-up and what surrounds it, resolved the same way. Three
    // hinges is the standard for a single leaf; a pair takes six, which the
    // hardware set already says and the drawing works out from the handing.
    hardware: Array.isArray(door.hardware) && door.hardware.length ? door.hardware : (t.hardware || []),
    leafFinishRef: pick('leafFinishRef', null),
    coreFinishRef: pick('coreFinishRef', null),
    hardwareFinishRef: pick('hardwareFinishRef', null),
    coreType: pick('coreType', 'solid'),
    trimProfileId: pick('trimProfileId', null),
    trimType: pick('trimType', 'Square / Flat Casing'), trimSize: pick('trimSize', 63.5),
    trimFinishRef: pick('trimFinishRef', null), trimWidth: pick('trimWidth', null),
    trimThickness: pick('trimThickness', null), trimJoint: pick('trimJoint', null),
    jambThickness: pick('jambThickness', 31.75),          // 1 1/4", LEON standard
    handleBackset: pick('handleBackset', null),
    hardwareNa: Array.isArray(door.hardwareNa) ? door.hardwareNa : (t.hardwareNa || []),
    glassType: pick('glassType', null), louverMaterial: pick('louverMaterial', null),
    panelRows: pick('panelRows', null), panelCols: pick('panelCols', null),
    panelProfile: pick('panelProfile', null),
    grooveCount: pick('grooveCount', null), grooveWidth: pick('grooveWidth', null),
    grooveOrientation: pick('grooveOrientation', null),
    liteKind: pick('liteKind', 'None'), liteW: pick('liteW', null),
    liteH: pick('liteH', null), liteSill: pick('liteSill', null),
    leafUndercut: pick('leafUndercut', 6.35),              // 1/4" air flow, LEON standard
    // Renamed from 'Acoustic Compression'; a door saved under the old string
    // is resolved to the new one here rather than falling through to a value
    // that is no longer in the list.
    gasketType: (v => v === 'Acoustic Compression' ? 'Acoustic Gasket' : v)(pick('gasketType', 'Acoustic Gasket')),
    hingeType: pick('hingeType', 'Butt Hinge'), hingeCount: pick('hingeCount', 3),
    overrides: door.overrides || {},
    // which fields this door sets for itself, so the UI can mark them
    ownFields: ['sizeMethod','leafW','leafH','leafThickness','roW','roH','handing','designId',
                'frameId','openingRuleId','finishRef','fireRating','ratingState','acousticRating',
                'hardwareSetId','wallKind','wallThickness',
                'coreType','trimProfileId','trimType','trimSize','jambThickness','leafUndercut',
                'liteKind','liteW','liteH','liteSill','gasketType','hingeType','hingeCount',
                'leafFinishRef','coreFinishRef','hardwareFinishRef']
      .filter(k => door[k] !== null && door[k] !== undefined && door[k] !== ''),
  };
}

function makeDoorLibrary() {
  return { designs: [], frames: [], rules: [], trims: [], hardware: [], hardwareSets: [], types: [] };
}

// ── The shipped door library ──────────────────────────────────────────────
// Real starting points, so nobody configures a standard apartment entry from
// nothing. Every value here is editable in the app — this is a seed, not a
// hard-coded rule. `mergeNewDoorLibrary` folds later additions into a library
// the team has already edited, the same forward-merge the scope families use.
const IN = n => n * 25.4;

const DEFAULT_OPENING_RULES = [
  makeOpeningRule({ id: 'orule-hm01', name: 'HM-01 — Hollow Metal, welded', frameSystem: 'Hollow Metal',
    leafToFrameJambEach: IN(1), leafToFrameHead: IN(2), leafToFrameSill: 0,
    frameToRoJambEach: IN(1), frameToRoHead: IN(2), frameToRoSill: 0,
    undercut: IN(0.75), maxLeafWidth: IN(48), maxLeafHeight: IN(96),
    source: 'LEON standard for welded HM frames.' }),
  makeOpeningRule({ id: 'orule-hm02', name: 'HM-02 — Hollow Metal, knock-down', frameSystem: 'Hollow Metal',
    leafToFrameJambEach: IN(1), leafToFrameHead: IN(2), leafToFrameSill: 0,
    frameToRoJambEach: IN(0.75), frameToRoHead: IN(1.5), frameToRoSill: 0,
    undercut: IN(0.75), maxLeafWidth: IN(48), maxLeafHeight: IN(96) }),
  makeOpeningRule({ id: 'orule-wd01', name: 'WD-01 — Wood frame, split jamb', frameSystem: 'Wood Frame',
    leafToFrameJambEach: IN(0.75), leafToFrameHead: IN(1.5), leafToFrameSill: 0,
    frameToRoJambEach: IN(0.75), frameToRoHead: IN(1.25), frameToRoSill: 0,
    undercut: IN(0.75), maxLeafWidth: IN(42), maxLeafHeight: IN(96) }),
  makeOpeningRule({ id: 'orule-al01', name: 'AL-01 — Aluminium, concealed', frameSystem: 'Aluminum',
    leafToFrameJambEach: IN(0.5), leafToFrameHead: IN(0.75), leafToFrameSill: IN(0.5),
    frameToRoJambEach: IN(0.5), frameToRoHead: IN(0.75), frameToRoSill: 0,
    undercut: IN(0.5), maxLeafWidth: IN(48), maxLeafHeight: IN(120) }),
  // LEON's OWN production standard, read off the reviewed 55 India Condo
  // interior door schedule (081400-2.3) and verified against all 30 rows of it.
  // The five rules above are generic frame-system defaults; these two are what
  // the shop actually builds to, which is why they lead the list.
  //   single swing:  frame = leaf + 3"  x  leaf + 2"      RO = frame + 1"  x  + 1/2"
  //   pair:          frame = 2 x leaf + 3 1/8"            (the 1/8 is the meeting clearance)
  //   pocket:        frame = leaf + 1 1/4" x leaf + 2"    RO = frame + 1/2" x + 1"
  // Note the asymmetry, which is real and easy to get wrong: the rough opening
  // gains a full inch in WIDTH (half a shim each side) but only half an inch in
  // HEIGHT, because there is a head shim and no sill shim.
  makeOpeningRule({ id: 'orule-leon-sw', name: 'LEON-SW — LEON standard, swing', frameSystem: 'Wood Frame',
    leafToFrameJambEach: IN(1.5), leafToFrameHead: IN(2), leafToFrameSill: 0,
    frameToRoJambEach: IN(0.5), frameToRoHead: IN(0.5), frameToRoSill: 0,
    // A pair carries an extra 1/8" between the leaves: 2 x 24" leaves come out
    // at a 51 1/8" frame, not 51".
    astragal: IN(0.125),
    undercut: IN(0.75), maxLeafWidth: IN(42), maxLeafHeight: IN(96),
    source: 'Measured from 55 India St. Condo interior door schedule, Rev 03 (081400-2.3), reviewed.' }),
  makeOpeningRule({ id: 'orule-leon-pkt', name: 'LEON-PKT — LEON standard, pocket', frameSystem: 'Wood Frame',
    leafToFrameJambEach: IN(0.625), leafToFrameHead: IN(2), leafToFrameSill: 0,
    frameToRoJambEach: IN(0.25), frameToRoHead: IN(1), frameToRoSill: 0,
    astragal: 0,
    undercut: IN(0.75), maxLeafWidth: IN(36), maxLeafHeight: IN(96),
    source: 'Measured from 55 India St. Condo interior door schedule, Rev 03 (081400-2.3), reviewed. Pocket openings sit in a 6 7/8" wall.' }),
  makeOpeningRule({ id: 'orule-inv01', name: 'INV-01 — Frameless / invisible', frameSystem: 'Frameless / Invisible',
    leafToFrameJambEach: IN(0.25), leafToFrameHead: IN(0.25), leafToFrameSill: IN(0.375),
    frameToRoJambEach: IN(1), frameToRoHead: IN(1), frameToRoSill: 0,
    undercut: IN(0.375), maxLeafWidth: IN(42), maxLeafHeight: IN(108) }),
];

const DEFAULT_FRAME_PROFILES = [
  makeFrameProfile({ id: 'dfrm-hm01', name: 'HM-01 Welded Hollow Metal', kind: 'Hollow Metal',
    jambWidth: IN(2), headWidth: IN(2), frameDepth: IN(5.25), stop: IN(0.625), rabbet: IN(1.75),
    material: '16ga galvannealed steel', wallMin: IN(3.25), wallMax: IN(6), openingRuleId: 'orule-hm01' }),
  makeFrameProfile({ id: 'dfrm-hm02', name: 'HM-02 Knock-Down Hollow Metal', kind: 'Hollow Metal',
    jambWidth: IN(2), headWidth: IN(2), frameDepth: IN(4.875), stop: IN(0.625), rabbet: IN(1.75),
    material: '18ga steel', wallMin: IN(3.25), wallMax: IN(5.5), openingRuleId: 'orule-hm02' }),
  makeFrameProfile({ id: 'dfrm-wd01', name: 'WD-01 Split Jamb Wood', kind: 'Split Jamb',
    jambWidth: IN(1.25), headWidth: IN(1.25), frameDepth: IN(4.5625), stop: IN(0.5), rabbet: 0,
    casing: IN(2.25), material: 'Primed MDF', wallMin: IN(4.5), wallMax: IN(5), openingRuleId: 'orule-wd01' }),
  // An Cuong Hi-Tech Veneer Door — read off their own UHT20 cutaway: a
  // finger-joint core wrapped in moisture-resistant MDF, faced in melamine /
  // laminate, with the casing moulded as part of the jamb and a bulb seal set
  // into the stop. This is a supplied system, not something LEON mills.
  makeFrameProfile({ id: 'dfrm-ac-uht20', name: 'AC-UHT20 An Cuong Hi-Tech Veneer',
    kind: 'Wrapped Jamb', manufacturer: 'An Cuong', profile: 'UHT20',
    jambWidth: IN(1.5), headWidth: IN(1.5), frameDepth: IN(4.875),
    stop: IN(0.5), rabbet: 0, casing: IN(2.5), integralCasing: true, sealGroove: true,
    coreMaterial: 'Finger Joint', skinMaterial: 'Moisture Resistant MDF',
    faceMaterial: 'Melamine', skinThickness: 9,
    material: 'MR MDF over finger-joint core', finish: 'Melamine / laminate',
    wallMin: IN(3.5), wallMax: IN(6.5), openingRuleId: 'orule-leon-sw' }),
  makeFrameProfile({ id: 'dfrm-al01', name: 'AL-01 Aluminium Concealed', kind: 'Aluminum',
    jambWidth: IN(1), headWidth: IN(1), frameDepth: IN(4.75), shadowGap: IN(0.375),
    material: 'Extruded aluminium', finish: 'Anodized', wallMin: IN(4), wallMax: IN(6), openingRuleId: 'orule-al01' }),
  makeFrameProfile({ id: 'dfrm-inv01', name: 'INV-01 Invisible Frame', kind: 'Frameless / Invisible',
    jambWidth: IN(0.75), headWidth: IN(0.75), frameDepth: IN(4.75), shadowGap: IN(0.25),
    material: 'Extruded aluminium, plaster-in', wallMin: IN(4.5), wallMax: IN(5.5), openingRuleId: 'orule-inv01' }),
];

// The leaf designs, parametric. The elevation is drawn from these numbers.
const DEFAULT_DOOR_DESIGNS = [
  makeDoorDesign({ id: 'ddes-slab', name: 'Flat Slab', kind: 'Flat Slab' }),
  makeDoorDesign({ id: 'ddes-veneer', name: 'Wood Veneer Flat Slab', kind: 'Wood Veneer Flat Slab' }),
  makeDoorDesign({ id: 'ddes-painted', name: 'Painted Flat Slab', kind: 'Painted Flat Slab' }),
  makeDoorDesign({ id: 'ddes-shaker', name: 'Shaker — 1 Panel', kind: 'Shaker',
    railTop: IN(5), railBottom: IN(8), stile: IN(5), panelRows: 1, panelCols: 1 }),
  makeDoorDesign({ id: 'ddes-shaker2', name: 'Shaker — 2 Panel', kind: 'Shaker',
    railTop: IN(5), railBottom: IN(8), railMid: IN(5), stile: IN(5), panelRows: 2, panelCols: 1 }),
  makeDoorDesign({ id: 'ddes-shaker5', name: 'Shaker — 5 Panel', kind: 'Shaker',
    railTop: IN(5), railBottom: IN(8), railMid: IN(4), stile: IN(5), panelRows: 5, panelCols: 1 }),
  makeDoorDesign({ id: 'ddes-groove1', name: '1 Vertical Groove', kind: '1 Vertical Groove',
    grooveCount: 1, grooveOrientation: 'Vertical', grooveWidth: IN(0.5) }),
  makeDoorDesign({ id: 'ddes-groove2', name: '2 Vertical Grooves', kind: '2 Vertical Grooves',
    grooveCount: 2, grooveOrientation: 'Vertical', grooveWidth: IN(0.5) }),
  makeDoorDesign({ id: 'ddes-groove3h', name: '3 Horizontal Grooves', kind: '3 Horizontal Grooves',
    grooveCount: 3, grooveOrientation: 'Horizontal', grooveWidth: IN(0.5) }),
  // Three of the thirty doors on the reviewed 55 India schedule are louvered —
  // laundry and mechanical closets. The louver is the leaf style, so it is
  // seeded as a design rather than left to be set on each door.
  makeDoorDesign({ id: 'ddes-louver', name: 'Louvered', kind: 'Louvered', liteKind: 'Louver' }),
  makeDoorDesign({ id: 'ddes-louver-full', name: 'Louvered — full height', kind: 'Louvered',
    liteKind: 'Custom Opening', liteW: null, liteH: null, liteSill: null,
    notes: 'Louver over most of the leaf. Set the opening size on the door where it differs.' }),
  makeDoorDesign({ id: 'ddes-hm', name: 'Hollow Metal Flush', kind: 'Hollow Metal' }),
  makeDoorDesign({ id: 'ddes-fire', name: 'Flush Fire-Rated', kind: 'Flush Fire-Rated' }),
];

// Pre-drawn starting points — §9 of the brief. Each is a real configuration,
// not a picture: pick one and the sizes, frame and rule are already right.
const DEFAULT_DOOR_TYPE_TEMPLATES = [
  { code: 'STD-ENTRY', name: 'LEON Standard Apartment Entry', category: 'Apartment Entry',
    operation: 'Swing', designId: 'ddes-painted', frameId: 'dfrm-hm01', openingRuleId: 'orule-hm01',
    leafW: IN(36), leafH: IN(84), leafThickness: IN(1.75), handing: 'RHR',
    fireRating: '60 Min', ratingState: 'Requested', wallKind: 'Metal Stud', wallThickness: IN(5.25) },
  { code: 'STD-SLAB', name: 'LEON Standard Interior Slab', category: 'Interior',
    operation: 'Swing', designId: 'ddes-slab', frameId: 'dfrm-wd01', openingRuleId: 'orule-wd01',
    leafW: IN(32), leafH: IN(80), leafThickness: IN(1.375), handing: 'RH' },
  { code: 'STD-SHAKER', name: 'LEON Standard Shaker Door', category: 'Interior',
    operation: 'Swing', designId: 'ddes-shaker2', frameId: 'dfrm-wd01', openingRuleId: 'orule-wd01',
    leafW: IN(32), leafH: IN(80), leafThickness: IN(1.375), handing: 'RH' },
  { code: 'STD-CONCEALED', name: 'LEON Standard Concealed Door', category: 'Interior',
    operation: 'Swing', designId: 'ddes-slab', frameId: 'dfrm-inv01', openingRuleId: 'orule-inv01',
    leafW: IN(32), leafH: IN(96), leafThickness: IN(1.75), handing: 'RH' },
  { code: 'STD-HM', name: 'LEON Standard Hollow Metal Door', category: 'Back of House',
    operation: 'Swing', designId: 'ddes-hm', frameId: 'dfrm-hm01', openingRuleId: 'orule-hm01',
    leafW: IN(36), leafH: IN(84), leafThickness: IN(1.75), handing: 'RH',
    fireRating: '90 Min', ratingState: 'Requested' },
  { code: 'STD-FRENCH', name: 'LEON Standard French Door', category: 'Interior',
    operation: 'Double Swing', designId: 'ddes-slab', frameId: 'dfrm-wd01', openingRuleId: 'orule-wd01',
    leafW: IN(30), leafH: IN(80), leafThickness: IN(1.375), handing: 'PAIR',
    liteKind: 'Full Lite' },
  { code: 'STD-CLOSET', name: 'LEON Standard Closet Bypass', category: 'Closet',
    operation: 'Bypass', designId: 'ddes-slab', frameId: 'dfrm-wd01', openingRuleId: 'orule-wd01',
    leafW: IN(30), leafH: IN(80), leafThickness: IN(1.375), handing: 'PAIR' },
  { code: 'STD-BARN', name: 'LEON Standard Barn Door', category: 'Interior',
    operation: 'Barn', designId: 'ddes-shaker', frameId: null, openingRuleId: 'orule-inv01',
    leafW: IN(36), leafH: IN(84), leafThickness: IN(1.375), handing: 'RH' },
  { code: 'STD-POCKET', name: 'LEON Standard Pocket Door', category: 'Interior',
    operation: 'Pocket', designId: 'ddes-slab', frameId: 'dfrm-wd01', openingRuleId: 'orule-wd01',
    leafW: IN(32), leafH: IN(80), leafThickness: IN(1.375), handing: 'RH' },
];


// ---- door hardware, read off the reviewed 55 India set ----------------------
// The hardware library shipped EMPTY, so every set had to be typed from scratch
// on every job. These are the actual items and the actual sets from the Condo
// interior door package (081400-2.3), which is a real, coherent starting
// library rather than an invented one — five items, five sets, and the
// function classes a residential job actually uses.
const DOOR_HARDWARE_FUNCTIONS = [
  { key: 'privacy', label: 'Privacy', hint: 'Locks from inside. Bathrooms, bedrooms.' },
  { key: 'passage', label: 'Passage', hint: 'Latches, does not lock. Closets, laundry, linen.' },
  { key: 'dummy', label: 'Dummy', hint: 'No latch — a pull and a catch. Double closet doors.' },
  { key: 'entry', label: 'Entry', hint: 'Keyed from outside.' },
  { key: 'pocketPrivacy', label: 'Pocket privacy', hint: 'Edge pull and a pocket lock.' },
  { key: 'pocketPassage', label: 'Pocket passage', hint: 'Edge pull, no lock.' },
];

const DEFAULT_DOOR_HARDWARE = [
  makeHardwareItem({ id: 'dhw-cebi-lever', name: 'Door Handle Set', category: 'Lever',
    manufacturer: 'CEBI', model: 'GIGI-A', productNumber: '51139201',
    finish: 'Polished Nickel', notes: 'Zamak. Architect documentation calls this level polished nickel.' }),
  makeHardwareItem({ id: 'dhw-cebi-lever-dummy', name: 'Door Handle (dummy)', category: 'Lever',
    manufacturer: 'CEBI', model: 'GIGI A 5113-Dummy', productNumber: 'GIGI A 5113-Dummy',
    finish: 'Polished Nickel', notes: 'Zamak. No latch — used in pairs on double closet doors.' }),
  makeHardwareItem({ id: 'dhw-cebi-lock', name: 'Mortise Lock Set (w/ strike plate)', category: 'Lock',
    manufacturer: 'CEBI', model: 'Pin Lock Set', productNumber: 'D 60010255',
    finish: 'Polished Nickel', notes: 'Zamak.' }),
  makeHardwareItem({ id: 'dhw-hafele-hinge', name: 'Hinge', category: 'Hinge',
    manufacturer: 'Hafele', model: '927.91.860', productNumber: '927.91.860',
    finish: 'Stainless Steel',
    // This is a real commercial fact, not a note: on a pre-hung unit the hinges
    // are already fitted and must not be counted or ordered again. On a slab
    // they are a separate line.
    notes: 'Part of the pre-hung door unit — do not order separately for a pre-hung door. 3 per single leaf, 6 per pair.' }),
  makeHardwareItem({ id: 'dhw-rockwood-stop', name: 'Door Stopper', category: 'Stop',
    manufacturer: 'Rockwood', model: 'RM877', productNumber: 'RM877', finish: 'Stainless Steel' }),
  makeHardwareItem({ id: 'dhw-viewer', name: 'Door Viewer (peephole)', category: 'Viewer',
    manufacturer: '', model: '', productNumber: '', finish: 'Polished Nickel',
    notes: 'Set at eye height. Entry doors only.' }),
  makeHardwareItem({ id: 'dhw-oh-stop', name: 'Overhead Door Stop', category: 'Overhead Stop',
    manufacturer: '', model: '', productNumber: '', finish: 'Satin Stainless',
    notes: 'Where a floor or wall stop cannot be used — tight lobbies, glass walls.' }),
  makeHardwareItem({ id: 'dhw-silencer', name: 'Frame Silencers', category: 'Silencer',
    manufacturer: '', model: '', productNumber: '', finish: 'Grey',
    notes: '3 per single leaf, in the strike jamb.' }),
  makeHardwareItem({ id: 'dhw-agb-catch', name: 'Dummy Set (Magnetic Catch)', category: 'Catch',
    manufacturer: 'AGB', model: 'B02404.36.78', productNumber: 'B02404.36.78',
    finish: 'Grey', notes: 'Plastic.' }),
];

// A set is a recipe. Quantities are per DOOR, and a pair takes two of the
// leaf-mounted items and six hinges rather than three.
const DEFAULT_DOOR_HARDWARE_SETS = [
  makeHardwareSet({ id: 'dhws-privacy-swing', code: 'PRIVACY-SW', name: 'Single swing — privacy',
    fn: 'privacy', appliesTo: 'Bathroom / Bedroom',
    lines: [
      { itemId: 'dhw-cebi-lever', qty: 1 },
      { itemId: 'dhw-cebi-lock', qty: 1 },
      { itemId: 'dhw-hafele-hinge', qty: 3, note: 'Included with a pre-hung unit.' },
      { itemId: 'dhw-rockwood-stop', qty: 1 },
    ],
    notes: 'LEON obtains separate approval for hardware selections; a set defines the requirement, not the final choice.' }),
  makeHardwareSet({ id: 'dhws-passage-swing', code: 'PASSAGE-SW', name: 'Single swing — passage',
    fn: 'passage', appliesTo: 'Closet / Laundry / Coat / Linen',
    lines: [
      { itemId: 'dhw-cebi-lever', qty: 1 },
      { itemId: 'dhw-hafele-hinge', qty: 3, note: 'Included with a pre-hung unit.' },
      { itemId: 'dhw-rockwood-stop', qty: 1 },
    ] }),
  makeHardwareSet({ id: 'dhws-dummy-double', code: 'DUMMY-DBL', name: 'Double swing — dummy',
    fn: 'dummy', appliesTo: 'Closet / Laundry',
    lines: [
      { itemId: 'dhw-cebi-lever-dummy', qty: 2 },
      { itemId: 'dhw-agb-catch', qty: 2 },
      { itemId: 'dhw-hafele-hinge', qty: 6, note: 'Six for a pair. Included with a pre-hung unit.' },
      { itemId: 'dhw-rockwood-stop', qty: 2 },
    ] }),
  makeHardwareSet({ id: 'dhws-privacy-pocket', code: 'PRIVACY-PKT', name: 'Pocket — privacy',
    fn: 'pocketPrivacy', appliesTo: 'Bathroom / Office',
    lines: [{ itemId: 'dhw-cebi-lock', qty: 1 }],
    notes: 'Pocket hardware (edge pull, pocket lock, track) is selected with the pocket frame supplier.' }),
  makeHardwareSet({ id: 'dhws-passage-pocket', code: 'PASSAGE-PKT', name: 'Pocket — passage',
    fn: 'pocketPassage', appliesTo: 'Closet / Hall',
    lines: [],
    notes: 'Pocket hardware is selected with the pocket frame supplier.' }),
];

function buildDefaultDoorLibrary() {
  return {
    rules: DEFAULT_OPENING_RULES.map(r => ({ ...r })),
    trims: DEFAULT_TRIM_PROFILES.map(t => ({ ...t })),
    frames: DEFAULT_FRAME_PROFILES.map(f => ({ ...f })),
    designs: DEFAULT_DOOR_DESIGNS.map(d => ({ ...d })),
    hardware: DEFAULT_DOOR_HARDWARE.map(h => ({ ...h })),
    hardwareSets: DEFAULT_DOOR_HARDWARE_SETS.map(h => ({ ...h })),
    types: DEFAULT_DOOR_TYPE_TEMPLATES.map(t => makeDoorType({ ...t, id: `dtype-${t.code.toLowerCase()}`, global: true })),
  };
}

// Forward-merge: a seed added later reaches a library the team has edited,
// and anything they changed or removed stays as they left it.
function mergeNewDoorLibrary(persisted) {
  const base = buildDefaultDoorLibrary();
  if (!persisted) return base;
  const out = { ...makeDoorLibrary(), ...persisted };
  Object.keys(base).forEach(k => {
    if (!Array.isArray(out[k])) out[k] = [];
    const have = new Set(out[k].map(x => x.id));
    base[k].forEach(seed => { if (!have.has(seed.id)) out[k].push(seed); });
  });
  // A FIELD added to a record later needs backfilling too, not just a new
  // record: handleHeight arrived after these rules were saved, and a rule
  // without it would draw at the fallback while showing an empty box in the
  // editor — the value on the drawing and the value on the screen disagreeing
  // is the worst of the three possible states.
  if (!Array.isArray(out.trims) || !out.trims.length) out.trims = DEFAULT_TRIM_PROFILES.map(t => ({ ...t }));
  // `joint` arrived after these profiles were saved. A trim drawing at a default
  // while its editor shows an empty box is the worst of the three states, so it
  // is backfilled the same way handleBackset was.
  out.trims = out.trims.map(t => {
    const need = ['joint', 'legThickness', 'legLength', 'legOffset'].some(k => t[k] === undefined || t[k] === null);
    if (!need) return t;
    return { ...t,
      joint: t.joint || 'Mitered',
      legThickness: (t.legThickness === undefined || t.legThickness === null) ? 6.35 : t.legThickness,
      legLength: (t.legLength === undefined || t.legLength === null) ? 38.1 : t.legLength,
      legOffset: (t.legOffset === undefined || t.legOffset === null) ? 12.7 : t.legOffset };
  });
  // 'Acoustic Compression' was the wrong name for it — it is a gasket. Renamed
  // on read so a door saved under the old string still resolves, the same way
  // RENAMED_SECURITY_ROLES migrates a role.
  out.types = (out.types || []).map(t => t.gasketType === 'Acoustic Compression'
    ? { ...t, gasketType: 'Acoustic Gasket' } : t);
  // The build-up fields (core / skin / face / integralCasing) arrived after
  // these frames were saved. Re-running each through the factory backfills the
  // new keys without touching a value anyone has set.
  out.frames = (out.frames || []).map(f => makeFrameProfile(f));
  out.rules = (out.rules || []).map(r => (
    (r.handleHeight === undefined || r.handleHeight === null
      || r.handleBackset === undefined || r.handleBackset === null
      || r.clearOpeningDeduction === undefined || r.clearOpeningDeduction === null)
      ? { ...r,
          clearOpeningDeduction: (r.clearOpeningDeduction === undefined || r.clearOpeningDeduction === null)
            ? 38.1 : r.clearOpeningDeduction,
          handleHeight: (r.handleHeight === undefined || r.handleHeight === null) ? 914.4 : r.handleHeight,
          handleBackset: (r.handleBackset === undefined || r.handleBackset === null) ? 60 : r.handleBackset }
      : r));
  return out;
}

// ═══════════════════════════════════════════════════ LEON Office
// Word, Sheets and Presentation share ONE document record. Three separate
// stores would mean three search implementations, three version histories and
// three ways to link a file to a project — and the whole point of building this
// inside the Hub is that a document knows which job it belongs to.
//
// A HARD CONSTRAINT that shapes everything here: persisted state lives in
// localStorage, which this browser caps at about 13 MB. So a document REFERENCES
// LEON assets — a render, a project photo, a drawing — by id, and never embeds a
// copy. Ten decks each carrying their own copy of the same render would fill the
// quota and take the rest of the app down with them.
const OFFICE_APPS = [
  { key: 'word', label: 'LEON Word', icon: '📄',
    blurb: 'Reports, letters, RFIs, minutes and specifications — with live project data in them.' },
  { key: 'sheet', label: 'LEON Sheets', icon: '📊',
    blurb: 'Formulas, tables and charts, connected to real LEON schedules instead of copied out of them.' },
  { key: 'slides', label: 'LEON Presentation', icon: '🖼️',
    blurb: 'Client decks, finish boards and project reviews, built from the project itself.' },
  { key: 'pdf', label: 'LEON PDF', icon: '📕',
    blurb: 'Finalize, review, combine, sign and issue — the last step before a document leaves the office.' },
];
const OFFICE_STATUSES = ['Draft', 'Internal Review', 'Final', 'Issued', 'Approved', 'Superseded', 'Archived'];
const OFFICE_SCOPES = ['Company', 'Project', 'Department', 'Personal'];

function makeOfficeDocument(data, by) {
  return {
    id: uid('doc'),
    name: 'Untitled', app: 'word',
    owner: by || '', createdBy: by || '', createdDate: todayISO(),
    modifiedBy: by || '', modifiedDate: todayISO(),
    // Links, never copies. Everything below points at a record that already
    // exists somewhere else in the Hub.
    projectId: null, scopeId: null, unitId: null, roomId: null,
    vendorId: null, accountId: null,
    folder: 'Company', tags: [],
    status: 'Draft', revision: 0,
    favorite: false, archived: false,
    sharedWith: [], templateSource: null, isTemplate: false,
    // The body. Its shape depends on `app` — see the three models below.
    body: null,
    versions: [],          // [{ n, date, by, note, body }]
    comments: [],          // [{ id, by, date, text, anchor, resolved, replies[] }]
    activity: [],
    ...data,
  };
}

// Word: an ordered list of blocks. Not HTML — a structure we can search,
// diff, export and put a live data block inside.
function makeWordBody() {
  return { pageSize: 'Letter', orientation: 'portrait',
           margins: { top: 25.4, right: 25.4, bottom: 25.4, left: 25.4 },
           header: '', footer: '', blocks: [] };
}
// Sheets: sparse cells keyed "r,c". Persisting a million empty cells is how a
// spreadsheet model dies; only what was typed is stored.
function makeSheetBody() {
  return { sheets: [{ id: uid('ws'), name: 'Sheet 1', cells: {}, cols: {}, rows: {},
                      merges: [], frozen: { r: 0, c: 0 } }],
           namedRanges: {}, connections: [], charts: [] };
}
// Presentation: slides of positioned elements, in slide-percentage coordinates
// so a deck reflows to any canvas size.
function makeSlidesBody() {
  return { size: '16:9', theme: 'leon-corporate', slides: [] };
}
function makeOfficeBody(app) {
  if (app === 'sheet') return makeSheetBody();
  if (app === 'slides') return makeSlidesBody();
  if (app === 'pdf') return makePdfBody();
  return makeWordBody();
}

// ---------------------------------------------------------------------------
// LEON PDF
// ---------------------------------------------------------------------------
// Three decisions shape this whole model, and every one of them is a constraint
// the browser imposes rather than a preference:
//
// 1. THE ORIGINAL IS NEVER TOUCHED. An imported PDF's bytes are stored once and
//    are immutable. Reordering, deleting, rotating, stamping and annotating all
//    happen as a structured LEON layer over it; a new file is only written when
//    someone exports or flattens. That is what makes "undo" possible on a
//    hundred-page document and what stops a markup session from quietly
//    destroying the architect's issued drawing.
// 2. PDF BYTES DO NOT GO IN localStorage. The app's persisted state has about
//    13 MB in total; one 300-page specification is bigger than that on its own.
//    Binaries live in IndexedDB (PDF_ASSET_DB) and the record below keeps only
//    an asset id. Putting a PDF in localStorage would take the entire Hub's
//    state down with it.
// 3. EXTRACTED TEXT IS STORED SEPARATELY from the binary, so search and OCR
//    results can be rebuilt or discarded without ever rewriting the source.
const PDF_ASSET_DB = 'leon-pdf-assets';
const PDF_OCR_STATUSES = ['Not Needed', 'Not Processed', 'Processing', 'Complete', 'Low Confidence', 'Failed'];
const PDF_SOURCE_TYPES = ['Uploaded', 'Scanned', 'LEON Word', 'LEON Sheets', 'LEON Presentation',
                          'Combined', 'Images', 'LEON Report', 'Extracted'];
const PDF_CATEGORIES = ['Contract', 'Estimate', 'Proposal', 'Submittal', 'Specification', 'Product Data',
                        'Invoice', 'Vendor Quote', 'Packing List', 'Purchase Order', 'Report', 'Letter',
                        'Manual', 'Warranty', 'Closeout', 'Drawing', 'Scanned Document', 'Form',
                        'Signed Document', 'Other'];
const PDF_ANNOTATION_TYPES = ['Highlight', 'Underline', 'Strikeout', 'Text Comment', 'Sticky Note',
                              'Callout', 'Text Box', 'Rectangle', 'Circle', 'Polygon', 'Polyline',
                              'Line', 'Arrow', 'Cloud', 'Freehand', 'Stamp', 'Measurement', 'Redaction'];
const PDF_COMMENT_STATES = ['Open', 'Accepted', 'Rejected', 'Completed', 'Cancelled'];
const PDF_FIELD_TYPES = ['Text', 'Multiline', 'Checkbox', 'Radio', 'Dropdown', 'Date', 'Number',
                         'Signature', 'Initial'];
const PDF_SIGNATURE_KINDS = ['LEON Approval', 'Drawn Signature', 'Typed Signature', 'Uploaded Signature'];
const PDF_JOB_STATES = ['Queued', 'Processing', 'Completed', 'Failed'];
const PDF_STAMP_LIBRARY = ['APPROVED', 'APPROVED AS NOTED', 'REVISE AND RESUBMIT', 'REVIEWED',
                           'FOR RECORD', 'VOID', 'SUPERSEDED', 'RECEIVED', 'CONFIDENTIAL'];
const PDF_WATERMARKS = ['DRAFT', 'FOR REVIEW', 'NOT FOR CONSTRUCTION', 'APPROVED', 'SUPERSEDED',
                        'CONFIDENTIAL', 'PRELIMINARY'];

// A page in the working document is a POINTER at a page of some source asset,
// plus what has been done to it. Reordering is reordering this list; deleting is
// removing an entry; inserting another PDF appends entries that point at a second
// asset. Nothing is rewritten until export, which is why a 300-page reorder is
// instant and reversible.
function makePdfPage(assetId, index, data) {
  return { id: uid('pg'), assetId, index, rotate: 0, crop: null,
           label: '', hidden: false, sourceRef: null, ...data };
}
function makePdfAnnotation(data, by) {
  return { id: uid('anno'), page: 0, type: 'Highlight',
           // Geometry is in PAGE-PERCENTAGE coordinates, never pixels, so a
           // markup lands in the same place at any zoom or on any screen.
           rect: { x: 0, y: 0, w: 0, h: 0 }, points: [], color: '#F5C518', opacity: 0.4,
           text: '', quote: '', author: by || '', authorId: null, date: todayISO(),
           state: 'Open', resolved: false, replies: [], mentions: [], ...data };
}
function makePdfBookmark(data) {
  return { id: uid('bm'), title: 'Bookmark', page: 0, children: [], ...data };
}
function makePdfField(data) {
  return { id: uid('fld'), page: 0, type: 'Text', name: '', label: '',
           rect: { x: 0, y: 0, w: 0.2, h: 0.03 }, value: '', options: [],
           required: false, readOnly: false, signerRole: '',
           leonSource: null,   // e.g. 'project.name' — filled from real data, reviewed before it lands
           ...data };
}
function makePdfSignature(data, by) {
  return { id: uid('sig'), kind: 'LEON Approval', fieldId: null, page: 0,
           rect: { x: 0, y: 0, w: 0.22, h: 0.06 },
           signerId: null, signerName: by || '', signerTitle: '', role: '',
           imageRef: null, typedName: '',
           signedDate: null, signedTime: null, revision: null, ...data };
}
function makePdfSignatureRequest(data, by) {
  return { id: uid('sreq'), signerIds: [], sequence: [], message: '',
           requestedBy: by || '', requestedDate: todayISO(),
           dueDate: null, status: 'Pending', responses: [], ...data };
}
// A redaction is a two-step act on purpose: it is MARKED, reviewed, then APPLIED.
// Applying writes a new asset with the content actually removed — a black box
// drawn over text is not a redaction, and treating it as one is how documents leak.
function makePdfRedaction(data, by) {
  return { id: uid('red'), page: 0, rect: { x: 0, y: 0, w: 0, h: 0 },
           reason: '', matchedText: '', applied: false,
           markedBy: by || '', markedDate: todayISO(), appliedDate: null, ...data };
}
function makePdfExtraction(data) {
  // Nothing extracted from a PDF ever writes to a LEON record on its own; this
  // record is the review step that stands between the two.
  return { id: uid('xtr'), kind: 'Vendor Quote', page: 0, rows: [], fields: {},
           confidence: null, sourceRegion: null, reviewed: false,
           targetModule: null, appliedDate: null, appliedBy: null, ...data };
}
function makePdfJob(data) {
  return { id: uid('job'), kind: 'export', status: 'Queued', progress: 0,
           message: '', startedDate: todayISO(), finishedDate: null, error: null, ...data };
}
function makePdfBody() {
  return {
    originalAssetId: null,   // immutable; the file exactly as it arrived
    workingAssetId: null,    // the last exported/flattened build, if any
    pageCount: 0,
    pages: [],               // makePdfPage[] — the order plan described above
    bookmarks: [],
    annotations: [],
    fields: [],
    signatures: [],
    signatureRequests: [],
    redactions: [],
    extractions: [],
    links: [],               // [{ id, page, rect, kind:'page'|'record'|'url', target }]
    packageSections: [],     // [{ id, title, pageIds[], divider }] — the binder/index model
    watermark: null,         // { text, opacity, rotation, position, pages }
    headerFooter: null,      // { left, center, right, includePageNumbers, startAt }
    security: { restrictEditing: false, restrictPrinting: false, passwordSet: false },
    ocrStatus: 'Not Processed',
    ocrConfidence: null,
    indexStatus: 'Not Processed',
    textIndexKey: null,      // IndexedDB key; extracted text never sits in the record
    formStatus: 'None',      // None | Fields Placed | In Progress | Submitted
    signatureStatus: 'Unsigned',  // Unsigned | Awaiting | Partially Signed | Signed | Locked
    sourceType: 'Uploaded',
    sourceDocId: null,       // the LEON Word/Sheet/Slides document this was published from
    sourceRevision: null,
    category: 'Other',
    documentNumber: '',
    pageLabels: {},
    flattened: false,
    optimized: false,
    locked: false,           // set once a final signature lands; edits fork a revision
    jobs: [],
  };
}
function makePdfDocumentData(data) { return makePdfBody(); }

// ---------------------------------------------------------------------------
// LEON Countertops — drawing, take-off and estimating
// ---------------------------------------------------------------------------
// Modelled on how the team already quotes countertops in Moraware CounterGo,
// because that workflow is proven in this business and there is no reason to
// invent a different one. The essential insight, and the reason it is fast, is
// that drawing a countertop is NOT freeform CAD. It is six decisions taken one
// at a time over the whole drawing: the outline, the corners, which edges are
// finished, where the sinks go, what material it is in, and what that costs.
// Each step asks one kind of question, so nobody is choosing an edge profile
// while still trying to get a dimension right.
//
// THE PRICING RULE THAT MATTERS MOST, and the one a naive area-based estimate
// gets wrong: material is charged BY THE SLAB, not by the countertop. A 69.2
// sq ft kitchen that needs two 128"x64" slabs is billed for 113.8 sq ft of
// slab. Fabrication is charged on the countertop area. Edge is charged per
// linear foot of FINISHED edge only — the runs against a wall are not billable
// edge and must never be counted as if they were.

// ---------------------------------------------------------------------------
// Per-software settings — every tool has its own dials
// ---------------------------------------------------------------------------
// Each software carried a set of hardcoded constants: a saw kerf, a waste
// percentage, a default slab size, a clearance. Sensible defaults, but they are
// this company's numbers and they change — so they belong to an admin, not to
// the source. This is the one registry behind all of them.
//
// SCHEMA-DRIVEN ON PURPOSE. A settings screen per software would be ten screens
// to build and ten to keep in step; declaring the fields as data means one
// panel renders them all, and adding a dial to any tool is one entry here.
//
// Reading a setting is `softwareSetting('stone', 'kerfMm')`. It answers from
// the persisted value if an admin has set one and from `def` below otherwise,
// so behaviour on day one is identical to the constants it replaces.
const SOFTWARE_SETTING_TYPES = ['number', 'text', 'toggle', 'select', 'list'];

const SOFTWARE_SETTINGS = {
  stone: {
    label: 'LEON Stone', icon: '🪨',
    sections: [
      { name: 'Cutting allowances', fields: [
        { key: 'kerfMm', label: 'Saw kerf + trim', type: 'number', unit: 'mm', def: 12,
          hint: 'Two pieces closer than this are touching once the blade has been through.' },
        { key: 'edgeClearanceMm', label: 'Edge clearance', type: 'number', unit: 'mm', def: 10,
          hint: 'Kept between a piece and the edge of the usable area.' },
        { key: 'slabTrimMm', label: 'Default edge trim on a slab', type: 'number', unit: 'mm', def: 0,
          hint: 'Used when a slab record carries no trim figure of its own.' },
        { key: 'minRemnantMm', label: 'Smallest offcut worth racking', type: 'number', unit: 'mm', def: 300,
          hint: 'Free rectangles smaller than this on either side are not offered as remnants.' },
      ] },
      { name: 'Slabs', fields: [
        { key: 'nominalLengthMm', label: 'Nominal slab length', type: 'number', unit: 'mm', def: 3200 },
        { key: 'nominalWidthMm', label: 'Nominal slab width', type: 'number', unit: 'mm', def: 1600 },
        { key: 'defaultThicknessMm', label: 'Default thickness', type: 'number', unit: 'mm', def: 30 },
        { key: 'targetYield', label: 'Target yield', type: 'number', unit: '%', def: 75,
          hint: 'What a layout is expected to achieve, for the estimate before pieces are laid out.' },
      ] },
      { name: 'Vocabulary', fields: [
        { key: 'edgeProfiles', label: 'Edge profiles', type: 'list',
          def: ['Eased', 'Bullnose', 'Half Bullnose', 'Ogee', 'Bevel', 'Mitered', 'Waterfall', 'Laminated / Built-up'] },
        { key: 'materials', label: 'Material types', type: 'list',
          def: ['Quartz', 'Quartzite', 'Granite', 'Marble', 'Porcelain', 'Sintered Stone', 'Dolomite',
                'Onyx', 'Soapstone', 'Travertine', 'Limestone', 'Solid Surface'] },
      ] },
    ],
  },
  countertops: {
    label: 'LEON Countertops', icon: '🧿',
    sections: [
      { name: 'Drawing defaults', fields: [
        { key: 'units', label: 'Units', type: 'select', options: ['inches', 'millimeters'], def: 'inches' },
        { key: 'defaultDepthIn', label: 'Default counter depth', type: 'number', unit: 'in', def: 25.5 },
        { key: 'defaultIslandDepthIn', label: 'Default island depth', type: 'number', unit: 'in', def: 38 },
        { key: 'defaultThicknessCm', label: 'Default thickness', type: 'number', unit: 'cm', def: 3 },
        { key: 'defaultOverhangIn', label: 'Default front overhang', type: 'number', unit: 'in', def: 1.5 },
        { key: 'defaultSplashHeightIn', label: 'Default splash height', type: 'number', unit: 'in', def: 4 },
        { key: 'roundToSixteenths', label: 'Round dimensions to the nearest 1/16"', type: 'toggle', def: true },
      ] },
      { name: 'Seams', fields: [
        { key: 'maxPieceLengthIn', label: 'Longest piece without a seam', type: 'number', unit: 'in', def: 120,
          hint: 'A suggested seam is offered past this. Where a seam actually goes is still a fabricator’s call.' },
      ] },
      { name: 'Quotes', fields: [
        { key: 'expirationDays', label: 'Quote expires after', type: 'number', unit: 'days', def: 30 },
        { key: 'estimateNoPrefix', label: 'Estimate number prefix', type: 'text', def: '' },
      ] },
    ],
  },
  doors: {
    label: 'LEON Doors', icon: '🚪',
    sections: [
      { name: 'Defaults', fields: [
        { key: 'units', label: 'Units shown', type: 'select', options: ['Imperial', 'Metric'], def: 'Imperial' },
        { key: 'defaultLeafThicknessMm', label: 'Default leaf thickness', type: 'number', unit: 'mm', def: 45 },
        { key: 'defaultUndercutMm', label: 'Default undercut', type: 'number', unit: 'mm', def: 10 },
      ] },
      { name: 'Schedule import', fields: [
        { key: 'expandAbbreviations', label: 'Suggest expansions for abbreviations', type: 'toggle', def: true,
          hint: 'SCWD → Solid Core Wood Door. Always a suggestion a person accepts, never applied on its own.' },
        { key: 'updateExistingMarks', label: 'A mark already on the job is updated, not duplicated', type: 'toggle', def: true },
      ] },
    ],
  },
  takeoff: {
    label: 'LEON Take-off', icon: '📐',
    sections: [
      { name: 'Measuring', fields: [
        { key: 'requireCalibration', label: 'Refuse to measure an uncalibrated sheet', type: 'toggle', def: true,
          hint: 'A PDF printed to fit is the normal case, and is exactly how a take-off goes silently wrong.' },
        { key: 'gridSnapIn', label: 'Grid snap', type: 'number', unit: 'in', def: 6 },
        { key: 'defaultScale', label: 'Assumed scale when none is read', type: 'text', def: '1/4" = 1\'-0"' },
      ] },
      { name: 'Dynamic Fill', fields: [
        { key: 'fillDpi', label: 'Render resolution', type: 'number', unit: 'dpi', def: 150,
          hint: 'Higher finds thinner lines and costs more time and memory.' },
        { key: 'fillInkThreshold', label: 'Ink threshold', type: 'number', def: 190,
          hint: 'Below this luminance a pixel counts as a line. Raise it on a faint scan.' },
        { key: 'fillEdgeSensitivity', label: 'Edge sensitivity', type: 'number', unit: 'px', def: 2,
          hint: 'How far a gap is closed before filling — the wall that does not quite meet the door jamb.' },
        { key: 'fillSpansPerFrame', label: 'Fill speed', type: 'number', unit: 'spans/frame', def: 3000 },
        { key: 'fillSimplifyPx', label: 'Outline simplification', type: 'number', unit: 'px', def: 1.5,
          hint: 'Without this a traced room comes back with tens of thousands of points.' },
      ] },
      { name: 'Snapping & search', fields: [
        { key: 'snapMaxSegments', label: 'Most line segments indexed for snapping', type: 'number', def: 200000 },
        { key: 'searchSensitivity', label: 'Visual search sensitivity', type: 'number', unit: '%', def: 84,
          hint: 'Lower tolerates a clipped symbol and finds more false positives. Results are always reviewed.' },
        { key: 'searchCoarseFactor', label: 'Search pyramid factor', type: 'number', def: 4 },
      ] },
      { name: 'Waste', fields: [
        { key: 'wasteCasework', label: 'Casework', type: 'number', unit: '%', def: 5 },
        { key: 'wasteCountertop', label: 'Countertop', type: 'number', unit: '%', def: 15 },
        { key: 'wasteTileFloor', label: 'Tile — floor', type: 'number', unit: '%', def: 10 },
        { key: 'wasteTileWall', label: 'Tile — wall', type: 'number', unit: '%', def: 12 },
        { key: 'wasteFlooring', label: 'Wood / SPC flooring', type: 'number', unit: '%', def: 8 },
        { key: 'wasteCarpet', label: 'Carpet — broadloom', type: 'number', unit: '%', def: 12 },
        { key: 'wasteBaseboard', label: 'Baseboard', type: 'number', unit: '%', def: 10 },
      ] },
    ],
  },
  casework: {
    label: 'LEON Casework', icon: '🪵',
    sections: [
      { name: 'Sheet stock', fields: [
        { key: 'sheetLengthMm', label: 'Sheet length', type: 'number', unit: 'mm', def: 2440 },
        { key: 'sheetWidthMm', label: 'Sheet width', type: 'number', unit: 'mm', def: 1220 },
        { key: 'sheetKerfMm', label: 'Saw kerf', type: 'number', unit: 'mm', def: 4 },
      ] },
      { name: 'Drawing conventions', fields: [
        { key: 'wallThicknessMm', label: 'Wall thickness drawn', type: 'number', unit: 'mm', def: 114,
          hint: 'A drawing convention, not a record — a wall record carries length and height only.' },
        { key: 'counterThicknessMm', label: 'Countertop thickness drawn', type: 'number', unit: 'mm', def: 30 },
        { key: 'counterOverhangMm', label: 'Countertop overhang drawn', type: 'number', unit: 'mm', def: 25 },
      ] },
    ],
  },
  surfaces: {
    label: 'LEON Tiles and Flooring', icon: '🧱',
    sections: [
      { name: 'The piece and the box', fields: [
        { key: 'perPack', label: 'Pieces per box', type: 'number', def: 8,
          hint: 'Used until a product carries its own. Box rounding is what creates surplus.' },
        { key: 'minReusableMm', label: 'Smallest offcut worth keeping', type: 'number', unit: 'mm', def: 300,
          hint: 'A remainder shorter than this is waste rather than the start of the next row.' },
        { key: 'sawKerfMm', label: 'Saw kerf', type: 'number', unit: 'mm', def: 3 },
        { key: 'expansionMm', label: 'Expansion gap', type: 'number', unit: 'mm', def: 10 },
        { key: 'maxSimPieces', label: 'Largest layout simulated', type: 'number', unit: 'pieces', def: 20000,
          hint: 'Past this the layout is incomplete, so that surface falls back to the rough allowance and says so.' },
      ] },
      { name: 'Cuts at the perimeter', fields: [
        { key: 'minStartMm', label: 'Shortest first piece in a row', type: 'number', unit: 'mm', def: 200 },
        { key: 'minEndMm', label: 'Shortest last piece in a row', type: 'number', unit: 'mm', def: 200,
          hint: 'The sliver-cut rule. The solver searches for a start position that satisfies it everywhere.' },
        { key: 'minStaggerMm', label: 'Minimum joint stagger', type: 'number', unit: 'mm', def: 300,
          hint: 'How far apart joints in adjacent rows must stay.' },
      ] },
      { name: 'Skirting & openings', fields: [
        { key: 'skirtingStockMm', label: 'Skirting stock length', type: 'number', unit: 'mm', def: 2440 },
        { key: 'skirtingHeightMm', label: 'Skirting height', type: 'number', unit: 'mm', def: 100 },
        { key: 'openingWidthMm', label: 'Default opening width', type: 'number', unit: 'mm', def: 800 },
        { key: 'openingFrameMm', label: 'Frame each side', type: 'number', unit: 'mm', def: 40 },
        { key: 'wallThicknessMm', label: 'Wall thickness drawn', type: 'number', unit: 'mm', def: 150,
          hint: 'A drawing convention, not a record.' },
      ] },
      { name: 'Underlay', fields: [
        { key: 'underlayRollLengthMm', label: 'Roll length', type: 'number', unit: 'mm', def: 15000 },
        { key: 'underlayRollWidthMm', label: 'Roll width', type: 'number', unit: 'mm', def: 1000 },
        { key: 'underlayPanelLengthMm', label: 'Panel length', type: 'number', unit: 'mm', def: 1200 },
        { key: 'underlayPanelWidthMm', label: 'Panel width', type: 'number', unit: 'mm', def: 600 },
      ] },
      { name: 'Adhesive & grout', fields: [
        { key: 'adhesiveBagKg', label: 'Adhesive bag', type: 'number', unit: 'kg', def: 25 },
        { key: 'groutBagKg', label: 'Grout bag', type: 'number', unit: 'kg', def: 5 },
        { key: 'groutDensityKgPerL', label: 'Grout density', type: 'number', unit: 'kg/L', def: 1.9 },
        { key: 'groutDepthMm', label: 'Grout depth', type: 'number', unit: 'mm', def: 8,
          hint: 'Normally the tile thickness.' },
      ] },
      { name: 'Set-out', fields: [
        { key: 'groutMm', label: 'Default grout joint', type: 'number', unit: 'mm', def: 3 },
        { key: 'wasteFloorPct', label: 'Floor waste', type: 'number', unit: '%', def: 10 },
        { key: 'wasteWallPct', label: 'Wall waste', type: 'number', unit: '%', def: 12 },
        { key: 'minCutPct', label: 'Smallest acceptable cut tile', type: 'number', unit: '%', def: 33,
          hint: 'A set-out that lands a sliver against a wall is reported rather than silently accepted.' },
      ] },
    ],
  },
  fenestration: {
    label: 'LEON Windows', icon: '🪟',
    sections: [
      { name: 'Geometry', fields: [
        { key: 'requireVerifiedCad', label: 'Only Verified CAD may be used for fabrication', type: 'toggle', def: true,
          hint: 'A hand-entered or approximate section can still be laid out; this stops it reaching a cut list.' },
        { key: 'defaultGlassThicknessMm', label: 'Default glass thickness', type: 'number', unit: 'mm', def: 24 },
        { key: 'defaultEdgeClearanceMm', label: 'Default edge clearance', type: 'number', unit: 'mm', def: 5 },
      ] },
    ],
  },
  studio: {
    label: 'Image & Render Studio', icon: '🎨',
    sections: [
      { name: 'Rendering', fields: [
        { key: 'featherPx', label: 'Default mask feather', type: 'number', unit: 'px', def: 2 },
        { key: 'shadowLift', label: 'Shadow lift', type: 'number', def: 0.15,
          hint: 'How far deep shadow is raised before the material is multiplied through it.' },
        { key: 'gamma', label: 'Luminance gamma', type: 'number', def: 1 },
      ] },
      { name: 'Approval', fields: [
        { key: 'approvalNotice', label: 'Notice shown on every render', type: 'text',
          def: 'Screen colour is not finish approval — approve against the physical sample.' },
      ] },
    ],
  },
  office: {
    label: 'LEON Office', icon: '🗂️',
    sections: [
      { name: 'Documents', fields: [
        { key: 'versionLimit', label: 'Versions kept per document', type: 'number', def: 12,
          hint: 'One per keystroke would fill the app’s storage; this is the cap on saved snapshots.' },
        { key: 'autosaveMs', label: 'Autosave delay', type: 'number', unit: 'ms', def: 700 },
        { key: 'defaultPageSize', label: 'Default page size', type: 'select', options: ['Letter', 'A4', 'Legal'], def: 'Letter' },
      ] },
      { name: 'PDF', fields: [
        { key: 'rasterDpi', label: 'DPI when a page must be rasterized', type: 'number', unit: 'dpi', def: 150,
          hint: 'Used by redaction and flattening, where a page becomes an image.' },
        { key: 'jpegQuality', label: 'JPEG quality', type: 'number', def: 0.9 },
      ] },
    ],
  },
};

// The live values, re-pointed by App() on every render — the same registry
// pattern as setActiveRolePermissions, and for the same reason: these are read
// from pure functions that have no way to reach React state.
let __activeSoftwareSettings = null;
function setActiveSoftwareSettings(map) { __activeSoftwareSettings = map || null; }
function softwareSettingDef(swKey, fieldKey) {
  const sw = SOFTWARE_SETTINGS[swKey];
  if (!sw) return undefined;
  for (const sec of sw.sections) {
    const f = sec.fields.find(x => x.key === fieldKey);
    if (f) return f.def;
  }
  return undefined;
}
function softwareSetting(swKey, fieldKey) {
  const saved = __activeSoftwareSettings && __activeSoftwareSettings[swKey];
  if (saved && Object.prototype.hasOwnProperty.call(saved, fieldKey) &&
      saved[fieldKey] !== null && saved[fieldKey] !== '') return saved[fieldKey];
  return softwareSettingDef(swKey, fieldKey);
}
function softwareSettingsDefaults(swKey) {
  const out = {};
  const sw = SOFTWARE_SETTINGS[swKey];
  if (sw) sw.sections.forEach(sec => sec.fields.forEach(f => { out[f.key] = f.def; }));
  return out;
}

const CT_UNITS = ['inches', 'millimeters'];
// A perimeter segment is one of these. It decides both what the drawing shows
// and what the estimate can charge for.
const CT_SEGMENT_KINDS = ['Finished', 'Unfinished', 'Splash', 'Appliance'];
const CT_CORNER_TREATMENTS = ['Standard', 'Outside Radius', 'Inside Radius', 'Clipped',
                              'Bumped Out', 'Notched', 'Inside Diagonal', 'Recessed Diagonal'];
const CT_EDGE_PROFILES = ['Eased', 'Square', 'Bevel', 'Double Bevel', 'Bullnose', 'Half Bullnose',
                          'Demi Bullnose', 'Cove', 'Ogee', 'DuPont', 'Miter', 'Waterfall',
                          'Laminated / Built-up'];
const CT_SINK_TYPES = ['Undermount', 'Drop-In', 'Farmhouse', 'Vessel', 'Integrated'];
const CT_CUTOUT_KINDS = ['Sink', 'Cooktop', 'Faucet Hole', 'Outlet', 'Other'];
const CT_SHAPE_TEMPLATES = ['Single Run', 'L-Shape', 'U-Shape', 'Galley', 'Island', 'Peninsula', 'Vanity', 'Custom'];
const CT_QUOTE_STATUSES = ['Draft', 'Active', 'Sent', 'Accepted', 'Ordered', 'Lost', 'Expired'];

// ---------------------------------------------------------------------------
// Office parity — the model Word, Excel and PowerPoint actually use
// ---------------------------------------------------------------------------
// Read straight out of blank Word.docx / Sheet.xlsx / Presentation.pptx the
// client supplied as the reference. These are not invented: the layout names
// and types are PowerPoint's own, the style ids are Word's, and the theme slots
// are the OOXML colour scheme. Matching them means a document made here reads
// as a normal document to anyone who has used Office, and it is also what makes
// "apply a theme" restyle a whole deck in one act.

// A THEME is one object shared by all three applications, exactly as in Office:
// two fonts and twelve colour slots. Everything else references a slot rather
// than a literal, which is why changing the theme changes the document.
function makeOfficeTheme(data) {
  return {
    id: uid('thm'), name: 'LEON',
    majorFont: 'Century Gothic Leon', minorFont: 'Century Gothic Leon',
    // dk/lt are the text and background pairs; Office writes dk1/lt1 as
    // windowText/window, which resolve to the reader's own black and white.
    dk1: '#1A1A1A', lt1: '#FFFFFF', dk2: '#2B2118', lt2: '#F3EFE9',
    accent1: '#8B5E34', accent2: '#B08968', accent3: '#6B7F5E',
    accent4: '#3E6B7A', accent5: '#A0522D', accent6: '#7C6A55',
    hlink: '#467886', folHlink: '#96607D',
    ...data,
  };
}
// Office's own default theme, kept so a document can be set back to something
// neutral — and so an imported look has somewhere to land.
const OFFICE_THEME_OFFICE = {
  id: 'office', name: 'Office',
  majorFont: 'Aptos Display', minorFont: 'Aptos',
  dk1: '#000000', lt1: '#FFFFFF', dk2: '#0E2841', lt2: '#E8E8E8',
  accent1: '#156082', accent2: '#E97132', accent3: '#196B24',
  accent4: '#0F9ED5', accent5: '#A02B93', accent6: '#4EA72E',
  hlink: '#467886', folHlink: '#96607D',
};

// ── Word ───────────────────────────────────────────────────────────────────
// Word's full built-in set. Ours had Title, Heading 1-4, Body, Caption and
// Quote; everything below that line was missing, and Subtitle, Intense Quote
// and List Paragraph are the ones people reach for constantly.
// `kind` matters: Word separates PARAGRAPH styles from CHARACTER styles (its
// "…Char" ids), and a run-level emphasis cannot be a paragraph style.
const WORD_STYLE_SET = [
  { id: 'Normal', name: 'Normal', kind: 'paragraph', builtin: true, sizePt: 12 },
  { id: 'Title', name: 'Title', kind: 'paragraph', builtin: true, sizePt: 28, bold: false, spaceAfter: 4 },
  { id: 'Subtitle', name: 'Subtitle', kind: 'paragraph', builtin: true, sizePt: 15, color: 'dk2' },
  { id: 'Heading1', name: 'Heading 1', kind: 'paragraph', builtin: true, sizePt: 20, color: 'accent1', spaceBefore: 12 },
  { id: 'Heading2', name: 'Heading 2', kind: 'paragraph', builtin: true, sizePt: 16, color: 'accent1', spaceBefore: 10 },
  { id: 'Heading3', name: 'Heading 3', kind: 'paragraph', builtin: true, sizePt: 14, color: 'accent1', spaceBefore: 8 },
  { id: 'Heading4', name: 'Heading 4', kind: 'paragraph', builtin: true, sizePt: 12, italic: true, color: 'accent1' },
  { id: 'Heading5', name: 'Heading 5', kind: 'paragraph', builtin: true, sizePt: 12, color: 'accent1' },
  { id: 'Heading6', name: 'Heading 6', kind: 'paragraph', builtin: true, sizePt: 12, italic: true, color: 'dk2' },
  { id: 'Heading7', name: 'Heading 7', kind: 'paragraph', builtin: true, sizePt: 12, color: 'dk2' },
  { id: 'Heading8', name: 'Heading 8', kind: 'paragraph', builtin: true, sizePt: 11, color: 'dk2' },
  { id: 'Heading9', name: 'Heading 9', kind: 'paragraph', builtin: true, sizePt: 11, italic: true, color: 'dk2' },
  { id: 'Quote', name: 'Quote', kind: 'paragraph', builtin: true, italic: true, indent: 24 },
  { id: 'IntenseQuote', name: 'Intense Quote', kind: 'paragraph', builtin: true, italic: true,
    color: 'accent1', indent: 36, ruled: true },
  { id: 'ListParagraph', name: 'List Paragraph', kind: 'paragraph', builtin: true, indent: 24 },
  { id: 'Caption', name: 'Caption', kind: 'paragraph', builtin: true, sizePt: 9, color: 'dk2' },
  { id: 'IntenseEmphasis', name: 'Intense Emphasis', kind: 'character', builtin: true, italic: true, color: 'accent1' },
  { id: 'IntenseReference', name: 'Intense Reference', kind: 'character', builtin: true, bold: true,
    smallCaps: true, color: 'accent1' },
];
// Letter, one-inch margins, half-inch header and footer — the numbers in the
// reference file, converted from twips (1440 per inch).
const WORD_PAGE_DEFAULT = {
  pageSize: 'Letter', orientation: 'portrait',
  margins: { top: 25.4, right: 25.4, bottom: 25.4, left: 25.4 },
  headerDistance: 12.7, footerDistance: 12.7, gutter: 0,
};

// ── Presentation ───────────────────────────────────────────────────────────
// PowerPoint's eleven standard layouts, with its own names and type codes. Ours
// had nine and none of the vertical ones. `ph` is the placeholder set each
// layout owns — and note that EVERY layout carries dt / ftr / sldNum, which is
// the slide furniture (date, footer, slide number) we had no concept of at all.
const SLIDE_LAYOUTS = [
  { key: 'title',        name: 'Title Slide',             type: 'title',           ph: ['ctrTitle', 'subTitle'] },
  { key: 'obj',          name: 'Title and Content',       type: 'obj',             ph: ['title', 'body'] },
  { key: 'secHead',      name: 'Section Header',          type: 'secHead',         ph: ['title', 'body'] },
  { key: 'twoObj',       name: 'Two Content',             type: 'twoObj',          ph: ['title', 'body', 'body'] },
  { key: 'twoTxTwoObj',  name: 'Comparison',              type: 'twoTxTwoObj',     ph: ['title', 'body', 'body', 'body', 'body'] },
  { key: 'titleOnly',    name: 'Title Only',              type: 'titleOnly',       ph: ['title'] },
  { key: 'blank',        name: 'Blank',                   type: 'blank',           ph: [] },
  { key: 'objTx',        name: 'Content with Caption',    type: 'objTx',           ph: ['title', 'body', 'body'] },
  { key: 'picTx',        name: 'Picture with Caption',    type: 'picTx',           ph: ['title', 'pic', 'body'] },
  { key: 'vertTx',       name: 'Title and Vertical Text', type: 'vertTx',          ph: ['title', 'body'] },
  { key: 'vertTitleAndTx', name: 'Vertical Title and Text', type: 'vertTitleAndTx', ph: ['title', 'body'] },
];
const SLIDE_FURNITURE = ['date', 'footer', 'slideNumber'];
// A master carries what every slide inherits — the theme, the furniture and the
// background. Without one, "change it everywhere" means editing every slide.
function makeSlideMaster(data) {
  return {
    id: uid('mstr'), name: 'LEON Master', themeId: null,
    showDate: false, dateText: '', showFooter: false, footerText: '',
    showSlideNumber: true, showOnTitleSlide: false,
    background: 'lt1', titleColor: 'dk2', bodyColor: 'dk1',
    ...data,
  };
}

// ── Sheets ─────────────────────────────────────────────────────────────────
// Excel's own defaults out of the reference workbook: Aptos Narrow 12pt, row
// height 16, base column width 10 characters.
const SHEET_DEFAULTS = { fontName: 'Aptos Narrow', fontSizePt: 12, rowHeight: 16, baseColWidth: 10 };
// Excel's built-in number formats, by the id it stores them under. Ours had a
// short list of modes; these are the ones a workbook actually round-trips.
const SHEET_NUMBER_FORMATS = [
  { id: 0,  name: 'General',    pattern: 'General' },
  { id: 1,  name: 'Integer',    pattern: '0' },
  { id: 2,  name: 'Two decimals', pattern: '0.00' },
  { id: 3,  name: 'Thousands',  pattern: '#,##0' },
  { id: 4,  name: 'Thousands, 2 dp', pattern: '#,##0.00' },
  { id: 9,  name: 'Percent',    pattern: '0%' },
  { id: 10, name: 'Percent, 2 dp', pattern: '0.00%' },
  { id: 11, name: 'Scientific', pattern: '0.00E+00' },
  { id: 12, name: 'Fraction',   pattern: '# ?/?' },
  { id: 14, name: 'Date',       pattern: 'mm-dd-yy' },
  { id: 15, name: 'Date, long', pattern: 'd-mmm-yy' },
  { id: 20, name: 'Time',       pattern: 'h:mm' },
  { id: 22, name: 'Date & time', pattern: 'm/d/yy h:mm' },
  { id: 44, name: 'Accounting', pattern: '_("$"* #,##0.00_)' },
  { id: 49, name: 'Text',       pattern: '@' },
];

// ── The price list ─────────────────────────────────────────────────────────
// Named, revisioned and shared across jobs, because a price list is a company
// decision and not a per-quote one. Every priced item carries the same four
// controls the team already relies on: whether it shows on the quote at all,
// whether a discount may touch it, whether the salesperson may override it on
// the quote, and its tax code.
function makeCtPriceItem(data) {
  return { id: uid('ctpi'), label: '', price: null, unit: 'each',
           hideOnQuote: false, allowDiscount: true, editableOnQuote: true, taxCode: '',
           perMaterial: {},   // materialId -> price, overriding the default above
           ...data };
}
function makeCtMaterial(data) {
  return {
    id: uid('ctmat'), name: '', type: 'Quartz',
    // A slab size per material, overridable per colour — this is what turns a
    // countertop area into a slab count, so it is not optional detail.
    slabLengthIn: 120, slabWidthIn: 56.5,
    allowOtherColor: true, allowDiscount: true, editableOnQuote: true, taxCode: '',
    priceGroups: [],   // [{ id, name, pricePerSqFt }]
    colors: [],        // [{ id, name, priceGroupId, pricePerSqFt, slabLengthIn, slabWidthIn, supplierRef }]
    vendorId: null,    // the real LEON vendor this material is bought from
    active: true, ...data,
  };
}
function makeCtPriceList(data, by) {
  return {
    id: uid('ctpl'), name: 'Retail', units: 'inches', revision: 1, status: 'Active',
    createdBy: by || '', createdDate: todayISO(),
    defaultTaxRate: null, defaultPaymentTerms: '', expirationDays: 30,
    roundLinesTo: 0.01, roundMaterialTo: 0.1,
    accountIds: [],            // which accounts may be quoted on this list
    materials: [],             // makeCtMaterial[]
    fabricationPerSqFt: null, installationPerSqFt: null,
    // Splash is normally charged at the material rate rather than a rate of its
    // own, which is why this carries a mode and not just a number.
    splash: { mode: 'material', pricePerSqFt: null, byHeight: [] },
    finishedEdges: {},         // profile -> $/lin ft
    applianceEdgePerLinFt: null,
    // A miter is charged on BOTH pieces: a miter on a 24" edge bills 4 lin ft.
    // Getting that wrong halves the charge on every waterfall in the job.
    miterPerLinFt: null, waterfallInstallCharge: null,
    corners: {},               // CT_CORNER_TREATMENTS entry -> price each
    cutouts: { sinkUndermount: [], sinkDropIn: [], sinkFarmhouse: [],
               faucetHole: null, cooktop: null, outlet: null, other: null },
    sinks: [],                 // sinks sold as product: [{ id, name, type, sizeIn, price }]
    otherItems: [],            // arbitrary quote lines
    allowOtherSinks: true, allowOtherItems: true,
    ...data,
  };
}

// ── The drawing ────────────────────────────────────────────────────────────
// A counter is a closed polygon in INCHES, points in drawing order. Segment i
// runs from point i to point i+1, so a segment and a corner are addressed by
// index and nothing has to be kept in step by hand.
function makeCtPoint(x, y, data) {
  return { x: x || 0, y: y || 0, treatment: 'Standard', radius: 0, ...(data || {}) };
}
function makeCtCounter(data) {
  return {
    id: uid('ctcnt'), name: 'Counter', template: 'Custom',
    points: [],                // makeCtPoint[]
    segments: [],              // one per edge: { kind, edgeProfile, splashHeight, note }
    cutouts: [],               // makeCtCutout[]
    seams: [],                 // [{ id, fromIndex, toIndex, auto }] — a cut line across the piece
    thicknessCm: 3,
    overhangs: { front: 0, left: 0, right: 0, back: 0 },
    ...data,
  };
}
function makeCtCutout(data) {
  return { id: uid('ctcut'), kind: 'Sink', sinkType: 'Undermount',
           name: '', widthIn: 0, depthIn: 0, x: 0, y: 0, rotation: 0,
           faucetHoles: 0, sinkProductId: null,
           // A cutout is LOCATED by x/y and DIMENSIONED by a setback and a
           // centerline — which is how a template shop actually reads one off a
           // drawing. The two are one fact seen twice, so the figures are
           // computed from x/y and write back to it rather than being stored a
           // second time and left to disagree. These say only which edges the
           // dimensions are taken from, which is what differs job to job.
           dimFrom: 'front',      // 'front' | 'back'  — the setback reference
           dimAlong: 'left',      // 'left'  | 'right' — the centerline reference
           // An OUTLET sits in the splash, not in the deck, so it is located
           // along a side and up from the deck rather than by x/y.
           segmentIndex: null, alongIn: 0, heightIn: 0, gangs: 1, qty: 1,
           // A FAUCET cutout: the bores, how far apart, and how big. The
           // legacy `faucetHoles` count on a SINK is kept and still priced —
           // plenty of jobs specify "three holes" and never locate them — but
           // a placed faucet is what a fabricator can actually drill from.
           spreadIn: 0, holeDiaIn: CT_FAUCET_HOLE_DIA_IN, faucetConfig: 'single',
           // A faucet is set out from the SINK it serves, never from the
           // counter — centred on it, or offset to one side of it. Storing an
           // independent x would leave the faucet behind the moment the sink
           // moved, which is the disagreement a shop drawing exists to prevent.
           sinkCutoutId: null, faucetAlign: 'center',
           ...data };
}
// A device cutout in stone is sized to the device, not to the plate that covers
// it. One gang is nominally 2 1/8 x 4 1/8; each further gang adds 1 13/16.
const CT_OUTLET_GANG_W = 2.125, CT_OUTLET_GANG_STEP = 1.8125, CT_OUTLET_H = 4.125;
// A faucet is DRILLED, not routed, so what matters is how many holes, how far
// apart, and how big — never a rectangle. 1 3/8" is the standard bore that
// takes almost every deck-mount faucet; the spread is what distinguishes the
// three configurations a plumber actually specifies.
const CT_FAUCET_HOLE_DIA_IN = 1.375;
// The setback a sink is normally cut at — front edge of the stone to the near
// edge of the opening. It varies with the cabinet and the bowl, so it is a
// DEFAULT and not a rule; it just stops every sink starting at zero.
const CT_SINK_SETBACK_IN = 4;
// And how far behind the bowl the faucet bores land.
const CT_FAUCET_BEHIND_SINK_IN = 3.5;
const CT_FAUCET_CONFIGS = [
  { key: 'single', label: 'Single hole', holes: 1, spreadIn: 0,
    note: 'One bore. A pull-down or bar faucet.' },
  { key: 'centerset', label: 'Centerset — 4" centres', holes: 3, spreadIn: 4,
    note: 'Handles and spout on one plate. 4" between the outer bores.' },
  { key: 'widespread', label: 'Widespread — 8" centres', holes: 3, spreadIn: 8,
    note: 'Separate handles. 8" between the outer bores.' },
  { key: 'single+soap', label: 'Single + soap dispenser', holes: 2, spreadIn: 4,
    note: 'The faucet and one accessory bore.' },
  { key: 'four', label: 'Faucet + 2 accessories', holes: 4, spreadIn: 4,
    note: 'Soap, air switch, filtered water — whatever the job carries.' },
];
// The bores of one faucet cutout, as offsets from its centre. This is what the
// drawing places and what the fabricator drills — the count alone never said
// where any of them went.
function ctFaucetHoles(cu) {
  const n = Math.max(1, Math.round(Number(cu && cu.faucetHoles) || 1));
  const spread = Math.max(0, Number(cu && cu.spreadIn) || 0);
  const dia = Number(cu && cu.holeDiaIn) || CT_FAUCET_HOLE_DIA_IN;
  // The spread is measured between the OUTER bores, the way a faucet is sold;
  // anything between them is spaced evenly across it.
  const step = n > 1 ? spread / (n - 1) : 0;
  const out = [];
  for (let i = 0; i < n; i++) out.push({ dx: -spread / 2 + i * step, dia });
  return out;
}
function ctOutletSizeIn(gangs) {
  const g = Math.max(1, Math.round(Number(gangs) || 1));
  return { widthIn: CT_OUTLET_GANG_W + (g - 1) * CT_OUTLET_GANG_STEP, heightIn: CT_OUTLET_H };
}
// An area is a room — KITCHEN, MASTER BATH, BAR. It holds its own counters and
// its own material, because that is how a job is priced and presented.
function makeCtArea(data) {
  return {
    id: uid('ctarea'), name: 'KITCHEN', counters: [],
    // Colour OPTIONS are alternatives shown side by side to the client, not
    // variants of the truth: one is selected, the rest are there to compare.
    colorOptions: [],          // [{ id, materialId, colorId, edgeProfile, selected }]
    splashOn: false, splashHeightIn: 4,
    slabPlan: null,            // { slabs: [{ id, lengthIn, widthIn, placements[] }], count }
    ...data,
  };
}
function makeCtQuote(data, by) {
  return {
    id: uid('ctq'), name: 'Untitled', status: 'Draft', revision: 0,
    projectId: null, scopeId: null, accountId: null, salespersonId: null,
    priceListId: null, estimateNo: '', notes: '', paymentTerms: '',
    address: '', expirationDate: null,
    areas: [],                 // makeCtArea[]
    taxRate: null, discount: null,
    revisions: [],             // frozen snapshots, the way a drawing revision works
    createdBy: by || '', createdDate: todayISO(),
    modifiedBy: by || '', modifiedDate: todayISO(),
    activity: [],
    ...data,
  };
}

const AI_DELIVERABLES = [
  { key: 'Take-Off', hint: 'Quantities counted off the drawings, by scope and unit type.' },
  { key: 'Shop Drawing', hint: 'A shop drawing set to submit for approval.' },
  { key: 'Submittal Package', hint: 'The submittal cover, product data and schedule.' },
  { key: 'Renders', hint: 'Presentation images of the finished work.' },
  { key: 'Specification Sheet', hint: 'A written spec for the scope as drawn.' },
  { key: 'Scope Narrative', hint: 'What is and is not included, in words, for a proposal.' },
  { key: 'Other', hint: 'Describe it in the brief below.' },
];
function makeAiTakeoffRequest(data, requestedBy) {
  return {
    id: uid('aitake'),
    deliverable: data.deliverable || 'Take-Off',
    drawingSetId: data.drawingSetId || null,
    drawingSetName: data.drawingSetName || '',
    scopeId: data.scopeId || null,
    department: data.department || null,
    instructions: data.instructions || '',
    status: 'Requested',
    requestedBy: requestedBy || '',
    requestedDate: todayISO(),
    // Filled when the take-off comes back.
    resultFile: null, resultFileUrl: null,
    deliveredBy: '', deliveredDate: null,
    notes: '',
    lineCount: null,
  };
}

// A share is a record, not just an action. Who sent what, to whom, when, and
// what they said — kept so the question "did anyone send this to the client?"
// has an answer. `subjectKey` scopes it to the thing shared (a report id, a
// project id) so each section can show its own trail.
// Sharing has to be reachable from deep inside components that were never
// given ctx — FileField is used in hundreds of places and takes no ctx at all.
// Same module-level registry pattern as setActiveRolePermissions: App points
// this at the live ctx on every render, and any component can ask for it.
let __activeShareCtx = null;
function setActiveShareCtx(c) { __activeShareCtx = c; }
function activeShareCtx() { return __activeShareCtx; }

function makeShare(data) {
  return {
    id: uid('share'), date: todayISO(), stamp: new Date().toISOString(),
    subjectKey: data.subjectKey || null,
    subject: data.subject || '', summary: data.summary || '',
    message: data.message || '',
    projectId: data.projectId || null, projectName: data.projectName || '',
    by: data.by || '', byUserId: data.byUserId || null,
    recipients: data.recipients || [],   // [{ name, email, userId, channel }]
    // For a package share: exactly which pieces went, and how many were left
    // out. "I sent them the drawings" is not the same as "I sent them Rev 1".
    items: data.items || [],             // [{ id, label }]
    itemsTotal: data.itemsTotal != null ? data.itemsTotal : null,
  };
}

function makeNotification(data) {
  return {
    id: uid('ntf'), date: todayISO(), stamp: new Date().toISOString(),
    event: data.event, title: data.title, body: data.body || '',
    toUserId: data.toUserId, byUser: data.byUser || '',
    projectId: data.projectId || null, projectName: data.projectName || '',
    link: data.link || null,          // { view, projectId, tab } — where to go
    read: false,
  };
}
// Per-person delivery preferences. Absent keys fall back to the event's own
// default, so adding a new event type never needs a migration.
function makeNotificationPrefs() {
  return { inApp: {}, email: {}, emailDigest: 'immediate', emailAddress: '' };
}
function wantsNotification(prefs, eventKey, channel) {
  const ev = notificationEvent(eventKey);
  if (!ev) return false;
  const map = (prefs && prefs[channel]) || {};
  if (map[eventKey] !== undefined) return !!map[eventKey];
  return channel === 'inApp' ? true : !!ev.email;   // in-app on by default
}
// The email side has nowhere to go from a browser — there is no server and an
// API key cannot live in a public page. So an email that a person's
// preferences ask for is QUEUED here instead of sent, and the queue is what a
// mail service drains once this app has a backend. Nothing is lost meanwhile,
// and the rules are already right when that day comes.
// `cc` exists because the person sending a share needs the copy in their own
// mailbox — that is where they keep the record of what went to a client.
function makeQueuedEmail(notification, toEmail, cc) {
  return {
    id: uid('mail'), notificationId: notification.id, to: toEmail || '', cc: cc || '',
    toUserId: notification.toUserId, subject: notification.title,
    body: notification.body, event: notification.event,
    projectName: notification.projectName,
    queuedAt: new Date().toISOString(), status: 'Queued', sentAt: null,
  };
}

// ---------------------------------------------------------------------------
// Bank-held client payments
// ---------------------------------------------------------------------------
// A client payment is two separate facts: the day the client PAID, and the day
// the money is actually AVAILABLE. When the bank holds a deposit and releases
// it in stages, conflating the two makes held money look like cash on hand.
// So the receipt is recorded on its real date at ZERO, and each release phase
// becomes its own money-in event. Internal control only — the client's own
// view still shows the payment as made on the day they made it.
function makeBankHold(fields) {
  return { held: true, bankName: '', note: '', releases: [], ...(fields || {}) };
}
function makeBankRelease(fields) {
  return {
    id: uid('rel'), name: '', plannedDate: '', amount: 0,
    releasedDate: null, releasedAmount: null, note: '',
    ...(fields || {}),
  };
}
// What the phases add up to, and what the bank has actually let go of.
function bankHoldAllocated(hold) {
  return ((hold && hold.releases) || []).reduce((n, r) => n + (Number(r.amount) || 0), 0);
}
function bankHoldReleased(hold) {
  return ((hold && hold.releases) || []).reduce((n, r) => n + (r.releasedDate ? (r.releasedAmount != null ? Number(r.releasedAmount) : Number(r.amount) || 0) : 0), 0);
}
// The phases must account for every dollar received — that reconciliation IS
// the control, so the UI blocks on it rather than warning about it.
function bankHoldBalanced(hold, total) {
  return Math.abs(bankHoldAllocated(hold) - (Number(total) || 0)) < 0.005;
}
function isBankHeld(rec) { return !!(rec && rec.bankHold && rec.bankHold.held); }
// Even split presets, with the rounding remainder landing on the last phase so
// the total always reconciles exactly.
function splitBankHoldAmounts(total, phases) {
  const t = Math.round((Number(total) || 0) * 100);
  const each = Math.floor(t / phases);
  const out = [];
  for (let i = 0; i < phases; i++) out.push((i === phases - 1 ? t - each * (phases - 1) : each) / 100);
  return out;
}

function makeCashEntry(data, createdBy) {
  return {
    id: uid('cash'),
    direction: data.direction === 'in' ? 'in' : 'out',
    label: data.label || '',
    category: data.category || (data.direction === 'in' ? 'Other Income' : 'Other Expense'),
    amount: Number(data.amount) || 0,
    date: data.date || todayISO(),
    recurrence: data.recurrence || 'One-off',
    // A recurring entry stops here; blank means "keeps going".
    endDate: data.endDate || null,
    // Optional: tie an overhead to a project if it genuinely belongs to one.
    projectId: data.projectId || null,
    // Charged to a company card rather than paid from cash — the money then
    // leaves when that card's statement is paid, not on this date.
    creditCardId: data.creditCardId || null,
    notes: data.notes || '',
    active: true,
    createdBy: createdBy || null, createdDate: todayISO(),
  };
}

const STAGE_STATUSES = ['Not Started', 'In Progress', 'Delayed', 'Completed'];

// Optional extra revision rounds a scope's stage list doesn't get by
// default (§ additional revision stages request) — Quote Revision and Shop
// Drawing Revision already appear once automatically via STAGE_DEFS above,
// and Take-Off never got one; real jobs sometimes need more than one round
// of any of the three. Rather than hardcoding a fixed count into every
// scope's pipeline, this lets a user insert another round, on any scope,
// exactly when it's actually needed — keyed by the anchor stage each
// family's revisions are inserted after.
// `level: 'project'` families operate on project.chronology (shared across
// every scope); the rest still operate on the one scope's own stages array.
const REVISION_STAGE_FAMILIES = {
  take_off: { label: 'Take-Off Revision', baseDays: 3, role: 'Take-Off Drafter', level: 'project' },
  quote_prep: { label: 'Quote Revision', baseDays: 3, role: 'Sales Person', level: 'project' },
  shop_drawings: { label: 'Shop Drawing Revision', baseDays: 5, role: 'Project Manager' },
};

// Who's assigned to a stage can only be changed by these roles, per explicit
// instruction — stricter than the general "scopes" module edit right, which
// still governs Start/Complete/Report Delay/Undo on the same table.
// "Production Manager" maps to Production Director, same convention as
// WORKLOAD_ROLES above (Production Manager is a per-project TEAM role, not a
// login/security role — Production Director is the actual login role).
const STAGE_ASSIGN_ROLES = ['Admin', 'Production Director', 'General Manager'];
function canAssignStage(role) { return roleHasCapability(role, 'stage.assign'); }

// Project Information, Team Assignments, and Contacts can only be edited by
// these roles, per explicit instruction — narrower than the general
// "overview"/"contacts" module edit rights, which still govern Notes and
// Scope Rollup on the Overview tab.
const PROJECT_INFO_EDIT_ROLES = ['Admin', 'Accounting', 'General Manager', 'Production Director'];
function canEditProjectInfo(role) { return roleHasCapability(role, 'projectInfo.edit'); }
// Editing an existing PO/PI's header fields (vendor, dates, terms, charges,
// notes) has no path today for anyone — narrower than general procurement
// edit rights (which cover creating/converting/status changes) since these
// are committed financial documents.
const PROCUREMENT_EDIT_ROLES = ['Admin', 'Accounting'];
function canEditPOPI(role) { return roleHasCapability(role, 'procurement.editPOPI'); }

// ---------------------------------------------------------------------------
// Editable role permissions (Role Permissions tab, Users)
// ---------------------------------------------------------------------------
// Permission logic used to live in ~23 separate hardcoded lists scattered
// through this file, which made "what can this role actually do?" a question
// nobody could answer without reading all 23. Those constants are still here
// and still authoritative for the DEFAULTS — but they are now only a seed:
// the live answer comes from an admin-editable, persisted map that a LEON
// admin edits in Users -> Role Permissions.
//
// The seed is COMPUTED from the old constants (buildDefaultRolePermissions
// below) rather than retyped, so day-one behaviour is identical by
// construction and can't drift from what the code used to do.

const ROLE_PERMISSION_LEVELS = ['none', 'view', 'edit'];

// The consequential actions that were previously guarded by their own
// standalone can*() function. `roles` records the ORIGINAL hardcoded list so
// the seed reproduces today's behaviour exactly; `group` only drives how the
// editor lays them out.
const CAPABILITY_DEFS = [
  { key: 'financials.view',          label: 'See financial figures',            group: 'Finance',    roles: FINANCIAL_ROLES },
  { key: 'vendorPricing.view',       label: 'See vendor price lists',           group: 'Finance',    roles: VENDOR_PRICING_ROLES },
  // Accounting added on the client's instruction, 2026-09-06 — which is also
  // what the code's own comment had said should happen since this capability
  // was written: Accounting is the function that actually checks an invoice
  // before it is paid.
  { key: 'invoices.approve',         label: 'Approve AP invoices',              group: 'Finance',    roles: ['Admin', 'Accounting', 'General Manager'] },
  { key: 'aia.approve',              label: 'Certify AIA applications',         group: 'Finance',    roles: AIA_BILLING_APPROVE_ROLES },
  { key: 'procurement.editPOPI',     label: 'Edit POs and Proforma Invoices',   group: 'Procurement', roles: PROCUREMENT_EDIT_ROLES },
  { key: 'freight.approveExport',    label: 'Approve freight (export side)',    group: 'Logistics',  roles: ['Export Manager', 'Admin'] },
  { key: 'freight.approveAdmin',     label: 'Approve freight (admin side)',     group: 'Logistics',  roles: ['Admin', 'Accounting'] },
  { key: 'delivery.approve',         label: 'Approve delivery requests',        group: 'Logistics',  roles: ['Logistic Manager', 'Admin'] },
  { key: 'delivery.overrideStatus',  label: 'Override delivery status',         group: 'Logistics',  roles: DELIVERY_STATUS_OVERRIDE_ROLES },
  { key: 'logistics.view',           label: 'See the Logistics module',         group: 'Logistics',  roles: LOGISTICS_ROLES },
  { key: 'inventory.control',        label: 'Receive / adjust / release stock', group: 'Inventory',  roles: ['Admin', 'Logistic Manager'] },
  { key: 'inventory.assist',         label: 'Assist in the warehouse',          group: 'Inventory',  roles: ['Admin', 'Logistic Manager'] },
  { key: 'inventory.create',         label: 'Create inventory records',         group: 'Inventory',  roles: ['Admin', 'Accounting', 'Logistic Manager'] },
  { key: 'material.allocate',        label: 'Request material for a job',       group: 'Inventory',
    roles: SECURITY_ROLES.filter(r => PORTAL_ROLES.indexOf(r) < 0) },
  { key: 'tariff.editClassification', label: 'Edit tariff classifications',     group: 'Compliance', roles: TARIFF_CLASSIFICATION_EDIT_ROLES },
  { key: 'tariff.editShipmentInfo',  label: 'Edit tariff shipment info',        group: 'Compliance', roles: TARIFF_SHIPMENT_INFO_EDIT_ROLES },
  { key: 'tradeCompliance.view',     label: 'See Trade Compliance & Tariffs',   group: 'Compliance', roles: TRADE_COMPLIANCE_VIEW_ROLES },
  { key: 'projectInfo.edit',         label: 'Edit core project information',    group: 'Project',    roles: PROJECT_INFO_EDIT_ROLES },
  { key: 'stage.assign',             label: 'Assign people to stages',          group: 'Project',    roles: STAGE_ASSIGN_ROLES },
  { key: 'productionTimeline.view',  label: 'See the Production Timeline',      group: 'Project',    roles: PRODUCTION_TIMELINE_ROLES },
  { key: 'workload.view',            label: 'See Employee Workload & Timeline', group: 'Project',    roles: WORKLOAD_ROLES },
  { key: 'changeLog.view',           label: 'See the Change Log',               group: 'Project',    roles: ['Admin'] },
  // "Every role" was taken literally and handed this to the four portal
  // accounts as well — a client able to delete the company's HR policies. The
  // comment on canEditDocLibrary says all EMPLOYEES, and PORTAL_ROLES is
  // exactly the line between the two.
  { key: 'docLibrary.edit',          label: 'Contribute to the Document Library', group: 'Project',
    roles: SECURITY_ROLES.filter(r => PORTAL_ROLES.indexOf(r) < 0) },
  // These three were hardcoded role checks with no entry in this matrix, so an
  // admin could not grant or revoke them without a code change. Their defaults
  // are the exact lists they replaced, so day-one behaviour is unchanged.
  { key: 'accounting.hub',           label: 'Open the Accounting hub (company cash)', group: 'Finance', roles: ACCOUNTING_HUB_ROLES },
  { key: 'collection.manage',        label: 'Manage LEON Collection (scopes, finishes, lead times)', group: 'Company', roles: ['Admin'] },
  { key: 'inventory.approveStockConversion', label: 'Approve leftover-to-stock conversion', group: 'Inventory', roles: ['Admin', 'Logistic Manager'] },
  { key: 'doors.library',            label: 'Edit the door library (rules, frames, trims, designs, hardware)', group: 'Company', roles: ['Admin', 'Senior Associate', 'Associates', 'Production Director'] },
  { key: 'casework.library',         label: 'Edit the casework library (cabinet modules, construction, components, hardware)', group: 'Company', roles: ['Admin', 'Senior Associate', 'Associates', 'Production Director'] },
];
const CAPABILITY_GROUPS = ['Project', 'Procurement', 'Finance', 'Logistics', 'Inventory', 'Compliance', 'Company'];

// Computes the seed map from the ORIGINAL constants, so the editable defaults
// start out exactly equal to the behaviour that was hardcoded.
function buildDefaultRolePermissions() {
  const out = {};
  SECURITY_ROLES.forEach(role => {
    const modules = {};
    ALL_MODULE_KEYS.forEach(m => {
      // Reproduce the original two-mechanism logic: view came from the
      // allowlist/blocklist (default allow), edit from MODULE_EDIT_RIGHTS
      // (default deny).
      const allow = MODULE_VIEW_ALLOWLIST[role];
      const blocked = MODULE_VIEW_BLOCKLIST[role];
      // DEFAULT-DENY: a module is visible only if the role is granted it.
      // The allowlist and blocklist are kept because several comments and the
      // Delivery Driver fix reference them, and because a blocklist entry is
      // still the clearest way to record WHY something was taken away.
      const granted = MODULE_VIEW_GRANTS[role];
      const canView = granted
        ? granted.indexOf(m.key) >= 0
        : (allow ? allow.includes(m.key) : (!blocked || !blocked.includes(m.key)));
      const rights = MODULE_EDIT_RIGHTS[role];
      const canEditIt = rights === '*' ? true : (Array.isArray(rights) && rights.includes(m.key));
      // Deny wins. The old two-mechanism model could express "may edit but
      // may not view" — and did, exactly once: Subcontractor/installation,
      // where the view allowlist was deliberately emptied as defense-in-depth
      // while the edit list still named the module. That edit right was
      // unreachable (Subcontractors render SubcontractorPortal, which never
      // calls canEditModule), so collapsing it to 'none' honours the stricter
      // of the two originals rather than silently widening access.
      modules[m.key] = !canView ? 'none' : (canEditIt ? 'edit' : 'view');
    });
    const capabilities = {};
    CAPABILITY_DEFS.forEach(c => { capabilities[c.key] = (c.roles || []).includes(role); });
    out[role] = { modules, capabilities };
  });
  return out;
}

// Forward-merge (same pattern as mergeNewScopeFamilies) so a role, module or
// capability added in a later version appears with its default without
// discarding what an admin already tuned.
function mergeNewRolePermissions(persisted) {
  const defaults = buildDefaultRolePermissions();
  const out = {};
  SECURITY_ROLES.forEach(role => {
    // A renamed role inherits whatever was saved under its old name, so an
    // admin's tuning survives the rename instead of resetting to defaults.
    const oldName = Object.keys(RENAMED_SECURITY_ROLES).find(k => RENAMED_SECURITY_ROLES[k] === role);
    const saved = (persisted && (persisted[role] || (oldName && persisted[oldName]))) || {};
    const modules = { ...defaults[role].modules };
    Object.keys(saved.modules || {}).forEach(k => {
      if (ROLE_PERMISSION_LEVELS.includes(saved.modules[k])) modules[k] = saved.modules[k];
    });
    const capabilities = { ...defaults[role].capabilities };
    Object.keys(saved.capabilities || {}).forEach(k => {
      if (k in capabilities) capabilities[k] = !!saved.capabilities[k];
    });
    out[role] = { modules, capabilities };
  });
  return out;
}

// ---- live registry -------------------------------------------------------
// canEditModule/canViewModule and every can*() gate below are plain functions
// called from hundreds of places that have no access to React state, so the
// active map is held here at module level and re-pointed by App on every
// render (setActiveRolePermissions). Assignment is idempotent and happens
// before any child renders, so a gate never reads a stale map. Falls back to
// the computed defaults until App has run once.
let __activeRolePermissions = null;
function setActiveRolePermissions(map) { __activeRolePermissions = map || null; }
function activeRolePermissions() {
  if (!__activeRolePermissions) __activeRolePermissions = buildDefaultRolePermissions();
  return __activeRolePermissions;
}
function roleModuleLevel(role, moduleKey) {
  const entry = activeRolePermissions()[role];
  if (!entry) return 'none';
  return entry.modules[moduleKey] || 'none';
}
// The single gate every former can*() function now consults.
function roleHasCapability(role, capabilityKey) {
  const entry = activeRolePermissions()[role];
  return !!(entry && entry.capabilities[capabilityKey]);
}


// A stage's responsibleRole is now a real TEAM_ROLES value (per the client's
// Team .xlsx), so "stage assignment mirrors team assignments" is a direct
// lookup — no separate vocabulary mapping needed anymore. Applied whenever
// stages are created (addScope) or a team role is (re)assigned
// (reassignTeamRole); it only ever fills in a stage that has no assignee
// yet, never overwrites a person's manual per-stage override.
function applyTeamDefaultsToStages(stages, team) {
  if (!team) return;
  stages.forEach(st => {
    if (st.assignedUserId) return;
    if (team[st.responsibleRole]) st.assignedUserId = team[st.responsibleRole];
  });
}

// ---------------------------------------------------------------------------
// Admin-extensible scope library (§8.2) — families -> categories -> options
// Seeded from the LEON Integra 2026 portfolio families named in the spec;
// category/option lists below are representative placeholders (the actual
// portfolio-derived lists are an external reference sheet not provided).
// ---------------------------------------------------------------------------
function makeOption(name, imageUrl) {
  return { id: uid('opt'), name, active: true, imageUrl: imageUrl || null };
}
function makeCategory(name, options) {
  // NB: must be an arrow, not `options.map(makeOption)` — Array.map passes the
  // INDEX as the second argument, which makeOption takes as imageUrl, so every
  // option after the first was seeded with imageUrl "1", "2", "3"... and
  // rendered as a broken image.
  return { id: uid('cat'), name, active: true, options: options.map(o => makeOption(o)) };
}
function makeFamily(name, categories, isWindowSystem, department, isCountertopSystem) {
  return {
    id: uid('fam'), name, active: true, categories, isWindowSystem: !!isWindowSystem,
    // Always supply + labour, on its own fabrication-shop schedule.
    isCountertopSystem: !!isCountertopSystem,
    // WHICH LOCATIONS SELL THIS FAMILY. Empty means everywhere — that is what
    // every existing family is, so nothing changes for the US operation.
    // Abu Dhabi classifies the work differently (countertops live inside
    // Joinery and Kitchen rather than being their own scope), and that is a
    // difference in the CATALOGUE, not a second application. One library, one
    // set of projects, filtered by where the client is.
    regions: [],
    // Some operations sell material only. A family flagged here can never be
    // sold as labour, so the scope-type picker offers one answer instead of
    // making someone remember the rule.
    supplyOnly: false,
    // The BOQ groupings INSIDE this scope. A quotation for Joinery is not one
    // number — it is doors, wardrobes, vanities and their tops, each measured
    // and priced separately and then subtotalled. These are not finish
    // selections; they are what a line of the bill of quantities is FOR.
    workItems: [],
    // Which operating department this kind of work belongs to. Defaults from
    // the window flag so the existing seed calls stay correct without being
    // rewritten; admin-editable per family from the Scope Library editor.
    department: DEPARTMENTS.includes(department) ? department : (isWindowSystem ? 'Windows' : DEFAULT_DEPARTMENT),
  };
}
// Single source of truth for "which department does this scope family belong
// to" — mirrors isWindowSystemFamily's role for the window flag.
// Which families a job may use, given where its client is.
//
// The rule is deliberately "a region that defines its own scopes gets ONLY
// those". Abu Dhabi's eight cover everything they sell, and their whole point
// is that the work is classified differently — a US "Countertops" family
// appearing there would contradict the rule that a countertop belongs inside
// Joinery or Kitchen. A region that has defined nothing falls back to the
// unrestricted families, which is every family that existed before this, so
// nothing changed for the US operation by adding it. The day Türkiye defines
// its own set it switches over by itself, with no code change.
function familiesForRegion(scopeLibrary, region) {
  const lib = (scopeLibrary || DEFAULT_SCOPE_LIBRARY).filter(f => f.active !== false);
  const own = region ? lib.filter(f => (f.regions || []).indexOf(region) >= 0) : [];
  if (own.length) return own;
  return lib.filter(f => !(f.regions || []).length);
}
// True when this family is only ever sold as material.
function familyIsSupplyOnly(familyName, scopeLibrary) {
  const fam = (scopeLibrary || DEFAULT_SCOPE_LIBRARY).find(f => f.name === familyName);
  return !!(fam && fam.supplyOnly);
}
// The BOQ groupings inside a scope — what a bill of quantities itemises.
function familyWorkItems(familyName, scopeLibrary) {
  const fam = (scopeLibrary || DEFAULT_SCOPE_LIBRARY).find(f => f.name === familyName);
  return (fam && fam.workItems) || [];
}

function familyDepartment(familyName, scopeLibrary) {
  const fam = (scopeLibrary || DEFAULT_SCOPE_LIBRARY).find(f => f.name === familyName);
  if (!fam) return DEFAULT_DEPARTMENT;
  return DEPARTMENTS.includes(fam.department) ? fam.department : (fam.isWindowSystem ? 'Windows' : DEFAULT_DEPARTMENT);
}

// A scope carries its own stamped department (set at creation, see makeScope)
// so re-classifying a family later never silently moves work already in
// flight; the family lookup is only the fallback for scopes created before
// the field existed.
function scopeDepartment(scope, scopeLibrary) {
  if (scope && DEPARTMENTS.includes(scope.department)) return scope.department;
  return familyDepartment(scope && scope.familyName, scopeLibrary);
}
// A project belongs to whichever departments its scopes do — derived, never
// stored, so adding a window scope to an interiors job reclassifies it
// automatically with nothing to keep in sync.
function projectDepartments(project, scopeLibrary) {
  const present = new Set(((project && project.scopes) || []).map(s => scopeDepartment(s, scopeLibrary)));
  // Union with the project's DECLARED departments (project.companyDepartment,
  // set when the lead is created). A job tagged Windows + Interiors whose
  // window scopes haven't been added yet must still reach the Windows team —
  // deriving from scopes alone would hide it from them until the first scope
  // exists, which is exactly when they need to be planning it.
  ((project && project.companyDepartment) || []).forEach(d => { if (DEPARTMENTS.includes(d)) present.add(d); });
  return DEPARTMENTS.filter(d => present.has(d));
}
function projectInDepartment(project, department, scopeLibrary) {
  if (!department || department === ALL_DEPARTMENTS) return true;
  const depts = projectDepartments(project, scopeLibrary);
  // With companyDepartment unioned in above, a project reaching zero
  // departments is genuinely unclassified (old data with an empty tag and no
  // scopes) — keep it visible rather than orphaning it.
  if (!depts.length) return true;
  return depts.includes(department);
}
function scopesInDepartment(project, department, scopeLibrary) {
  const list = (project && project.scopes) || [];
  if (!department || department === ALL_DEPARTMENTS) return list;
  return list.filter(s => scopeDepartment(s, scopeLibrary) === department);
}
// ---------------------------------------------------------------------------
// Supplier finish catalog (An Cuong) — lookup helpers
// ---------------------------------------------------------------------------
// ANCUONG_CATALOG is a global defined by finishes/ancuong-catalog.js, loaded
// as a plain script in index.html. It is reference data shipped WITH the app,
// never persisted to localStorage — a scope only ever stores the small
// reference below (id/code/name/image path), not the record itself, so
// re-generating the catalog updates every scope's finish automatically.
// Registered supplier catalogs. Each is a global defined by its own plain
// script in index.html. Adding a supplier is one entry here plus one script
// tag — nothing else in the picker changes.
const SUPPLIER_CATALOGS = [
  // LEON's own portfolio first — these are OUR standard options (door styles,
  // edge profiles, cores), not a third party's range, so they lead the picker.
  { key: 'leon', label: 'LEON Integra', get: () => (typeof LEON_CATALOG !== 'undefined' ? LEON_CATALOG : []) },
  { key: 'ancuong', label: 'An Cuong', get: () => (typeof ANCUONG_CATALOG !== 'undefined' ? ANCUONG_CATALOG : []) },
  { key: 'vicrown', label: 'Vicrown Quartz', get: () => (typeof VICROWN_CATALOG !== 'undefined' ? VICROWN_CATALOG : []) },
  { key: 'patcraft', label: 'Patcraft', get: () => (typeof PATCRAFT_CATALOG !== 'undefined' ? PATCRAFT_CATALOG : []) },
  { key: 'acwood', label: 'AC Engineered Wood', get: () => (typeof ACWOOD_CATALOG !== 'undefined' ? ACWOOD_CATALOG : []) },
  { key: 'kutahya', label: 'NG - Kutahya', get: () => (typeof KUTAHYA_CATALOG !== 'undefined' ? KUTAHYA_CATALOG : []) },
  { key: 'ghs', label: 'GHS Quartz', get: () => (typeof GHS_CATALOG !== 'undefined' ? GHS_CATALOG : []) },
  { key: 'imundex', label: 'Imundex', get: () => (typeof IMUNDEX_CATALOG !== 'undefined' ? IMUNDEX_CATALOG : []) },
  { key: 'iisdoo', label: 'IISDOO', get: () => (typeof IISDOO_CATALOG !== 'undefined' ? IISDOO_CATALOG : []) },
  // Paint rather than a material, but it behaves identically: a lacquer or
  // painted finish is specified by colour number the same way a slab is
  // specified by decor name, so it belongs in the same picker rather than in a
  // second library nobody would think to open.
  { key: 'sherwin', label: 'Sherwin-Williams', get: () => (typeof SHERWIN_CATALOG !== 'undefined' ? SHERWIN_CATALOG : []) },
];
// Every catalog is bought from a company, so every catalog gets a vendor record
// and the two are linked on first load — an unlinked catalog cannot be ordered
// against, and leaving seven of them unlinked made that the team's chore.
// `country` only so the vendor card isn't blank; everything else is theirs to fill.
const SUPPLIER_VENDOR_SEED = {
  leon: { name: 'LEON Integra', country: 'United States', notes: 'Our own portfolio — the standard door styles, edge profiles and cores we offer.' },
  ancuong: { name: 'An Cuong Wood-Working JSC', country: 'Vietnam', website: 'ancuong.com' },
  vicrown: { name: 'Vicrown Quartz', country: 'Vietnam' },
  patcraft: { name: 'Patcraft', country: 'United States', website: 'patcraft.com' },
  acwood: { name: 'AC Engineered Wood', country: 'Vietnam', website: 'kemberfloors.com' },
  kutahya: { name: 'NG Kutahya Seramik', country: 'Turkey', website: 'ngkutahyaseramik.com.tr' },
  ghs: { name: 'GHS Quartz (Grand Home Stone Vina)', country: 'Vietnam', website: 'grandhomestonevina.vn' },
  imundex: { name: 'Imundex', country: 'Vietnam' },
  iisdoo: { name: 'IISDOO Design', country: 'China', website: 'iisdoodesign.com' },
  sherwin: { name: 'Sherwin-Williams', country: 'United States', website: 'sherwin-williams.com',
             notes: 'Paint colours for lacquer and painted finishes. Specified by colour number (e.g. SW 7008 Alabaster).' },
};

// ---- supplier -> vendor --------------------------------------------------
// A finish catalog belongs to a VENDOR — the company we actually buy it from,
// raise POs against and pay. The catalogs ship as static data keyed by a short
// supplier key, and vendors are the team's own records, so the two are joined
// by a persisted map rather than baked in: `supplierVendorLinks[supKey] =
// vendorId`. Selection pickers, the library and the vendor page all read it,
// and a selection stores the vendor on the reference so a scope still knows
// who supplies it even if the mapping is changed later.
// One-time join, run only when supplierVendorLinks has never been persisted:
// every catalog gets a vendor (matched by name if one already exists, created
// from SUPPLIER_VENDOR_SEED if not) and the link is written. After that the
// stored map is authoritative — an unlink is a decision, not a gap to refill.
// Forward-merge, not a one-shot seed. The original version returned early the
// moment ANY links had been persisted, which was right for "don't refill a link
// someone deliberately removed" but wrong for a catalog added later — it could
// never get a vendor at all, and an unlinked catalog cannot be ordered against.
// Now only keys that have never been seen are seeded; a key already in the map,
// including one deliberately cleared to null, is left exactly as it is.
function seedSupplierVendors(vendors, persistedLinks) {
  const known = persistedLinks || null;
  const missing = SUPPLIER_CATALOGS.filter(c => !known || !(c.key in known));
  if (known && !missing.length) return { vendors, links: known };
  const out = [...vendors];
  const links = { ...(known || {}) };
  (known ? missing : SUPPLIER_CATALOGS).forEach(c => {
    const seed = SUPPLIER_VENDOR_SEED[c.key] || { name: c.label };
    const want = seed.name.toLowerCase();
    let v = out.find(x => (x.name || '').toLowerCase() === want)
         || out.find(x => (x.name || '').toLowerCase().includes(c.label.toLowerCase()));
    if (!v) {
      v = makeVendor(seed.name, '', '', '', '');
      v.country = seed.country || '';
      v.website = seed.website || '';
      v.notes = seed.notes || `Finish supplier — the ${c.label} catalog in Supplier Finishes is theirs.`;
      out.push(v);
    }
    links[c.key] = v.id;
  });
  return { vendors: out, links };
}

let __activeSupplierVendorLinks = {};
function setActiveSupplierVendorLinks(map) { __activeSupplierVendorLinks = map || {}; }
function supplierVendorId(supKey) { return __activeSupplierVendorLinks[supKey] || null; }
function supplierLabel(supKey) {
  const c = SUPPLIER_CATALOGS.find(x => x.key === supKey);
  return c ? c.label : supKey;
}
// The name to show for a catalog: the linked vendor's, falling back to the
// supplier's own label while nobody has linked it yet.
function supplierDisplayName(supKey, vendors) {
  const id = supplierVendorId(supKey);
  const v = id && (vendors || []).find(x => x.id === id);
  return v ? v.name : supplierLabel(supKey);
}
function vendorSupplierKeys(vendorId) {
  return SUPPLIER_CATALOGS.filter(c => __activeSupplierVendorLinks[c.key] === vendorId).map(c => c.key);
}

// ---- overrides -----------------------------------------------------------
// The shipped catalogs are the suppliers' own data and stay immutable. Edits
// and deletions made in LEON Collection are held SEPARATELY as a sparse
// override map, keyed "<supplier>:<id>", and merged on read. Two reasons:
// re-importing a supplier never wipes the team's corrections, and localStorage
// only ever carries the handful of records actually changed rather than a copy
// of ~5,700 catalog entries.
// Deletion is a soft `hidden` flag, matching the rest of the app.
function supplierFinishKey(sup, id) { return sup + ':' + id; }

let __activeSupplierOverrides = {};
let __overrideVersion = 0;
function setActiveSupplierOverrides(map) {
  __activeSupplierOverrides = map || {};
  __overrideVersion++;
  __supplierIndex = null;   // force the merged view to rebuild
}
function activeSupplierOverrides() { return __activeSupplierOverrides; }

// Flattened and merged once per override change — tagging ~5,700 records with
// their supplier on every keystroke would be wasteful, and the shipped
// catalogs never change at runtime.
// Finishes the team imported themselves, rather than a catalog shipped with
// the app. They live in persisted state (there is no server to write a new
// finishes/*.js to), are tagged with the supplier they belong to, and are
// merged into the same flattened index, so a picker, a search and a vendor
// page cannot tell the difference.
let __activeImportedFinishes = [];
function setActiveImportedFinishes(list) {
  __activeImportedFinishes = list || [];
  __supplierIndex = null;
}
function importedFinishes() { return __activeImportedFinishes; }
function makeImportedFinish(data) {
  return {
    id: data.id || uid('impfin'), sup: data.sup, supLabel: data.supLabel || supplierLabel(data.sup),
    code: data.code || '', name: data.name || '', cat: data.cat || 'Imported',
    img: data.img || '', collection: data.collection || '', color: data.color || '', style: data.style || '',
    imported: true, importedDate: data.importedDate || todayISO(), importedBy: data.importedBy || null,
  };
}

let __supplierIndex = null;
function supplierCatalog(includeHidden) {
  if (!includeHidden && __supplierIndex) return __supplierIndex;
  const ov = activeSupplierOverrides();
  const out = [];
  importedFinishes().forEach(r => {
    const o = ov[supplierFinishKey(r.sup, r.id)];
    if (o && o.hidden && !includeHidden) return;
    out.push(o ? Object.assign({}, r, o, { edited: true }) : r);
  });
  SUPPLIER_CATALOGS.forEach(src => {
    const list = src.get() || [];
    for (let i = 0; i < list.length; i++) {
      const r = list[i];
      const base = r.sup ? r : Object.assign({ sup: src.key, supLabel: src.label }, r);
      const o = ov[supplierFinishKey(base.sup, base.id)];
      if (!o) { out.push(base); continue; }
      if (o.hidden && !includeHidden) continue;
      out.push(Object.assign({}, base, o, { edited: true }));
    }
  });
  if (!includeHidden) __supplierIndex = out;
  return out;
}
// Constructions grouped by supplier, each ordered by how much of that
// supplier's catalog it covers, so the lines used most sit at the top.
function supplierCategories() {
  const counts = {};
  supplierCatalog().forEach(r => {
    const k = r.sup + '\u0000' + r.cat;
    if (!counts[k]) counts[k] = { sup: r.sup, supLabel: r.supLabel, cat: r.cat, count: 0 };
    counts[k].count++;
  });
  const list = Object.keys(counts).map(k => counts[k]);
  list.sort((a, b) => a.supLabel.localeCompare(b.supLabel) || b.count - a.count || a.cat.localeCompare(b.cat));
  return list;
}
function supplierGroups() {
  const groups = [];
  supplierCategories().forEach(c => {
    let g = groups.find(x => x.key === c.sup);
    if (!g) { g = { key: c.sup, label: c.supLabel, cats: [] }; groups.push(g); }
    g.cats.push(c);
  });
  return groups;
}
// Type-ahead within ONE supplier's construction. Matches decor name, supplier
// code and colour, so "MS 202" and "folkstone" both land on the same product.
function searchSupplierFinishes(sup, category, query, limit) {
  const q = String(query || '').trim().toLowerCase();
  const pool = supplierCatalog().filter(r => (!sup || r.sup === sup) && (!category || r.cat === category));
  if (!q) {
    // Same imaged-first rule when simply browsing, not searching.
    const ordered = pool.slice().sort((a, b) => (b.img ? 1 : 0) - (a.img ? 1 : 0) || (a.name || '').localeCompare(b.name || ''));
    return ordered.slice(0, limit || 40);
  }
  const squash = v => String(v || '').toLowerCase().replace(/[^a-z0-9]/g, '');
  const sq = squash(q);
  const scored = [];
  for (let i = 0; i < pool.length; i++) {
    const r = pool[i];
    const name = (r.name || '').toLowerCase();
    const code = squash(r.code);
    // A supplier's own code is not always the code the team quotes in. Vicrown
    // publishes VQ###; LEON sells the same range as LQ### and every quotation,
    // colour board and client conversation uses the LEON code. So `leonCode`
    // is searched exactly like `code` — otherwise typing the code off our own
    // quotation finds nothing, which reads as the product not existing.
    const lcode = squash(r.leonCode);
    let rank = -1;
    if (name === q) rank = 0;
    else if (name.startsWith(q)) rank = 1;
    else if (code.startsWith(sq) || (lcode && lcode.startsWith(sq))) rank = 2;
    else if (name.includes(q)) rank = 3;
    else if (code.includes(sq) || (lcode && lcode.includes(sq))) rank = 4;
    else if ((r.style || '').toLowerCase().includes(q)) rank = 5;
    else if ((r.color || '').toLowerCase().includes(q)) rank = 6;
    else if ((r.collection || '').toLowerCase().includes(q)) rank = 7;
    if (rank >= 0) scored.push({ r, rank });
    if (scored.length > 400) break;
  }
  // Records WITH a swatch sort ahead of those without, at equal relevance.
  // Some suppliers publish images for only part of their range, and because
  // names often start with a size ("120x300 …") the image-less ones can other-
  // wise monopolise the first screens and read as "the import is broken".
  scored.sort((a, b) => a.rank - b.rank
    || (b.r.img ? 1 : 0) - (a.r.img ? 1 : 0)
    || (a.r.name || '').localeCompare(b.r.name || ''));
  return scored.slice(0, limit || 40).map(x => x.r);
}
// What actually gets stored on a scope — small, stable, and enough to render
// the chip without the catalog being present.
function makeSupplierFinishRef(rec) {
  if (!rec) return null;
  return {
    source: rec.sup || 'ancuong', supLabel: rec.supLabel || '',
    // Stamped at selection time, not looked up on read: a scope must still
    // know who supplies its finish if the catalog is re-linked afterwards.
    vendorId: supplierVendorId(rec.sup) || null,
    id: rec.id, code: rec.code || '', name: rec.name || '',
    cat: rec.cat || '', img: rec.img || '',
  };
}
// What a CLIENT may see of a chosen finish. A client picks a look; who supplies
// it, and under what code, is ours — publishing it hands them our sourcing.
function clientSafeFinish(ref) {
  if (!ref) return null;
  return { name: ref.name || '', img: ref.img || '', cat: ref.cat || '' };
}

// ---- per-department project teams ---------------------------------------
// A job is staffed twice: one team for its Windows work, one for its
// Interiors work. project.teams holds both; project.team (the single
// company-wide team that predates the split) is migrated into both by
// normalizeProject and then no longer read.
function projectTeamFor(project, department) {
  const teams = (project && project.teams) || {};
  if (department && department !== ALL_DEPARTMENTS && teams[department]) return teams[department];
  // No department in context (cross-project reports, dashboards): fall back
  // to whichever department has actually staffed the role.
  const merged = {};
  DEPARTMENTS.forEach(d => {
    Object.keys(teams[d] || {}).forEach(role => { if (!merged[role] && teams[d][role]) merged[role] = teams[d][role]; });
  });
  return merged;
}
// Single-role lookup. Returns null rather than borrowing the other
// department's person when a department is named explicitly — "nobody is
// assigned" is real information and shouldn't be papered over.
// Is this person the Sales Person assigned to this job, in either department?
// Assignment on a job is itself a grant: the salesperson who owns the client
// relationship can see that job's costs and release its subcontractor
// invoices, even where their ROLE has no financial access at all. It is scoped
// to the one project they were put on — not to every job in the company.
function isProjectSalesPerson(project, userId) {
  if (!project || !userId) return false;
  return DEPARTMENTS.some(d => teamMemberFor(project, 'Sales Person', d) === userId);
}
function teamMemberFor(project, role, department) {
  const teams = (project && project.teams) || {};
  if (department && department !== ALL_DEPARTMENTS) {
    const t = teams[department];
    return (t && t[role]) || null;
  }
  for (let i = 0; i < DEPARTMENTS.length; i++) {
    const t = teams[DEPARTMENTS[i]];
    if (t && t[role]) return t[role];
  }
  return null;
}

// The workhorse for filtering every scope-linked collection (vendor
// estimates, POs, submittals, production records, deliveries, installation
// records, financial lines...) down to one department in a single check.
function scopeIdsForDepartment(project, department, scopeLibrary) {
  return new Set(scopesInDepartment(project, department, scopeLibrary).map(s => s.id));
}
// Records that hang off a scopeId, filtered to the active department. A
// record with a null scopeId is project-level, not department-specific —
// kept visible rather than hidden, since hiding it would lose it entirely.
function recordsInDepartment(records, project, department, scopeLibrary, key) {
  if (!department || department === ALL_DEPARTMENTS) return records || [];
  const ids = scopeIdsForDepartment(project, department, scopeLibrary);
  const field = key || 'scopeId';
  return (records || []).filter(r => !r[field] || ids.has(r[field]));
}

// ---------------------------------------------------------------------------
// Standard finish library (§13-19) — shared option lists, referenced both by
// the Selections catalog below and by the Material Library's per-category
// spec fields further down, so the same lists are defined exactly once.
// Every dropdown built from these still allows a free-text "Custom" entry.
// ---------------------------------------------------------------------------
const CASEWORK_WOOD_SPECIES = ['White Oak', 'Natural White Oak', 'Light White Oak', 'Washed White Oak', 'Rift-Cut White Oak', 'European Oak', 'Walnut', 'American Walnut', 'European Walnut', 'Maple', 'Ash'];
const FINISH_TYPES = ['Natural Veneer', 'Engineered Veneer', 'Painted MDF', 'Lacquer', 'Melamine', 'Laminate / HPL', 'PET', 'Acrylic', 'Glass', 'Metal'];
const COLOR_FAMILIES = ['Warm White', 'Off-White', 'Cream', 'Greige', 'Taupe', 'Cashmere', 'Mushroom', 'Sand', 'Beige', 'Sage', 'Olive', 'Charcoal', 'Black'];
const SHEEN_TEXTURES = ['Natural', 'Matte', 'Super Matte', 'Satin', 'Semi-Gloss', 'High Gloss', 'Brushed', 'Textured', 'Fluted', 'Reeded'];
const CASEWORK_STYLES = ['Flat / Slab', 'Shaker', 'Slim Shaker', 'Fluted', 'Reeded', 'Glass Front', 'Metal Frame + Glass'];
const DOOR_HARDWARE_FINISHES = ['Satin Nickel', 'Brushed Nickel', 'Polished Nickel', 'Brushed Stainless Steel', 'Chrome', 'Matte Black', 'Brushed Brass', 'Satin Brass', 'Antique Brass', 'Bronze', 'Gunmetal'];

const COUNTERTOP_MATERIALS = ['Quartz', 'Quartzite', 'Marble', 'Granite', 'Porcelain', 'Sintered Stone', 'Solid Surface', 'Other Natural Stone'];
const COUNTERTOP_LOOKS = ['Calacatta', 'Calacatta Gold', 'Carrara', 'Statuario', 'Taj Mahal', 'Travertine', 'Limestone', 'Warm White Veining', 'Cream / Beige', 'Gray', 'Black', 'Concrete Look', 'Solid White'];
const COUNTERTOP_FINISHES = ['Polished', 'Honed', 'Leathered', 'Matte', 'Textured'];
const COUNTERTOP_THICKNESS_STONE = ['2 cm', '3 cm', 'Custom'];
const COUNTERTOP_THICKNESS_PORCELAIN = ['6 mm', '12 mm', '20 mm', 'Custom'];
const COUNTERTOP_EDGE_PROFILES = ['Eased', 'Pencil', 'Beveled', 'Bullnose', 'Half Bullnose', 'Ogee', 'Mitered', 'Waterfall', 'Laminated / Built-Up', 'Custom'];

const TILE_MATERIALS = ['Porcelain', 'Ceramic', 'Marble', 'Natural Stone', 'Mosaic', 'Glass', 'Terrazzo', 'Cement', 'Exterior Paver'];
const TILE_SIZES_IMPERIAL = ['2" x 2"', '3" x 6"', '3" x 12"', '4" x 4"', '4" x 12"', '6" x 6"', '12" x 12"', '12" x 24"', '24" x 24"', '24" x 48"', '30" x 30"', '30" x 60"', '36" x 36"', '36" x 72"', '48" x 48"', '48" x 96"', 'Mosaic', 'Large Format', 'Slab', 'Custom'];
const TILE_SIZES_METRIC = ['300 x 600 mm', '600 x 600 mm', '600 x 1200 mm', '750 x 1500 mm', '900 x 900 mm', '900 x 1800 mm', '1200 x 1200 mm', '1200 x 2400 mm', 'Custom'];
const TILE_THICKNESS = ['3 mm', '5 mm', '6 mm', '8 mm', '9 mm', '10 mm', '12 mm', '20 mm', 'Custom'];
const TILE_APPLICATIONS = ['Wall', 'Floor', 'Exterior'];

const EWF_THICKNESS = ['10 mm', '12 mm', '14 mm', '15 mm', '18 mm', '20 mm', 'Custom'];
const EWF_WEAR_LAYER = ['1.5 mm', '2 mm', '3 mm', '4 mm', '6 mm', 'Custom'];
const EWF_WIDTH = ['4"', '5"', '6"', '7"', '7.5"', '8"', '9"', '10"', 'Custom'];
const EWF_PATTERNS = ['Straight', 'Wide Plank', 'Herringbone', 'Chevron', 'Custom'];
const EWF_LENGTH_TYPES = ['Fixed', 'Random'];

const SPC_THICKNESS = ['4 mm', '4.5 mm', '5 mm', '5.5 mm', '6 mm', '6.5 mm', '7 mm', '8 mm', 'Custom'];
const SPC_WEAR_LAYER = ['6 mil', '8 mil', '12 mil', '20 mil', '22 mil', '30 mil', 'Custom'];

const BASEBOARD_HEIGHT = ['3"', '3.5"', '4"', '4.5"', '5"', '5.5"', '6"', '8"', 'Custom'];
const BASEBOARD_THICKNESS = ['1/2"', '5/8"', '3/4"', '1"', 'Custom'];

const DIMENSION_UNITS = ['Imperial', 'Metric'];

const DEFAULT_SCOPE_LIBRARY = [
  makeFamily('Casework', [
    makeCategory('Style', CASEWORK_STYLES),
    makeCategory('Wood Species', CASEWORK_WOOD_SPECIES),
    makeCategory('Finish Type', FINISH_TYPES),
    makeCategory('Color Family', COLOR_FAMILIES),
    makeCategory('Sheen / Texture', SHEEN_TEXTURES),
    makeCategory('Hardware', ['Brushed Brass Pull', 'Matte Black Bar Pull', 'Integrated J-Channel']),
    makeCategory('Interior Finish', ['Melamine White', 'Matching Veneer', 'Painted']),
  ]),
  makeFamily('Countertop', [
    makeCategory('Material', COUNTERTOP_MATERIALS),
    makeCategory('Color / Look', COUNTERTOP_LOOKS),
    makeCategory('Finish', COUNTERTOP_FINISHES),
    makeCategory('Edge Profile', COUNTERTOP_EDGE_PROFILES),
    makeCategory('Thickness', ['2cm', '3cm']),
  ], false, undefined, true),   // isCountertopSystem — fabrication-shop schedule
  makeFamily('Doors', [
    makeCategory('Door Type', ['Single Swing', 'Double Swing', 'Pocket', 'Barn']),
    makeCategory('Material', ['Solid Core Wood', 'MDF Paint Grade', 'Glass Panel']),
    makeCategory('Finish', ['Stained', 'Painted White', 'Painted Custom']),
    makeCategory('Hardware Finish', DOOR_HARDWARE_FINISHES),
  ]),
  // isWindowSystem (last arg, true) flags this family as using the
  // specialized dependency-graph Window Schedule template (buildWindowSchedule,
  // below) instead of/alongside the generic sequential stage engine — see
  // isWindowSystemFamily. Admin-togglable per family from the Scope Library
  // editor, so this isn't limited to just this one seed family long-term.
  makeFamily('Window Systems', [
    makeCategory('Frame Material', ['Aluminum Thermally Broken', 'Vinyl', 'Wood-Clad']),
    makeCategory('Glazing Type', ['Double Pane Low-E', 'Triple Pane', 'Impact-Rated']),
    makeCategory('Finish Color', ['Black Anodized', 'Bronze', 'White']),
    makeCategory('Hardware', ['Standard Casement Crank', 'Multi-Point Lock']),
  ], true),
  // Flooring used to be one family covering wood/SPC/carpet all at once —
  // split into three so each scope only shows its own relevant categories
  // (grouped back together visually under a "Flooring" optgroup wherever a
  // scope family is picked — see FLOORING_FAMILY_NAMES).
  makeFamily('Engineered Wood Flooring', [
    makeCategory('Species', ['White Oak', 'Hickory', 'Walnut']),
    makeCategory('Finish', ['Matte UV', 'Wire-Brushed Oil', 'Satin']),
    makeCategory('Plank Width', EWF_WIDTH),
    makeCategory('Pattern', EWF_PATTERNS),
  ]),
  makeFamily('SPC / LVT Flooring', [
    makeCategory('Color / Pattern', ['Coastal Oak', 'Greige Stone', 'Weathered Grey']),
    makeCategory('Plank Width', EWF_WIDTH),
    makeCategory('Finish', ['Matte', 'Satin', 'Textured']),
  ]),
  makeFamily('Carpet', [
    makeCategory('Pile Type', ['Loop Pile', 'Cut Pile', 'Pattern Loop']),
    makeCategory('Fiber', ['Nylon', 'Polyester', 'Wool Blend']),
    makeCategory('Color', ['Greige', 'Charcoal', 'Warm Taupe']),
  ]),
  makeFamily('Tile', [
    makeCategory('Material', TILE_MATERIALS),
    makeCategory('Size', TILE_SIZES_IMPERIAL),
    makeCategory('Thickness', TILE_THICKNESS),
    makeCategory('Finish', ['Polished', 'Matte', 'Textured']),
  ]),
  makeFamily('Baseboards / Trims', [
    makeCategory('Material', ['Solid Wood', 'MDF', 'PVC']),
    makeCategory('Height', BASEBOARD_HEIGHT),
    makeCategory('Thickness', BASEBOARD_THICKNESS),
    makeCategory('Profile', ['Square Edge', 'Colonial', 'Ogee', 'Modern Flat']),
  ]),
  makeFamily('Other / Custom', []),
  // ── Abu Dhabi ────────────────────────────────────────────────────────────
  // The Gulf operation classifies the work differently and sells material only.
  // Four scopes, and the split that matters: a countertop is NOT its own scope
  // there — a vanity top belongs to Joinery and a kitchen top to Kitchen,
  // because that is how the client's BOQ is written and how it is awarded.
  // Tiles and Stone are therefore surfaces only, never tops.
  // These carry `regions: ['Abu Dhabi']`, so they never appear on a US job and
  // the US families never appear on a Gulf one — one library, filtered by where
  // the client is.
  Object.assign(makeFamily('Joinery', [
    makeCategory('Finish Type', FINISH_TYPES),
    makeCategory('Color Family', COLOR_FAMILIES),
    makeCategory('Sheen / Texture', SHEEN_TEXTURES),
    makeCategory('Hardware', ['Brushed Brass Pull', 'Matte Black Bar Pull', 'Integrated J-Channel']),
  ]), {
    regions: ['Abu Dhabi'], supplyOnly: true,
    workItems: ['Doors', 'Wardrobes', 'Vanities', 'Vanity Countertops', 'Vanity Backsplash'],
  }),
  Object.assign(makeFamily('Kitchen', [
    makeCategory('Finish Type', FINISH_TYPES),
    makeCategory('Color Family', COLOR_FAMILIES),
    makeCategory('Sheen / Texture', SHEEN_TEXTURES),
    makeCategory('Hardware', ['Brushed Brass Pull', 'Matte Black Bar Pull', 'Integrated J-Channel']),
  ]), {
    regions: ['Abu Dhabi'], supplyOnly: true,
    workItems: ['Kitchen Cabinets', 'Kitchen Countertops', 'Kitchen Backsplash'],
  }),
  Object.assign(makeFamily('Tiles', [
    makeCategory('Color Family', COLOR_FAMILIES),
    makeCategory('Finish Type', FINISH_TYPES),
  ]), {
    regions: ['Abu Dhabi'], supplyOnly: true,
    // Surfaces only. No countertops — those are inside Joinery and Kitchen.
    workItems: ['Wall Tiles', 'Floor Tiles', 'Tile Skirting'],
  }),
  Object.assign(makeFamily('Stone', [
    makeCategory('Color Family', COLOR_FAMILIES),
    makeCategory('Finish Type', FINISH_TYPES),
  ]), {
    regions: ['Abu Dhabi'], supplyOnly: true,
    workItems: ['Wall Stone', 'Floor Stone', 'Stone Skirting'],
  }),
  // Supplied goods rather than fabricated work — separate scopes because they
  // are separately specified, separately awarded and separately delivered.
  Object.assign(makeFamily('Sanitary Ware', [
    makeCategory('Finish Type', ['Chrome', 'Brushed Nickel', 'Matte Black', 'Brushed Brass', 'White']),
  ]), {
    regions: ['Abu Dhabi'], supplyOnly: true,
    workItems: ['WCs', 'Bidets', 'Cisterns & Frames', 'Shower Trays', 'Bathtubs'],
  }),
  Object.assign(makeFamily('Bathroom Fixtures', [
    makeCategory('Finish Type', ['Chrome', 'Brushed Nickel', 'Matte Black', 'Brushed Brass']),
  ]), {
    regions: ['Abu Dhabi'], supplyOnly: true,
    workItems: ['Mixers & Taps', 'Showers', 'Accessories', 'Mirrors', 'Towel Rails'],
  }),
  Object.assign(makeFamily('Sinks', [
    makeCategory('Finish Type', ['Stainless Steel', 'Granite Composite', 'Ceramic', 'Fireclay']),
  ]), {
    regions: ['Abu Dhabi'], supplyOnly: true,
    workItems: ['Kitchen Sinks', 'Vanity Basins', 'Utility Sinks'],
  }),
  Object.assign(makeFamily('Office Furniture', [
    makeCategory('Finish Type', FINISH_TYPES),
    makeCategory('Color Family', COLOR_FAMILIES),
  ]), {
    regions: ['Abu Dhabi'], supplyOnly: true,
    workItems: ['Desks', 'Workstations', 'Storage', 'Seating', 'Reception & Meeting'],
  }),
];

// The three families a scope-family picker groups under a "Flooring"
// heading (see AddScopeModal) — Flooring itself isn't a real family
// (nothing to select), it's just the visual category label.
const FLOORING_FAMILY_NAMES = ['Engineered Wood Flooring', 'SPC / LVT Flooring', 'Carpet'];

// Single source of truth for "does this scope use the Window Schedule
// template" (§ Window & Exterior Door System scheduling workflow) — checked
// wherever that branch matters (addScope, AddScopeModal, ScopeBlock, the
// Gantt dashboard) so classification logic never has to be duplicated.
function isWindowSystemFamily(familyName, scopeLibrary) {
  const fam = (scopeLibrary || []).find(f => f.name === familyName);
  return !!(fam && fam.isWindowSystem);
}
// Countertops are the one kind of work that is ALWAYS supply and labour — the
// slab is bought and the same scope templates, fabricates and installs it — so
// the family carries the flag and the scope type is not an open question.
// Same shape as isWindowSystemFamily: a flag on the family, one accessor.
function isCountertopFamily(familyName, scopeLibrary) {
  const fam = (scopeLibrary || []).find(f => f.name === familyName);
  return !!(fam && fam.isCountertopSystem);
}

// ---------------------------------------------------------------------------
// Window System Lead-Time Library (admin-editable defaults, §14) — one entry
// per named window/door system, holding the default duration (in days) for
// each stage of that system's specialized schedule. Copied into a scope's
// own windowSchedule.leadTimesSnapshot at scope-creation time (never a live
// reference — mirrors instantiateStages copying STAGE_DEFS.baseDays into
// each stage's own `duration`), so editing a library entry here never
// changes the schedule of a project already created against it.
// ---------------------------------------------------------------------------
function makeWindowLeadTimeEntry(data) {
  return {
    id: uid('wlt'),
    name: data.name,
    active: true,
    profileApprovalDays: Number(data.profileApprovalDays) || 0,
    glassLeadDays: Number(data.glassLeadDays) || 0,
    fabDrawingApprovalDays: Number(data.fabDrawingApprovalDays) || 0,
    factoryFirstDeliveryDays: Number(data.factoryFirstDeliveryDays) || 0,
    factoryFullProductionDays: Number(data.factoryFullProductionDays) || 0,
    shippingDays: Number(data.shippingDays) || 0,
  };
}

// Seed durations given in weeks in the spec, converted to days (×7) since
// every other duration field in the app (STAGE_DEFS baseDays, etc.) is
// stored in days.
const DEFAULT_WINDOW_LEAD_TIME_LIBRARY = [
  makeWindowLeadTimeEntry({ name: 'ARTEVO', profileApprovalDays: 21, glassLeadDays: 28, fabDrawingApprovalDays: 14, factoryFirstDeliveryDays: 56, factoryFullProductionDays: 84, shippingDays: 21 }),
  makeWindowLeadTimeEntry({ name: '4500 System', profileApprovalDays: 21, glassLeadDays: 28, fabDrawingApprovalDays: 14, factoryFirstDeliveryDays: 49, factoryFullProductionDays: 70, shippingDays: 21 }),
  makeWindowLeadTimeEntry({ name: 'SYNEGO Sliding Doors', profileApprovalDays: 21, glassLeadDays: 28, fabDrawingApprovalDays: 14, factoryFirstDeliveryDays: 63, factoryFullProductionDays: 98, shippingDays: 21 }),
];

// Forward-merge pattern (mirrors mergeNewScopeFamilies/mergeNewTeamMembers) —
// a session with persisted state would otherwise never see systems added to
// DEFAULT_WINDOW_LEAD_TIME_LIBRARY later. Matched by name; an admin's own
// edits to existing entries are left untouched.
function mergeNewWindowLeadTimeEntries(persistedLib) {
  const existingNames = new Set((persistedLib || []).map(e => e.name));
  const missing = DEFAULT_WINDOW_LEAD_TIME_LIBRARY.filter(e => !existingNames.has(e.name));
  return missing.length ? [...persistedLib, ...missing] : persistedLib;
}

// ---------------------------------------------------------------------------
// Interiors Lead-Time Library — the Interiors-department counterpart to the
// Window System Lead-Time Library above.
// ---------------------------------------------------------------------------
// Interior scope durations used to be hardcoded in STAGE_DEFS.baseDays, one
// set of numbers for every kind of work — so an Engineered Wood Flooring
// scope and a Casework scope were scheduled with identical production time,
// which is simply not true in the shop. This gives each interiors scope
// family its own editable per-stage durations, so "engineered wood
// production is really 4 weeks" is a number an admin can just fix.
//
// Same contract as the window library: values are COPIED into each stage's
// own `duration` at scope-creation time (instantiateStages), never held as a
// live reference — editing the library never retroactively reschedules a
// project already created against it. The project's complexity multiplier
// still applies on top of these values, exactly as it did to STAGE_DEFS
// baseDays (confirmed explicitly), so the library sets the BASE duration,
// not the final one.
function defaultInteriorStageDays() {
  const days = {};
  ALL_STAGE_DEFS.forEach(d => { days[d.key] = d.baseDays; });
  return days;
}
function makeInteriorLeadTimeEntry(data) {
  data = data || {};
  return {
    id: uid('ilt'),
    familyName: data.familyName || '',
    active: true,
    // Sparse-safe: always a complete map over STAGE_DEFS keys, so a stage
    // added to STAGE_DEFS later can never leave an entry with a hole in it.
    stageDays: { ...defaultInteriorStageDays(), ...(data.stageDays || {}) },
    // The RUNNING ORDER, per scope type, once an admin has edited it — an
    // ordered list of stage keys. Absent means "use the built-in template",
    // which is what every entry starts as: storing only what was deliberately
    // changed is what lets a stage added to the catalogue later still reach
    // the families nobody has customised.
    stageTemplates: { ...(data.stageTemplates || {}) },
  };
}
// The variants a family's schedule can be edited as. Countertops are always
// supply + labour on the fabrication-shop order, so they have exactly one.
function templateVariantsForFamily(familyName, scopeLibrary) {
  return scopeTypeOptions(familyName, scopeLibrary);
}
function templateVariantLabel(familyName, scopeLibrary, variant) {
  if (isCountertopFamily(familyName, scopeLibrary)) return 'Countertops';
  return scopeTypeLabel(familyName, scopeLibrary, variant);
}
// One entry per interiors-classified family in DEFAULT_SCOPE_LIBRARY. Seeded
// from the existing STAGE_DEFS baseDays so behaviour on day one is
// byte-identical to before the library existed — every real per-family
// number is then a deliberate admin edit, not a guess baked in here.
const DEFAULT_INTERIOR_LEAD_TIME_LIBRARY = DEFAULT_SCOPE_LIBRARY
  .map(f => makeInteriorLeadTimeEntry({ familyName: f.name }));

// Forward-merge (mirrors mergeNewScopeFamilies / mergeNewWindowLeadTimeEntries)
// — a family added to the scope library later gets a lead-time entry without
// clobbering the durations an admin already tuned. Matched by familyName.
function mergeNewInteriorLeadTimeEntries(persistedLib) {
  const existing = new Set((persistedLib || []).map(e => e.familyName));
  const missing = DEFAULT_INTERIOR_LEAD_TIME_LIBRARY.filter(e => !existing.has(e.familyName));
  const merged = missing.length ? [...(persistedLib || []), ...missing] : (persistedLib || []);
  // Backfill any stage key added to STAGE_DEFS after an entry was saved.
  return merged.map(e => ({ ...e, stageDays: { ...defaultInteriorStageDays(), ...(e.stageDays || {}) }, stageTemplates: { ...(e.stageTemplates || {}) } }));
}

// Resolves the stage definitions a NEW scope should be built from: the
// reduced window set for a window-classified family, otherwise the generic
// interiors set with this family's own library durations substituted in for
// baseDays. This is the single place the two departments' schedule templates
// diverge — instantiateStages itself stays generic over whatever defs it's
// handed.
// Which running order this scope uses. The family decides first — a window
// system and a countertop each have a schedule shape of their own that no
// scope type overrides — and only then does the scope type trim it.
function scopeStageTemplateKey(familyName, scopeLibrary, scopeType) {
  if (isWindowSystemFamily(familyName, scopeLibrary)) return 'window';
  if (isCountertopFamily(familyName, scopeLibrary)) return 'countertop';
  if (scopeType === 'Labor Only') return 'laborOnly';
  return 'standard';
}
// Countertops are always supply + labour, so the scope type is not theirs to
// answer — asked and settled explicitly. Everything else takes what was chosen.
function effectiveScopeType(familyName, scopeLibrary, scopeType) {
  const allowed = scopeTypeOptions(familyName, scopeLibrary);
  if (allowed.length === 1) return allowed[0];
  return allowed.includes(scopeType) ? scopeType : DEFAULT_SCOPE_TYPE;
}
function stageDefsForScope(familyName, scopeLibrary, interiorLeadTimeLibrary, scopeType) {
  const type = effectiveScopeType(familyName, scopeLibrary, scopeType);
  const templateKey = scopeStageTemplateKey(familyName, scopeLibrary, type);
  const entry = (interiorLeadTimeLibrary || []).find(e => e.familyName === familyName && e.active !== false);
  // A running order an admin edited under Lead Times wins over the built-in
  // one. Only the variants they actually touched are stored, so a family
  // nobody has customised still follows the template as it evolves.
  const saved = entry && entry.stageTemplates && entry.stageTemplates[type];
  const keys = Array.isArray(saved) && saved.length ? saved : templateStageKeys(templateKey);
  const defs = keys.map(key => {
    const def = stageDefByKey(key);
    if (!def) return null;
    // The family's own tuned duration wins over the catalogue default.
    const tuned = entry && Number(entry.stageDays[key]) > 0 ? Number(entry.stageDays[key]) : def.baseDays;
    return { ...def, baseDays: tuned, name: templateStageName(templateKey, key, def.name) };
  }).filter(Boolean);
  // Labor Only is already nothing but the labour steps, so there is nothing
  // left for the Supply Only trim to take off it. An edited order is taken as
  // written — the admin chose those stages, so nothing is trimmed off it.
  return saved ? defs : stageDefsForScopeType(defs, type);
}

// Suggested standard scope names per family (§12) — shown as autocomplete
// suggestions on the "Scope Name" field; typing anything else (Other/Custom)
// is always allowed, this is a convenience list, not a restriction.
const SUGGESTED_SCOPE_NAMES = {
  'Casework': ['Kitchens', 'Bathroom Casework / Vanities', 'Amenities Casework', 'Closets / Wardrobes', 'Architectural Casework / Millwork'],
  'Doors': ['Entry Doors', 'Interior Doors', 'Amenities Doors', 'Pocket Doors', 'Specialty Doors'],
  'Countertop': ['Countertops', 'Backsplashes', 'Natural Stone / Slab Work'],
  'Tile': ['Unit Tiles', 'Amenities Tiles', 'Wall Tiles', 'Floor Tiles', 'Exterior Tiles / Pavers'],
  'Engineered Wood Flooring': ['Unit Engineered Wood Flooring', 'Amenities Engineered Wood Flooring'],
  'SPC / LVT Flooring': ['Unit SPC / LVT Flooring', 'Amenities SPC / LVT Flooring'],
  'Carpet': ['Unit Carpet', 'Amenities Carpet'],
  'Baseboards / Trims': ['Unit Baseboards', 'Amenities Baseboards', 'Architectural Trims'],
};

// ---------------------------------------------------------------------------
// Material Specification Library (§2, §4, §14-22) — a company-wide, reusable
// catalog of materials/products. Each material carries its own structured
// spec fields (per category) plus reusable documents (tech data, care &
// maintenance, warranty, installation, certifications). Once uploaded here,
// a material's documents never need to be re-uploaded per project — projects
// reference the material by id from their Selections.
// ---------------------------------------------------------------------------
const MATERIAL_CATEGORIES = ['Casework', 'Countertop / Stone', 'Tile', 'Engineered Wood Flooring', 'SPC / LVT Flooring', 'Carpet', 'Baseboard / Trim', 'Door', 'Other'];
const MATERIAL_DOC_TYPES = ['Technical Data Sheet', 'Care & Maintenance', 'Cleaning Instructions', 'Manufacturer Warranty', 'Product Warranty', 'Installation Instructions', 'Safety / Technical Certification', 'Other Product Documentation'];

// Per-category structured spec fields (§20 general rule: standard-size
// dropdown + custom entry, kept separate from thickness/wear-layer where the
// requirements call that out explicitly). `options` fields render as a
// Select-with-Custom; plain fields render as free text.
const MATERIAL_FIELD_DEFS = {
  'Casework': [
    { key: 'species', label: 'Wood Species', options: CASEWORK_WOOD_SPECIES },
    { key: 'finishType', label: 'Finish Type', options: FINISH_TYPES },
    { key: 'colorFamily', label: 'Color Family', options: COLOR_FAMILIES },
    { key: 'sheenTexture', label: 'Sheen / Texture', options: SHEEN_TEXTURES },
    { key: 'style', label: 'Casework Style', options: CASEWORK_STYLES },
  ],
  'Countertop / Stone': [
    { key: 'material', label: 'Material', options: COUNTERTOP_MATERIALS },
    { key: 'look', label: 'Color / Look', options: COUNTERTOP_LOOKS },
    { key: 'finish', label: 'Finish', options: COUNTERTOP_FINISHES },
    { key: 'slabWidth', label: 'Slab Width' },
    { key: 'slabLength', label: 'Slab Length' },
    { key: 'actualThickness', label: 'Actual Material Thickness', options: [...COUNTERTOP_THICKNESS_STONE, ...COUNTERTOP_THICKNESS_PORCELAIN] },
    { key: 'finishedEdgeThickness', label: 'Finished / Visual Edge Thickness' },
    { key: 'edgeProfile', label: 'Edge Profile', options: COUNTERTOP_EDGE_PROFILES },
    { key: 'backsplashHeight', label: 'Backsplash Height' },
  ],
  'Tile': [
    { key: 'material', label: 'Material', options: TILE_MATERIALS },
    { key: 'nominalSize', label: 'Nominal Size', options: [...TILE_SIZES_IMPERIAL, ...TILE_SIZES_METRIC] },
    { key: 'actualSize', label: 'Actual Size' },
    { key: 'thickness', label: 'Thickness', options: TILE_THICKNESS },
    { key: 'finish', label: 'Finish' },
    { key: 'edge', label: 'Edge' },
    { key: 'application', label: 'Wall / Floor / Exterior', options: TILE_APPLICATIONS },
    { key: 'slipRating', label: 'Slip Rating' },
  ],
  'Engineered Wood Flooring': [
    { key: 'species', label: 'Species' },
    { key: 'finish', label: 'Finish' },
    { key: 'overallThickness', label: 'Overall Thickness', options: EWF_THICKNESS },
    { key: 'wearLayer', label: 'Wear Layer Thickness', options: EWF_WEAR_LAYER },
    { key: 'plankWidth', label: 'Plank Width', options: EWF_WIDTH },
    { key: 'plankLength', label: 'Plank Length' },
    { key: 'lengthType', label: 'Fixed / Random Length', options: EWF_LENGTH_TYPES },
    { key: 'minLength', label: 'Minimum Length' },
    { key: 'maxLength', label: 'Maximum Length' },
    { key: 'lengthPct', label: 'Length Percentage (if applicable)' },
    { key: 'installationMethod', label: 'Installation Method' },
    { key: 'pattern', label: 'Pattern', options: EWF_PATTERNS },
  ],
  'SPC / LVT Flooring': [
    { key: 'overallThickness', label: 'Overall Thickness', options: SPC_THICKNESS },
    { key: 'wearLayer', label: 'Wear Layer', options: SPC_WEAR_LAYER },
    { key: 'plankWidth', label: 'Plank Width' },
    { key: 'plankLength', label: 'Plank Length' },
    { key: 'underlaymentThickness', label: 'Underlayment Thickness' },
    { key: 'installationMethod', label: 'Installation Method' },
    { key: 'waterproofRating', label: 'Waterproof Rating' },
    { key: 'fireRating', label: 'Fire Rating' },
  ],
  'Carpet': [
    { key: 'pileType', label: 'Pile Type', options: ['Loop Pile', 'Cut Pile', 'Pattern Loop'] },
    { key: 'fiber', label: 'Fiber' },
    { key: 'color', label: 'Color' },
    { key: 'faceWeight', label: 'Face Weight' },
    { key: 'padThickness', label: 'Pad Thickness' },
    { key: 'installationMethod', label: 'Installation Method' },
    { key: 'flammabilityRating', label: 'Flammability Rating' },
  ],
  'Baseboard / Trim': [
    { key: 'material', label: 'Material' },
    { key: 'height', label: 'Height', options: BASEBOARD_HEIGHT },
    { key: 'thickness', label: 'Thickness', options: BASEBOARD_THICKNESS },
    { key: 'length', label: 'Length' },
    { key: 'profile', label: 'Profile' },
    { key: 'finish', label: 'Finish' },
  ],
  'Door': [
    { key: 'doorType', label: 'Door Type' },
    { key: 'material', label: 'Material' },
    { key: 'finish', label: 'Finish' },
    { key: 'hardwareFinish', label: 'Hardware Finish', options: DOOR_HARDWARE_FINISHES },
  ],
  'Other': [
    { key: 'description', label: 'Description' },
  ],
};

function makeMaterial(data, createdBy) {
  return {
    id: uid('mat'),
    name: data.name, category: data.category,
    manufacturer: data.manufacturer || '', vendorId: data.vendorId || null, vendorName: data.vendorName || '',
    productCode: data.productCode || '',
    unit: data.unit || 'Imperial',
    specs: data.specs || {},
    notes: data.notes || '',
    documents: [],
    active: true,
    createdDate: todayISO(), createdBy,
  };
}
function makeMaterialDocument(docType, name, file, fileUrl, uploadedBy) {
  return { id: uid('matdoc'), docType, name, file, fileUrl, uploadDate: todayISO(), uploadedBy };
}

// ---------------------------------------------------------------------------
// Scope supporting documents
// ---------------------------------------------------------------------------
// The material itself now lives in Supplier Finishes — a real catalog with the
// supplier's own records behind it — so a second library of material records
// only duplicated it. What is genuinely missing there is the PAPERWORK: the
// technical data sheet, the care and cleaning instructions, the warranty, the
// installation guide. Those hang off a scope FAMILY, and optionally off one
// selection CATEGORY within it, so the right sheet surfaces where the choice
// is actually made.
// ── The selection meeting ───────────────────────────────────────────────────
// Selections are often made LIVE, sitting with the client and entering what
// they choose on the spot. When that happens the choices are not just data —
// they are a decision taken at a place, on a date, with named people in the
// room, and that is what makes them defensible three months later when someone
// asks why the tile is this one.
//
// It hangs off the REVISION rather than off the scope, because a revision is
// already "this is the version we committed to", which is exactly the moment a
// selection meeting produces. A scope can therefore hold several meetings over
// its life, each tied to what was actually chosen that day.
//
// `clientPresent` is a deliberate FIELD and not an inference from the attendee
// list: "the client was in the room" is the fact that matters, and it should be
// answerable without reading names.
function makeSelectionMeeting(data) {
  const d = data || {};
  return {
    held: !!d.held,
    date: d.date || todayISO(),
    location: d.location || '',
    clientPresent: !!d.clientPresent,
    // Who was there. Free text on purpose — the client's designer, their
    // spouse, a contractor's PM: not everyone in the room is in the Hub.
    attendees: d.attendees || '',
    notes: d.notes || '',
  };
}

function makeScopeDocument(data, uploadedBy) {
  return {
    id: uid('scopedoc'),
    familyName: data.familyName || '',
    // null = applies to the whole family, not one category of it.
    categoryId: data.categoryId || null,
    docType: data.docType || MATERIAL_DOC_TYPES[0],
    name: data.name || '',
    file: data.file || null, fileUrl: data.fileUrl || null,
    vendorId: data.vendorId || null,
    notes: data.notes || '',
    active: true,
    uploadedBy: uploadedBy || null, uploadDate: data.uploadDate || todayISO(),
  };
}
// The old material-library categories were not the scope families, so the
// documents already on file are mapped across rather than dropped.
const MATERIAL_CATEGORY_TO_FAMILY = {
  'Casework': 'Casework',
  'Countertop / Stone': 'Countertop',
  'Tile': 'Tile',
  'Engineered Wood Flooring': 'Engineered Wood Flooring',
  'SPC / LVT Flooring': 'SPC / LVT Flooring',
  'Carpet': 'Carpet',
  'Baseboard / Trim': 'Baseboards / Trims',
  'Door': 'Doors',
  'Other': 'Other / Custom',
};
// One-time lift of every document already attached to a material record into
// the new library. Runs only when scopeDocuments has never been persisted; the
// material name is kept in the document name so the context is not lost.
function seedScopeDocuments(materialLibrary, persisted) {
  if (persisted) return persisted;
  const out = [];
  (materialLibrary || []).forEach(m => {
    (m.documents || []).forEach(d => {
      out.push(makeScopeDocument({
        familyName: MATERIAL_CATEGORY_TO_FAMILY[m.category] || 'Other / Custom',
        docType: d.docType, name: `${m.name} — ${d.name}`,
        file: d.file, fileUrl: d.fileUrl, uploadDate: d.uploadDate,
        notes: m.manufacturer ? `${m.manufacturer}${m.productCode ? ` · ${m.productCode}` : ''}` : '',
      }, d.uploadedBy));
    });
  });
  return out;
}
// Every document filed against this family, optionally narrowed to one of its
// selection categories. A family-wide document shows for every category, which
// is what "applies to the whole family" has to mean at the point of use.
function scopeDocumentsFor(scopeDocuments, familyName, categoryId) {
  return (scopeDocuments || []).filter(d => d.active !== false && d.familyName === familyName
    && (categoryId === undefined || !d.categoryId || d.categoryId === categoryId));
}

// ---------------------------------------------------------------------------
// Appliance & Fixture Specification Library (§ casework appliance/fixture
// request) — reusable, company-wide spec records (same reuse pattern as the
// Material Library above: the file/spec lives once, projects only ever hold
// a link to it) plus per-project "instance" link-records, since a single
// spec (e.g. one refrigerator model) can be placed multiple times in a
// project (Refrigerator 01/02) or shared between a Casework scope and its
// related Countertop scope (a sink affects both).
// ---------------------------------------------------------------------------
const APPLIANCE_TYPES = ['Range / Stove', 'Cooktop', 'Wall Oven', 'Hood', 'Microwave', 'Refrigerator', 'Freezer', 'Dishwasher', 'Wine Cooler', 'Washer', 'Dryer', 'Other Appliance / Custom'];
const FIXTURE_TYPES = ['Kitchen Sink', 'Bar Sink', 'Vanity Sink', 'Utility Sink', 'Faucet', 'Pot Filler', 'Soap Dispenser', 'Garbage Disposal', 'Water Filtration System', 'Other Fixture / Custom'];
const SPEC_DOC_TYPES = ['Specification Sheet', 'Installation Manual', 'Cut Sheet'];

function makeApplianceSpec(data, createdBy) {
  return {
    id: uid('appl'),
    applianceType: data.applianceType || APPLIANCE_TYPES[0],
    manufacturer: data.manufacturer || '', model: data.model || '', modelNumber: data.modelNumber || '',
    finish: data.finish || '', dimensions: data.dimensions || '',
    voltage: data.voltage || '', gasRequirement: data.gasRequirement || '', plumbingRequirement: data.plumbingRequirement || '', ventilationRequirement: data.ventilationRequirement || '',
    documents: [], notes: data.notes || '', revision: 0, active: true, createdDate: todayISO(), createdBy,
  };
}
function makeFixtureSpec(data, createdBy) {
  return {
    id: uid('fix'),
    fixtureType: data.fixtureType || FIXTURE_TYPES[0],
    manufacturer: data.manufacturer || '', model: data.model || '', modelNumber: data.modelNumber || '',
    finish: data.finish || '', dimensions: data.dimensions || '', mountingType: data.mountingType || '',
    plumbingRequirements: data.plumbingRequirements || '', cutoutDimensions: data.cutoutDimensions || '',
    documents: [], notes: data.notes || '', revision: 0, active: true, createdDate: todayISO(), createdBy,
  };
}
function makeSpecDocument(docType, name, file, fileUrl, uploadedBy) {
  return { id: uid('specdoc'), docType, name, file, fileUrl, uploadDate: todayISO(), uploadedBy };
}
// Per-project link record — resolves its spec live from the shared library
// (ctx.applianceLibrary/fixtureLibrary), so the file only ever lives once.
function makeApplianceInstance(data, createdBy) {
  return {
    id: uid('applinst'), scopeId: data.scopeId, specId: data.specId, label: data.label || '',
    unitType: data.unitType || '', room: data.room || '', cabinetArea: data.cabinetArea || '',
    shopDrawingId: data.shopDrawingId || null, notes: data.notes || '', createdDate: todayISO(), createdBy,
  };
}
function makeFixtureInstance(data, createdBy) {
  return {
    id: uid('fixinst'), scopeId: data.scopeId, countertopScopeId: data.countertopScopeId || null, specId: data.specId, label: data.label || '',
    unitType: data.unitType || '', room: data.room || '', cabinetArea: data.cabinetArea || '',
    shopDrawingId: data.shopDrawingId || null, notes: data.notes || '', createdDate: todayISO(), createdBy,
  };
}

// ---------------------------------------------------------------------------
// Shop Drawing / Submittal & Client Response classification (§1) — each is
// its own append-only revision history (Rev. 0, Rev. 1, ...), never
// overwritten; a Client Response links to the exact submittal revision it
// answers.
// ---------------------------------------------------------------------------
// ---- casework submittal records -------------------------------------------
// Read out of the 55 India Rev 03 set. That document runs on three code systems
// that the Hub had no record of, so all three lived as text on 166 slides:
// the architect's finish tags, the architect's keynotes with LEON's answers,
// and the submittal itself with its revision log. Each is modelled here.

// A finish tag is the ARCHITECT'S code for a material — [WD-2], [MW-4], [CT-1].
// It is not our supplier finish and it is not our material record; it is the
// name the drawings, the architect's schedule and the reviewer's comments all
// use. It points AT a supplier finish, which is what makes a callout checkable.
// The prefix carries meaning in every set I have seen, so it is offered rather
// than invented — but the code is free text, because it is the architect's to
// choose and a picker that refused theirs would be worse than useless.
const CW_TAG_FAMILIES = [
  { prefix: 'WD', label: 'Wood / veneer' },
  { prefix: 'MW', label: 'Millwork / painted' },
  { prefix: 'CT', label: 'Countertop' },
  { prefix: 'ST', label: 'Stone' },
  { prefix: 'MTL', label: 'Metal' },
  { prefix: 'WO', label: 'Wall oven / appliance' },
  { prefix: 'RF', label: 'Refrigeration' },
  { prefix: 'DW', label: 'Dishwasher' },
  { prefix: 'VH', label: 'Vent hood' },
  { prefix: 'SK', label: 'Sink' },
  { prefix: 'LT', label: 'Lighting' },
  { prefix: 'GL', label: 'Glass' },
  { prefix: 'HW', label: 'Hardware' },
];
function cwTagFamilyFor(code) {
  const m = String(code || '').trim().toUpperCase().match(/^([A-Z]+)/);
  if (!m) return null;
  return CW_TAG_FAMILIES.find(f => f.prefix === m[1]) || null;
}
function makeCwFinishTag(data, createdBy) {
  const d = data || {};
  return {
    id: d.id || uid('cwtag'),
    // Which trade this belongs to. Casework and doors share these three
    // structures because they are the same idea with a different numbering
    // convention — but a door submittal and a casework submittal are issued and
    // approved separately, so each record says which it is and each tool shows
    // only its own. Anything saved before this reads as casework, which is what
    // it was.
    discipline: d.discipline || 'casework',
    // The architect's code, verbatim and upper-cased — it is quoted back in
    // their comments, so it has to match theirs exactly.
    code: String(d.code || '').trim().toUpperCase(),
    name: d.name || '',                 // "Rift white oak, horizontal grain"
    description: d.description || '',
    // What it actually is. The whole point of the record: a callout can then be
    // checked against a real catalogue product rather than a typed name.
    finishRef: d.finishRef || null,
    // Where it applies — cabinet door, drawer box, shelf, panel. Free text,
    // because the architect writes this and every set words it differently.
    appliesTo: d.appliesTo || '',
    // A tag is a thing that gets RE-SPECIFIED. 55 India moved WD-2 to WD-3 at
    // Rev 00 comment 8, after a selection update. Superseding rather than
    // editing keeps the old code resolvable, because it is still quoted in
    // every issued revision and in the architect's own comments.
    supersededById: d.supersededById || null,
    supersededDate: d.supersededDate || null,
    active: d.active !== false,
    createdBy: createdBy || d.createdBy || '', createdDate: d.createdDate || todayISO(),
  };
}

// An architect keynote (FN32) and LEON's answer (A.FN32). The answers in the
// 55 India set do three genuinely different jobs and carrying them all as prose
// is how an exclusion gets forgotten:
//   commitment — we will build it this way, and it is now a dimension we owe
//   exclusion  — we are not doing this / not responsible for it
//   question   — we need the reviewer to decide before we can build
//   noted      — acknowledged, nothing follows from it
// Only the first two have consequences after approval; an exclusion is what a
// back-charge argument turns on, and a commitment is what a site measure is
// checked against.
const CW_KEYNOTE_ANSWER_KINDS = [
  { key: 'commitment', label: 'Commitment', icon: '\u2713',
    hint: 'We will build it this way. This becomes something we owe.' },
  { key: 'exclusion', label: 'Exclusion', icon: '\u2298',
    hint: 'Not in our scope, or not our responsibility. What a back-charge turns on.' },
  { key: 'question', label: 'Question back', icon: '?',
    hint: 'The reviewer has to decide before this can be built.' },
  { key: 'noted', label: 'Noted', icon: '\u2014',
    hint: 'Acknowledged. Nothing follows from it.' },
];
function cwKeynoteAnswerKind(key) {
  return CW_KEYNOTE_ANSWER_KINDS.find(k => k.key === key) || null;
}
function makeCwKeynote(data, createdBy) {
  const d = data || {};
  return {
    id: d.id || uid('cwkn'),
    // Which trade this belongs to. Casework and doors share these three
    // structures because they are the same idea with a different numbering
    // convention — but a door submittal and a casework submittal are issued and
    // approved separately, so each record says which it is and each tool shows
    // only its own. Anything saved before this reads as casework, which is what
    // it was.
    discipline: d.discipline || 'casework',
    code: String(d.code || '').trim().toUpperCase(),   // FN32
    text: d.text || '',                                 // the architect's words
    source: d.source || '',                             // "Hacin"
    // LEON's answer. `code` is derived (A.FN32) rather than typed, so the pair
    // can never drift apart.
    answer: d.answer || '',
    answerKind: d.answerKind || 'noted',
    // Which pages of OUR set answer it. In the source document this is the
    // whole mechanism — every answer ends in a page reference.
    pageRefs: Array.isArray(d.pageRefs) ? d.pageRefs.slice() : [],
    // Which casework types it lands on, so a type's own sheet can print the
    // keynotes that actually apply to it rather than all twenty-one.
    caseworkTypeIds: Array.isArray(d.caseworkTypeIds) ? d.caseworkTypeIds.slice() : [],
    answeredBy: d.answeredBy || createdBy || '',
    answeredDate: d.answeredDate || null,
    active: d.active !== false,
    createdBy: createdBy || d.createdBy || '', createdDate: d.createdDate || todayISO(),
  };
}
function cwKeynoteAnswerCode(kn) {
  return kn && kn.code ? `A.${kn.code}` : '';
}

// The submittal itself: the approval page and the revision log. The four
// outcomes are NOT a new vocabulary — they are the app's own submittal
// statuses, so a casework submittal and a shop-drawing thread mean the same
// thing by the same words.
const CW_SUBMITTAL_OUTCOMES = ['Approved', 'Approved as Noted', 'Revise & Resubmit', 'Rejected'];
const CW_SUBMITTAL_REVIEWERS = [
  { key: 'architect', label: 'Architect / Designer', outcomes: true },
  { key: 'gc', label: 'General Contractor / CM', outcomes: false },
  { key: 'owner', label: 'Owner\u2019s Representative', outcomes: false },
];
const CW_SUBMITTAL_CONTENTS = [
  'Casework shop drawings (plans, elevations, sections, and details)',
  'Material specifications (core, finish, hardware, etc.)',
  'Appliance & accessory coordination (if applicable)',
  'Notes & key clarifications',
];
function makeCwSubmittalReview(data) {
  const d = data || {};
  return { outcome: d.outcome || '', name: d.name || '', date: d.date || null, note: d.note || '' };
}
// One entry of the revision log: what the reviewer said at a given revision and
// what we answered. Kept flat and numbered exactly as the source document
// numbers them, because the reviewer quotes the number back.
function makeCwSubmittalLogEntry(data) {
  const d = data || {};
  return {
    id: d.id || uid('cwlog'),
    rev: d.rev || '',                    // "REV00"
    ref: d.ref || '',                    // "8"  -> answered as "A.8"
    date: d.date || null,
    comment: d.comment || '',
    response: d.response || '',
    pageRefs: Array.isArray(d.pageRefs) ? d.pageRefs.slice() : [],
    resolved: !!d.resolved,
  };
}
function makeCwSubmittal(data, createdBy) {
  const d = data || {};
  return {
    id: d.id || uid('cwsub'),
    // Which trade this belongs to. Casework and doors share these three
    // structures because they are the same idea with a different numbering
    // convention — but a door submittal and a casework submittal are issued and
    // approved separately, so each record says which it is and each tool shows
    // only its own. Anything saved before this reads as casework, which is what
    // it was.
    discipline: d.discipline || 'casework',
    number: d.number || '',              // "03"
    revision: d.revision || '',          // "REV03"
    date: d.date || todayISO(),
    // 55 India issues one book per unit family — Artist Unit, Condo Unit — and
    // they revise independently, so the book is part of the record.
    book: d.book || '',
    projectName: d.projectName || '', location: d.location || '',
    submittedBy: d.submittedBy || 'LEON INTEGRA',
    preparedById: d.preparedById || null, preparedBy: d.preparedBy || '',
    contents: Array.isArray(d.contents) ? d.contents.slice() : CW_SUBMITTAL_CONTENTS.slice(),
    reviews: d.reviews ? cloneDeep(d.reviews) : {
      architect: makeCwSubmittalReview(), gc: makeCwSubmittalReview(), owner: makeCwSubmittalReview(),
    },
    reviewerComments: d.reviewerComments || '',
    log: Array.isArray(d.log) ? cloneDeep(d.log) : [],
    // Which casework types this book covers, and the file that was issued.
    caseworkTypeIds: Array.isArray(d.caseworkTypeIds) ? d.caseworkTypeIds.slice() : [],
    fileUrl: d.fileUrl || null, fileName: d.fileName || '',
    // The Hub already has submittal threads with these same four outcomes. A
    // casework submittal LINKS to one rather than growing a second set of
    // attachments, comments and revisions — the same rule the Window Schedule
    // approval trackers follow.
    submittalThreadId: d.submittalThreadId || null,
    issued: !!d.issued, issuedDate: d.issuedDate || null,
    createdBy: createdBy || d.createdBy || '', createdDate: d.createdDate || todayISO(),
  };
}

const SUBMITTAL_STATUSES = ['Draft', 'Internal Review', 'Submitted', 'Awaiting Response', 'Approved', 'Approved as Noted', 'Revise & Resubmit', 'Rejected', 'Superseded', 'Closed'];

function makeSubmittalThread(scopeId, docType, name, vendorId, vendorName, createdBy, requiredByDate) {
  return {
    id: uid('subm'), scopeId, docType, name,
    vendorId: vendorId || null, vendorName: vendorName || '',
    respondingToSubmittalId: null, respondingToRevisionNumber: null,
    // Optional — set by the Window Schedule's approval trackers (§ Window
    // Schedule template) so a thread can show "required by X vs. actually
    // approved Y"; every other submittal leaves this null, unaffected.
    requiredByDate: requiredByDate || null,
    status: 'Draft', revisions: [],
    createdDate: todayISO(), createdBy,
  };
}

// ---------------------------------------------------------------------------
// Quality control
// ---------------------------------------------------------------------------
// An AREA is the part of the job an inspection covers — a floor, a unit, a
// room, a run of casework. Set up per project (and optionally tied to a scope)
// so an inspection is booked against something real rather than a free-typed
// location that is spelled three ways by the third week.
function makeQcArea(data, createdBy) {
  return {
    id: uid('qcarea'), name: data.name || '', scopeId: data.scopeId || null,
    description: data.description || '', active: true,
    createdBy: createdBy || null, createdDate: todayISO(),
  };
}
const QC_INSPECTION_STATUSES = ['Scheduled', 'In Progress', 'Passed', 'Passed with Notes', 'Failed', 'Cancelled'];
// A result that is not a clean pass has to say what was wrong — the whole
// point of the record is that the shop can act on it.
// LEON's OWN area inspections. Deliberately a different vocabulary from
// QC_RESULTS above, which belongs to the vendor's factory QC report — they are
// two different records and merging them would lose that. What was wrong is
// that this list was hardcoded inline in the modal while the note rule below
// lived in a constant, so the two could drift apart silently and the "a result
// other than a clean pass needs a note" rule would stop firing without a trace.
const QC_INSPECTION_RESULTS = ['Passed', 'Passed with Notes', 'Failed'];
const QC_RESULTS_NEEDING_NOTES = ['Passed with Notes', 'Failed'];
const QC_INSPECTOR_TYPES = ['In-house', 'Third Party'];
// The checks themselves. Kept as free rows rather than a fixed form: what is
// checked on a countertop template is not what is checked on a carpet install.
function makeQcCheckItem(text) {
  return { id: uid('qcchk'), item: text || '', result: '', note: '' };
}
function makeQcInspection(data, createdBy) {
  return {
    id: uid('qcinsp'),
    areaId: data.areaId || null, scopeId: data.scopeId || null,
    title: data.title || '',
    inspectorType: QC_INSPECTOR_TYPES.includes(data.inspectorType) ? data.inspectorType : 'In-house',
    inspectorId: data.inspectorId || null,
    scheduledDate: data.scheduledDate || todayISO(),
    status: 'Scheduled',
    startedDate: null, completedDate: null,
    result: '', notes: '',
    checklist: (data.checklist || []).map(makeQcCheckItem),
    photos: [], documents: [],
    createdBy: createdBy || null, createdDate: todayISO(),
    activityLog: [],
  };
}
function qcInspectionOpen(i) { return !['Passed', 'Passed with Notes', 'Failed', 'Cancelled'].includes(i.status); }

// ---------------------------------------------------------------------------
// Unplanned business cost / potential loss (§9)
// ---------------------------------------------------------------------------
const UNPLANNED_COST_REASONS = [
  'Estimating Omission', 'Vendor Error', 'Production Error', 'Remake / Replacement', 'Damage',
  'Measurement Error', 'Design / Shop Drawing Error', 'Additional Freight', 'Expedited / Air Freight',
  'Tariff / Customs Difference', 'Installation Error', 'Additional Field Labor', 'Local Purchase',
  'Warranty', 'Internal Company Error', 'Other',
];
const RECOVERABILITY_STATUSES = ['Pending Determination', 'Recoverable from Client', 'Recoverable from Vendor', 'Company Responsibility', 'Business Loss'];
// A vendor estimate/PO in any of these categories was, by definition, part of
// the original approved plan — everything else is unplanned by default (§9).
const PLANNED_VENDOR_CATEGORIES = ['Original Order', 'Change Order', 'Samples'];
function isUnplannedCost(category) {
  return !PLANNED_VENDOR_CATEGORIES.includes(category);
}

// ---------------------------------------------------------------------------
// Projected Profitability (§6-8, §10) — per-scope cost breakdown, rolled up
// to the project. Accounting/Admin only (reuses FINANCIAL_ROLES).
// ---------------------------------------------------------------------------
const PROFIT_COST_FIELDS = [
  { key: 'vendorCost', label: 'Vendor / Material Cost' },
  { key: 'oceanFreight', label: 'Shipping / Ocean Freight' },
  { key: 'domesticFreight', label: 'Domestic Freight' },
  { key: 'tariffs', label: 'Tariffs' },
  { key: 'dutiesCustoms', label: 'Duties / Customs' },
  { key: 'installation', label: 'Installation / Field Cost' },
  { key: 'warehousing', label: 'Warehousing / Storage' },
  { key: 'overhead', label: 'Overhead Allocation' },
  { key: 'other', label: 'Other Anticipated Costs' },
];
const DEFAULT_TARGET_MARGIN_PCT = 30;
function blankCostBreakdown() {
  const c = {};
  PROFIT_COST_FIELDS.forEach(f => { c[f.key] = 0; });
  return c;
}
function makeScopeProfitability() {
  return { salesValue: 0, costs: blankCostBreakdown(), actual: blankCostBreakdown(), targetMarginPct: DEFAULT_TARGET_MARGIN_PCT, baseline: null };
}

// ---------------------------------------------------------------------------
// Team directory (people who can be assigned to team roles / tasks)
// ---------------------------------------------------------------------------
// NO STAFF PASSWORD IS SHIPPED IN THIS FILE, deliberately.
//
// It used to seed every one of the 26 accounts with one shared string. That was
// tolerable while the app only ever ran on one Mac. The moment it was served
// from a public URL it stopped being tolerable: the sign-in screen guards the
// APP, but the FILES are served before it, so anyone who opened /data.jsx read
// the password that unlocked every account — and the whole editable permission
// matrix sat on top of it.
//
// Seeded people now have NO password and cannot sign in until one is set on
// them, which happens in the app (Users, or My Profile) and lives only in this
// browser's storage. There is still no hashing and no server to verify against
// — that is what Supabase Auth replaces at cutover 2, and this file should stop
// carrying credentials well before then.
//
// The demo-scenario logins below are the one exception: they are fictional
// accounts that exist to walk someone through the app, so their password is
// deliberately obvious and is named for what it is.
const DEMO_SCENARIO_PASSWORD = 'Walkthrough2026!';
function usernameFromEmail(email) { return email.split('@')[0]; }
// The real company roster, imported from MS_Users.xlsx. The original demo
// people (Marisol Chen, Rachel Kim, Ivy Marchetti and the rest) were removed
// once the real roster landed — do not reintroduce them, and do not reference
// them by name from seed code: personIdByName() returns null for a name that
// isn't here, so nothing throws, but the reference would silently do nothing.
const TEAM_DIRECTORY = [
  // ---- Imported from MS_Users.xlsx (company roster) ----
  { id: uid('person'), name: 'Ahmet Z. Poyraz', roles: ['Production Manager'], securityRole: 'Production Director', email: 'ahmet@leonintegra.com', active: true },
  { id: uid('person'), name: 'Ana Paula', roles: ['Project Coordinator'], securityRole: 'Project Coordinator', email: 'ana@leonintegra.com', active: true },
  { id: uid('person'), name: 'Aydogan Kilic', roles: [], securityRole: 'Installation Team & Field Foreman', email: 'aydogan@leonintegra.com', active: true },
  { id: uid('person'), name: 'Beatriz Ferreira', roles: [], securityRole: 'Junior Associate', email: 'beatriz@leonintegra.com', active: true },
  { id: uid('person'), name: 'Brittany Shellington', roles: ['Sales Person'], securityRole: 'Associates', email: 'brittany@leonintegra.com', active: true },
  { id: uid('person'), name: 'Burak Eyliksever', roles: [], securityRole: 'Associates', email: 'burak@leonintegra.com', active: true },
  { id: uid('person'), name: 'Cansu Kanatli', roles: [], securityRole: 'Associates', email: 'cansu@leonintegra.com', active: true },
  { id: uid('person'), name: 'Carolline Martire', roles: ['Ownership/Admin'], securityRole: 'Admin', email: 'carolline@leonintegra.com', active: true },
  { id: uid('person'), name: 'Ece Tekin', roles: [], securityRole: 'Senior Associate', email: 'ece@leonintegra.com', active: true },
  { id: uid('person'), name: 'Eray Korpe', roles: [], securityRole: 'Associates', email: 'eray@leonintegra.com', active: true },
  { id: uid('person'), name: 'Francine Negrini Galvao', roles: [], securityRole: 'Junior Associate', email: 'francine@leonintegra.com', active: true },
  { id: uid('person'), name: 'Hilal Sonmez', roles: ['Project Manager'], securityRole: 'General Manager', email: 'hilal@leonintegra.com', active: true },
  { id: uid('person'), name: 'Ibrahim Algur', roles: ['Ownership/Admin'], securityRole: 'Admin', email: 'ibrahim@leonintegra.com', active: true },
  { id: uid('person'), name: 'Jaciel Junior', roles: ['Logistic Manager'], securityRole: 'Logistic Manager', email: 'junior@leonintegra.com', active: true },
  { id: uid('person'), name: 'Luiza Lima', roles: [], securityRole: 'Junior Associate', email: 'luiza@leonintegra.com', active: true },
  { id: uid('person'), name: 'Nisanur Bostanci', roles: ['Export Manager'], securityRole: 'Export Manager', email: 'nisanur@leonintegra.com', active: true },
  { id: uid('person'), name: 'Rafaela Dantas de Meneses Xavier', roles: [], securityRole: 'Junior Associate', email: 'Rafaela@leonintegra.com', active: true },
  { id: uid('person'), name: 'Raiane Caroline', roles: [], securityRole: 'Junior Associate', email: 'Raiane@leonintegra.com', active: true },
  { id: uid('person'), name: 'Sena Erdic', roles: [], securityRole: 'Associates', email: 'sena@leonintegra.com', active: true },
  { id: uid('person'), name: 'Serdal Durgan', roles: [], securityRole: 'Associates', email: 'serdal@leonintegra.com', active: true },
  { id: uid('person'), name: 'Serkan Acartepe', roles: ['Export Manager'], securityRole: 'Export Manager', email: 'serkan@leonintegra.com', active: true },
  { id: uid('person'), name: 'Sinan Demirci', roles: ['Production Manager'], securityRole: 'Production Director', email: 'sinan@leonintegra.com', active: true },
  { id: uid('person'), name: 'Syntique Decothe', roles: [], securityRole: 'Junior Associate', email: 'syntique@leonintegra.com', active: true },
  { id: uid('person'), name: 'Talha Algur', roles: ['Ownership/Admin'], securityRole: 'Admin', email: 'talha@leonintegra.com', active: true },
  { id: uid('person'), name: 'Thiago Soares', roles: ['Accounting Manager'], securityRole: 'Accounting', email: 'thiago@leonintegra.com', active: true },
  { id: uid('person'), name: 'Tom Distefano', roles: ['Sales Person'], securityRole: 'Associates', email: 'tom@leonintegra.com', active: true },
  // Delivery Driver logins — internal team members (no separate company
  // collection, unlike Subcontractor above), gated on securityRole ===
  // 'Delivery Driver'. Also assignable as a delivery's helperId.
].map(p => ({ ...p, username: usernameFromEmail(p.email), password: '' }));

// Backfills username/password onto a team member loaded from localStorage
// from before this login feature existed, so old persisted sessions don't
// get locked out.
// Security roles that were renamed or retired. Persisted state on any machine
// still carries the old string, so it is migrated on read rather than left to
// silently fall out of every permission lookup.
const RENAMED_SECURITY_ROLES = { 'Exporter Manager': 'Export Manager' };
function normalizeTeamMember(p) {
  if (RENAMED_SECURITY_ROLES[p.securityRole]) p.securityRole = RENAMED_SECURITY_ROLES[p.securityRole];
  if (!p.username) p.username = usernameFromEmail(p.email || '');
  // No backfill: a person with no password simply cannot sign in until one is
  // set on them. Filling in a default here is exactly how one shared password
  // ended up on 26 accounts.
  if (p.password === undefined) p.password = '';
  if (p.photoUrl === undefined) p.photoUrl = null;
  if (p.phone === undefined) p.phone = '';
  if (p.mobile === undefined) p.mobile = '';
  // Who this person reports to, for the Chart of Organization — a
  // teamDirectory id, or null for someone at the top of the chart.
  // A tree can only place a person once, so this stays the PRIMARY (solid
  // line) manager and decides where the card sits. Additional managers go in
  // alsoReportsToIds and are shown on the card as dotted-line reporting —
  // real in a lot of companies, and lying about it helps nobody.
  if (p.reportsToId === undefined) p.reportsToId = null;
  // Which office this person works out of. Same vocabulary as an account's
  // location, deliberately — one list of places, so a person in Abu Dhabi and a
  // client in Abu Dhabi are recognisably in the same place. Blank means it has
  // not been set, which is different from being in the default office.
  if (p.officeLocation === undefined) p.officeLocation = '';
  // Which dashboard blocks this person chose. null = never customised, so the
  // role default applies and a better default later still reaches them.
  if (p.dashboardBlocks === undefined) p.dashboardBlocks = null;
  // Personal email sign-off, used verbatim at the bottom of anything this
  // person shares. Blank falls back to name + title + company.
  if (p.emailSignature === undefined) p.emailSignature = '';
  // The real signature is the designed banner people already use in Outlook or
  // Gmail — an image, not typed text. It is uploaded once and reused on every
  // branded email. The text field stays as the PLAIN-TEXT fallback, because a
  // mailto: draft cannot carry an image.
  if (p.emailSignatureImage === undefined) p.emailSignatureImage = null;
  if (p.emailSignatureImageName === undefined) p.emailSignatureImageName = '';
  if (!Array.isArray(p.alsoReportsToIds)) p.alsoReportsToIds = [];
  // Per-user permission overrides (Phase 10) — sparse {moduleKey: 'view' |
  // 'edit' | 'none'}, checked first by canEditModule/canViewModule before
  // falling back to the role default.
  if (!p.permissionOverrides) p.permissionOverrides = {};
  // Which operating department(s) this person works in — the data-scope axis
  // that sits alongside securityRole's capability axis. Everyone who predates
  // the split gets BOTH, so the change is purely additive: nobody loses
  // access on upgrade, and access is narrowed deliberately per person.
  p.departments = normalizeDepartments(p.departments);
  // Cosmetic Title (Phase 10) — shown on profile/org-chart cards, distinct
  // from securityRole which governs actual permissions.
  if (p.title === undefined) p.title = '';
  // Rich profile fields (Phase 10) — editable only by the person themselves
  // or an Admin (enforced in the UI, not here).
  if (!p.pictures) p.pictures = [];
  if (p.bio === undefined) p.bio = '';
  if (p.education === undefined) p.education = '';
  if (p.experience === undefined) p.experience = '';
  if (p.aspirations === undefined) p.aspirations = '';
  // Birthday — stored as a full ISO date (year is whatever they entered, but
  // only month/day are ever read back out, since it recurs every year on
  // everyone's Calendar; see birthdayOccurrencesInRange, lib.jsx).
  if (p.birthday === undefined) p.birthday = null;
  return p;
}
// A browser that already saved its own team directory before a new roster
// member (e.g. a fresh company import) existed would otherwise never see
// them — persisted state always wins over the seed constant. This adds any
// seed member not already present (matched by email) without disturbing
// anyone the user has since edited, deactivated, or added themselves.
// Documents that ship WITH the app as real files under library/, rather than
// being uploaded through the browser. The Document Library normally stores an
// uploaded file as base64 inside localStorage — fine for a checklist, fatal
// for a 188MB portfolio — so these carry a relative fileUrl instead and are
// served like any other static asset.
const DEFAULT_LIBRARY_DOCS = [
  {
    id: 'lib-leon-portfolio-2026',
    name: 'LEON Portfolio 2026',
    category: 'Standards & Specifications',
    file: 'Leon_Portfolio_2026.pdf',
    fileUrl: 'library/Leon_Portfolio_2026.pdf',
    uploadedBy: 'LEON Integra',
    date: '2026-09-04',
    note: 'Full company portfolio — casework, countertops, doors, windows and flooring. The casework door styles, materials, handles, door cores and countertop edge profiles in Supplier Finishes are extracted from this document.',
  },
];
// Forward-merge (mirrors mergeNewTeamMembers below) so a shipped document
// appears for installs that already have their own saved library. Matched by
// id, and the shipped copy wins so a corrected path reaches everyone.
function mergeNewLibraryDocs(persisted) {
  const list = Array.isArray(persisted) ? persisted.slice() : [];
  DEFAULT_LIBRARY_DOCS.forEach(doc => {
    const i = list.findIndex(d => d.id === doc.id);
    if (i >= 0) list[i] = { ...list[i], ...doc };
    else list.push(doc);
  });
  return list;
}

function mergeNewTeamMembers(persistedList) {
  const existingEmails = new Set(persistedList.map(p => (p.email || '').toLowerCase()));
  const missing = TEAM_DIRECTORY.filter(p => !existingEmails.has((p.email || '').toLowerCase()));
  return [...persistedList, ...missing];
}

// Same problem, same fix, for the scope library (e.g. splitting the old
// single 'Flooring' family into three) — a session with persisted state
// would otherwise never see families added to DEFAULT_SCOPE_LIBRARY later.
// Matched by name; an admin's own customizations to existing families are
// left untouched.
// Repairs options seeded with a bare index as their imageUrl (see makeCategory).
// A real image is a data: URI or a path — never a bare number — so anything
// numeric is cleared back to "no image".
function repairOptionImages(lib) {
  (lib || []).forEach(f => (f.categories || []).forEach(c => (c.options || []).forEach(o => {
    if (o.imageUrl !== null && o.imageUrl !== undefined && /^\d+$/.test(String(o.imageUrl))) o.imageUrl = null;
  })));
  return lib;
}

function mergeNewScopeFamilies(persistedLib) {
  const existingNames = new Set(persistedLib.map(f => f.name));
  const missing = DEFAULT_SCOPE_LIBRARY.filter(f => !existingNames.has(f.name));
  // The retired single 'Flooring' family (replaced by the three above) is
  // deactivated rather than removed — existing scopes were already migrated
  // off it (normalizeProject), this just stops it from being offered again
  // for new scopes while leaving it visible in the admin library for history.
  const merged = repairOptionImages(missing.length ? [...persistedLib, ...missing] : persistedLib);
  return merged.map(f => {
    let next = f.name === 'Flooring' ? { ...f, active: false } : f;
    // isWindowSystem didn't exist before the Window Schedule feature — backfill
    // it the same way the seed defaults it (true only for "Window Systems"),
    // without touching a value an admin already set explicitly.
    if (next.isWindowSystem === undefined) next = { ...next, isWindowSystem: next.name === 'Window Systems' };
    // Same backfill for the countertop flag, added with the fabrication-shop
    // template — defaulted the way the seed does, never over an explicit value.
    if (next.isCountertopSystem === undefined) next = { ...next, isCountertopSystem: next.name === 'Countertop' };
    // department didn't exist before the Windows/Interiors split — backfill
    // it from the window flag, the same way the seed defaults it, without
    // overwriting a value an admin already chose explicitly.
    if (!DEPARTMENTS.includes(next.department)) next = { ...next, department: next.isWindowSystem ? 'Windows' : DEFAULT_DEPARTMENT };
    return next;
  });
}

// Backfills vendor/freight-forwarder records saved before the General
// Info/Billing/Contacts extension existed.
function normalizeVendor(v) {
  if (!v.vendorType) v.vendorType = 'Material Supplier';
  if (!v.status) v.status = 'Active';
  if (v.contactTitle === undefined) v.contactTitle = '';
  if (v.mobile === undefined) v.mobile = '';
  if (v.website === undefined) v.website = '';
  if (v.city === undefined) v.city = '';
  if (v.state === undefined) v.state = '';
  if (v.zip === undefined) v.zip = '';
  if (v.country === undefined) v.country = '';
  if (v.createdDate === undefined) v.createdDate = null;
  if (!v.contacts) v.contacts = [];
  v.contacts.forEach(c => {
    if (c.notes === undefined) c.notes = '';
    if (c.preferredContactMethod === undefined) c.preferredContactMethod = '';
    if (c.createdDate === undefined) c.createdDate = null;
  });
  if (!v.billing) v.billing = makeBillingInfo();
  if (!v.attachments) v.attachments = [];
  if (!v.activityLog) v.activityLog = [];
  if (!v.catalog) v.catalog = [];
  if (!v.priceList) v.priceList = [];
  return v;
}
// Catalog: open to everyone to add, but once added it can only be removed by
// an Admin — so `addedBy`/`addedDate` matter (an append-only-by-default
// record) but there's no per-entry edit permission beyond that delete gate.
function makeCatalogEntry(data, addedBy) {
  return {
    id: uid('cat'), name: data.name, date: data.date || todayISO(), revisionNumber: data.revisionNumber || '',
    scopeApplicable: data.scopeApplicable || '', file: data.file || '', fileUrl: data.fileUrl || null,
    addedBy, addedDate: todayISO(),
  };
}
function makePriceListEntry(data, addedBy) {
  return {
    id: uid('price'), name: data.name, date: data.date || todayISO(), revisionNumber: data.revisionNumber || '',
    file: data.file || '', fileUrl: data.fileUrl || null, notes: data.notes || '',
    addedBy, addedDate: todayISO(),
  };
}
function normalizeSubcontractor(s) {
  if (!s.billing) s.billing = makeBillingInfo();
  if (!s.projectIds) s.projectIds = [];
  if (!s.status) s.status = 'Active';
  if (!s.attachments) s.attachments = [];
  if (!s.activityLog) s.activityLog = [];
  // Subcontractors that existed before the registration workflow are
  // grandfathered in as already-approved (they're already active, with real
  // project/invoice history) rather than retroactively treated as
  // unregistered — only brand-new signups start at 'Draft'.
  if (!s.registrationStatus) {
    s.registrationStatus = 'Approved';
    s.w9File = s.w9File ?? null; s.w9FileUrl = s.w9FileUrl ?? null;
    s.coiFile = s.coiFile ?? null; s.coiFileUrl = s.coiFileUrl ?? null;
    s.submittedDate = s.submittedDate ?? null; s.submittedBy = s.submittedBy ?? null;
    s.approvedDate = s.approvedDate ?? s.createdDate ?? null; s.approvedBy = s.approvedBy ?? 'System (migrated)';
    s.editRequestedDate = null; s.editRequestNote = '';
    s.registrationHistory = s.registrationHistory || [];
  }
  return s;
}
// Accounts never had a normalize pass before (no fields have been added
// since — this is the first) — attachments/activityLog for the global
// record-access standard.
function normalizeAccount(a) {
  if (!a.attachments) a.attachments = [];
  if (!a.activityLog) a.activityLog = [];
  // Additional contacts (Contact Log) — the same idea as
  // VendorContactsBlock/vendor.contacts, reused here so one person's
  // contact info can be found under whichever accounts it's relevant to.
  if (!a.contacts) a.contacts = [];
  a.contacts.forEach(c => {
    if (c.notes === undefined) c.notes = '';
    if (c.preferredContactMethod === undefined) c.preferredContactMethod = '';
    if (c.photoUrl === undefined) c.photoUrl = null;
    if (c.createdDate === undefined) c.createdDate = null;
  });
  // What kind of company this Account is (General Contractor, Architecture
  // Firm, etc.) — shown alongside the name wherever accounts are listed.
  if (a.accountType === undefined) a.accountType = 'Other';
  if (a.logoUrl === undefined) a.logoUrl = null;
  if (a.contactMobile === undefined) a.contactMobile = '';
  if (a.website === undefined) a.website = '';
  if (a.notes === undefined) a.notes = '';
  if (a.createdDate === undefined) a.createdDate = null;
  return a;
}
function normalizeApInvoice(inv) {
  if (!inv.payments) inv.payments = [];
  if (!inv.history) inv.history = [];
  if (inv.currency === undefined) inv.currency = 'USD';
  if (inv.dueDate === undefined) inv.dueDate = null;
  if (!inv.lines) inv.lines = [];
  if (inv.invoiceGroupId === undefined) inv.invoiceGroupId = null;
  if (inv.expenseCategory === undefined) inv.expenseCategory = '';
  if (inv.recoverability === undefined) inv.recoverability = null;
  return inv;
}

// ---------------------------------------------------------------------------
// Date helpers
// ---------------------------------------------------------------------------
function toISO(d) { return d.toISOString().slice(0, 10); }
function fromISO(s) { return new Date(s + 'T00:00:00'); }
function addDays(dateStr, days) {
  const d = fromISO(dateStr);
  d.setDate(d.getDate() + days);
  return toISO(d);
}
function addMonths(dateStr, months) {
  const d = fromISO(dateStr);
  const day = d.getDate();
  const target = new Date(d.getFullYear(), d.getMonth() + months, 1);
  // Clamp to the last day of the target month so 31 Jan + 1 month is 28/29 Feb,
  // not 2/3 March — a rent day of the 31st must not drift into the next month.
  const lastDay = new Date(target.getFullYear(), target.getMonth() + 1, 0).getDate();
  target.setDate(Math.min(day, lastDay));
  return toISO(target);
}
// Sunday of the week containing dateStr. Named distinctly from app.jsx's own
// startOfWeek so the two can never shadow each other.
function startOfWeekISO(dateStr) { return addDays(dateStr, -fromISO(dateStr).getDay()); }
function daysBetween(aStr, bStr) {
  const a = fromISO(aStr), b = fromISO(bStr);
  return Math.round((b - a) / 86400000);
}
// "Today" depends on where you are. At 03:52 in Ho Chi Minh City it is already
// the 6th while Boston is still on the 5th, so a shared calendar that computed
// one answer for everyone would put a colleague's work on the wrong day and
// stamp their records with it too.
//
// The zone comes from the signed-in person's office, re-pointed by App() on
// every render — the same module-level registry pattern as rolePermissions and
// complexityLevels, because todayISO is called from hundreds of pure functions
// with no route to React state. Unset falls back to the browser's own zone,
// which is what every existing caller already assumed.
let __activeTimeZone = null;
function setActiveTimeZone(tz) { __activeTimeZone = tz || null; }
function activeTimeZone() { return __activeTimeZone; }
function todayISO() {
  if (!__activeTimeZone) return toISO(new Date());
  try {
    // en-CA formats as YYYY-MM-DD, which is the shape every date in this app
    // is stored in — so there is no re-parsing and no month/day ambiguity.
    return new Intl.DateTimeFormat('en-CA', {
      timeZone: __activeTimeZone, year: 'numeric', month: '2-digit', day: '2-digit',
    }).format(new Date());
  } catch (e) { return toISO(new Date()); }
}
// A stored birthday recurs every year — this returns its date in a given
// year (month/day only, whatever year it was originally entered with is
// otherwise ignored). Feb 29 in a non-leap target year lands on Feb 28.
function birthdayInYear(birthdayISO, year) {
  if (!birthdayISO) return null;
  const d = fromISO(birthdayISO);
  const wasFeb29 = d.getMonth() === 1 && d.getDate() === 29;
  const result = new Date(d);
  result.setFullYear(year);
  if (wasFeb29 && result.getMonth() !== 1) result.setDate(0);
  return toISO(result);
}
function fmtDate(s) {
  if (!s) return '—';
  const d = fromISO(s);
  return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
}
function fmtMoney(n) {
  if (n === null || n === undefined || isNaN(n)) return '—';
  const neg = n < 0;
  const v = Math.abs(n).toLocaleString('en-US', { minimumFractionDigits: 0, maximumFractionDigits: 0 });
  return (neg ? '-$' : '$') + v;
}
function fmtPct(n) {
  if (n === null || n === undefined || isNaN(n)) return '—';
  return n.toFixed(1) + '%';
}
// Matches the client's own AIA "Application for Payment" template's number
// style exactly: no currency symbol, 2 decimals, a bare dash for zero, and
// negatives in parentheses rather than a leading minus sign.
function fmtAia(n) {
  if (n === null || n === undefined || isNaN(n)) return '-';
  const rounded = Math.round(n * 100) / 100;
  if (rounded === 0) return '-';
  const neg = rounded < 0;
  const v = Math.abs(rounded).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
  return neg ? `(${v})` : v;
}
function fmtAiaPct(n) {
  if (n === null || n === undefined || isNaN(n)) return '0%';
  return Math.round(n) + '%';
}

// ---------------------------------------------------------------------------
// Stage instantiation — "New Project Created" automation (§5)
// ---------------------------------------------------------------------------
function instantiateStages(startDateStr, complexityLevel, defs) {
  const mult = complexityMultiplier(complexityLevel);
  let cursor = startDateStr;
  return (defs || STAGE_DEFS).map((def, i) => {
    const duration = Math.round(def.baseDays * mult);
    const plannedStart = cursor;
    const plannedDue = addDays(plannedStart, duration);
    cursor = plannedDue;
    return {
      id: uid('stage'),
      key: def.key,
      name: def.name,
      order: i,
      responsibleRole: def.role,
      assignedUserId: null,
      duration,
      plannedStart,
      plannedDue,
      actualStart: null,
      actualCompletion: null,
      status: i === 0 ? 'In Progress' : 'Not Started',
      delayDays: 0,
      delayReason: null,
      delayNote: null,
    };
  });
}
function instantiateChronology(startDateStr, complexityLevel) {
  return instantiateStages(startDateStr, complexityLevel, PROJECT_CHRONOLOGY_STAGE_DEFS);
}
// Backward scheduling (§ jobsite-date lead time request) — given the date
// material needs to BE at the jobsite (the Delivery to Jobsite stage's
// planned completion), work out what Schedule Start Date would produce that,
// so a scope can be planned from either end. Sums the same
// Math.round(baseDays * mult) durations instantiateStages accumulates
// forward, through and including delivery_jobsite, then subtracts that from
// the target date — exact inverse of the forward cursor, so re-instantiating
// from the returned start date lands delivery_jobsite's plannedDue back on
// jobsiteDateStr.
function startDateForJobsiteDate(jobsiteDateStr, complexityLevel) {
  const mult = complexityMultiplier(complexityLevel);
  const idx = STAGE_DEFS.findIndex(d => d.key === 'delivery_jobsite');
  let totalDays = 0;
  for (let i = 0; i <= idx; i++) totalDays += Math.round(STAGE_DEFS[i].baseDays * mult);
  return addDays(jobsiteDateStr, -totalDays);
}

// ---------------------------------------------------------------------------
// Window & Exterior Door System schedule (§ Window Schedule template) — a
// second, purely additive schedule structure attached to a scope alongside
// its normal `stages` array (which stays exactly as instantiateStages always
// built it — nothing here replaces or shortens it). Where the generic stage
// engine is a strict sequence, this is a small fixed dependency graph: Glass
// Approval runs in parallel with Profile/System Approval (neither depends on
// the other), Fabrication Drawing Approval needs both, and Factory
// Production (both its First-Delivery and Full-Production milestones) can't
// start until all three approvals are in hand — a true MAX-of-dependencies
// join, computed by computeWindowSchedule (lib.jsx), not a simple sum.
// Durations are copied from the matched windowLeadTimeLibrary entry at
// construction time (leadTimesSnapshot) — never a live reference — the same
// convention instantiateStages already uses when it copies STAGE_DEFS.
// baseDays into each stage's own `duration` field.
// ---------------------------------------------------------------------------
function buildWindowSchedule({ startDate, systemId, finish, glass, windowLeadTimeLibrary }) {
  const system = (windowLeadTimeLibrary || []).find(e => e.id === systemId) || null;
  const leadTimesSnapshot = {
    profileApprovalDays: system ? system.profileApprovalDays : 0,
    glassLeadDays: system ? system.glassLeadDays : 0,
    fabDrawingApprovalDays: system ? system.fabDrawingApprovalDays : 0,
    factoryFirstDeliveryDays: system ? system.factoryFirstDeliveryDays : 0,
    factoryFullProductionDays: system ? system.factoryFullProductionDays : 0,
    shippingDays: system ? system.shippingDays : 0,
  };
  function makeNode(key, name, dependsOn, duration) {
    return {
      id: uid('winnode'), key, name, dependsOn, duration,
      baselineStart: null, baselineEnd: null,
      forecastStart: null, forecastEnd: null,
      actualStart: null, actualEnd: null,
      status: 'Not Started',
      delayDays: 0, delayReason: null, delayNote: null,
      linkedRecords: { vendorEstimateIds: [], purchaseOrderIds: [], submittalThreadId: null, productionRecordId: null, exportContainerIds: [], deliveryIds: [] },
    };
  }
  const profileNode = makeNode('profile_approval', 'Profile/System Approval', [], leadTimesSnapshot.profileApprovalDays);
  const glassNode = makeNode('glass_approval', 'Glass Approval', [], leadTimesSnapshot.glassLeadDays);
  const fabNode = makeNode('fab_drawing_approval', 'Fabrication Drawing Approval', [profileNode.id, glassNode.id], leadTimesSnapshot.fabDrawingApprovalDays);
  const factoryDeps = [profileNode.id, glassNode.id, fabNode.id];
  const firstDeliveryNode = makeNode('factory_first_delivery', 'Factory: First Delivery', factoryDeps, leadTimesSnapshot.factoryFirstDeliveryDays);
  const fullProductionNode = makeNode('factory_full_production', 'Factory: Full Production', factoryDeps, leadTimesSnapshot.factoryFullProductionDays);
  const shippingNode = makeNode('shipping', 'Shipping', [fullProductionNode.id], leadTimesSnapshot.shippingDays);
  const nodes = [profileNode, glassNode, fabNode, firstDeliveryNode, fullProductionNode, shippingNode];

  return {
    id: uid('winsched'),
    systemId: system ? system.id : null,
    systemName: system ? system.name : '',
    finish: finish || null,
    glass: glass || null,
    createdDate: todayISO(),
    startDate,
    leadTimesSnapshot,
    nodes,
    approvals: {
      profileSystem: { nodeId: profileNode.id, submittalThreadId: null, requiredByDate: null },
      glass: { nodeId: glassNode.id, submittalThreadId: null, requiredByDate: null },
      fabricationDrawing: { nodeId: fabNode.id, submittalThreadId: null, requiredByDate: null },
    },
    deliveryPhases: [],
    revisions: [],
    baseline: { capturedDate: null, nodeSnapshots: [] },
    history: [],
  };
}

// ---------------------------------------------------------------------------
// Seed builder
// ---------------------------------------------------------------------------
function personByName(name) { return TEAM_DIRECTORY.find(p => p.name === name) || null; }
// Seed and demo code refers to people by name. Anyone can leave the company, so
// a missing name resolves to null (nobody assigned) rather than throwing.
function personIdByName(name) { const p = personByName(name); return p ? p.id : null; }

// A new project starts with NOBODY assigned. This used to pre-fill every role
// from the demo roster; guessing who covers a role on the real roster would be
// worse than leaving it blank, because a wrong name looks like a decision.
// Use Assign Team on the project to staff it, per department.
function defaultTeamAssignment() {
  return {};
}

function makeScope(name, familyName, startDate, complexity, opts) {
  opts = opts || {};
  // Window-classified families get the reduced WINDOW_STAGE_DEFS set;
  // interiors families get their own per-family durations from the Interiors
  // Lead-Time Library when one is supplied. Seed callers pass neither library
  // and fall through to the static defaults, so seeded data is unchanged.
  const scopeType = opts.scopeType || DEFAULT_SCOPE_TYPE;
  const stages = instantiateStages(startDate, complexity, stageDefsForScope(familyName, opts.scopeLibrary, opts.interiorLeadTimeLibrary, scopeType));
  const family = DEFAULT_SCOPE_LIBRARY.find(f => f.name === familyName);
  const selections = {};
  if (family) {
    family.categories.forEach(cat => {
      if (opts.selectAll) {
        const opt = cat.options[Math.floor(Math.random() * cat.options.length)];
        selections[cat.id] = opt ? opt.id : null;
      } else {
        selections[cat.id] = null;
      }
    });
  }
  return {
    id: uid('scope'),
    name,
    familyName,
    // Supply Only or Supply & Install — decides whether this scope has an
    // installation phase at all.
    scopeType,
    // Stamped at creation from the family's classification (Windows /
    // Interiors) so the scope's department stays stable even if the family
    // is later re-classified in the Scope Library.
    department: opts.department || familyDepartment(familyName, opts.scopeLibrary),
    selections,
    materialLinks: {},
    // Optional named sub-areas within this scope (e.g. under a Tile scope:
    // "Primary Bath Shower Surround", "Powder Room Floor"), each carrying
    // its own selections when one set of choices for the whole scope isn't
    // enough. Empty by default — most scopes never need this.
    mainAreaName: '',
    selectionAreas: [],
    supplierFinishes: {},
    // Snapshot of scope.selections + selectionAreas taken on every edit —
    // "every editing will be entered as a new revision set" (§ selections
    // request). The live selections above stay the single source of truth
    // for everything else that reads them; this is an append-only history.
    selectionRevisions: [],
    stages,
    // Snapshots of scope.stages taken before each Start/Complete/Report
    // Delay, so a status change can be undone (§ scopes/schedule request).
    // Scope-wide rather than per-stage because Report Delay cascades planned
    // dates across every downstream stage in one action.
    stageHistory: [],
    documents: [],
    submittals: [],
    clientResponses: [],
    profitability: makeScopeProfitability(),
    quantity: opts.quantity ?? null,
    unit: opts.unit || 'Units',
  };
}

// ---------------------------------------------------------------------------
// Vendor / Subcontractor / Accounts Payable (§ vendor-billing-AP request)
// ---------------------------------------------------------------------------
const VENDOR_TYPES = ['Manufacturer', 'Material Supplier', 'Freight / Logistics', 'Customs Broker', 'Warehouse', 'Local Fabricator', 'Service Provider', 'Other'];
const VENDOR_STATUSES = ['Active', 'Inactive', 'On Hold'];
const VENDOR_CONTACT_ROLES = ['Sales Contact', 'Production Contact', 'Shipping Contact', 'Accounting Contact', 'Management Contact', 'Other'];
const SUBCONTRACTOR_TRADES = ['Cabinet Installer', 'Door Installer', 'Countertop Installer', 'Tile Installer', 'Flooring Installer', 'Carpet Installer', 'Baseboard / Trim Installer', 'Millwork Installer', 'Field Technician', 'Punch List', 'Delivery / Material Handling', 'General Labor', 'Other'];
const PAYMENT_METHODS = ['ACH', 'Wire Transfer', 'Check', 'Credit Card', 'Other'];
const CURRENCIES = ['USD', 'EUR', 'GBP', 'CAD', 'MXN', 'CNY', 'Other'];

function makeBillingInfo() {
  return {
    sameAsCompany: true,
    companyName: '', contact: '', email: '', phone: '',
    address: '', city: '', state: '', zip: '', country: '',
    paymentTerms: '', paymentMethod: PAYMENT_METHODS[0], currency: 'USD',
    taxInfo: '', accountingNotes: '',
  };
}
function makeVendorContact(role) {
  return { id: uid('vc'), role: role || VENDOR_CONTACT_ROLES[0], name: '', title: '', phone: '', mobile: '', email: '', notes: '', preferredContactMethod: '', createdDate: todayISO() };
}
// Account Contact Log — same shape/idea as vendor.contacts, but roles suit
// the client side of the relationship (owner/developer/architect people)
// rather than a vendor's own staff.
// Estimator leads the list for the same reason it leads a project's contacts:
// it is who a bid goes to. Keeping it here as well means the project-level
// Estimator can be filled from the account rather than retyped per job.
const ACCOUNT_CONTACT_ROLES = ['Estimator', 'Owner Representative', 'Property Manager', 'Billing Contact', 'Site Contact', 'Architect / Designer', 'Other'];
const PREFERRED_CONTACT_METHODS = ['Phone', 'Mobile', 'Email', 'Other'];
function makeAccountContact(role) {
  return { id: uid('ac'), role: role || ACCOUNT_CONTACT_ROLES[0], name: '', title: '', phone: '', mobile: '', email: '', notes: '', preferredContactMethod: '', photoUrl: null, createdDate: todayISO() };
}
// Job titles common across the accounts this company deals with (owners,
// GCs, architects/designers, property managers, etc.) — offered as a
// dropdown on every person-contact form (Account/Vendor/Project contacts).
// The current value is always unioned in too (see TitleSelect, components.jsx)
// so older freeform titles already on record are never silently hidden.
const CONTACT_TITLES = [
  'Owner', 'Principal', 'President', 'Vice President',
  'Director of Construction', 'Director of Operations', 'Director of Design',
  'Project Executive', 'Project Manager', 'Project Coordinator', 'Assistant Project Manager',
  'Construction Manager', 'Superintendent', 'Site Supervisor', 'General Contractor',
  'Architect', 'Interior Designer', 'Design Manager', 'Design Director',
  'Estimator', 'Purchasing Manager', 'Procurement Manager', 'Production Manager',
  'Sales Manager', 'Business Development Manager',
  'Accounting Manager', 'Controller', 'AP Manager', 'AR Manager', 'Bookkeeper',
  'Property Manager', 'Facilities Manager', 'Development Manager', 'VP Development', 'VP Construction',
  'Owner Representative', 'Client Representative', 'Executive Assistant', 'Other',
];
// Who LEON is doing business with, on the client side of an Account — used
// on the account itself (categorize the company) and shown alongside its
// name wherever accounts are listed.
// Where the client is, which is a different question from what kind of company
// they are. It is deliberately a TAG on the account rather than a fork of the
// account list: one client can be searched for in one place, and a second
// location does not mean a second directory to keep in step. It is also not a
// department — department means TRADE here (Windows vs Interiors), and a window
// job in Abu Dhabi is both.
// ---------------------------------------------------------------------------
// Unassigned work — using a tool before there is a job to attach it to
// ---------------------------------------------------------------------------
// Every software used to demand a project before it would do anything, which is
// wrong for how the work actually starts: a drawing arrives, someone measures
// it, and only later does it become a job — or never does. So there is a
// SCRATCH project per person: a real project record in every respect, so every
// module keeps working unchanged, but flagged and held in its own collection so
// it can never be counted as a job.
//
// Kept OUT of `projects` entirely rather than filtered out of it. A flag that
// every dashboard, report, pipeline and total has to remember to exclude is a
// flag that will eventually be forgotten in one of them; a separate collection
// cannot be miscounted because it is not there to count.
// Plain names for the arrays a tool may have written into a workspace, so the
// move screen says "3 drawing sets" rather than "drawingSets: 3".
const SCRATCH_LABELS = {
  drawingSets: 'Drawing sets', drawingSheets: 'Sheets', takeoffItems: 'Take-off records',
  takeOffs: 'Take-offs', doors: 'Doors', caseworkRooms: 'Casework rooms',
  caseworkRuns: 'Casework runs', caseworkItems: 'Cabinets', stoneCutList: 'Cut-list pieces',
  studioDocuments: 'Render studio documents', countertopQuotes: 'Countertop quotes',
  boqs: 'Bills of quantities', scopes: 'Scopes', documents: 'Documents',
};

function makeScratchProject(ownerId, ownerName) {
  return {
    id: uid('scratch'), scratch: true, ownerId: ownerId || null,
    projectNumber: '', name: 'Unassigned work' + (ownerName ? ' — ' + ownerName : ''),
    accountId: null, pipelineStatus: 'Lead', companyDepartment: DEPARTMENTS.slice(),
    department: '', complexity: 'Medium', address: '', projectType: 'Residential',
    displayImageUrl: null, notes: '', deleteRequest: null,
    contacts: {}, teams: {}, scopes: [], chronology: [], changeLog: [],
    drawingSets: [], drawingSheets: [], takeoffItems: [], takeOffs: [],
    quoteRevisions: [], documents: [], issues: [], deliveries: [],
    productionRecords: [], apInvoices: [], changeOrders: [],
    createdDate: todayISO(),
  };
}

// ---------------------------------------------------------------------------
// Bill of Quantities — how the Gulf operation quotes
// ---------------------------------------------------------------------------
// A BOQ is not the interiors Quote Analysis with different words. It is a
// different SHAPE: the bill is written per apartment type, and within a type
// per scope, and within a scope per work item. Quantities are entered for ONE
// apartment of that type and then multiplied by how many of them there are —
// which is the whole reason the client's bill is organised this way, because
// a tower is thirty of Type A and twelve of Type B.
//
// So the three levels are: unit type → scope → work item. Nothing here is a
// second copy of a project record: the scopes are the project's own families
// (filtered by the client's region) and the work items are the family's own
// `workItems`.
const BOQ_UNITS = ['nos', 'set', 'm²', 'm', 'lm', 'sqft', 'lin ft', 'lot'];
const BOQ_STATUSES = ['Draft', 'Issued', 'Revised', 'Accepted', 'Lost'];
// The contract currency and the one the client also wants to read. The rate is
// STAMPED on the bill with its date — a live rate would silently change a total
// that has already been sent, which is the one thing a quotation must not do.
// `CURRENCIES` above is the billing list of codes and is left alone. This adds
// the display detail a quotation needs — a symbol and a name — for the codes a
// bill is actually written in, without a second vocabulary of codes.
const CURRENCY_DETAIL = {
  AED: { symbol: 'AED', name: 'UAE Dirham' },
  USD: { symbol: '$', name: 'US Dollar' },
  EUR: { symbol: '€', name: 'Euro' },
  TRY: { symbol: '₺', name: 'Turkish Lira' },
  GBP: { symbol: '£', name: 'Pound Sterling' },
  CAD: { symbol: 'CA$', name: 'Canadian Dollar' },
  MXN: { symbol: 'MX$', name: 'Mexican Peso' },
  CNY: { symbol: '¥', name: 'Chinese Yuan' },
};
const QUOTE_CURRENCIES = ['AED', 'USD', 'EUR', 'GBP', 'TRY'];
function currencyLabel(code) {
  const c = CURRENCY_DETAIL[code];
  return c ? `${code} — ${c.name}` : code;
}
function fmtCurrency(amount, code) {
  const c = CURRENCY_DETAIL[code];
  const n = (Number(amount) || 0).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 });
  if (!c) return `${n} ${code || ''}`.trim();
  return c.symbol === code ? `${code} ${n}` : `${c.symbol}${n}`;
}

// One apartment type on the bill. `count` is how many of them the job has, and
// it is what turns a per-unit rate into a contract value.
function makeBoqUnitType(data) {
  return { id: uid('but'), code: '', name: '', count: 1, areaSqm: null, notes: '', ...data };
}
// One line of the bill. `qty` is PER APARTMENT of its unit type — never the
// extended figure, which is derived. Storing the extended number would go wrong
// the moment the unit count changes.
function makeBoqLine(data) {
  return {
    id: uid('boql'), unitTypeId: null, familyName: '', workItem: '',
    description: '', spec: '', qty: 0, unit: 'nos', rate: null, notes: '',
    ...data,
  };
}
function makeBoq(data, by) {
  return {
    id: uid('boq'), name: 'Bill of Quantities', status: 'Draft', revision: 0,
    projectId: null, accountId: null,
    // Contract currency first; the second is shown alongside as a courtesy.
    currency: 'AED', secondaryCurrency: 'USD',
    fxRate: null,      // secondary per 1 of currency, e.g. 0.2723 USD per AED
    fxDate: null,      // stamped when the rate was entered, and shown with it
    unitTypes: [], lines: [],
    notes: '', terms: '',
    createdBy: by || '', createdDate: todayISO(),
    modifiedBy: by || '', modifiedDate: todayISO(),
    revisions: [], activity: [],
    ...data,
  };
}

// ---------------------------------------------------------------------------
// Dashboard blocks — what each person chooses to see when they sign in
// ---------------------------------------------------------------------------
// Declared as data so the picker, the defaults and the permission check all read
// one list. `gate` names the ctx capability that must be true for a block to be
// OFFERED at all — an estimator is never shown a cash block to un-tick, and a
// saved preference for a block they can no longer see is ignored on render
// rather than leaking it. `roles` narrows the DEFAULT set only; anyone allowed
// to see a block may still add it.
const DASHBOARD_BLOCKS = [
  { key: 'myWork', label: 'My work', icon: '✅', span: 1,
    blurb: 'Everything assigned to you that is open, soonest first.' },
  { key: 'dueAlerts', label: 'Due & overdue', icon: '⚠️', span: 1,
    blurb: 'What is late or lands this week, computed fresh every time.' },
  { key: 'projects', label: 'Projects', icon: '🏗️', span: 2,
    blurb: 'The job list with its status filter — the classic dashboard.' },
  { key: 'pipeline', label: 'Sales pipeline', icon: '💼', span: 1, gate: 'canSeeFin',
    blurb: 'Leads, quotations out, active jobs and the win rate.' },
  { key: 'quoteChase', label: 'Quotes to chase', icon: '📨', span: 1, gate: 'canSeeFin',
    blurb: 'Quotations due a follow-up this week.' },
  { key: 'cash', label: 'Cash this month', icon: '💰', span: 1, gate: 'canSeeAccountingHub',
    blurb: 'Money in and out against their forecast dates.' },
  { key: 'jobProgress', label: 'Job progress', icon: '📈', span: 1, gate: 'canSeeAccountingHub',
    blurb: 'Physical progress against the money on the same scope.' },
  { key: 'deliveries', label: 'Deliveries ahead', icon: '🚚', span: 1, gate: 'canSeeLogistics',
    blurb: 'What is booked to arrive, and what has slipped.' },
  { key: 'issues', label: 'Open issues', icon: '🚩', span: 1,
    blurb: 'Site issues still waiting on somebody.' },
  { key: 'inbox', label: 'Recent notifications', icon: '🔔', span: 1,
    blurb: 'What has just happened on your jobs.' },
];
// The default layout per security role — a starting point, not a restriction.
// Anything not listed falls back to DASHBOARD_DEFAULT.
const DASHBOARD_DEFAULT = ['myWork', 'dueAlerts', 'projects'];
const DASHBOARD_ROLE_DEFAULTS = {
  'Admin': ['myWork', 'dueAlerts', 'pipeline', 'cash', 'projects'],
  'Accounting': ['cash', 'jobProgress', 'dueAlerts', 'myWork'],
  'General Manager': ['pipeline', 'jobProgress', 'dueAlerts', 'projects'],
  'Sales Person': ['pipeline', 'quoteChase', 'myWork', 'projects'],
  'Account Executive': ['pipeline', 'quoteChase', 'myWork', 'projects'],
  'Project Coordinator': ['myWork', 'dueAlerts', 'issues', 'projects'],
  'Production Director': ['myWork', 'dueAlerts', 'jobProgress', 'projects'],
  'Logistic Manager': ['deliveries', 'myWork', 'dueAlerts', 'projects'],
  'Export Manager': ['deliveries', 'myWork', 'dueAlerts', 'projects'],
};
function dashboardBlocksFor(role) {
  return DASHBOARD_ROLE_DEFAULTS[role] || DASHBOARD_DEFAULT;
}
// A block is only offered if its gate is satisfied. No gate means everyone.
function dashboardBlockAllowed(block, ctx) {
  if (!block.gate) return true;
  return !!ctx[block.gate];
}

// ---------------------------------------------------------------------------
// LEON's own offices, and what time it is in them
// ---------------------------------------------------------------------------
// Deliberately SEPARATE from ACCOUNT_REGIONS. Where a client is and where one
// of our offices is are two different questions: we have people in São Paulo
// and Ho Chi Minh City with no client base there, and clients in places we have
// no office. Conflating them would force one list to answer both badly.
//
// The IANA zone is what matters — not a fixed offset, which is wrong twice a
// year everywhere that keeps daylight saving. Boston does, Istanbul and São
// Paulo no longer do, and the Gulf and Vietnam never have; the browser's own
// zone database knows all of that, so nothing here has to.
const LEON_OFFICES = [
  { key: 'Boston',    city: 'Boston',            country: 'USA',      flag: '🇺🇸', tz: 'America/New_York' },
  { key: 'SaoPaulo',  city: 'São Paulo',         country: 'Brazil',   flag: '🇧🇷', tz: 'America/Sao_Paulo' },
  { key: 'Istanbul',  city: 'Istanbul',          country: 'Türkiye',  flag: '🇹🇷', tz: 'Europe/Istanbul' },
  { key: 'AbuDhabi',  city: 'Abu Dhabi',         country: 'UAE',      flag: '🇦🇪', tz: 'Asia/Dubai' },
  { key: 'HCMC',      city: 'Ho Chi Minh City',  country: 'Vietnam',  flag: '🇻🇳', tz: 'Asia/Ho_Chi_Minh' },
];
// Offices were first recorded using the CLIENT region list, before there were
// offices in Brazil and Vietnam. Read those old values through here rather than
// migrating, so nobody's record has to be rewritten.
const LEGACY_OFFICE_KEYS = { 'USA': 'Boston', 'Abu Dhabi': 'AbuDhabi', 'Türkiye': 'Istanbul' };
function officeByKey(key) {
  if (!key) return null;
  const k = LEGACY_OFFICE_KEYS[key] || key;
  return LEON_OFFICES.find(o => o.key === k) || null;
}
function officeLabel(key) {
  const o = officeByKey(key);
  return o ? `${o.flag} ${o.city}` : (key || '');
}
function officeTimeZone(key) {
  const o = officeByKey(key);
  return o ? o.tz : null;
}
// The time in a zone right now, as a person reads it. Intl does the whole job —
// there is no offset arithmetic here to get wrong.
function timeInZone(tz, date) {
  try {
    return new Intl.DateTimeFormat(undefined, {
      timeZone: tz, hour: '2-digit', minute: '2-digit', hour12: false,
    }).format(date || new Date());
  } catch (e) { return '—'; }
}
// Whether that zone is on a different calendar DAY from the viewer, which is
// the thing that actually catches people out when they schedule a call.
function dayOffsetInZone(tz, date) {
  try {
    const d = date || new Date();
    const there = new Intl.DateTimeFormat('en-CA', { timeZone: tz, year: 'numeric', month: '2-digit', day: '2-digit' }).format(d);
    const here = new Intl.DateTimeFormat('en-CA', { year: 'numeric', month: '2-digit', day: '2-digit' }).format(d);
    if (there === here) return 0;
    return there > here ? 1 : -1;
  } catch (e) { return 0; }
}

const ACCOUNT_REGIONS = ['USA', 'Abu Dhabi', 'Türkiye'];
// A flag reads faster than a word in a list of forty accounts. It is a label
// only — `account.region` still stores the plain name, so filtering, grouping
// and any future per-location settings never depend on an emoji.
const ACCOUNT_REGION_FLAGS = { 'USA': '🇺🇸', 'Abu Dhabi': '🇦🇪', 'Türkiye': '🇹🇷' };
function accountRegion(account) { return (account && account.region) || ACCOUNT_REGIONS[0]; }
function accountRegionLabel(region) {
  const r = region || ACCOUNT_REGIONS[0];
  const flag = ACCOUNT_REGION_FLAGS[r];
  return flag ? flag + ' ' + r : r;
}
const ACCOUNT_TYPES = [
  'General Contractor', 'Owner / Developer', 'Real Estate Investment Firm', 'Property Management',
  'Architecture Firm', 'Interior Design Firm', 'Construction Consultant', 'Hospitality Group',
  'Residential Client', 'Commercial Client', 'Other',
];
function makeVendor(name, contactPerson, phone, email, address, defaultPaymentTerms, notes) {
  return {
    id: uid('vendor'),
    name, vendorType: 'Material Supplier', status: 'Active',
    contactPerson, contactTitle: '', phone, mobile: '', email, website: '',
    address, city: '', state: '', zip: '', country: '',
    defaultPaymentTerms: (defaultPaymentTerms || []).map(t => ({ id: uid('vpt'), ...t })),
    notes: notes || '',
    contacts: [],
    billing: makeBillingInfo(),
    createdDate: todayISO(),
  };
}
// registrationStatus drives the subcontractor self-service registration
// workflow (§ subcontractor registration request): Draft (editable) →
// Submitted (locked, pending Accounting review) → Approved (locked) →
// Edit Requested (locked, pending Accounting release back to Draft).
const SUBCONTRACTOR_REGISTRATION_STATUSES = ['Draft', 'Submitted', 'Approved', 'Edit Requested'];
function makeSubcontractor(data, createdBy) {
  return {
    // The office they work out of — see normalizeTeamMember for why this shares
    // the account location vocabulary.
    officeLocation: '',
    id: uid('subc'),
    companyName: data.companyName || '', contactName: data.contactName || '',
    trade: data.trade || SUBCONTRACTOR_TRADES[0],
    phone: data.phone || '', mobile: data.mobile || '', email: data.email || '',
    address: data.address || '', city: data.city || '', state: data.state || '', zip: data.zip || '',
    status: 'Active',
    emergencyContact: data.emergencyContact || '', emergencyPhone: data.emergencyPhone || '',
    notes: data.notes || '',
    billing: makeBillingInfo(),
    projectIds: [],
    username: data.username || '', password: data.password || '',
    createdDate: todayISO(), createdBy,
    registrationStatus: 'Draft',
    w9File: null, w9FileUrl: null, coiFile: null, coiFileUrl: null,
    submittedDate: null, submittedBy: null,
    approvedDate: null, approvedBy: null,
    editRequestedDate: null, editRequestNote: '',
    registrationHistory: [],
  };
}

// ---------------------------------------------------------------------------
// Estimate -> Purchase Order -> Proforma Invoice (§ procurement chain
// request). Estimates and POs are the existing vendorEstimates/freightEstimates
// and purchaseOrders/freightPOs arrays, extended with status/currency/
// revision-history fields below (see normalizeProject). The PI stage is new
// and shared across both party types — one array, distinguished by
// partyType, the same idiom AP invoices already use.
// ---------------------------------------------------------------------------
const ESTIMATE_STATUSES = ['Draft', 'Received', 'Under Review', 'Approved', 'Rejected', 'Superseded', 'Converted to PO'];
const PO_STATUSES = ['Draft', 'Pending Approval', 'Approved', 'Sent to Vendor', 'Vendor Accepted', 'Revised', 'Cancelled', 'Converted to PI'];
const PI_STATUSES = ['Received', 'Under Review', 'Approved', 'Revision Requested', 'Approved for Payment', 'Partially Paid', 'Paid', 'Cancelled', 'Superseded'];
// PI amount more than this fraction over its PO's amount requires Admin/
// General Manager review before it can be approved for payment.
const PI_VARIANCE_REVIEW_THRESHOLD = 0.05;

// Shared revision-entry shape for Estimate/PO/PI revision history. Carries
// both the new named fields (revisionNumber/revisedAmount/notes) and the
// older field names (revision/amount/note) the existing vendor/freight
// estimate revision UI already reads, so nothing upstream needs rewriting.
function makeRevisionEntry(data, revisedBy) {
  return {
    id: uid('rev'),
    revision: data.revisionNumber, revisionNumber: data.revisionNumber,
    amount: data.revisedAmount, revisedAmount: data.revisedAmount, previousAmount: data.previousAmount,
    date: data.date || todayISO(), revisedBy, reasonForRevision: data.reasonForRevision || '',
    notes: data.notes || '', note: data.notes || '',
    file: data.file || null, fileUrl: data.fileUrl || null,
  };
}
// One line of a PI's material list (§ PI material list request) — links to
// a Material Library entry when the material is already cataloged, or
// stays free-text (materialId null) when it isn't. This is the record an
// Export Container's own material lines point back to (never duplicated,
// only referenced) and, once a container is confirmed received at the
// warehouse, the record a real quantity Allocation is created from.
// Single assignee (not multi-attendee like Meetings) with date/time/duration
// and repeatable photos — modeled on the Delivery Driver Hub work, not
// Meetings, because it needs to surface on the shared Calendar with a real
// assignee, which Meetings never do.
function makeJobsiteVisit(data, createdBy) {
  return {
    id: uid('visit'), date: data.date || todayISO(), time: data.time || '', durationMinutes: data.durationMinutes || null,
    assigneeId: data.assigneeId || null, purpose: data.purpose || '', notes: data.notes || '',
    pictures: data.pictures || [], createdBy, createdDate: todayISO(),
    // Set only on a visit auto-created as someone's follow-up — points back
    // at the visit that scheduled it.
    followUpFromVisitId: data.followUpFromVisitId || null,
  };
}
function makePiMaterialLine(data) {
  return {
    id: uid('piml'), materialId: data.materialId || null, description: data.description || '',
    // itemNo is the line number on the source document; itemCode is the
    // manufacturer's identifier. Both are captured from an imported sheet so
    // a line can be traced back to the vendor's own paperwork.
    itemNo: data.itemNo || '', itemName: data.itemName || '',
    itemCode: data.itemCode || '', dimensions: data.dimensions || '',
    width: data.width || '', height: data.height || '', depth: data.depth || '',
    thickness: data.thickness || '',
    quantity: Number(data.quantity) || 0, unit: data.unit || 'Units', unitCost: Number(data.unitCost) || 0,
    notes: data.notes || '',
  };
}
function makeProformaInvoice(data, createdBy) {
  return {
    id: uid('pi'), piNumber: data.piNumber, partyType: data.partyType,
    poId: data.poId || null, estimateId: data.estimateId || null,
    vendorId: data.vendorId || null, vendorName: data.vendorName || '', projectId: data.projectId, scopeId: data.scopeId || null,
    piDate: data.piDate || todayISO(), currency: data.currency || 'USD', amount: Number(data.amount) || 0,
    paymentTerms: data.paymentTerms || '', depositRequirement: data.depositRequirement || '', balanceRequirement: data.balanceRequirement || '',
    freight: Number(data.freight) || 0, taxes: Number(data.taxes) || 0, duties: Number(data.duties) || 0, otherCharges: Number(data.otherCharges) || 0,
    file: data.file || null, fileUrl: data.fileUrl || null, notes: data.notes || '',
    materialLines: data.materialLines || [],
    status: 'Received', revisions: [], createdDate: todayISO(), createdBy, apInvoiceId: null,
  };
}

// Unified Accounts Payable invoice — covers Vendor, Freight, and Subcontractor
// invoices in one structure so there is one connected payable workflow.
const AP_PARTY_TYPES = ['Vendor', 'Freight', 'Subcontractor', 'Miscellaneous'];
// Charge types offered on a Freight invoice's line items (§ freight invoice
// request) — a curated, freight-relevant subset of PROFIT_COST_FIELDS so
// each line's dollars land on the right project/scope's actual cost bucket
// (adjustScopeActualCost, app.jsx) instead of one lump freight number.
const FREIGHT_CHARGE_TYPES = PROFIT_COST_FIELDS.filter(f => ['oceanFreight', 'domesticFreight', 'tariffs', 'dutiesCustoms', 'warehousing', 'other'].includes(f.key));
// What a Miscellaneous invoice (§ misc invoice request) is typically FOR —
// job-related expenses that never touch the procurement pipeline (no vendor
// estimate/PO/PI), distinct from UNPLANNED_COST_REASONS above (which is
// about WHY a cost happened, not what it was).
const MISC_EXPENSE_CATEGORIES = [
  'Permits & Fees', 'Equipment Rental', 'Jobsite Cleaning / Dumpster', 'Storage Rental',
  'Travel & Lodging', 'Professional Services (Legal / Consulting)', 'Bank / Wire Fees',
  'Insurance', 'Repairs & Maintenance', 'Miscellaneous Supplies', 'Other',
];
// A SUBCONTRACTOR invoice is for work on a specific job, so the person who sold
// and owns that job checks it before Accounting ever sees it — they are the only
// one who knows whether the work was actually performed as contracted.
// 'Pending Sales Approval' is where a sub invoice lands on submission.
const AP_APPROVAL_STATUSES = ['Draft', 'Submitted', 'Pending Sales Approval', 'Pending Approval', 'Revision Requested', 'Approved', 'Rejected', 'Sent to Accounting'];
const AP_PAYMENT_STATUSES = ['Unpaid', 'Partially Paid', 'Paid', 'On Hold', 'Disputed', 'Cancelled'];

function makeApHistoryEntry(user, action) {
  const now = new Date();
  return { id: uid('aph'), date: toISO(now), time: now.toTimeString().slice(0, 5), user, action };
}
// A card statement line entered in error is VOIDED, not erased — the record and
// its change-log line stay, so the correction still explains itself. Everything
// that counts money reads through liveApInvoices() and skips a voided row.
function apInvoiceLive(inv) { return !!inv && !inv.voided; }
function liveApInvoices(project) { return ((project && project.apInvoices) || []).filter(apInvoiceLive); }

function makeApInvoice(data, submittedBy) {
  return {
    id: uid('apinv'),
    partyType: data.partyType, vendorId: data.vendorId || null, vendorName: data.vendorName || '',
    projectId: data.projectId || null, scopeId: data.scopeId || null,
    invoiceNumber: data.invoiceNumber || '', invoiceDate: data.invoiceDate, dueDate: data.dueDate || null,
    poReference: data.poReference || '',
    amount: Number(data.amount) || 0, currency: data.currency || 'USD',
    file: data.file || null, fileUrl: data.fileUrl || null,
    description: data.description || '', servicePeriod: data.servicePeriod || '',
    // A Subcontractor invoice routes to the project's Sales Person first — they
    // own the job and are the only one who can confirm the work was performed
    // as contracted. Accounting never sees it until Sales has approved.
    approvalStatus: data.approvalStatus || (data.partyType === 'Subcontractor' ? 'Pending Sales Approval' : 'Submitted'),
    // Who owes the sales review, and the outcome once they've given it.
    salesApproverId: data.salesApproverId || null,
    salesApprovedBy: null, salesApprovedDate: null, salesApprovalNote: '',
    paymentStatus: 'Unpaid',
    payments: [],
    submittedBy, submissionDate: todayISO(),
    approvedBy: null, approvalDate: null, rejectionReason: null,
    notes: data.notes || '',
    // Per-scope line items (§ subcontractor multi-project invoice request) —
    // { scopeId, description, amount }. Optional: a plain single-project
    // Vendor/Freight invoice just leaves this empty and uses the top-level
    // amount/description as always. invoiceGroupId links the sibling
    // per-project invoice records a multi-project submission was split into
    // (AP invoices live inside each project's own apInvoices array, so one
    // invoice touching several projects becomes one record per project,
    // sharing this id — see splitInvoiceAcrossProjects, app.jsx).
    lines: data.lines || [], invoiceGroupId: data.invoiceGroupId || null,
    // Miscellaneous-invoice-only (§ misc invoice request) — what kind of
    // job-related expense this was, and whether it's recoverable, matching
    // the same vocabulary a Vendor Estimate's unplanned-cost fields already
    // use (RECOVERABILITY_STATUSES) so unplannedCostItems (lib.jsx) can
    // treat both sources uniformly.
    expenseCategory: data.expenseCategory || '', recoverability: data.recoverability || null,
    history: [makeApHistoryEntry(submittedBy, `Invoice ${data.invoiceNumber || ''} submitted (${data.partyType}${data.vendorName ? ` — ${data.vendorName}` : ''}, ${fmtMoney(Number(data.amount) || 0)}).`)],
  };
}
function makeApPayment(data, enteredBy) {
  return { id: uid('appay'), date: data.date, amount: Number(data.amount) || 0, method: data.method, reference: data.reference || '', enteredBy, notes: data.notes || '' };
}
// Financial Hub Issues (Phase 7) — a payment problem Accounting raises
// against the project itself, distinct from a single AP invoice's dispute.
// holdScope optionally cascades a warning to Delivery/Installation Hubs
// until the issue is resolved.
const FINANCIAL_ISSUE_HOLD_SCOPES = ['None', 'Deliveries', 'Installations', 'Both'];
function makeFinancialIssue(data, createdBy) {
  return {
    id: uid('finissue'), description: data.description || '', planOfAction: data.planOfAction || '',
    assigneeIds: data.assigneeIds || [], holdScope: data.holdScope && data.holdScope !== 'None' ? data.holdScope : null,
    status: 'Open', createdBy: createdBy || null, createdDate: todayISO(), resolvedDate: null,
  };
}

// Backfills every field added after a project may have already been created —
// applied both to freshly-seeded projects and to projects loaded from
// localStorage, so state persisted before a field existed never crashes.
// PRIVATE JOBS. A project can be restricted to a named list of people —
// `isPrivate` plus `visibleToUserIds`. Two decisions worth keeping:
//  * The Admin security role always retains access, and the screen says so.
//    Without it, a job whose only named person leaves the company becomes
//    unreachable by everyone, with no route back except editing the browser's
//    storage. "Private from everyone except an administrator" is a smaller
//    promise than "private", but it is one the app can actually keep.
//  * Whoever turns privacy on is added to the list automatically, so nobody
//    locks themselves out of their own job in one click.
// projectVisibleTo() is the ONLY place this is decided; every list, report,
// dashboard and search reads a project set that has already been filtered.
// Logins that are NOT colleagues: they reach the app through their own portal
// and have no place in an internal people picker or share list.
// The assistants a brief can be handed to. Adding one is an entry here.
//
// Be precise about what a page can and cannot do:
//  * The CLIPBOARD copy is the only step guaranteed to work, so it always runs.
//  * A desktop app can only be reached through a URL scheme it registered on
//    this machine. An unregistered scheme does nothing at all — the browser
//    ignores it silently — so the web hand-off follows either way, and on macOS
//    the desktop app usually claims its own domain and takes that instead.
//  * A URL cannot carry a document. The brief describes the request and names
//    the file; the person attaches it on the other side. Nothing here uploads.
const AI_PROVIDERS = [
  {
    key: 'claude', name: 'Claude', vendor: 'Anthropic',
    scheme: 'claude://new?q=', schemeLimit: 1500,
    web: 'https://claude.ai/new?q=', webLimit: 6000,
    note: 'Opens a new chat with the brief already written in.',
  },
  {
    key: 'chatgpt', name: 'ChatGPT', vendor: 'OpenAI',
    // The macOS app registers chatgpt:// but does not document a prompt
    // parameter, so the scheme is tried bare and the brief travels by the web
    // URL and the clipboard. Claiming otherwise would be a guess.
    scheme: 'chatgpt://', schemeLimit: 0,
    web: 'https://chatgpt.com/?q=', webLimit: 4000,
    note: 'Opens a new chat with the brief already written in.',
  },
];
function aiProviderByKey(key) {
  return AI_PROVIDERS.find(p => p.key === key) || AI_PROVIDERS[0];
}
// Which assistant this person picked last, so the choice is offered pre-selected
// rather than asked cold every time. Per-browser convenience only — wrapped
// because a private window or blocked site data makes storage throw.
const AI_PROVIDER_PREF_KEY = 'leon-ai-provider';
function rememberedAiProvider() {
  try { return localStorage.getItem(AI_PROVIDER_PREF_KEY) || null; } catch (e) { return null; }
}
function rememberAiProvider(key) {
  try { localStorage.setItem(AI_PROVIDER_PREF_KEY, key); } catch (e) {}
}
// ── USA sales tax, by state ──────────────────────────────────────
// Imported from USA_Sales_Tax_Library_Avg_Combined.xlsx and audited: 50 states
// plus DC, the eight no-local-option states match their statutory rates exactly,
// and the four no-sales-tax states are zero. The numbers are sound.
//
// What is NOT sound is treating them as an answer, and the UI must never let
// them read as one:
//  * `rate` is a STATE AVERAGE of state + local. A real job is taxed by its own
//    jurisdiction — Chicago is 10.25% against Illinois's 8.98% average — so
//    wherever there is local option this is a STARTING POINT to be confirmed,
//    never the rate to quote. `exact: true` marks the handful of states with no
//    local option, where the average genuinely IS the rate.
//  * In many states a contractor improving real property is the CONSUMER of the
//    materials: tax is paid at purchase and no sales tax is charged to the
//    client on the contract at all. Applying a combined rate to a whole contract
//    value is then wrong in the other direction. The Hub cannot decide that —
//    it turns on the state, the contract and the work — so it offers the rate
//    and says plainly that whether the contract is taxable is a separate
//    question for the person quoting.
//  * Rates change. US_SALES_TAX_AS_OF dates the snapshot, so an old figure is
//    visibly old rather than quietly wrong.
const US_SALES_TAX_AS_OF = '2026-09-05';
const US_SALES_TAX_RATES = [
  { code: 'AL', name: 'Alabama', rate: 0.0946 },
  { code: 'AK', name: 'Alaska', rate: 0.0182, note: 'No STATE sales tax. This is a local average — many Alaskan boroughs charge nothing at all.' },
  { code: 'AZ', name: 'Arizona', rate: 0.0854 },
  { code: 'AR', name: 'Arkansas', rate: 0.0948 },
  { code: 'CA', name: 'California', rate: 0.0903 },
  { code: 'CO', name: 'Colorado', rate: 0.0789 },
  { code: 'CT', name: 'Connecticut', rate: 0.0635, exact: true },
  { code: 'DE', name: 'Delaware', rate: 0.0, noSalesTax: true },
  { code: 'FL', name: 'Florida', rate: 0.0698 },
  { code: 'GA', name: 'Georgia', rate: 0.0756 },
  { code: 'HI', name: 'Hawaii', rate: 0.045 },
  { code: 'ID', name: 'Idaho', rate: 0.0603 },
  { code: 'IL', name: 'Illinois', rate: 0.0898 },
  { code: 'IN', name: 'Indiana', rate: 0.07, exact: true },
  { code: 'IA', name: 'Iowa', rate: 0.0694 },
  { code: 'KS', name: 'Kansas', rate: 0.0871 },
  { code: 'KY', name: 'Kentucky', rate: 0.06, exact: true },
  { code: 'LA', name: 'Louisiana', rate: 0.1013 },
  { code: 'ME', name: 'Maine', rate: 0.055, exact: true },
  { code: 'MD', name: 'Maryland', rate: 0.06, exact: true },
  { code: 'MA', name: 'Massachusetts', rate: 0.0625, exact: true },
  { code: 'MI', name: 'Michigan', rate: 0.06, exact: true },
  { code: 'MN', name: 'Minnesota', rate: 0.0814 },
  { code: 'MS', name: 'Mississippi', rate: 0.0706 },
  { code: 'MO', name: 'Missouri', rate: 0.0844 },
  { code: 'MT', name: 'Montana', rate: 0.0, noSalesTax: true },
  { code: 'NE', name: 'Nebraska', rate: 0.0698 },
  { code: 'NV', name: 'Nevada', rate: 0.0824 },
  { code: 'NH', name: 'New Hampshire', rate: 0.0, noSalesTax: true },
  { code: 'NJ', name: 'New Jersey', rate: 0.066 },
  { code: 'NM', name: 'New Mexico', rate: 0.0768 },
  { code: 'NY', name: 'New York', rate: 0.0854 },
  { code: 'NC', name: 'North Carolina', rate: 0.071 },
  { code: 'ND', name: 'North Dakota', rate: 0.0709 },
  { code: 'OH', name: 'Ohio', rate: 0.0729 },
  { code: 'OK', name: 'Oklahoma', rate: 0.0906 },
  { code: 'OR', name: 'Oregon', rate: 0.0, noSalesTax: true },
  { code: 'PA', name: 'Pennsylvania', rate: 0.0634 },
  { code: 'RI', name: 'Rhode Island', rate: 0.07, exact: true },
  { code: 'SC', name: 'South Carolina', rate: 0.0749 },
  { code: 'SD', name: 'South Dakota', rate: 0.0611 },
  { code: 'TN', name: 'Tennessee', rate: 0.0961 },
  { code: 'TX', name: 'Texas', rate: 0.082 },
  { code: 'UT', name: 'Utah', rate: 0.0742 },
  { code: 'VT', name: 'Vermont', rate: 0.0643 },
  { code: 'VA', name: 'Virginia', rate: 0.0577 },
  { code: 'WA', name: 'Washington', rate: 0.0957 },
  { code: 'WV', name: 'West Virginia', rate: 0.066 },
  { code: 'WI', name: 'Wisconsin', rate: 0.0572 },
  { code: 'WY', name: 'Wyoming', rate: 0.0539 },
  { code: 'DC', name: 'Washington, D.C.', rate: 0.06, exact: true, verify: true, note: 'District rate was scheduled to rise — confirm before quoting.' },
];
const US_STATE_BY_CODE = US_SALES_TAX_RATES.reduce((m, s) => { m[s.code] = s; return m; }, {});

// Reading the state out of a free-text address. Deliberately conservative: it
// looks for a two-letter code in the last comma-separated part (the "MA 02116"
// tail of a normal US address), then for a full state name anywhere. Anything
// it is not sure about returns null, because a WRONG state silently applies a
// wrong tax rate to a real quote — no suggestion is much better than a
// confident bad one.
function stateFromAddress(address) {
  const raw = String(address || '').trim();
  if (!raw) return null;
  const parts = raw.split(',').map(p => p.trim()).filter(Boolean);
  // "..., MA 02116" or "..., MA"
  for (let i = parts.length - 1; i >= 0 && i >= parts.length - 2; i--) {
    const m = parts[i].match(/\b([A-Z]{2})\b(?:\s+\d{5}(?:-\d{4})?)?\s*$/);
    if (m && US_STATE_BY_CODE[m[1]]) return US_STATE_BY_CODE[m[1]];
  }
  // A spelled-out state name, longest first so "Washington, D.C." beats
  // "Washington" and "West Virginia" beats "Virginia".
  const lower = raw.toLowerCase();
  const byName = US_SALES_TAX_RATES.slice().sort((a, b) => b.name.length - a.name.length);
  for (const s of byName) {
    if (lower.indexOf(s.name.toLowerCase()) >= 0) return s;
  }
  return null;
}
// New states reach an existing browser without touching a rate someone has
// already corrected — the same forward-merge as mergeNewTeamMembers.
function mergeNewSalesTaxRates(persisted) {
  const have = {};
  (persisted || []).forEach(s => { have[s.code] = s; });
  const out = (persisted || []).slice();
  US_SALES_TAX_RATES.forEach(s => { if (!have[s.code]) out.push({ ...s }); });
  return out;
}
function salesTaxRateFor(rates, code) {
  return (rates || US_SALES_TAX_RATES).find(s => s.code === code) || null;
}

// Sales tax is a property of the JOB, not of a quote: it follows the
// jurisdiction the work is installed in, and every quote, change order and
// invoice on that job answers to the same rate. Held on the project so they
// cannot disagree.
//
// Exemption is a DOCUMENT, not a checkbox — and the document is the FORM, not
// its number. A certificate reference typed into a box is a claim about a piece
// of paper; on an audit it is the paper itself that is asked for. So the
// exemption certificate is ATTACHED to the job, and it is the missing
// attachment that makes the exemption incomplete. The reference is kept beside
// it because that is how the certificate is looked up, but a number without a
// form does not clear the finding.
function projectTaxRate(project) {
  if (!project) return 0;
  if (project.taxExempt) return 0;
  return Number(project.taxRatePct) || 0;
}
function projectTaxExemptIncomplete(project) {
  return !!(project && project.taxExempt && !(project.taxExemptForm && project.taxExemptForm.url));
}
function projectVisibleTo(project, userId, securityRole) {
  if (!project || !project.isPrivate) return true;
  if (securityRole === 'Admin') return true;
  return (project.visibleToUserIds || []).indexOf(userId) >= 0;
}
function visibleProjectsFor(projects, userId, securityRole) {
  return (projects || []).filter(p => projectVisibleTo(p, userId, securityRole));
}
function normalizeProject(p) {
  if (p.isPrivate === undefined) p.isPrivate = false;
  if (p.taxRatePct === undefined) p.taxRatePct = 0;
  if (p.taxExempt === undefined) p.taxExempt = false;
  if (p.taxExemptRef === undefined) p.taxExemptRef = '';
  if (p.taxExemptForm === undefined) p.taxExemptForm = null;   // { name, url }
  if (p.taxExemptExpiry === undefined) p.taxExemptExpiry = null;
  if (p.taxNote === undefined) p.taxNote = '';
  if (!p.visibleToUserIds) p.visibleToUserIds = [];
  // Windows/Interiors split — a project used to carry ONE team covering all
  // its work. That team genuinely was responsible for both departments, so
  // it is copied to both rather than guessed at; splitting them is then a
  // deliberate edit per project, and nobody silently loses their assignment.
  if (!p.teams) {
    const legacy = p.team || {};
    p.teams = {};
    DEPARTMENTS.forEach(d => { p.teams[d] = { ...legacy }; });
  } else {
    DEPARTMENTS.forEach(d => { if (!p.teams[d]) p.teams[d] = {}; });
  }
  // Take-offs gained a department tag so a windows take-off and an interiors
  // take-off don't sit in one undifferentiated list. Inferred from the linked
  // scope where there is one; otherwise left null ("Unassigned") rather than
  // guessed, so nothing is silently mislabelled.
  // Issues gained a response THREAD (rather than one resolution string) so a
  // response can be an update, a hand-off, or a resolution — each carrying its
  // own attachments and follow-up. The original `resolution`/`resolvedBy` are
  // kept and mirrored from the latest resolving response, so every existing
  // reader still works.
  // Field services (measurements / installations / punch) gained a requester +
  // close-out. Existing records are left 'Pending Field Work' unless they were
  // already complete, in which case they are treated as closed out so nothing
  // finished suddenly reappears as outstanding.
  const backfillCloseout = (rec, doneWhen) => {
    if (rec.requestedById === undefined) rec.requestedById = rec.assigneeId || null;
    if (!rec.closeoutStatus) rec.closeoutStatus = doneWhen ? 'Closed Out' : 'Pending Field Work';
    if (rec.closedOutBy === undefined) rec.closedOutBy = null;
    if (rec.closedOutDate === undefined) rec.closedOutDate = null;
    if (rec.closeoutNote === undefined) rec.closeoutNote = '';
  };
  // Payment terms gained cash-planning fields. expectedDate is the FORECAST;
  // when it is null the date is derived from the trigger's stage instead, so a
  // term follows the schedule until someone overrides it.
  (p.paymentTerms || []).forEach(t => {
    if (t.expectedDate === undefined) t.expectedDate = null;   // null = derive
    if (t.receivedDate === undefined) t.receivedDate = null;
    if (t.receivedAmount === undefined) t.receivedAmount = null;
    if (t.amountOverride === undefined) t.amountOverride = null;
  });
  (p.apInvoices || []).forEach(inv => {
    if (inv.salesApproverId === undefined) inv.salesApproverId = null;
    if (inv.salesApprovedBy === undefined) inv.salesApprovedBy = null;
    if (inv.salesApprovedDate === undefined) inv.salesApprovedDate = null;
    if (inv.salesApprovalNote === undefined) inv.salesApprovalNote = '';
  });
  (p.fieldMeasurements || []).forEach(t => backfillCloseout(t, t.status === 'Complete'));
  (p.installationRecords || []).forEach(r => backfillCloseout(r, r.status === 'Complete' || r.status === 'Completed'));
  (p.punchItems || []).forEach(i => backfillCloseout(i, /complete|verified|closed/i.test(i.status || '')));
  (p.deliveries || []).forEach(d => {
    if (!d.destinationType) d.destinationType = 'Jobsite';
    if (d.subcontractorId === undefined) d.subcontractorId = null;
  });
  (p.issues || []).forEach(i => {
    if (!Array.isArray(i.responses)) {
      i.responses = [];
      if (i.resolution) {
        i.responses.push({
          id: uid('iresp'), date: i.dateResolved || i.dateRaised, by: i.resolvedBy || '',
          text: i.resolution, outcome: 'Resolved', attachments: [],
          followUpAssigneeId: null, followUpDueDate: null,
        });
      }
    }
    if (i.followUpAssigneeId === undefined) i.followUpAssigneeId = null;
    if (i.followUpDueDate === undefined) i.followUpDueDate = null;
    if (!Array.isArray(i.attachments)) i.attachments = [];
  });
  (p.takeOffs || []).forEach(t => {
    if (t.department === undefined) {
      const sc = (p.scopes || []).find(x => x.id === t.scopeId);
      t.department = sc ? scopeDepartment(sc) : null;
    }
  });
  (p.scopes || []).forEach(s => {
    (s.stages || []).forEach(st => { if (st.assignedUserId === undefined) st.assignedUserId = null; });
    // Windows/Interiors split — scopes created before departments existed are
    // classified from their family, which is exactly what they'd have got had
    // the field existed at the time.
    if (!DEPARTMENTS.includes(s.department)) s.department = familyDepartment(s.familyName);
    // Where in the scope a given selection actually applies — e.g. on a
    // Kitchen scope, the cabinet finish for "Base cabinets" vs "Uppers".
    // Free text keyed by category id, because the vocabulary differs per
    // trade and any fixed list would be wrong somewhere.
    // The application area this scope's MAIN selection group covers — e.g.
    // "Kitchen" on a Casework scope. Additional groups (selectionAreas below)
    // are peers of it, each covering their own area: "Base Cabinets",
    // "Upper Cabinets". Empty means the group is simply unnamed.
    if (s.mainAreaName === undefined) s.mainAreaName = '';
    if (!s.selectionLocations) s.selectionLocations = {};
    // Supplier finish chosen per selection category (An Cuong catalog) —
    // keyed by category id like selectionLocations, holding only a small
    // reference (see makeSupplierFinishRef), never the whole catalog record.
    if (!s.supplierFinishes) s.supplierFinishes = {};
    (s.selectionAreas || []).forEach(a => { if (!a.supplierFinishes) a.supplierFinishes = {}; });
    // Extra document sections a coordinator adds under a scope in the Shop
    // Drawing Hub. The structural sections — Shop Drawing / Submittal and
    // Client Response — are code, not data, and cannot be removed; these are
    // the ones the team creates and can delete.
    if (!Array.isArray(s.documentSections)) s.documentSections = [];
    // Supply and labour are SEPARATE scopes for every family; countertops are
    // the one combined package. So a scope with nothing stored backfills to
    // Supply Only, and a countertop is corrected to Supply & Install on read
    // by scopeSoldAs rather than here — this runs with no live scopeLibrary,
    // and the family rule is admin-editable.
    // (The comment that used to sit here claimed the backfill was
    // supply-and-install, which contradicted DEFAULT_SCOPE_TYPE and had done
    // since the split. The constant was right; the comment was not.)
    if (!s.scopeType) s.scopeType = DEFAULT_SCOPE_TYPE;
    if (!s.selectionAreas) s.selectionAreas = [];
    if (!s.selectionRevisions) s.selectionRevisions = [];
    if (s.selectionsLocked === undefined) s.selectionsLocked = false;
    if (!s.stageHistory) s.stageHistory = [];
    // Undo history used to store a bare stages array per entry; now each
    // entry also names which stage row the change belongs to, so the Undo
    // control can show per-row instead of once for the whole scope. Old-
    // shape entries are just an undo buffer, not real data — safe to drop.
    else if (s.stageHistory.length && Array.isArray(s.stageHistory[0])) s.stageHistory = [];
    // The single 'Flooring' family used to cover Engineered Wood, SPC/LVT,
    // and Carpet all at once (every category from all three shown together
    // on every flooring scope) — split into three real families so each
    // scope only shows the categories relevant to its own type. Existing
    // scopes are remapped by keyword match on their own name; unmatched
    // ones default to Engineered Wood as the most common case.
    if (s.familyName === 'Flooring') {
      const n = (s.name || '').toLowerCase();
      s.familyName = /spc|lvt/.test(n) ? 'SPC / LVT Flooring' : /carpet/.test(n) ? 'Carpet' : 'Engineered Wood Flooring';
    }
  });
  // Contacts used to allow exactly one "Additional Contact" (a fixed slot in
  // the contacts-by-role dict); replaced by an open-ended list so a project
  // can have as many extra contacts as it needs. Anything already entered
  // into that old single slot is migrated in, not discarded.
  if (!p.additionalContacts) {
    p.additionalContacts = [];
    const legacy = p.contacts && p.contacts['Additional Contact'];
    if (legacy && (legacy.company || legacy.person || legacy.phone || legacy.email)) {
      p.additionalContacts.push({ id: uid('contact'), label: 'Additional Contact', ...legacy });
    }
  }
  if (p.team && p.team['Lead Dispatcher'] === undefined) {
    // Lead Dispatcher is a new team role (was folded into Take-Off Drafter
    // before) — default an existing project to whoever's already doing its
    // Lead Review stage, so nothing looks unassigned after the split.
    const leadReviewStage = (p.scopes || []).flatMap(s => s.stages || []).find(st => st.key === 'lead_review');
    p.team['Lead Dispatcher'] = (leadReviewStage && leadReviewStage.assignedUserId) || p.team['Take-Off Drafter'] || null;
  }
  (p.scopes || []).forEach(s => {
    (s.stages || []).forEach(st => {
      if (st.key === 'lead_review' && st.responsibleRole === 'Take-Off Drafter') st.responsibleRole = 'Lead Dispatcher';
    });
  });
  // Chronology migration: Lead Review/Take-Off/Quote Prep/Quote Revision
  // move from being duplicated once per scope to one shared instance per
  // project. A project persisted from before this change still has these
  // stages baked into every scope — take the first scope's copies
  // (preserving status/dates/assignee, so no progress is lost) as the
  // canonical project-level instance, then strip those 4 keys out of every
  // scope's own stages array. A project with no such stages anywhere (a
  // freshly-seeded one, whose scopes were already built without them)
  // just gets a fresh instance.
  if (!p.chronology) {
    const chronologyKeys = ['lead_review', 'take_off', 'quote_prep', 'quote_revision'];
    const donorScope = (p.scopes || []).find(s => (s.stages || []).some(st => chronologyKeys.includes(st.key)));
    p.chronology = donorScope
      ? donorScope.stages.filter(st => chronologyKeys.includes(st.key))
      : instantiateChronology(todayISO(), p.complexity);
    (p.scopes || []).forEach(s => { s.stages = (s.stages || []).filter(st => !chronologyKeys.includes(st.key)); });
  }
  if (!p.chronologyHistory) p.chronologyHistory = [];
  if (p.chronologyProjectedCompletion === undefined) p.chronologyProjectedCompletion = null;
  if (p.contractFile === undefined) p.contractFile = null;
  if (p.contractFileUrl === undefined) p.contractFileUrl = null;
  if (p.contractSignedDate === undefined) p.contractSignedDate = null;
  (p.quotationFollowUps || []).forEach(f => { if (f.assigneeId === undefined) f.assigneeId = null; });
  if (!p.drawingSets) p.drawingSets = [];
  // Drawing Sets can no longer be deleted, only voided with a reason, and
  // must always carry a reviewer (§ drawing sets request). Existing sets
  // default to whoever's currently the project's Project Manager — the
  // role that already reviews incoming drawing sets today — so nothing
  // looks unassigned after the upgrade; a manager can reassign from there.
  p.drawingSets.forEach(d => {
    if (d.status === undefined) d.status = 'Active';
    if (d.voidReason === undefined) d.voidReason = null;
    if (d.voidedDate === undefined) d.voidedDate = null;
    if (d.voidedBy === undefined) d.voidedBy = null;
    if (d.reviewerAssigneeId === undefined) d.reviewerAssigneeId = (p.team && p.team['Project Manager']) || null;
  });
  if (!p.takeOffs) p.takeOffs = [];
  // Requests to have a take-off produced from a drawing set — see
  // makeAiTakeoffRequest. Admin-only surface for now.
  if (!Array.isArray(p.aiTakeoffRequests)) p.aiTakeoffRequests = [];
  // Additive and isolated — see makeQuoteAnalysis. An old project simply has none.
  if (!Array.isArray(p.quoteAnalyses)) p.quoteAnalyses = [];
  // LEON Doors — the door records and the project's own door types.
  if (!Array.isArray(p.doors)) p.doors = [];
  if (!Array.isArray(p.doorTypes)) p.doorTypes = [];
  if (!Array.isArray(p.doorImports)) p.doorImports = [];
  // Additive; an old project simply gains an untouched closeout.
  if (!p.closeout) p.closeout = makeCloseout();
  // A checklist item added to the seed later reaches every existing job, the
  // same forward-merge the scope families and lead-time libraries use.
  else {
    const have = new Set((p.closeout.checklist || []).map(i => i.key).filter(Boolean));
    CLOSEOUT_CHECKLIST_SEED.forEach(seed => {
      if (!have.has(seed.key)) p.closeout.checklist.push(makeCloseoutItem(seed));
    });
    if (!p.closeout.portfolio) p.closeout.portfolio = makeCloseout().portfolio;
    ['photos', 'documents', 'lessons'].forEach(k => {
      if (!Array.isArray(p.closeout[k])) p.closeout[k] = [];
    });
  }
  if (!Array.isArray(p.qcAreas)) p.qcAreas = [];
  if (!Array.isArray(p.qcInspections)) p.qcInspections = [];
  // Renders moved from one flat, always-scoped list to named sets with their
  // own revision history (§ renders request) — each render image now lives
  // inside a revision, and a set's scope is optional. Legacy p.renders
  // entries are grouped by (scopeId, name) into one set per group, each
  // prior entry becoming one revision holding that one image.
  if (!p.renderSets) {
    const legacy = p.renders || [];
    const groups = new Map();
    legacy.forEach(r => {
      const key = `${r.scopeId || ''}::${r.name}`;
      if (!groups.has(key)) groups.set(key, []);
      groups.get(key).push(r);
    });
    p.renderSets = [...groups.values()].map(entries => {
      const first = entries[0];
      return {
        id: uid('rset'), scopeId: first.scopeId || null, name: first.name,
        createdBy: null, createdDate: first.date,
        revisions: entries.sort((a, b) => a.revision - b.revision).map(r => ({
          id: uid('rrev'), revisionNumber: r.revision, date: r.date, note: r.note || '',
          sharedWithClient: !!r.sharedWithClient,
          images: [{ id: uid('rimg'), imageUrl: r.imageUrl || null, caption: '' }],
        })),
      };
    });
  }
  delete p.renders;
  if (!p.deliveries) p.deliveries = [];
  p.deliveries.forEach(d => {
    if (d.packingListId === undefined) d.packingListId = null;
    if (d.warehouseReleaseId === undefined) d.warehouseReleaseId = null;
    if (d.carrier === undefined) d.carrier = '';
    if (d.driver === undefined) d.driver = '';
    if (d.vehicleInfo === undefined) d.vehicleInfo = '';
    if (d.trackingBolNumber === undefined) d.trackingBolNumber = '';
    if (d.jobsiteContact === undefined) d.jobsiteContact = '';
    if (d.deliveryAddress === undefined) d.deliveryAddress = '';
    if (d.deliveryStatus === undefined) d.deliveryStatus = 'Pending';
    if (d.deliveryTime === undefined) d.deliveryTime = '';
    if (d.proofOfDeliveryUrl === undefined) d.proofOfDeliveryUrl = null;
    if (!d.deliveryPictures) d.deliveryPictures = [];
    if (d.signedPackingListUrl === undefined) d.signedPackingListUrl = null;
    if (d.billOfLadingUrl === undefined) d.billOfLadingUrl = null;
    if (d.damageShortageNotes === undefined) d.damageShortageNotes = '';
    // Request/approval + manual-line fields, added when the Delivery tab
    // gained its request-and-approve workflow. Deliveries that already
    // existed (all created through the warehouse-release pipeline, which
    // implies Logistics already actioned them) are backfilled as pre-Approved
    // rather than landing in the new Requested queue.
    if (d.deliveryNumber === undefined) d.deliveryNumber = `DEL-LEGACY-${d.id.slice(-4).toUpperCase()}`;
    if (d.approvalStatus === undefined) d.approvalStatus = 'Approved';
    if (d.requestedBy === undefined) d.requestedBy = null;
    if (d.requestedDate === undefined) d.requestedDate = d.date || todayISO();
    if (d.approvedBy === undefined) d.approvedBy = null;
    if (d.approvedDate === undefined) d.approvedDate = null;
    if (d.quantity === undefined) d.quantity = null;
    if (d.unit === undefined) d.unit = '';
    if (d.areas === undefined) d.areas = '';
    if (d.wantsWarehouseAllocation === undefined) d.wantsWarehouseAllocation = false;
    if (d.receiverName === undefined) d.receiverName = '';
    if (d.receiverPhone === undefined) d.receiverPhone = '';
    if (d.clientSignatureUrl === undefined) d.clientSignatureUrl = null;
    if (!d.lines) d.lines = [];
    normalizeDelivery(d);
  });
  if (!p.exportDocuments) p.exportDocuments = [];
  // exportContainers moved to a top-level collection (§ multi-project
  // containers) — the App-level exportContainers useState migrates any
  // legacy project-nested containers before this normalization ever runs,
  // so nothing here needs to read or backfill p.exportContainers; just drop
  // the stale nested copy so it can't drift from the real collection.
  delete p.exportContainers;
  (p.issues || []).forEach(i => { if (!i.attachments) i.attachments = []; });
  if (!p.meetings) p.meetings = [];
  p.meetings.forEach(m => {
    if (m.time === undefined) m.time = '';
    if (m.durationMinutes === undefined) m.durationMinutes = null;
  });
  if (!p.jobsiteVisits) p.jobsiteVisits = [];
  if (!p.freightEstimates) p.freightEstimates = [];
  if (!p.freightPOs) p.freightPOs = [];
  if (!p.installationRecords) p.installationRecords = [];
  p.installationRecords.forEach(r => {
    if (r.assignedSubcontractorId === undefined) r.assignedSubcontractorId = null;
    // Installation Scheduled workflow (Phase 6) — PC schedules+assigns,
    // installer approves or requests a reschedule, then logs a completion
    // outcome (with a return visit if partial). Existing records default to
    // 'Approved' so nothing already in flight suddenly needs installer
    // sign-off it was never asked for.
    if (r.approvalStatus === undefined) r.approvalStatus = 'Approved';
    if (r.rescheduleRequest === undefined) r.rescheduleRequest = null;
    if (r.scheduledTime === undefined) r.scheduledTime = '';
    if (r.durationMinutes === undefined) r.durationMinutes = '';
    if (r.outcome === undefined) r.outcome = null;
    if (r.completionNotes === undefined) r.completionNotes = '';
    if (r.returnVisit === undefined) r.returnVisit = null;
  });
  if (!p.dailyFieldReports) p.dailyFieldReports = [];
  if (!p.fieldIssues) p.fieldIssues = [];
  if (!p.materialReceipts) p.materialReceipts = [];
  if (!p.punchItems) p.punchItems = [];
  p.punchItems.forEach(pi => {
    if (pi.responseStatus === undefined) pi.responseStatus = null;
    if (pi.responseNotes === undefined) pi.responseNotes = '';
    if (!pi.responsePhotos) pi.responsePhotos = [];
    if (pi.repairCompletedDate === undefined) pi.repairCompletedDate = null;
    if (pi.respondedBy === undefined) pi.respondedBy = null;
    if (pi.respondedDate === undefined) pi.respondedDate = null;
    // Punch Lists workflow (Phase 6) — PC assigns a subcontractor, the
    // subcontractor schedules a return date before responding, and PC
    // approves/rejects the response (rejection clears the scheduled return
    // so the subcontractor has to book another one).
    if (pi.scopeId === undefined) pi.scopeId = null;
    if (pi.assignedSubcontractorId === undefined) pi.assignedSubcontractorId = null;
    if (pi.scheduledReturnDate === undefined) pi.scheduledReturnDate = null;
    if (pi.scheduledReturnTime === undefined) pi.scheduledReturnTime = null;
    if (pi.scheduledReturnDuration === undefined) pi.scheduledReturnDuration = null;
    if (pi.lastRejectionNote === undefined) pi.lastRejectionNote = null;
  });
  if (!p.fieldMeasurements) p.fieldMeasurements = [];
  // Per-scope "Not Applicable" flag for Field Measurement (Phase 6) — keyed
  // by scopeId, replaces that scope's Field Measurement section with a badge
  // instead of an empty thread list when a scope genuinely has none.
  if (!p.fieldMeasurementNA) p.fieldMeasurementNA = {};
  if (!p.financialIssues) p.financialIssues = [];
  // Two-person (Admin + Accounting) deletion sign-off.
  if (p.deleteRequest === undefined) p.deleteRequest = null;
  if (!p.applianceInstances) p.applianceInstances = [];
  if (!p.fixtureInstances) p.fixtureInstances = [];
  if (!p.sov) p.sov = [];
  // Lines entered before this field existed are grandfathered as manual
  // (sourceType null) — still fully editable/removable as before; only
  // lines created going forward come through syncSovFromContract and always
  // carry a real source reference.
  p.sov.forEach(cat => cat.items.forEach(item => {
    if (item.sourceType === undefined) item.sourceType = null;
    if (item.sourceId === undefined) item.sourceId = null;
  }));
  if (!p.aiaHeaderDefaults) p.aiaHeaderDefaults = makeAiaHeaderDefaults();
  else Object.entries(makeAiaHeaderDefaults()).forEach(([k, v]) => { if (p.aiaHeaderDefaults[k] === undefined) p.aiaHeaderDefaults[k] = v; });
  if (!p.applications) p.applications = [];
  p.applications.forEach(a => {
    if (a.status === 'Certified') a.status = 'Approved/Certified';
    if (a.header === undefined) a.header = {};
    if (!a.history) a.history = [];
    if (a.voidReason === undefined) a.voidReason = null;
    // A line's single "stored" figure before this migration becomes its
    // starting stored balance, with nothing yet marked incorporated/added
    // this period — a faithful reading of what that one number meant
    // (materials on hand, not yet distinguished by movement).
    [a.lines, a.coLines].forEach(store => {
      Object.values(store || {}).forEach(line => {
        if (line.storedPreviousBalance === undefined) {
          line.storedPreviousBalance = line.stored || 0;
          line.storedIncorporated = 0;
          line.storedAdded = 0;
        }
      });
    });
  });
  if (!p.productionRecords) p.productionRecords = [];
  p.productionRecords.forEach(r => {
    (r.qcReports || []).forEach(qc => { if (qc.followUpAssigneeId === undefined) qc.followUpAssigneeId = null; });
    if (r.startedDate === undefined) r.startedDate = null;
    if (r.completedDate === undefined) r.completedDate = null;
  });
  if (!p.apInvoices) p.apInvoices = [];
  (p.apInvoices || []).forEach(normalizeApInvoice);
  if (p.retainagePct === undefined) p.retainagePct = 0;
  if (!p.address) p.address = '';
  if (!p.projectType) p.projectType = /clubhouse|amenity|hq|studio/i.test(p.name) ? 'Commercial' : 'Residential';
  if (!p.companyDepartment) p.companyDepartment = [/window/i.test(p.department) ? 'Windows' : 'Interiors'];
  else if (!Array.isArray(p.companyDepartment)) p.companyDepartment = [p.companyDepartment];
  if (p.sizeSqFt === undefined) p.sizeSqFt = null;
  if (p.unitQuantity === undefined) p.unitQuantity = null;
  if (p.buildingStories === undefined) p.buildingStories = null;
  if (!p.laborType) p.laborType = 'Standard';
  // The estimator is who a bid is actually sent to and chased with, so they
  // are the first contact on a project, ahead of the GC — during the bid phase
  // they are the only person who matters, and the quote follow-ups read from here.
  if (!p.contacts['Estimator']) p.contacts['Estimator'] = { company: '', person: '', phone: '', email: '' };
  if (!p.contacts['Billing Contact']) p.contacts['Billing Contact'] = { company: '', person: '', phone: '', email: '' };
  if (!p.contacts['Additional Contact']) p.contacts['Additional Contact'] = { company: '', person: '', phone: '', email: '' };
  // More contact fields (title/mobile/preferred method/notes) on both the
  // fixed project-contact roles and the open-ended additionalContacts list.
  Object.values(p.contacts).forEach(c => {
    if (c.title === undefined) c.title = '';
    if (c.mobile === undefined) c.mobile = '';
    if (c.preferredContactMethod === undefined) c.preferredContactMethod = '';
    if (c.notes === undefined) c.notes = '';
  });
  (p.additionalContacts || []).forEach(c => {
    if (c.title === undefined) c.title = '';
    if (c.mobile === undefined) c.mobile = '';
    if (c.preferredContactMethod === undefined) c.preferredContactMethod = '';
    if (c.notes === undefined) c.notes = '';
  });
  // A follow-up now records WHICH quote it was about and whether an email went
  // out, so "have we chased Rev 2?" is answerable. Old rows simply have neither.
  (p.quotationFollowUps || []).forEach(f => {
    if (f.quoteRevisionId === undefined) f.quoteRevisionId = null;
    if (f.sentTo === undefined) f.sentTo = null;
    if (f.emailed === undefined) f.emailed = false;
  });
  (p.quoteRevisions || []).forEach(q => {
    if (q.clientFile === undefined) { q.clientFile = q.file || null; delete q.file; }
    if (q.internalAnalysisFile === undefined) q.internalAnalysisFile = null;
    if (q.isFinal === undefined) q.isFinal = false;
  });
  (p.paymentRequisitions || []).forEach(r => {
    if (r.retainageHeld === undefined) r.retainageHeld = 0;
    if (r.sourceApplicationId === undefined) r.sourceApplicationId = null;
  });
  if (!p.proformaInvoices) p.proformaInvoices = [];
  p.proformaInvoices.forEach(pi => {
    if (!pi.materialLines) pi.materialLines = [];
    pi.materialLines.forEach(l => { if (l.itemCode === undefined) l.itemCode = ''; if (l.dimensions === undefined) l.dimensions = ''; });
  });
  function normalizeRevisionCompat(r) {
    if (r.revisionNumber === undefined) r.revisionNumber = r.revision;
    if (r.revisedAmount === undefined) r.revisedAmount = r.amount;
    if (r.previousAmount === undefined) r.previousAmount = null;
    if (r.revisedBy === undefined) r.revisedBy = null;
    if (r.reasonForRevision === undefined) r.reasonForRevision = '';
    if (r.notes === undefined) r.notes = r.note || '';
  }
  (p.vendorEstimates || []).forEach(v => {
    if (!v.revisions) v.revisions = [{ id: uid('ver'), revision: 1, amount: v.amount, date: v.date, file: null, note: '' }];
    v.revisions.forEach(normalizeRevisionCompat);
    if (v.vendorId === undefined) v.vendorId = null;
    if (!v.category) v.category = 'Original Order';
    if (v.scopeId === undefined) v.scopeId = null;
    if (v.unplannedReason === undefined) v.unplannedReason = null;
    if (v.recoverability === undefined) v.recoverability = isUnplannedCost(v.category) ? 'Pending Determination' : null;
    if (!v.status) v.status = v.poId ? 'Converted to PO' : (v.pmApproved && v.ownerApproved) ? 'Approved' : 'Received';
    if (!v.currency) v.currency = 'USD';
    if (!v.estimateNumber) v.estimateNumber = `EST-${v.id.replace(/\D/g, '').slice(-5).padStart(5, '0')}`;
  });
  (p.purchaseOrders || []).forEach(po => {
    if (!po.poNumber) po.poNumber = `PO-${po.id.replace(/\D/g, '').slice(-5).padStart(5, '0')}`;
    if (!po.status) po.status = 'Approved';
    if (!po.revisions) po.revisions = [];
    po.revisions.forEach(normalizeRevisionCompat);
    if (!po.currency) po.currency = 'USD';
    if (po.scopeId === undefined) {
      const ve = (p.vendorEstimates || []).find(v => v.id === po.vendorEstimateId);
      po.scopeId = ve ? ve.scopeId : null;
    }
    if (po.vendorId === undefined) {
      const ve = (p.vendorEstimates || []).find(v => v.id === po.vendorEstimateId);
      po.vendorId = ve ? ve.vendorId : null;
    }
    if (po.deliveryTerms === undefined) po.deliveryTerms = '';
    if (po.requiredDate === undefined) po.requiredDate = null;
    if (po.notes === undefined) po.notes = '';
  });
  (p.freightPOs || []).forEach(po => {
    if (!po.poNumber) po.poNumber = `PO-${po.id.replace(/\D/g, '').slice(-5).padStart(5, '0')}`;
    if (!po.status) po.status = 'Approved';
    if (!po.revisions) po.revisions = [];
    po.revisions.forEach(normalizeRevisionCompat);
    if (!po.currency) po.currency = 'USD';
    if (po.scopeId === undefined) {
      const fe = (p.freightEstimates || []).find(f => f.id === po.freightEstimateId);
      po.scopeId = fe ? fe.scopeId : null;
    }
    if (po.deliveryTerms === undefined) po.deliveryTerms = '';
    if (po.requiredDate === undefined) po.requiredDate = null;
    if (po.notes === undefined) po.notes = '';
  });
  (p.freightEstimates || []).forEach(f => {
    if (!f.revisions) f.revisions = [{ id: uid('ver'), revision: 1, amount: f.amount, date: f.date, file: f.file || null, fileUrl: f.fileUrl || null, note: '' }];
    f.revisions.forEach(normalizeRevisionCompat);
    if (f.forwarderId === undefined) f.forwarderId = null;
    if (!f.category) f.category = 'Original Order';
    if (f.scopeId === undefined) f.scopeId = null;
    if (!f.status) f.status = f.poId ? 'Converted to PO' : (f.exportApproved && f.adminApproved) ? 'Approved' : 'Received';
    if (!f.currency) f.currency = 'USD';
    if (!f.estimateNumber) f.estimateNumber = `EST-${f.id.replace(/\D/g, '').slice(-5).padStart(5, '0')}`;
  });
  (p.exportDocuments || []).forEach(d => { if (d.scopeId === undefined) d.scopeId = p.scopes[0]?.id || null; });
  (p.scopes || []).forEach(s => {
    if (!s.submittals) s.submittals = [];
    if (!s.clientResponses) s.clientResponses = [];
    if (!s.profitability) s.profitability = makeScopeProfitability();
    if (!s.profitability.actual) s.profitability.actual = blankCostBreakdown();
    if (!s.materialLinks) s.materialLinks = {};
  });
  return p;
}


// ---------------------------------------------------------------------------
// Warehouse Hub — Material Allocation & Inventory Control (§ warehouse request)
// ---------------------------------------------------------------------------
const INVENTORY_TRANSACTION_TYPES = ['Receiving', 'Withdrawal', 'Adjustment', 'Transfer', 'Return', 'Damage', 'Stock Check'];
const ALLOCATION_STATUSES = ['Reserved', 'Confirmed', 'Partially Released', 'Fully Released', 'Delivered', 'Cancelled', 'Reallocated', 'Converted to Stock'];

// Material Allocation permission is separate from Physical Inventory Control
// permission (§2, explicit instruction) — Sales can allocate available stock
// to a project but can never receive/adjust/release physical stock.
// ANYONE on staff may REQUEST material against a job — instructed 2026-09-06.
// Allocating is asking for stock to be set aside, not moving it: the request is
// refused outright when the stock is not there, and what is actually held and
// released stays with Inventory. So the old shape was wrong twice over — it
// gated a request behind a capability most people did not have, and then punched
// a hole in that gate with a hardcoded "Sales Person" clause the Role
// Permissions editor could not see or switch off.
// The capability is KEPT so an admin can still take it away from a role, but its
// default is now everyone who is not a portal account.
function canAllocateMaterial(user) {
  if (!user) return false;
  return roleHasCapability(user.securityRole, 'material.allocate');
}
function canControlInventory(role) {
  return roleHasCapability(role, 'inventory.control');
}
function canAssistWarehouse(role) {
  return roleHasCapability(role, 'inventory.assist') || canControlInventory(role);
}
// Inventory Creation is a distinct grant from physical stock control (§1,
// explicit instruction) — Accounting can create a material record but still
// cannot receive/adjust/release stock (canControlInventory stays Admin/Logistic Manager only).
function canCreateInventory(role) {
  return roleHasCapability(role, 'inventory.create');
}

function makeWarehouse(name, address) {
  return { id: uid('wh'), name, address: address || '', active: true };
}
// Truck/vehicle maintenance (Phase 8) — kept off the Delivery model itself,
// keyed off whichever driver is normally assigned to a truck rather than
// modeling truck<->delivery assignment from scratch (per the roadmap's own
// call for the simplest correct approach). A single active maintenance
// window is enough — a new one just overwrites the last once it's done.
function makeTruck(data) {
  return {
    id: uid('truck'), name: data.name || '', plateNumber: data.plateNumber || '', assignedDriverId: data.assignedDriverId || null,
    active: true, maintenanceStart: data.maintenanceStart || null, maintenanceEnd: data.maintenanceEnd || null, maintenanceReason: data.maintenanceReason || '',
  };
}
function truckUnderMaintenanceOn(truck, dateStr) {
  if (!truck.maintenanceStart || !dateStr) return false;
  const end = truck.maintenanceEnd || truck.maintenanceStart;
  return dateStr >= truck.maintenanceStart && dateStr <= end;
}
function makeWarehouseMaterial(data, createdBy) {
  return {
    id: uid('whmat'), name: materialNameLabel(data.name), category: materialCategoryLabel(data.category),
    referenceNumber: data.referenceNumber === undefined ? null : data.referenceNumber,
    itemId: data.itemId || '', description: data.description || '', scopeFamily: data.scopeFamily || '',
    manufacturerVendor: data.manufacturerVendor || '', finishColor: data.finishColor || '', dimensions: data.dimensions || '',
    unitOfMeasure: data.unitOfMeasure || 'Units',
    currentStock: data.currentStock === undefined || data.currentStock === null ? 0 : data.currentStock,
    initialStock: data.initialStock === undefined ? null : data.initialStock,
    unitCost: data.unitCost === undefined || data.unitCost === null ? 0 : Number(data.unitCost),
    warehouseId: data.warehouseId, storageLocation: data.storageLocation || '', active: true,
    dateReceived: data.dateReceived || null, relatedPoId: data.relatedPoId || null, relatedPiId: data.relatedPiId || null,
    pictures: data.pictures || [], documents: data.documents || [], notes: data.notes || '',
    createdBy: createdBy || data.createdBy || null, createdDate: data.createdDate || todayISO(),
    activityLog: data.activityLog || [],
    importedFrom: data.importedFrom || null,
  };
}
function makeInventoryTransaction(data) {
  return {
    id: uid('invtx'), materialId: data.materialId, type: data.type,
    quantity: data.quantity === undefined ? null : data.quantity,
    date: data.date || todayISO(), company: data.company || null, po: data.po || null,
    invoiceNumber: data.invoiceNumber || null, requestedBy: data.requestedBy || null,
    recipientText: data.recipientText || null, notes: data.notes || '',
    status: data.status || 'Completed', enteredBy: data.enteredBy || null,
    importedFrom: data.importedFrom || null,
    // Receiving Report fields (only meaningful when type === 'Receiving') —
    // kept on the same record rather than a parallel collection, matching
    // how every other Warehouse activity is already logged as one
    // transaction stream.
    projectId: data.projectId || null, scopeId: data.scopeId || null, vendorId: data.vendorId || null, containerId: data.containerId || null,
    expectedQuantity: data.expectedQuantity === undefined ? null : data.expectedQuantity,
    shortQuantity: data.shortQuantity === undefined ? 0 : Number(data.shortQuantity) || 0,
    overQuantity: data.overQuantity === undefined ? 0 : Number(data.overQuantity) || 0,
    damagedQuantity: data.damagedQuantity === undefined ? 0 : Number(data.damagedQuantity) || 0,
    photos: data.photos || [],
  };
}
function makeMaterialAllocation(data, allocatedBy) {
  return {
    id: uid('alloc'), materialId: data.materialId, projectId: data.projectId, scopeId: data.scopeId || null,
    building: data.building || '', floor: data.floor || '', unit: data.unit || '',
    quantityAllocated: Number(data.quantityAllocated) || 0, quantityReleased: 0, quantityDelivered: 0,
    unitOfMeasure: data.unitOfMeasure || 'Units', warehouseId: data.warehouseId, storageLocation: data.storageLocation || '',
    allocationDate: data.allocationDate || todayISO(), allocatedBy, requiredDate: data.requiredDate || null,
    notes: data.notes || '', status: 'Reserved', reallocatedFromId: data.reallocatedFromId || null, history: [],
    costContribution: 0, stockConversionRequest: null,
  };
}
// A single collection covers both the Shortage/Damage/Discrepancy Report and
// the Logistics Claims Report — a discrepancy IS a claim once it has a
// dollar amount and a carrier/vendor on the hook for it, so splitting them
// into two collections would just require keeping two records in sync for
// the same real-world event. `claimType` distinguishes the two report views.
const LOGISTICS_CLAIM_TYPES = ['Shortage', 'Damage', 'Missing', 'Incorrect Material', 'Wrong Finish', 'Wrong Quantity', 'Freight Claim', 'Other'];
const LOGISTICS_CLAIM_STATUSES = ['Open', 'Under Review', 'Claim Filed', 'Replacement Requested', 'Resolved', 'Denied'];
function makeLogisticsClaim(data, createdBy) {
  return {
    id: uid('lclaim'), claimNumber: data.claimNumber, projectId: data.projectId || null, scopeId: data.scopeId || null,
    vendorId: data.vendorId || null, carrierId: data.carrierId || null, containerId: data.containerId || null,
    claimType: data.claimType || 'Shortage', description: data.description || '',
    photos: data.photos || [], responsibleParty: data.responsibleParty || '',
    claimAmount: Number(data.claimAmount) || 0, amountRecovered: Number(data.amountRecovered) || 0,
    replacementRequired: !!data.replacementRequired, replacementStatus: data.replacementStatus || '', replacementEta: data.replacementEta || null,
    status: 'Open', dateSubmitted: data.dateSubmitted || todayISO(),
    createdBy: createdBy || null, createdDate: todayISO(), attachments: data.attachments || [],
  };
}
function makeAllocationHistoryEntry(data) {
  const now = new Date();
  return {
    id: uid('alloch'), date: toISO(now), time: now.toTimeString().slice(0, 5), user: data.user,
    material: data.material, previousAllocation: data.previousAllocation === undefined ? null : data.previousAllocation,
    newAllocation: data.newAllocation === undefined ? null : data.newAllocation,
    originalProjectId: data.originalProjectId || null, newProjectId: data.newProjectId || null,
    quantity: data.quantity === undefined ? null : data.quantity, reason: data.reason || '', notes: data.notes || '',
  };
}
// Generic append-only activity-log entry (§9) — used on material records,
// releases, and packing lists, wherever a bespoke history shape (like
// makeAllocationHistoryEntry above) isn't already a better fit.
function makeActivityEntry(data) {
  const now = new Date();
  return {
    id: uid('act'), date: toISO(now), time: now.toTimeString().slice(0, 5), user: data.user,
    action: data.action, previousValue: data.previousValue === undefined ? null : data.previousValue,
    newValue: data.newValue === undefined ? null : data.newValue, notes: data.notes || '',
  };
}
// Personal To-Do / Calendar items — cross-project, per-user (§ My To-Do
// request). Not nested inside a project since an Event or To-Do a person
// adds for themselves isn't necessarily tied to any one job.
const PERSONAL_ITEM_TYPES = ['To-Do', 'Meeting', 'Virtual Meeting', 'Event', 'Reminder', 'Other'];
const PERSONAL_ITEM_PRIORITIES = ['Low', 'Medium', 'High'];
const PERSONAL_ITEM_REPEAT_OPTIONS = ['None', 'Daily', 'Weekly', 'Monthly'];
const PERSONAL_ITEM_DURATIONS = [15, 30, 45, 60, 90, 120, 180, 240, 480];
function makePersonalItem(data, userId, createdBy) {
  return {
    id: uid('pitem'), userId, type: data.type || 'To-Do', title: data.title,
    date: data.date || null, time: data.time || '', durationMinutes: data.durationMinutes || null, location: data.location || '',
    projectId: data.projectId || null, scopeId: data.scopeId || null,
    // Set only when this personal item was auto-created FROM a project
    // Meeting record (one per attendee) — lets the project's own Meetings
    // tab tell those apart from a Meeting a person logged independently in
    // their own My To-Do (which the Meetings tab also surfaces, read-only).
    sourceMeetingId: data.sourceMeetingId || null,
    attendeeIds: data.attendeeIds || [], repeat: data.repeat || 'None', recurrenceGroupId: data.recurrenceGroupId || null,
    attachments: data.attachments || [], private: !!data.private,
    notes: data.notes || '', priority: data.priority || 'Medium', status: 'Open',
    createdBy, createdDate: todayISO(),
  };
}
// Backfill for personal items persisted before the richer form (time,
// location, attendees, repeat, attachments, private, priority, duration) existed.
function normalizePersonalItem(i) {
  if (i.time === undefined) i.time = '';
  if (i.durationMinutes === undefined) i.durationMinutes = null;
  if (i.location === undefined) i.location = '';
  if (i.projectId === undefined) i.projectId = null;
  if (i.scopeId === undefined) i.scopeId = null;
  if (!i.attendeeIds) i.attendeeIds = [];
  if (i.repeat === undefined) i.repeat = 'None';
  if (i.recurrenceGroupId === undefined) i.recurrenceGroupId = null;
  if (!i.attachments) i.attachments = [];
  if (i.private === undefined) i.private = false;
  if (i.priority === undefined) i.priority = 'Medium';
  if (i.sourceMeetingId === undefined) i.sourceMeetingId = null;
  return i;
}
function availableQuantity(material, allocations) {
  const active = (allocations || []).filter(a => a.materialId === material.id && ['Reserved', 'Confirmed', 'Partially Released'].includes(a.status));
  const held = active.reduce((s, a) => s + (a.quantityAllocated - (a.quantityReleased || 0)), 0);
  return material.currentStock - held;
}
const RELEASE_STATUSES = ['Released', 'Packed', 'Delivered'];
function makeWarehouseRelease(data, releasedBy) {
  return {
    id: uid('rls'), releaseNumber: data.releaseNumber, projectId: data.projectId, scopeId: data.scopeId || null,
    lines: data.lines || [], releasedBy, releaseDate: data.releaseDate || todayISO(), notes: data.notes || '',
    status: 'Released', packingListId: null,
  };
}
function makePackingList(data, preparedBy) {
  return {
    id: uid('pl'), packingListNumber: data.packingListNumber, projectId: data.projectId, scopeId: data.scopeId || null,
    releaseId: data.releaseId || null,
    // Carries the originating delivery's own request number too — "the same
    // added to the packing list information" — so the two are cross-referable
    // without having to look up the delivery record.
    deliveryNumber: data.deliveryNumber || null,
    projectName: data.projectName || '', address: data.address || '', deliveryContact: data.deliveryContact || '',
    releaseDate: data.releaseDate || todayISO(), plannedDeliveryDate: data.plannedDeliveryDate || null,
    lines: data.lines || [], preparedBy, preparedDate: todayISO(), notes: data.notes || '',
    deliveryId: null,
  };
}
const DELIVERY_STATUSES = ['Pending', 'In Transit', 'Delivered', 'Partially Delivered', 'Delayed', 'Cancelled'];
// A delivery starts as a Request (anyone can create one) awaiting Logistic
// Manager approval; once Approved it becomes Scheduled (deliveryStatus
// governs the rest — Pending/In Transit through Delivered, tracked in the
// existing pipeline). Rejected stops it there.
const DELIVERY_APPROVAL_STATUSES = ['Pending Approval', 'Approved', 'Rejected'];
// The driver's on-site outcome (§ delivery request/driver-hub request) —
// distinct from deliveryStatus/approvalStatus above: this is specifically
// what happened when the truck actually got there. null until the driver
// submits the proof-of-delivery flow.
const DELIVERY_OUTCOMES = ['Complete', 'Partial', 'Cancelled'];
// One delivery record, built from Warehouse Allocations (never a freehand
// description) and routed to a specific person for approval rather than
// just "anyone with the Logistic Manager role" (§ delivery request request).
// Consolidates what used to be three near-identical inline literals
// (addDelivery/requestDelivery/createDeliveryFromPackingList) now that all
// three need the same new scheduling/driver/outcome fields.
function makeDelivery(data, createdBy) {
  return {
    id: uid('del'), deliveryNumber: data.deliveryNumber || `DEL-${uid('').slice(-6).toUpperCase()}`,
    scopeId: data.scopeId || null, description: data.description || '', quantity: data.quantity ?? null, unit: data.unit || '',
    areas: data.areas || '', notes: data.notes || '', wantsWarehouseAllocation: !!data.wantsWarehouseAllocation,
    date: data.date || todayISO(), deliveryTime: data.deliveryTime || '',
    slipFile: data.slipFile || null, slipFileUrl: data.slipFileUrl || null, jobsitePhoto: data.jobsitePhoto || null,
    packingListId: data.packingListId || null, warehouseReleaseId: data.warehouseReleaseId || null,
    // Optional direct link to the export container this delivery's material
    // came from — the existing chain (lines[].allocationId -> materialAllocation
    // -> materialId -> warehouseMaterial.containerId) is indirect; this lets a
    // Window Schedule delivery phase reference its container in one hop.
    containerId: data.containerId || null,
    // Where the material is actually going. Most deliveries go to the jobsite,
    // but material is also routinely dropped at a subcontractor's shop for
    // pre-fabrication before it ever reaches site — that had nowhere to be
    // recorded, so those deliveries were being logged as jobsite runs.
    destinationType: data.destinationType || 'Jobsite',
    subcontractorId: data.subcontractorId || null,
    carrier: data.carrier || '', driver: data.driver || '', vehicleInfo: data.vehicleInfo || '',
    trackingBolNumber: data.trackingBolNumber || '', jobsiteContact: data.jobsiteContact || '', deliveryAddress: data.deliveryAddress || '',
    deliveryStatus: data.deliveryStatus || 'Pending', proofOfDeliveryUrl: null, deliveryPictures: [],
    signedPackingListUrl: null, billOfLadingUrl: null, damageShortageNotes: '',
    approvalStatus: data.approvalStatus || 'Pending Approval', requestedBy: data.requestedBy || createdBy, requestedDate: todayISO(),
    approvedBy: null, approvedDate: null, receiverName: '', receiverPhone: '', clientSignatureUrl: null,
    // lines reference a materialAllocation (never a freehand quantity) —
    // { allocationId, materialId, description, quantity, unit, deliveredQuantity }
    lines: data.lines || [],
    // Who it's assigned to for approval — defaults to the project's own
    // Logistic Manager team assignment, but can be overridden per delivery.
    approverId: data.approverId || null,
    // Set only once Approved (Phase 5's scheduling modal) — a driver/helper
    // is a real teamDirectory id, not free text, so the Delivery Driver Hub
    // and the Calendar can both key off it directly.
    driverId: data.driverId || null, helperId: data.helperId || null, durationMinutes: data.durationMinutes || null,
    outcome: null, cancelReason: null, driverNotes: '',
    createdBy, createdDate: todayISO(),
  };
}
function normalizeDelivery(d) {
  if (d.approverId === undefined) d.approverId = null;
  if (d.driverId === undefined) d.driverId = null;
  if (d.helperId === undefined) d.helperId = null;
  if (d.durationMinutes === undefined) d.durationMinutes = null;
  if (d.outcome === undefined) d.outcome = null;
  if (d.cancelReason === undefined) d.cancelReason = null;
  if (d.driverNotes === undefined) d.driverNotes = '';
  if (d.containerId === undefined) d.containerId = null;
  (d.lines || []).forEach(l => {
    if (l.allocationId === undefined) l.allocationId = null;
    if (l.deliveredQuantity === undefined) l.deliveredQuantity = null;
  });
}
// Backfills for records persisted before these fields existed — same pattern as normalizeProject.
// Corrections to the text the Monday.com "Asset stock" import brought in. Kept
// as a MAP rather than edited into the data once, because the same rows are
// re-read from the hardcoded seed on a fresh browser — a one-off edit would
// only fix this machine. Keyed on the lowercased original, applied before the
// casing rules below.
// Only unambiguous typos are listed. "ENDUEGLUE" and the stray "S" in
// "FROST HELADA S SANDED" are left alone: they might be how the product is
// actually labelled, and a guess dressed as a correction is worse than a typo.
const MATERIAL_TEXT_FIXES = {
  'silicone sealent': 'silicone sealant',
  'insolation mat': 'insulation mat',
  // The replacement text is used VERBATIM — a token carrying a digit keeps the
  // case it is given here — so N*77 is written as it reads on the product, and
  // matches its sibling "Frost Helada S Sanded N*77". The second key catches
  // rows already corrected to the lowercase form before this was noticed.
  'frost helada unsande n*77': 'frost helada unsanded N*77',
  'frost helada unsanded n*77': 'frost helada unsanded N*77',
  'wood ultrabonde 373': 'wood ultrabond 373',   // MAPEI's adhesive is Ultrabond
};
function materialTextFixed(value) {
  const v = String(value == null ? '' : value).trim().replace(/\s+/g, ' ');
  const fix = MATERIAL_TEXT_FIXES[v.toLowerCase()];
  return fix === undefined ? v : fix;
}
// Product names were SHOUTED by the same import. They get TITLE case, not the
// sentence case the categories get, because a name carries brand names and part
// codes that sentence case destroys: "WOOD MSI MS007 TRANSITIONAL ADHESIVE"
// would become "Wood msi ms007 transitional adhesive". A token holding a DIGIT
// is a code or a size (MS007, 1MM, N*77, 373) and keeps the case it was given;
// a short all-caps token is an acronym (MSI), not a shouted word.
function materialNameLabel(value) {
  const v = materialTextFixed(value);
  if (!v) return v;
  return v.split(' ').map(word => {
    if (/\d/.test(word)) return word;
    if (word.length <= 3 && word === word.toUpperCase() && /[A-Z]/.test(word)) return word;
    return word.replace(/[A-Za-z\u00C0-\u024F]+/g, w => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase());
  }).join(' ');
}
// Inventory categories arrived from the Monday.com "Asset stock" board exactly
// as they had been typed there — GROUT, MEMBRANE - SPACERS, Glue — so the tab
// bar read as three different conventions. The category is FREE TEXT on the
// material (not a picker), so the fix cannot be a fixed list: it is one rule,
// applied wherever a category is stored. Sentence case — first letter up, the
// rest down — which is what the client asked for and what stops "GROUT" and
// "Grout" ever becoming two tabs.
function materialCategoryLabel(value) {
  const v = materialTextFixed(value);
  if (!v) return v;
  return v.charAt(0).toUpperCase() + v.slice(1).toLowerCase();
}
function normalizeWarehouseMaterial(m) {
  if (m.itemId === undefined) m.itemId = '';
  if (m.description === undefined) m.description = '';
  if (m.scopeFamily === undefined) m.scopeFamily = '';
  if (m.manufacturerVendor === undefined) m.manufacturerVendor = '';
  if (m.finishColor === undefined) m.finishColor = '';
  if (m.dimensions === undefined) m.dimensions = '';
  if (m.unitCost === undefined || m.unitCost === null) m.unitCost = 0;
  if (m.dateReceived === undefined) m.dateReceived = null;
  if (m.relatedPoId === undefined) m.relatedPoId = null;
  if (m.relatedPiId === undefined) m.relatedPiId = null;
  if (!m.pictures) m.pictures = [];
  if (!m.documents) m.documents = [];
  if (m.notes === undefined) m.notes = '';
  if (m.createdBy === undefined) m.createdBy = null;
  if (m.createdDate === undefined) m.createdDate = null;
  if (!m.activityLog) m.activityLog = [];
  m.category = materialCategoryLabel(m.category);
  m.name = materialNameLabel(m.name);
  return m;
}
function normalizeAllocation(a) {
  if (a.quantityDelivered === undefined) a.quantityDelivered = 0;
  if (a.costContribution === undefined) a.costContribution = 0;
  // Completed-job leftover materials -> stock (Phase 9) — a pending
  // conversion request sits here until both Admin and Logistic Manager
  // approve it (dual sign-off, mirrors the Vendor Estimate pm/owner pattern).
  if (a.stockConversionRequest === undefined) a.stockConversionRequest = null;
  return a;
}

// ---- Import: Monday.com "Asset stock" board -> warehouseMaterials/inventoryTransactions ----
// Generic on purpose (§4-11) — a future Projects/Clients/Vendors board export
// can reuse the same {category -> materials -> transactions} shape and this
// same build function, not a one-off script tied to this specific import.
const WAREHOUSE_IMPORT_GROUPS = [{"category":"GROUT","materials":[{"name":"IVORY","referenceNumber":902,"initialStock":28,"currentStock":27,"stockCheckDate":"2026-05-07","transactions":[{"type":"Withdrawal","quantity":-1,"date":"2024-11-12","company":null,"po":null,"invoiceNumber":"24685","requestedBy":"BAYAN","recipientText":"MATTA , DIANNE","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Deleted member"}]},{"name":"BIRCH","referenceNumber":903,"initialStock":15,"currentStock":11,"stockCheckDate":"2026-05-07","transactions":[{"type":"Withdrawal","quantity":-1,"date":"2024-04-18","company":null,"po":null,"invoiceNumber":"24421","requestedBy":"BARBARA","recipientText":"ELAN SASSOON","notes":null,"status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-1,"date":"2024-10-02","company":null,"po":null,"invoiceNumber":"24623","requestedBy":"BARBARA","recipientText":"DESJOURDY, LYNNE ADN PAUL","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Deleted member"},{"type":"Withdrawal","quantity":-1,"date":"2025-11-12","company":null,"po":null,"invoiceNumber":"24915","requestedBy":"BARBARA","recipientText":"PAUL GRANT:79 SHARIDAN ST. JAMAICA PLAIN","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-1,"date":"2025-12-17","company":null,"po":null,"invoiceNumber":"24918","requestedBy":"BARBARA","recipientText":"SLR ARCHITECTURE INC.:29 BEVERLY RD. NEWTON","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Jaciel Junior"}]},{"name":"PEARL","referenceNumber":988,"initialStock":28,"currentStock":19,"stockCheckDate":"2026-05-07","transactions":[{"type":"Withdrawal","quantity":-1,"date":"2024-05-02","company":null,"po":null,"invoiceNumber":"REF#0524-008","requestedBy":"THERENCE","recipientText":"21 ALVESTON ST- JAMAICA PLAIN","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Deleted member"},{"type":"Withdrawal","quantity":-1,"date":"2024-05-07","company":null,"po":null,"invoiceNumber":null,"requestedBy":"THERENCE","recipientText":"23 ALVESTON ST","notes":"HARLITON","status":"Completed","enteredBy":"Deleted member"},{"type":"Withdrawal","quantity":-1,"date":"2024-05-15","company":null,"po":null,"invoiceNumber":"24438","requestedBy":"BAYAN","recipientText":"17-2 ALVESTON ST - JAMAICA PLAIN","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Deleted member"},{"type":"Withdrawal","quantity":-2,"date":"2024-10-02","company":null,"po":null,"invoiceNumber":"24623","requestedBy":"BARBARA","recipientText":"DESJOURDY, LYNNE ADN PAUL","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Deleted member"},{"type":"Withdrawal","quantity":-2,"date":"2025-05-02","company":null,"po":null,"invoiceNumber":"24791","requestedBy":"BAYAN","recipientText":"KORN INTERIORS:FRED CHIN","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-1,"date":"2025-12-05","company":null,"po":null,"invoiceNumber":"24903","requestedBy":"BARBARA","recipientText":"MURRAY, PETER","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-1,"date":"2026-05-21","company":null,"po":null,"invoiceNumber":"25000","requestedBy":null,"recipientText":"SCHWARTZ, SUNNY","notes":null,"status":"Completed","enteredBy":"Jaciel Junior"}]},{"name":"PRALINE","referenceNumber":928,"initialStock":14,"currentStock":11,"stockCheckDate":"2026-05-07","transactions":[{"type":"Withdrawal","quantity":-1,"date":"2025-05-28","company":"IDEAL","po":null,"invoiceNumber":"24791","requestedBy":"CLERIO","recipientText":"KORN INTERIORS:FRED CHIN","notes":null,"status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-1,"date":"2026-05-12","company":"IDEAL","po":null,"invoiceNumber":"25004","requestedBy":"BARBARA","recipientText":"SCHWARTZ, SUNNY","notes":null,"status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-2,"date":"2026-07-14","company":"IDEAL","po":null,"invoiceNumber":"25033","requestedBy":"BARBARA","recipientText":"HARRIS JOB","notes":null,"status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Receiving","quantity":1,"date":"2026-08-17","company":"IDEAL","po":null,"invoiceNumber":"25054","requestedBy":"BARBARA","recipientText":"PRIME BUILDING","notes":null,"status":"Completed","enteredBy":"Jaciel Junior"}]},{"name":"ANTIQUE WHITE","referenceNumber":940,"initialStock":38,"currentStock":35,"stockCheckDate":"2026-05-07","transactions":[{"type":"Withdrawal","quantity":-1,"date":"2024-10-24","company":null,"po":null,"invoiceNumber":"24660","requestedBy":"BAYAN","recipientText":"BOXER, ROBERT","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Deleted member"},{"type":"Stock Check","quantity":-1,"date":"2024-10-26","company":null,"po":null,"invoiceNumber":null,"requestedBy":null,"recipientText":"LOSS","notes":"JUNIO DRUMOND","status":"STOCK  CHECK","enteredBy":"Deleted member"},{"type":"Withdrawal","quantity":-2,"date":"2026-06-17","company":null,"po":null,"invoiceNumber":null,"requestedBy":"BARBARA","recipientText":"SHOWROOM","notes":null,"status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Receiving","quantity":1,"date":"2026-07-09","company":null,"po":null,"invoiceNumber":null,"requestedBy":null,"recipientText":"RETURN 21 WINSLOW","notes":null,"status":"Completed","enteredBy":"Jaciel Junior"}]},{"name":"STANDARD WHITE","referenceNumber":931,"initialStock":32,"currentStock":9,"stockCheckDate":"2026-05-07","transactions":[{"type":"Withdrawal","quantity":-7,"date":"2024-04-25","company":null,"po":null,"invoiceNumber":"24423","requestedBy":"BARBARA","recipientText":"325 HOPPING BROOK RD","notes":null,"status":"Completed","enteredBy":"Deleted member"},{"type":"Withdrawal","quantity":-2,"date":"2024-04-29","company":null,"po":null,"invoiceNumber":"24386","requestedBy":"BAYAN","recipientText":"75 CLIFF RD - WALTHAM","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Deleted member"},{"type":"Receiving","quantity":1,"date":"2024-05-02","company":null,"po":null,"invoiceNumber":null,"requestedBy":"BARBARA","recipientText":"RETURN SHOW ROOM","notes":"JUNIO DRUMOND","status":"Received","enteredBy":"Deleted member"},{"type":"Withdrawal","quantity":-2,"date":"2024-11-12","company":null,"po":null,"invoiceNumber":null,"requestedBy":"WELLINGTON","recipientText":"SCIBELLI, VINCENZO","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Deleted member"},{"type":"Withdrawal","quantity":-8,"date":"2024-12-10","company":null,"po":null,"invoiceNumber":"1224-010","requestedBy":"ELEANDRO","recipientText":"249 COREY PROJECT","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Deleted member"},{"type":"Withdrawal","quantity":-1,"date":"2024-12-12","company":null,"po":null,"invoiceNumber":"24704","requestedBy":"BARBARA","recipientText":"HALLIDAT, ALETA","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Deleted member"},{"type":"Withdrawal","quantity":-1,"date":"2025-04-04","company":null,"po":null,"invoiceNumber":"24777","requestedBy":"BARBARA","recipientText":"DICK, DONALD","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-1,"date":"2026-01-06","company":null,"po":null,"invoiceNumber":null,"requestedBy":"CLERIO","recipientText":"MURRAY, PETER","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-1,"date":"2026-05-12","company":null,"po":null,"invoiceNumber":"25000","requestedBy":"BARBARA","recipientText":"SCHWARTZ, SUNNY","notes":"BRUNO","status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Receiving","quantity":1,"date":"2026-05-21","company":null,"po":null,"invoiceNumber":"25000","requestedBy":null,"recipientText":"RETURN SCHWARTZ, SUNNY","notes":null,"status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-2,"date":"2026-07-01","company":null,"po":null,"invoiceNumber":null,"requestedBy":"BARBARA","recipientText":"SHOWROOM","notes":null,"status":"Completed","enteredBy":"Jaciel Junior"}]},{"name":"LIGHT BUFF","referenceNumber":945,"initialStock":19,"currentStock":18,"stockCheckDate":"2026-05-07","transactions":[{"type":"Withdrawal","quantity":-1,"date":"2026-03-31","company":null,"po":null,"invoiceNumber":null,"requestedBy":"MICHAEL","recipientText":"42 LORNA RD","notes":null,"status":"Completed","enteredBy":"Jaciel Junior"}]},{"name":"STANDARD GRAY","referenceNumber":933,"initialStock":83,"currentStock":86,"stockCheckDate":"2026-05-07","transactions":[{"type":"Receiving","quantity":5,"date":"2024-05-13","company":null,"po":null,"invoiceNumber":null,"requestedBy":"NATTAN","recipientText":"RETURN 19 POND VIEW DR - NANTUCKET","notes":"HARLITON","status":"Received","enteredBy":"Deleted member"},{"type":"Withdrawal","quantity":-2,"date":"2024-07-09","company":null,"po":null,"invoiceNumber":null,"requestedBy":"BAYAN","recipientText":"33 OLD ROWN RD- WALPOLE","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Deleted member"}]},{"name":"STANDARD GRAY (UNSANDED)","referenceNumber":933,"initialStock":6,"currentStock":6,"stockCheckDate":"2026-05-07","transactions":[]},{"name":"CORNSILK","referenceNumber":906,"initialStock":5,"currentStock":4,"stockCheckDate":"2026-05-07","transactions":[{"type":"Withdrawal","quantity":-1,"date":"2025-04-29","company":null,"po":null,"invoiceNumber":null,"requestedBy":"CLERIO","recipientText":"154 WOODLAND","notes":null,"status":"Completed","enteredBy":"Jaciel Junior"}]},{"name":"SILVERADO","referenceNumber":949,"initialStock":22,"currentStock":38,"stockCheckDate":"2026-05-07","transactions":[{"type":"Withdrawal","quantity":-2,"date":"2024-03-26","company":null,"po":null,"invoiceNumber":"24379","requestedBy":"NATTAN","recipientText":"17 ALVESTON ST- JAMAIC PLAIN","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Deleted member"},{"type":"Withdrawal","quantity":-4,"date":"2024-03-26","company":null,"po":null,"invoiceNumber":"REF#0324-047","requestedBy":"NATTAN","recipientText":"1055 CAMBRIDGE ST","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Deleted member"},{"type":"Withdrawal","quantity":-2,"date":"2024-04-22","company":null,"po":null,"invoiceNumber":null,"requestedBy":"WELLINGTON","recipientText":"TERRAZZA","notes":"JUNIOR","status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-4,"date":"2024-04-29","company":null,"po":null,"invoiceNumber":null,"requestedBy":"CLERIO","recipientText":"14 WINSLOW RD","notes":"HARLITON","status":"Completed","enteredBy":"Deleted member"},{"type":"Withdrawal","quantity":-3,"date":"2024-05-07","company":null,"po":null,"invoiceNumber":null,"requestedBy":"THERENCE","recipientText":"TERRAZZA","notes":"HARLITON","status":"Completed","enteredBy":"Deleted member"},{"type":"Withdrawal","quantity":-6,"date":"2024-05-10","company":null,"po":null,"invoiceNumber":null,"requestedBy":"BAYAN","recipientText":"29 MECLEAN ST- WELLESLEY","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Deleted member"},{"type":"Receiving","quantity":2,"date":"2024-05-17","company":null,"po":null,"invoiceNumber":null,"requestedBy":"BARBARA","recipientText":"RETURN 14 WINSLOW RD","notes":"JUNIO DRUMOND","status":"Received","enteredBy":"Deleted member"},{"type":"Receiving","quantity":1,"date":"2024-06-21","company":null,"po":null,"invoiceNumber":null,"requestedBy":"BARBARA","recipientText":"RETURN SHOW ROOM","notes":"JUNIO DRUMOND","status":"Received","enteredBy":"Deleted member"},{"type":"Withdrawal","quantity":-4,"date":"2024-07-01","company":null,"po":null,"invoiceNumber":"24550","requestedBy":"BAYAN","recipientText":"PICK-UP KATE COLLINS","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Deleted member"},{"type":"Receiving","quantity":40,"date":"2024-07-09","company":null,"po":"28861","invoiceNumber":null,"requestedBy":"BARBARA","recipientText":"H.B FULLER","notes":"JUNIO DRUMOND","status":"Received","enteredBy":"Deleted member"},{"type":"Withdrawal","quantity":-2,"date":"2024-07-11","company":null,"po":null,"invoiceNumber":"24561","requestedBy":"BAYAN","recipientText":"44 PIER 7 CHARLESTONWN","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Deleted member"},{"type":"Withdrawal","quantity":-2,"date":"2024-08-09","company":null,"po":null,"invoiceNumber":"24599","requestedBy":"BAYAN","recipientText":"DIB.SAED","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Deleted member"},{"type":"Withdrawal","quantity":-10,"date":"2024-09-20","company":null,"po":null,"invoiceNumber":"0924-015","requestedBy":"THERENCE","recipientText":"249 COREY PROJECT","notes":"JUNIOR","status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-1,"date":"2024-10-24","company":null,"po":null,"invoiceNumber":null,"requestedBy":"CAROLLINE","recipientText":"771 RIVERSIDE DR-METHUEN","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Deleted member"},{"type":"Withdrawal","quantity":-2,"date":"2024-10-26","company":null,"po":null,"invoiceNumber":null,"requestedBy":null,"recipientText":"WAREHOUSE -LOSS BAG","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-1,"date":"2024-11-13","company":null,"po":null,"invoiceNumber":"24638","requestedBy":"BARBARA","recipientText":"BRIAR DESIGN:MARILYN'S HOUSE","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Deleted member"},{"type":"Withdrawal","quantity":-10,"date":"2024-11-19","company":null,"po":null,"invoiceNumber":"Ref # 1124-014","requestedBy":"ELEANDRO","recipientText":"249 COREY PROJECT","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Deleted member"},{"type":"Withdrawal","quantity":-1,"date":"2024-12-09","company":null,"po":null,"invoiceNumber":"24593","requestedBy":"BARBARA","recipientText":"SCIBELLI, VINCENZO","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Deleted member"},{"type":"Withdrawal","quantity":-1,"date":"2025-01-23","company":null,"po":null,"invoiceNumber":"24740","requestedBy":"BARBARA","recipientText":"HANDYMAND PRO LLC","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Deleted member"},{"type":"Withdrawal","quantity":-1,"date":"2025-02-12","company":null,"po":null,"invoiceNumber":"24735/ IDEAL - 23","requestedBy":"BARBARA","recipientText":"COMBS, LEE ANNE","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Deleted member"},{"type":"Withdrawal","quantity":-2,"date":"2025-04-09","company":null,"po":null,"invoiceNumber":"IDEAL -41","requestedBy":"IBRAHIM","recipientText":"C STUMPO 36 BONNYBROOK RD","notes":null,"status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-2,"date":"2025-04-17","company":null,"po":null,"invoiceNumber":"24788","requestedBy":"BARBARA","recipientText":"SCHINDLER, SHARON","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-1,"date":"2025-04-28","company":null,"po":null,"invoiceNumber":"24793","requestedBy":"BAYAN","recipientText":"GOWER DESIGN GROUP","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-1,"date":"2025-06-02","company":null,"po":null,"invoiceNumber":"24820/IDEAL-59","requestedBy":"BAYAN","recipientText":"WALTUCK,BEN","notes":null,"status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-1,"date":"2025-06-04","company":null,"po":null,"invoiceNumber":"24826","requestedBy":"BARBARA","recipientText":"INV.:24826","notes":"JUNIOR","status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-1,"date":"2025-06-13","company":null,"po":null,"invoiceNumber":"24822 - IDEAL 65","requestedBy":"BARBARA","recipientText":"BRIAR DESIGN:74 EATON RD. NEEDHMA","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Receiving","quantity":40,"date":"2025-07-21","company":null,"po":null,"invoiceNumber":null,"requestedBy":"BARBARA","recipientText":"TEC SPECIALTY PRODUCTS LLC","notes":"JUNIO DRUMOND","status":"Received","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-1,"date":"2025-08-20","company":null,"po":null,"invoiceNumber":null,"requestedBy":"CLERIO","recipientText":"144 WORCESTER","notes":null,"status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-1,"date":"2025-08-27","company":null,"po":null,"invoiceNumber":null,"requestedBy":"MICHAEL - CLERIO","recipientText":"144 WORCESTER","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-1,"date":"2025-09-19","company":null,"po":null,"invoiceNumber":null,"requestedBy":"BARBARA","recipientText":"HYNES, DAVID","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-1,"date":"2026-01-28","company":null,"po":null,"invoiceNumber":null,"requestedBy":"CLERIO","recipientText":"C STUMPO INC: 332 BEACON ST","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-1,"date":"2026-05-05","company":null,"po":null,"invoiceNumber":null,"requestedBy":"MICHAEL","recipientText":"21 WINSLOW","notes":null,"status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Receiving","quantity":2,"date":"2026-05-07","company":null,"po":null,"invoiceNumber":null,"requestedBy":null,"recipientText":"FIND","notes":null,"status":"Received","enteredBy":"Jaciel Junior"}]},{"name":"SILVERADO (UNSANDED)","referenceNumber":949,"initialStock":19,"currentStock":15,"stockCheckDate":"2026-05-07","transactions":[{"type":"Withdrawal","quantity":-4,"date":"2024-03-26","company":null,"po":null,"invoiceNumber":"REF#0324-047","requestedBy":"NATTAN","recipientText":"1055 CAMBRIDGE ST","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Deleted member"}]},{"name":"BRIGHT WHITE","referenceNumber":910,"initialStock":38,"currentStock":13,"stockCheckDate":"2026-05-07","transactions":[{"type":"Withdrawal","quantity":-5,"date":"2024-04-03","company":null,"po":null,"invoiceNumber":null,"requestedBy":"NATTAN","recipientText":"55 NORTH MAIN S ROCHESTER NH","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Deleted member"},{"type":"Withdrawal","quantity":-1,"date":"2024-04-09","company":null,"po":null,"invoiceNumber":"24380","requestedBy":"BDS","recipientText":"230 TREMONT ST","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Deleted member"},{"type":"Receiving","quantity":5,"date":"2024-05-13","company":null,"po":null,"invoiceNumber":null,"requestedBy":"NATTAN","recipientText":"RETURN 19 POND VIEW DR - NANTUCKET","notes":"HARLITON","status":"Received","enteredBy":"Deleted member"},{"type":"Receiving","quantity":3,"date":"2024-06-13","company":null,"po":null,"invoiceNumber":null,"requestedBy":null,"recipientText":"RETURN TERRAZZA","notes":"JAIR JR","status":"Received","enteredBy":"Deleted member"},{"type":"Withdrawal","quantity":-1,"date":"2024-09-10","company":null,"po":null,"invoiceNumber":null,"requestedBy":"CLERIO","recipientText":"21 NORWELL AVE SCITUATE - JEREMY HENRY","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Deleted member"},{"type":"Withdrawal","quantity":-2,"date":"2024-10-02","company":null,"po":null,"invoiceNumber":null,"requestedBy":"CLERIO","recipientText":"C STUMPO INC.:152 WOODLAND RD. CHESTNUT HILL","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Deleted member"},{"type":"Withdrawal","quantity":-1,"date":"2024-11-04","company":null,"po":null,"invoiceNumber":null,"requestedBy":"BARBARA","recipientText":"BRIAR DESIGN:101 WACHUSETT RD. NEEDHAM","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Deleted member"},{"type":"Withdrawal","quantity":-1,"date":"2025-03-31","company":null,"po":null,"invoiceNumber":"24743","requestedBy":"CLERIO","recipientText":"PORTLAND GRP.:SPLASH OF NEWTON:5 HIPPOGRIFFE RD. DENNIS","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-1,"date":"2025-04-17","company":null,"po":null,"invoiceNumber":"24788","requestedBy":"BARBARA","recipientText":"SCHINDLER, SHARON","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-1,"date":"2025-06-13","company":null,"po":null,"invoiceNumber":"24822- IDEAL -65","requestedBy":"BARBARA","recipientText":"BRIAR DESIGN:74 EATON RD. NEEDHMA","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-1,"date":"2025-12-17","company":null,"po":null,"invoiceNumber":"24918","requestedBy":"BARBARA","recipientText":"SLR ARCHITECTURE INC.:29 BEVERLY RD. NEWTON","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-1,"date":"2025-12-05","company":null,"po":null,"invoiceNumber":"24903","requestedBy":"BARBARA","recipientText":"MURRAY, PETER","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-1,"date":"2026-02-11","company":null,"po":null,"invoiceNumber":"24942","requestedBy":"BARBARA","recipientText":"DANA, ALAN","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-1,"date":"2026-03-27","company":null,"po":null,"invoiceNumber":"24976","requestedBy":"BARBARA","recipientText":"MORRISSEY, BRIAN","notes":null,"status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-1,"date":"2026-05-05","company":null,"po":null,"invoiceNumber":null,"requestedBy":"MICHAEL","recipientText":"21 WINSLOW","notes":null,"status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-1,"date":"2026-05-18","company":null,"po":null,"invoiceNumber":"24974","requestedBy":"BARBARA","recipientText":"ZWACK, PHIL","notes":null,"status":"On hold","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-10,"date":"2026-06-17","company":"LEON","po":null,"invoiceNumber":"LEON-243","requestedBy":"ALEX KOGAN","recipientText":"45 BARTLETT","notes":null,"status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-2,"date":"2026-06-17","company":null,"po":null,"invoiceNumber":null,"requestedBy":"BARBARA","recipientText":"SHOWROOM","notes":null,"status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-13,"date":"2026-06-18","company":"LEON","po":null,"invoiceNumber":"LEON-244","requestedBy":"ALEX KOGAN","recipientText":"45 BARTLETT","notes":null,"status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Receiving","quantity":20,"date":"2026-06-22","company":null,"po":"29356","invoiceNumber":null,"requestedBy":null,"recipientText":"RECEIVED TEC","notes":null,"status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Receiving","quantity":1,"date":"2026-07-09","company":null,"po":null,"invoiceNumber":null,"requestedBy":null,"recipientText":"RETURN 21 WINSLOW","notes":null,"status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-10,"date":"2026-08-07","company":"LEON","po":null,"invoiceNumber":"LEON 261","requestedBy":null,"recipientText":"45 BARTLETT","notes":null,"status":"Completed","enteredBy":"Jaciel Junior"}]},{"name":"BRIGHT WHITE (UNSANDED)","referenceNumber":910,"initialStock":27,"currentStock":32,"stockCheckDate":"2026-05-07","transactions":[{"type":"Receiving","quantity":1,"date":"2024-05-13","company":null,"po":null,"invoiceNumber":null,"requestedBy":"NATTAN","recipientText":"RETURN 19 POND VIEW DR - NANTUCKET","notes":"HARLITON","status":"Received","enteredBy":"Deleted member"},{"type":"Receiving","quantity":4,"date":null,"company":null,"po":null,"invoiceNumber":null,"requestedBy":null,"recipientText":"STOCK","notes":null,"status":"Completed","enteredBy":"Deleted member"}]},{"name":"ALMOND","referenceNumber":984,"initialStock":22,"currentStock":22,"stockCheckDate":"2026-05-07","transactions":[]},{"name":"DOVE GRAY","referenceNumber":908,"initialStock":null,"currentStock":12,"stockCheckDate":"2026-05-07","transactions":[{"type":"Receiving","quantity":20,"date":"2024-04-10","company":null,"po":"28752","invoiceNumber":null,"requestedBy":"BDS","recipientText":"H.B. FULLER","notes":"JUNIO DRUMOND","status":"Received","enteredBy":"Deleted member"},{"type":"Withdrawal","quantity":-3,"date":"2024-04-29","company":null,"po":null,"invoiceNumber":null,"requestedBy":"CLERIO","recipientText":"14 WINSLOW RD","notes":"HARLITON","status":"Completed","enteredBy":"Deleted member"},{"type":"Withdrawal","quantity":-1,"date":"2024-04-30","company":null,"po":"28791","invoiceNumber":"24428","requestedBy":"BAYAN","recipientText":"30 VINEY ARD RD","notes":"HARLITON","status":"Completed","enteredBy":"Deleted member"},{"type":"Withdrawal","quantity":-1,"date":"2024-05-28","company":null,"po":null,"invoiceNumber":"24433","requestedBy":"BARBARA","recipientText":"211 CORY ST - WEST ROCBURY","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Deleted member"},{"type":"Withdrawal","quantity":-1,"date":"2024-11-12","company":null,"po":null,"invoiceNumber":null,"requestedBy":"WELLINGTON","recipientText":"SCIBELLI, VINCENZO","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Deleted member"},{"type":"Withdrawal","quantity":-1,"date":"2024-12-09","company":null,"po":null,"invoiceNumber":"24593","requestedBy":"BARBARA","recipientText":"SCIBELLI, VINCENZO","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Deleted member"},{"type":"Withdrawal","quantity":-1,"date":"2026-05-05","company":null,"po":null,"invoiceNumber":null,"requestedBy":"MICHAEL","recipientText":"21 WINSLOW","notes":null,"status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-1,"date":"2026-07-13","company":null,"po":null,"invoiceNumber":"25030","requestedBy":null,"recipientText":"60 LYMAN RD.","notes":null,"status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Receiving","quantity":1,"date":"2026-07-29","company":null,"po":null,"invoiceNumber":null,"requestedBy":null,"recipientText":"RETURN - 21 WINSLOW","notes":null,"status":"Completed","enteredBy":"Jaciel Junior"}]},{"name":"LIGHT SMOKE","referenceNumber":915,"initialStock":11,"currentStock":8,"stockCheckDate":"2026-05-07","transactions":[{"type":"Stock Check","quantity":-3,"date":"2024-10-26","company":null,"po":null,"invoiceNumber":null,"requestedBy":null,"recipientText":"LOSS","notes":null,"status":"STOCK  CHECK","enteredBy":"Deleted member"}]},{"name":"SABLE","referenceNumber":925,"initialStock":6,"currentStock":6,"stockCheckDate":"2026-05-07","transactions":[]},{"name":"LIGHT PEWTER","referenceNumber":927,"initialStock":15,"currentStock":15,"stockCheckDate":"2026-05-07","transactions":[]},{"name":"CHARCOAL GRAY","referenceNumber":929,"initialStock":8,"currentStock":32,"stockCheckDate":"2026-05-07","transactions":[{"type":"Withdrawal","quantity":-1,"date":"2024-03-26","company":null,"po":null,"invoiceNumber":"24379","requestedBy":"NATTAN","recipientText":"17 ALVESTON ST- JAMAIC PLAIN","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Deleted member"},{"type":"Receiving","quantity":4,"date":"2024-05-13","company":null,"po":null,"invoiceNumber":null,"requestedBy":"NATTAN","recipientText":"RETURN 19 POND VIEW DR - NANTUCKET","notes":"HARLITON","status":"Received","enteredBy":"Deleted member"},{"type":"Withdrawal","quantity":-3,"date":"2024-05-17","company":null,"po":null,"invoiceNumber":"REF#0524-32","requestedBy":"CAROLLINE","recipientText":"771 RIVERSIDE DR - METHUEN","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Deleted member"},{"type":"Receiving","quantity":3,"date":"2024-05-30","company":null,"po":null,"invoiceNumber":null,"requestedBy":"FRED","recipientText":"RETURN - 771 RIVERSIDE DR - METHUEN","notes":"JUNIO DRUMOND","status":"Received","enteredBy":"Deleted member"},{"type":"Stock Check","quantity":-1,"date":"2024-10-26","company":null,"po":null,"invoiceNumber":null,"requestedBy":null,"recipientText":"LOOS","notes":"JUNIO DRUMOND","status":"STOCK  CHECK","enteredBy":"Deleted member"},{"type":"Withdrawal","quantity":-4,"date":"2024-10-29","company":null,"po":"28988","invoiceNumber":null,"requestedBy":"BAYAN","recipientText":"C STUMPO INC.:119 CABOT STREET, NEWTON","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Deleted member"},{"type":"Withdrawal","quantity":-1,"date":"2025-01-07","company":null,"po":null,"invoiceNumber":"24722/ IDEAL -10","requestedBy":"BARBARA","recipientText":"PRESTON, DIANE","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Deleted member"},{"type":"Withdrawal","quantity":-1,"date":"2025-06-13","company":null,"po":null,"invoiceNumber":"24822- IDEAL -65","requestedBy":"BARBARA","recipientText":"BRIAR DESIGN:74 EATON RD. NEEDHMA","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-1,"date":"2025-07-15","company":null,"po":null,"invoiceNumber":null,"requestedBy":"BARBARA / CLERIO","recipientText":"C STUMPO INC.:170 ELGIN RD. NEWTON,MA","notes":null,"status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-3,"date":"2025-07-29","company":null,"po":null,"invoiceNumber":"24868","requestedBy":"BARBARA","recipientText":"SHASHOUA, MICHAEL:90 ADDINGTON RD. BROOKLINE","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Receiving","quantity":32,"date":"2025-08-14","company":null,"po":"29203","invoiceNumber":null,"requestedBy":"BARBARA","recipientText":"RECEIVED - TEC SPECIALTY PRODUCTS","notes":"JUNIO DRUMOND","status":"Received","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-1,"date":"2025-11-18","company":null,"po":null,"invoiceNumber":"24913","requestedBy":"BARBARA","recipientText":"WHALEN, ELISABETH:AMANDA NAIVEN","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-1,"date":"2026-01-28","company":null,"po":null,"invoiceNumber":null,"requestedBy":"CLERIO","recipientText":"C STUMPO INC: 332 BEACON ST","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Receiving","quantity":2,"date":"2026-05-07","company":null,"po":null,"invoiceNumber":null,"requestedBy":null,"recipientText":"FOUND","notes":null,"status":"Received","enteredBy":"Jaciel Junior"}]},{"name":"MOCHA","referenceNumber":932,"initialStock":8,"currentStock":8,"stockCheckDate":"2026-05-07","transactions":[]},{"name":"SLATE GRAY","referenceNumber":934,"initialStock":38,"currentStock":24,"stockCheckDate":"2026-05-07","transactions":[{"type":"Withdrawal","quantity":-1,"date":"2024-05-21","company":null,"po":null,"invoiceNumber":"24531","requestedBy":"BARBARA","recipientText":"35 ROYCE RD NEWTON","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Deleted member"},{"type":"Withdrawal","quantity":-1,"date":"2024-07-11","company":null,"po":null,"invoiceNumber":"24561","requestedBy":"BAYAN","recipientText":"44 PIER 7 CHARLESTONWN","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Deleted member"},{"type":"Withdrawal","quantity":-1,"date":"2024-10-15","company":null,"po":null,"invoiceNumber":"24643","requestedBy":"BARBARA","recipientText":"PAUL, JAMES","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Deleted member"},{"type":"Stock Check","quantity":-3,"date":"2024-10-26","company":null,"po":null,"invoiceNumber":null,"requestedBy":null,"recipientText":"LOSS","notes":"JUNIO DRUMOND","status":"STOCK  CHECK","enteredBy":"Deleted member"},{"type":"Withdrawal","quantity":-1,"date":"2025-04-01","company":null,"po":null,"invoiceNumber":"24782","requestedBy":"BARBARA","recipientText":"JASMINE'S HOUSE","notes":null,"status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-4,"date":"2025-04-11","company":null,"po":null,"invoiceNumber":null,"requestedBy":"JUNIOR","recipientText":"C STUMPO INC.:36 BONNYBROOK RD","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-1,"date":"2025-06-02","company":null,"po":null,"invoiceNumber":"24820/IDEAL-59","requestedBy":"BAYAN","recipientText":"WALTUCK,BEN","notes":null,"status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-1,"date":"2026-03-31","company":null,"po":null,"invoiceNumber":null,"requestedBy":"MICHAEL","recipientText":"42 LORNA RD","notes":null,"status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-1,"date":"2026-05-05","company":null,"po":null,"invoiceNumber":null,"requestedBy":"MICHAEL","recipientText":"152 WOODLAND","notes":null,"status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-1,"date":"2026-05-05","company":null,"po":null,"invoiceNumber":null,"requestedBy":"MICHAEL","recipientText":"21 WISLOW","notes":null,"status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Receiving","quantity":1,"date":"2026-07-09","company":null,"po":null,"invoiceNumber":null,"requestedBy":null,"recipientText":"RETURN 21 WINSLOW","notes":null,"status":"Completed","enteredBy":"Jaciel Junior"}]},{"name":"RAVEN","referenceNumber":941,"initialStock":21,"currentStock":15,"stockCheckDate":"2026-05-07","transactions":[{"type":"Withdrawal","quantity":-1,"date":"2024-04-09","company":null,"po":null,"invoiceNumber":"24380","requestedBy":"BDS","recipientText":"230 TREMONT ST","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Deleted member"},{"type":"Withdrawal","quantity":-2,"date":"2024-04-18","company":null,"po":null,"invoiceNumber":"24420","requestedBy":"BAYAN","recipientText":"53 N COMMON ST - LYNN","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Deleted member"},{"type":"Withdrawal","quantity":-2,"date":"2024-05-01","company":null,"po":null,"invoiceNumber":"24432","requestedBy":"BDS","recipientText":"230 TREMONT ST- BOSTON","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Deleted member"},{"type":"Withdrawal","quantity":-2,"date":"2024-05-10","company":null,"po":null,"invoiceNumber":"24427","requestedBy":"BAYAN","recipientText":"29 MECLEAN ST- WELLESLEY","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Deleted member"},{"type":"Receiving","quantity":1,"date":"2024-05-13","company":null,"po":null,"invoiceNumber":null,"requestedBy":"NATTAN","recipientText":"RETURN 19 POND VIEW DR - NANTUCKET","notes":"HARLITON","status":"Received","enteredBy":"Deleted member"},{"type":"Withdrawal","quantity":-1,"date":"2024-05-21","company":null,"po":null,"invoiceNumber":"24531","requestedBy":"BARBARA","recipientText":"35 ROYCE RD NEWTON","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Deleted member"},{"type":"Receiving","quantity":1,"date":"2024-06-13","company":null,"po":null,"invoiceNumber":null,"requestedBy":null,"recipientText":"RETURN TERRAZZA","notes":"JAIR JR","status":"Received","enteredBy":"Deleted member"},{"type":"Stock Check","quantity":-3,"date":"2024-10-26","company":null,"po":null,"invoiceNumber":null,"requestedBy":null,"recipientText":"LOSS","notes":"JUNIO DRUMOND","status":"STOCK  CHECK","enteredBy":"Deleted member"},{"type":"Withdrawal","quantity":-3,"date":"2024-10-29","company":null,"po":null,"invoiceNumber":"28988","requestedBy":"BAYAN","recipientText":"C STUMPO INC.:119 CABOT STREET, NEWTON","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Deleted member"},{"type":"Withdrawal","quantity":-3,"date":"2024-11-19","company":null,"po":null,"invoiceNumber":null,"requestedBy":"JUNIOR","recipientText":"C STUMPO INC.:119 CABOT STREET, NEWTON","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Deleted member"},{"type":"Withdrawal","quantity":-4,"date":"2024-12-02","company":null,"po":null,"invoiceNumber":"24701","requestedBy":"BAYAN","recipientText":"C STUMPO INC.:119 CABOT STREET, NEWTON","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Deleted member"},{"type":"Withdrawal","quantity":-2,"date":"2025-01-23","company":null,"po":null,"invoiceNumber":"24730","requestedBy":"BAYAN","recipientText":"MACAULEY, LEIHA","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Deleted member"},{"type":"Receiving","quantity":1,"date":"2025-04-23","company":null,"po":null,"invoiceNumber":null,"requestedBy":"BAYAN","recipientText":"SHOWROOM","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-1,"date":"2025-04-23","company":null,"po":null,"invoiceNumber":"24766","requestedBy":"BAYAN","recipientText":"HOLDEN, DEVON:58 CUTLER STREET","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Receiving","quantity":20,"date":"2025-04-23","company":null,"po":null,"invoiceNumber":null,"requestedBy":"BARBARA","recipientText":"TEC SPECIALTY PRODUCTS","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-2,"date":"2025-04-28","company":null,"po":null,"invoiceNumber":"24793","requestedBy":"BAYAN","recipientText":"GOWER DESIGN GROUP","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-2,"date":"2025-06-25","company":null,"po":null,"invoiceNumber":null,"requestedBy":"BARBARA","recipientText":"BRING TO SHOWROOM","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-1,"date":"2026-02-26","company":null,"po":null,"invoiceNumber":"LEON-192","requestedBy":"ALEX","recipientText":"36 MILFORD PROJECT","notes":null,"status":"Completed","enteredBy":"Jaciel Junior"}]},{"name":"LIGHT CHOCOLATE","referenceNumber":944,"initialStock":18,"currentStock":18,"stockCheckDate":"2026-05-07","transactions":[]},{"name":"ESPRESSO","referenceNumber":958,"initialStock":23,"currentStock":21,"stockCheckDate":"2026-05-07","transactions":[{"type":"Withdrawal","quantity":-1,"date":"2024-06-27","company":null,"po":"28857","invoiceNumber":null,"requestedBy":"BAYAN","recipientText":"33 OLD TOWN RD - WALPOLE","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Deleted member"},{"type":"Withdrawal","quantity":-1,"date":"2025-09-17","company":null,"po":null,"invoiceNumber":"24878","requestedBy":"BARBARA","recipientText":"MASOOD, SOHAIL","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Jaciel Junior"}]},{"name":"SANDSTONE BEIGE","referenceNumber":961,"initialStock":23,"currentStock":21,"stockCheckDate":"2026-05-07","transactions":[{"type":"Withdrawal","quantity":-1,"date":"2024-10-02","company":null,"po":null,"invoiceNumber":"24623","requestedBy":"BARBARA","recipientText":"DESJOURDY, LYNNE ADN PAUL","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Deleted member"},{"type":"Withdrawal","quantity":-2,"date":null,"company":null,"po":null,"invoiceNumber":null,"requestedBy":"MICHAEL","recipientText":"21 WINSLOW","notes":null,"status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Receiving","quantity":1,"date":null,"company":null,"po":null,"invoiceNumber":null,"requestedBy":null,"recipientText":"21 WINSLOW RETURN","notes":null,"status":"Completed","enteredBy":"Jaciel Junior"}]},{"name":"COFFEE","referenceNumber":969,"initialStock":21,"currentStock":17,"stockCheckDate":"2026-05-07","transactions":[{"type":"Withdrawal","quantity":-4,"date":"2024-03-26","company":null,"po":null,"invoiceNumber":"24379","requestedBy":"NATTAN","recipientText":"17 ALESTON ST- JAMAICA PLAIN","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Deleted member"}]},{"name":"WARM TAUPE","referenceNumber":973,"initialStock":24,"currentStock":24,"stockCheckDate":"2026-05-07","transactions":[]},{"name":"SUMMER WHEAT","referenceNumber":982,"initialStock":29,"currentStock":29,"stockCheckDate":"2026-05-07","transactions":[]},{"name":"PARCHMENT","referenceNumber":991,"initialStock":8,"currentStock":8,"stockCheckDate":"2026-05-07","transactions":[]},{"name":"SAND","referenceNumber":985,"initialStock":13,"currentStock":13,"stockCheckDate":"2026-05-07","transactions":[]},{"name":"DARK WALNUT","referenceNumber":994,"initialStock":12,"currentStock":9,"stockCheckDate":"2026-05-07","transactions":[{"type":"Withdrawal","quantity":-3,"date":"2025-03-05","company":null,"po":null,"invoiceNumber":"24725/ 24749","requestedBy":"BARBARA","recipientText":"HICKOX WILLIAMS ARCHITECTS:25 BRADDOCK PARK, BOSTON","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-1,"date":"2026-05-05","company":null,"po":null,"invoiceNumber":null,"requestedBy":"MICHAEL","recipientText":"21 WINSLOW","notes":null,"status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Receiving","quantity":1,"date":"2026-05-07","company":null,"po":null,"invoiceNumber":null,"requestedBy":null,"recipientText":"FOUND","notes":null,"status":"Received","enteredBy":"Jaciel Junior"}]},{"name":"MIST","referenceNumber":939,"initialStock":43,"currentStock":32,"stockCheckDate":"2026-05-07","transactions":[{"type":"Withdrawal","quantity":-1,"date":"2024-07-03","company":null,"po":null,"invoiceNumber":"24562","requestedBy":null,"recipientText":"65 GORDON RD- NEWTON","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Deleted member"},{"type":"Withdrawal","quantity":-1,"date":"2024-10-26","company":null,"po":null,"invoiceNumber":null,"requestedBy":null,"recipientText":"WAREHOUSE - 1 LOST BAG","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Deleted member"},{"type":"Withdrawal","quantity":-2,"date":"2024-11-13","company":null,"po":null,"invoiceNumber":"24638","requestedBy":"BARBARA","recipientText":"BRIAR DESIGN:MARILYN'S HOUSE","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Deleted member"},{"type":"Withdrawal","quantity":-1,"date":"2024-12-09","company":null,"po":null,"invoiceNumber":"24593","requestedBy":"BARBARA","recipientText":"SCIBELLI, VINCENZO","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Deleted member"},{"type":"Withdrawal","quantity":-1,"date":"2025-05-02","company":null,"po":null,"invoiceNumber":"24791","requestedBy":"BAYAN","recipientText":"KORN INTERIORS:FRED CHIN","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-1,"date":"2025-06-13","company":null,"po":null,"invoiceNumber":"24822- IDEAL -65","requestedBy":"BARBARA","recipientText":"BRIAR DESIGN:74 EATON RD. NEEDHMA","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-1,"date":"2025-08-01","company":null,"po":null,"invoiceNumber":"24638","requestedBy":"BARBARA","recipientText":"BRIAR DESIGN:MARILYN'S HOUSE","notes":null,"status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-1,"date":"2025-11-18","company":null,"po":null,"invoiceNumber":"24913","requestedBy":"BARBARA","recipientText":"WHALEN, ELISABETH:AMANDA NAIVEN","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-1,"date":"2026-05-05","company":null,"po":null,"invoiceNumber":null,"requestedBy":"MICHAEL","recipientText":"21 WINSLOW","notes":null,"status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Receiving","quantity":1,"date":"2026-05-07","company":null,"po":null,"invoiceNumber":null,"requestedBy":null,"recipientText":"FOUND","notes":null,"status":"Received","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-1,"date":"2026-05-12","company":null,"po":"29328","invoiceNumber":"24987","requestedBy":"BARBARA","recipientText":"C STUMPO","notes":"BRUNO","status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-1,"date":null,"company":null,"po":"29348","invoiceNumber":"25016","requestedBy":"BARBARA","recipientText":"THOMAS, HELEN","notes":null,"status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-1,"date":"2026-06-16","company":null,"po":"29342-43","invoiceNumber":"25009","requestedBy":null,"recipientText":"ELISABETH, WHALEN","notes":null,"status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Receiving","quantity":1,"date":"2026-07-29","company":null,"po":null,"invoiceNumber":null,"requestedBy":null,"recipientText":"RETURN - 21 WINSLOW","notes":null,"status":"Completed","enteredBy":"Jaciel Junior"}]},{"name":"MIST (UNSANDED)","referenceNumber":939,"initialStock":10,"currentStock":10,"stockCheckDate":"2026-05-07","transactions":[]},{"name":"STERLING","referenceNumber":909,"initialStock":3,"currentStock":7,"stockCheckDate":"2026-05-07","transactions":[{"type":"Withdrawal","quantity":-1,"date":"2024-03-26","company":null,"po":null,"invoiceNumber":"24379","requestedBy":"NATTAN","recipientText":"17 ALVESTON ST- JAMAIC PLAIN","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Deleted member"},{"type":"Withdrawal","quantity":-1,"date":"2024-04-02","company":null,"po":null,"invoiceNumber":"24360","requestedBy":"BARBARA","recipientText":"548 SOUTH ST. CARLISE","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Deleted member"},{"type":"Receiving","quantity":40,"date":"2024-05-02","company":null,"po":"28790","invoiceNumber":null,"requestedBy":"BARBARA","recipientText":"HB FULLER","notes":"HARLITON","status":"Received","enteredBy":"Deleted member"},{"type":"Receiving","quantity":1,"date":"2024-05-02","company":null,"po":null,"invoiceNumber":null,"requestedBy":"BARBARA","recipientText":"RETURN SHOW ROOM","notes":"JUNIO DRUMOND","status":"Received","enteredBy":"Deleted member"},{"type":"Withdrawal","quantity":-1,"date":"2024-05-06","company":null,"po":null,"invoiceNumber":"24400","requestedBy":"BARBARA","recipientText":"182 FOREST ST  WALTHAM","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Deleted member"},{"type":"Withdrawal","quantity":-1,"date":"2024-05-02","company":null,"po":null,"invoiceNumber":null,"requestedBy":"BARBARA","recipientText":"17 ALVESTON ST- JAMAIC PLAIN","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Deleted member"},{"type":"Withdrawal","quantity":-2,"date":"2024-05-10","company":null,"po":null,"invoiceNumber":"24427","requestedBy":"BAYAN","recipientText":"29 MECLEAN ST- WELLESLEY","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Deleted member"},{"type":"Withdrawal","quantity":-1,"date":"2024-05-13","company":null,"po":null,"invoiceNumber":"24434","requestedBy":"BAYAN","recipientText":"PICK UP - DEB BENDETSON","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Deleted member"},{"type":"Receiving","quantity":1,"date":"2024-05-13","company":null,"po":null,"invoiceNumber":null,"requestedBy":"NATTAN","recipientText":"RETURN 19 POND VIEW DR - NANTUCKET","notes":"HARLITON","status":"Received","enteredBy":"Deleted member"},{"type":"Withdrawal","quantity":-5,"date":"2024-05-17","company":null,"po":null,"invoiceNumber":null,"requestedBy":"BARBARA","recipientText":"SHOW ROOM - JUNIOR","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Deleted member"},{"type":"Withdrawal","quantity":-6,"date":"2024-05-22","company":null,"po":null,"invoiceNumber":null,"requestedBy":"BARBARA","recipientText":"SHOW ROOM - FRED - DELIVERY","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Deleted member"},{"type":"Withdrawal","quantity":-4,"date":"2024-06-24","company":null,"po":null,"invoiceNumber":"24548","requestedBy":"BARBARA","recipientText":"42 8TH ST CHARLESTOWN","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Deleted member"},{"type":"Receiving","quantity":4,"date":"2024-06-21","company":null,"po":null,"invoiceNumber":null,"requestedBy":"BARBARA","recipientText":"RETURN SHOW ROOM","notes":"JUNIO DRUMOND","status":"Received","enteredBy":"Deleted member"},{"type":"Withdrawal","quantity":-2,"date":"2024-07-01","company":null,"po":null,"invoiceNumber":"24551","requestedBy":"BAYAN","recipientText":"PICK- UP  DEB BENDEETSON","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Deleted member"},{"type":"Withdrawal","quantity":-1,"date":"2024-07-19","company":null,"po":null,"invoiceNumber":"24580","requestedBy":"BARBARA","recipientText":"PICK-UP PHIL ZWACK","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Deleted member"},{"type":"Withdrawal","quantity":-5,"date":"2024-07-23","company":null,"po":null,"invoiceNumber":"24576","requestedBy":"BAYAN","recipientText":"75 ASH ST - WESTON","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-2,"date":"2024-08-23","company":null,"po":null,"invoiceNumber":"24604","requestedBy":"BAYAN","recipientText":"SAED DIB","notes":"JUNIOR","status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-1,"date":null,"company":null,"po":null,"invoiceNumber":null,"requestedBy":"CLERIO","recipientText":"21 NORWELL AVE SCITUATE - JEREMY HENRY","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Deleted member"},{"type":"Withdrawal","quantity":-2,"date":"2024-09-18","company":null,"po":null,"invoiceNumber":"24627","requestedBy":"BAYAN","recipientText":"PICK UP- ALGRUSBY HOME","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Deleted member"},{"type":"Withdrawal","quantity":-1,"date":"2024-10-02","company":null,"po":null,"invoiceNumber":"24623","requestedBy":"BARBARA","recipientText":"DESJOURDY, LYNNE ADN PAUL","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Deleted member"},{"type":"Withdrawal","quantity":-1,"date":"2024-10-24","company":null,"po":null,"invoiceNumber":"24660","requestedBy":"BAYAN","recipientText":"BOXER, ROBERT","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Deleted member"},{"type":"Stock Check","quantity":-5,"date":"2024-10-26","company":null,"po":null,"invoiceNumber":null,"requestedBy":null,"recipientText":"LOSS","notes":"JUNIO DRUMOND","status":"STOCK  CHECK","enteredBy":"Deleted member"},{"type":"Withdrawal","quantity":-2,"date":"2024-10-29","company":null,"po":"28988","invoiceNumber":null,"requestedBy":"BAYAN","recipientText":"C STUMPO INC.:119 CABOT STREET, NEWTON","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Deleted member"},{"type":"Withdrawal","quantity":-1,"date":"2024-11-04","company":null,"po":null,"invoiceNumber":"24668","requestedBy":"BARBARA","recipientText":"BRIAR DESIGN:101 WACHUSETT RD. NEEDHAM","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Deleted member"},{"type":"Withdrawal","quantity":-1,"date":"2024-11-06","company":null,"po":null,"invoiceNumber":"24656","requestedBy":"BARBARA","recipientText":"GOODMAN, DEBRA","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Deleted member"},{"type":"Withdrawal","quantity":-1,"date":"2024-11-11","company":null,"po":null,"invoiceNumber":"24664","requestedBy":"BARBARA","recipientText":"LEE, ALICE","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Deleted member"},{"type":"Withdrawal","quantity":-3,"date":"2024-11-13","company":null,"po":null,"invoiceNumber":"24638","requestedBy":"BARBARA","recipientText":"BRIAR DESIGN:MARILYN'S HOUSE","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Deleted member"},{"type":"Receiving","quantity":40,"date":"2024-11-25","company":null,"po":"29007","invoiceNumber":null,"requestedBy":"BARBARA","recipientText":"H.B.FULLER","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Deleted member"},{"type":"Withdrawal","quantity":-2,"date":"2024-12-06","company":null,"po":null,"invoiceNumber":"1224-008","requestedBy":"THERENCE","recipientText":"WHALEN, ELISABETH:7 SMUGGLERS COVE RD. CAPE ELIZABETH","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Deleted member"},{"type":"Withdrawal","quantity":-1,"date":"2024-12-09","company":null,"po":null,"invoiceNumber":"24593","requestedBy":"BARBARA","recipientText":"SCIBELLI, VINCENZO","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Deleted member"},{"type":"Withdrawal","quantity":-1,"date":"2024-12-12","company":null,"po":null,"invoiceNumber":"24704","requestedBy":"BARBARA","recipientText":"HALLIDAT, ALETA","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Deleted member"},{"type":"Withdrawal","quantity":-5,"date":"2024-12-16","company":null,"po":null,"invoiceNumber":null,"requestedBy":"CLERIO","recipientText":"154 WOODLAND RD CHESTNUT HILL","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Deleted member"},{"type":"Withdrawal","quantity":-2,"date":"2024-12-17","company":null,"po":null,"invoiceNumber":null,"requestedBy":"CLERIO","recipientText":"C STUMPO","notes":null,"status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-3,"date":"2025-01-07","company":null,"po":null,"invoiceNumber":"24722/ IDEAL -10","requestedBy":"BARBARA","recipientText":"PRESTON, DIANE","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Deleted member"},{"type":"Withdrawal","quantity":-1,"date":"2025-01-09","company":null,"po":null,"invoiceNumber":"24708/ IDEAL - 11","requestedBy":"BARBARA","recipientText":"CARNEY, KATHLEEN","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Deleted member"},{"type":"Receiving","quantity":1,"date":"2025-01-14","company":null,"po":null,"invoiceNumber":"24708/ IDEAL-11","requestedBy":"BARBARA","recipientText":"RETURN CARNEY, KATHLEEN","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Deleted member"},{"type":"Withdrawal","quantity":-1,"date":"2025-02-28","company":null,"po":null,"invoiceNumber":"24756/IDEAL-27","requestedBy":"BAYAN","recipientText":"NICK TROCKI","notes":"FRED CABRAL","status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-1,"date":"2025-03-11","company":null,"po":null,"invoiceNumber":"24727  / IDEAL-32","requestedBy":"THERENCE","recipientText":"WHALEN, ELISABETH:7 SMUGGLERS COVE RD. CAPE ELIZABETH","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-1,"date":"2025-03-31","company":null,"po":null,"invoiceNumber":"24743","requestedBy":"CLERIO","recipientText":"PORTLAND GRP.:SPLASH OF NEWTON:5 HIPPOGRIFFE RD. DENNIS","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-2,"date":"2025-04-08","company":null,"po":null,"invoiceNumber":"24775","requestedBy":"CLERIO","recipientText":"C STUMPO INC.:36 BONNYBROOK RD","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-5,"date":"2025-04-23","company":null,"po":null,"invoiceNumber":"24766 / IDEAL 45","requestedBy":"BAYAN","recipientText":"HOLDEN, DEVON:58 CUTLER STREET","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-2,"date":"2025-05-02","company":null,"po":null,"invoiceNumber":"24791","requestedBy":"BAYAN","recipientText":"KORN INTERIORS:FRED CHIN","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-3,"date":"2025-05-08","company":null,"po":null,"invoiceNumber":"24800","requestedBy":"BAYAN","recipientText":"EDESIA KITCHEN &BATH STUDIO:D'Ambrosio","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-1,"date":"2025-06-13","company":null,"po":null,"invoiceNumber":"24822- IDEAL -65","requestedBy":"BARBARA","recipientText":"BRIAR DESIGN:74 EATON RD. NEEDHMA","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-1,"date":"2025-06-25","company":null,"po":null,"invoiceNumber":null,"requestedBy":"BARBARA","recipientText":"BRING TO SHOWROOM","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-5,"date":"2025-07-10","company":null,"po":null,"invoiceNumber":null,"requestedBy":"BARBARA","recipientText":"SHASHOUA, MICHAEL:90 ADDINGTON RD. BROOKLINE","notes":null,"status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-2,"date":"2025-07-29","company":null,"po":null,"invoiceNumber":"24868","requestedBy":"BARBARA","recipientText":"SHASHOUA, MICHAEL:90 ADDINGTON RD. BROOKLINE","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Receiving","quantity":32,"date":"2025-08-14","company":null,"po":"29203","invoiceNumber":null,"requestedBy":"BARBARA","recipientText":"RECEIVED - TEC SPECIALTY PRODUCTS","notes":"JUNIO DRUMOND","status":"Received","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-2,"date":"2025-08-20","company":null,"po":null,"invoiceNumber":null,"requestedBy":"CLERIO","recipientText":"144 WORCESTER","notes":null,"status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-3,"date":"2025-09-17","company":null,"po":null,"invoiceNumber":"24878","requestedBy":"BARBARA","recipientText":"MASOOD, SOHAIL","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-3,"date":"2025-08-27","company":null,"po":null,"invoiceNumber":null,"requestedBy":"MICHAEL - CLERIO","recipientText":"144 WORCESTER","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-3,"date":"2025-09-26","company":null,"po":null,"invoiceNumber":"24889","requestedBy":"BARBARA","recipientText":"SLR ARCHITECTURE INC.:57 ELIOT ST. JAMAICA PLAIN","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-3,"date":"2025-11-04","company":null,"po":null,"invoiceNumber":"24908","requestedBy":"BARBARA","recipientText":"PAUL GRANT:JOHN AND TARA CARROLL","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-2,"date":"2025-10-30","company":null,"po":null,"invoiceNumber":"24911","requestedBy":"BARBARA","recipientText":"MOUNTAIN DOG  DEVELOPMENT","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-6,"date":"2025-11-18","company":null,"po":null,"invoiceNumber":"24913","requestedBy":"BARBARA","recipientText":"WHALEN, ELISABETH:AMANDA NAIVEN","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-3,"date":"2025-11-12","company":null,"po":null,"invoiceNumber":"24915","requestedBy":"BARBARA","recipientText":"PAUL GRANT:79 SHARIDAN ST. JAMAICA PLAIN","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-1,"date":"2026-01-06","company":null,"po":null,"invoiceNumber":null,"requestedBy":"CLERIO","recipientText":"MURRAY, PETER","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-1,"date":"2026-02-11","company":null,"po":null,"invoiceNumber":"24942","requestedBy":"BARBARA","recipientText":"DANA, ALAN","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-1,"date":"2026-02-12","company":null,"po":null,"invoiceNumber":"24940","requestedBy":"BARBARA","recipientText":"C STUMPO INC.:152 WOODLAND RD. CHESTNUT HILL","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Receiving","quantity":16,"date":"2026-02-17","company":null,"po":"29290","invoiceNumber":null,"requestedBy":"BARBARA","recipientText":"RECEIVED - TEC SPECIALTY PRODUCTS","notes":null,"status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-4,"date":"2026-03-04","company":null,"po":null,"invoiceNumber":"24972","requestedBy":"BARBARA","recipientText":"ORDWAY, JACK","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-3,"date":"2026-03-17","company":null,"po":null,"invoiceNumber":"24964","requestedBy":"BARBARA","recipientText":"SLR ARCHITECTURE INC.:21 WEBSTER ST. NEWTON","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-1,"date":"2026-04-06","company":null,"po":null,"invoiceNumber":"24977","requestedBy":null,"recipientText":"MACROSTIE, AHRY","notes":null,"status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-1,"date":"2026-05-18","company":null,"po":null,"invoiceNumber":"24974","requestedBy":"BARBARA","recipientText":"ZWACK, PHIL","notes":null,"status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Receiving","quantity":5,"date":"2026-05-07","company":null,"po":null,"invoiceNumber":null,"requestedBy":null,"recipientText":"FOUND","notes":null,"status":"Received","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-2,"date":"2026-05-12","company":null,"po":"29328","invoiceNumber":"24987","requestedBy":"BARBARA","recipientText":"C STUMPO","notes":"BRUNO","status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-2,"date":null,"company":null,"po":"29348","invoiceNumber":"25016","requestedBy":"BARBARA","recipientText":"THOMAS, HELEN","notes":null,"status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-1,"date":"2026-06-16","company":null,"po":"29342-43","invoiceNumber":"25009","requestedBy":null,"recipientText":"ELISABETH, WHALEN","notes":null,"status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-7,"date":"2026-06-17","company":"LEON","po":null,"invoiceNumber":"LEON-243","requestedBy":"ALEX KOGAN","recipientText":"45 BARTLETT","notes":null,"status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-6,"date":"2026-06-18","company":"LEON","po":null,"invoiceNumber":"LEON-244","requestedBy":"ALEX KOGAN","recipientText":"45 BARTLETT","notes":null,"status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Receiving","quantity":20,"date":"2026-06-22","company":null,"po":"29356","invoiceNumber":null,"requestedBy":null,"recipientText":"RECEIVED TEC","notes":null,"status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-2,"date":"2026-06-26","company":null,"po":null,"invoiceNumber":null,"requestedBy":"CLERIO","recipientText":"C STUMPO 60 LYMAN RD","notes":null,"status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-1,"date":"2026-07-14","company":null,"po":null,"invoiceNumber":"25033","requestedBy":"BARBARA","recipientText":"HARRIS JOB","notes":null,"status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Receiving","quantity":1,"date":"2026-07-29","company":null,"po":null,"invoiceNumber":null,"requestedBy":null,"recipientText":"RETURN - 21 WINSLOW","notes":null,"status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-10,"date":"2026-08-07","company":"LEON","po":null,"invoiceNumber":"LEON 261","requestedBy":null,"recipientText":"45 BARTLETT","notes":null,"status":"Completed","enteredBy":"Jaciel Junior"}]},{"name":"STERLING (UNSANDED)","referenceNumber":909,"initialStock":16,"currentStock":16,"stockCheckDate":"2026-05-07","transactions":[]},{"name":"SILHOUETTE","referenceNumber":935,"initialStock":25,"currentStock":16,"stockCheckDate":"2026-05-07","transactions":[{"type":"Withdrawal","quantity":-1,"date":"2024-04-29","company":null,"po":null,"invoiceNumber":null,"requestedBy":"CLERIO","recipientText":"14  WINSLOW RD","notes":"HARLITON","status":"Completed","enteredBy":"Deleted member"},{"type":"Withdrawal","quantity":-5,"date":"2025-04-09","company":null,"po":null,"invoiceNumber":"IDEAL-41","requestedBy":"IBRAHIM","recipientText":"C STUMPO 36 BONNYBROOK RD","notes":null,"status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-1,"date":"2025-08-27","company":null,"po":null,"invoiceNumber":null,"requestedBy":"CAROLLINE","recipientText":"144 WORCESTER","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-2,"date":"2026-06-26","company":null,"po":null,"invoiceNumber":null,"requestedBy":"CLERIO","recipientText":"C STUMPO 60 LYMAN RD","notes":null,"status":"Completed","enteredBy":"Jaciel Junior"}]},{"name":"OPTIC WHITE (UNSANDED)","referenceNumber":912,"initialStock":24,"currentStock":11,"stockCheckDate":"2026-05-07","transactions":[{"type":"Withdrawal","quantity":-4,"date":"2024-03-26","company":null,"po":null,"invoiceNumber":"REF#0324-047","requestedBy":"NATTAN","recipientText":"1055 CAMBRIDGE ST","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Deleted member"},{"type":"Withdrawal","quantity":-1,"date":"2024-05-07","company":null,"po":null,"invoiceNumber":null,"requestedBy":"THERENCE","recipientText":"23 ALVESTON ST","notes":"HARLITON","status":"Completed","enteredBy":"Deleted member"},{"type":"Withdrawal","quantity":-2,"date":"2024-06-11","company":null,"po":null,"invoiceNumber":null,"requestedBy":"THERENCE","recipientText":"TERRAZZA PROJECT","notes":"JUNIOR","status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-4,"date":"2024-07-10","company":null,"po":null,"invoiceNumber":null,"requestedBy":"JUNIOR","recipientText":"TERRAZZA PROJECT","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Deleted member"},{"type":"Withdrawal","quantity":-1,"date":"2024-10-26","company":null,"po":null,"invoiceNumber":null,"requestedBy":null,"recipientText":"WAREHOUSE -  LOST BAG","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Deleted member"},{"type":"Withdrawal","quantity":-1,"date":"2025-04-29","company":null,"po":null,"invoiceNumber":null,"requestedBy":"CLERIO","recipientText":"99 FLORENCE ST","notes":null,"status":"Completed","enteredBy":"Jaciel Junior"}]},{"name":"LIGHT COOL GRAY","referenceNumber":905,"initialStock":19,"currentStock":13,"stockCheckDate":"2026-05-07","transactions":[{"type":"Withdrawal","quantity":-1,"date":"2024-11-13","company":null,"po":null,"invoiceNumber":"24638","requestedBy":"BARBARA","recipientText":"BRIAR DESIGN:MARILYN'S HOUSE","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Deleted member"},{"type":"Withdrawal","quantity":-1,"date":"2025-06-13","company":null,"po":null,"invoiceNumber":"24822- IDEAL -65","requestedBy":"BARBARA","recipientText":"BRIAR DESIGN:74 EATON RD. NEEDHMA","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-1,"date":"2026-01-14","company":null,"po":null,"invoiceNumber":null,"requestedBy":"CLERIO","recipientText":"95 WALKER ST - PICK UP","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-3,"date":"2026-01-28","company":null,"po":null,"invoiceNumber":null,"requestedBy":"CLERIO","recipientText":"C STUMPO INC: 332 BEACON ST","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Jaciel Junior"}]},{"name":"STEEL GRAY","referenceNumber":937,"initialStock":23,"currentStock":22,"stockCheckDate":"2026-05-07","transactions":[{"type":"Withdrawal","quantity":-1,"date":"2025-05-02","company":null,"po":null,"invoiceNumber":"24791","requestedBy":"BAYAN","recipientText":"KORN INTERIORS:FRED CHIN","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Jaciel Junior"}]},{"name":"OPTIC WHITE","referenceNumber":912,"initialStock":3,"currentStock":6,"stockCheckDate":"2026-05-07","transactions":[{"type":"Withdrawal","quantity":-1,"date":"2024-03-26","company":null,"po":null,"invoiceNumber":"24379","requestedBy":"NATTAN","recipientText":"17 ALVESTON ST- JAMAIC PLAIN","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Deleted member"},{"type":"Withdrawal","quantity":-3,"date":"2024-03-26","company":null,"po":null,"invoiceNumber":"REF#0324-047","requestedBy":"NATTAN","recipientText":"1055 CAMBRIDGE ST","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Deleted member"},{"type":"Receiving","quantity":32,"date":"2024-05-02","company":null,"po":"28790","invoiceNumber":null,"requestedBy":"BARBARA","recipientText":"HB FULLER","notes":"HARLITON","status":"Received","enteredBy":"Deleted member"},{"type":"Withdrawal","quantity":-1,"date":"2024-05-10","company":null,"po":null,"invoiceNumber":"24427","requestedBy":"BAYAN","recipientText":"29 MECLEAN ST- WELLESLEY","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Deleted member"},{"type":"Withdrawal","quantity":-1,"date":"2024-05-15","company":null,"po":null,"invoiceNumber":"24438","requestedBy":"BAYAN","recipientText":"17-2 ALVESTON ST - JAMAICA PLAIN","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Deleted member"},{"type":"Receiving","quantity":2,"date":"2024-06-13","company":null,"po":null,"invoiceNumber":null,"requestedBy":null,"recipientText":"RETURN TERRAZZA","notes":"JAIR JR","status":"Received","enteredBy":"Deleted member"},{"type":"Withdrawal","quantity":-1,"date":"2024-06-20","company":null,"po":null,"invoiceNumber":"24554","requestedBy":"BAYAN","recipientText":"39 HIGHGATE ST","notes":"JUNIOR","status":"Completed","enteredBy":"Deleted member"},{"type":"Withdrawal","quantity":-4,"date":"2024-07-11","company":null,"po":null,"invoiceNumber":"24563","requestedBy":"BAYAN","recipientText":"39 MARSHAL ST BROOKLINE","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Deleted member"},{"type":"Withdrawal","quantity":-2,"date":"2024-07-11","company":null,"po":null,"invoiceNumber":"24568","requestedBy":"BAYAN","recipientText":"1573 GREAT PLAIN AVE NEEDHAM","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Deleted member"},{"type":"Withdrawal","quantity":-1,"date":"2024-10-24","company":null,"po":null,"invoiceNumber":null,"requestedBy":"CAROLLINE","recipientText":"771 RIVERSIDE DR-METHUEN","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Deleted member"},{"type":"Withdrawal","quantity":-1,"date":"2024-10-24","company":null,"po":null,"invoiceNumber":null,"requestedBy":null,"recipientText":"TERRAZZA","notes":null,"status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-2,"date":"2024-10-26","company":null,"po":null,"invoiceNumber":null,"requestedBy":null,"recipientText":"WAREHOUSE - 1 LOST BAG","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Deleted member"},{"type":"Withdrawal","quantity":-1,"date":"2024-12-11","company":null,"po":null,"invoiceNumber":null,"requestedBy":"BAYAN","recipientText":"23 ARNOLD RD , WESLLWSLY HILL","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Deleted member"},{"type":"Withdrawal","quantity":-1,"date":"2025-04-01","company":null,"po":null,"invoiceNumber":"24782","requestedBy":"BARBARA","recipientText":"JASMINE'S HOUSE","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-5,"date":"2025-04-09","company":null,"po":null,"invoiceNumber":"IDEAL -41","requestedBy":null,"recipientText":"C STUMPO 36 BONNYBROOK RD","notes":null,"status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-1,"date":"2025-07-23","company":null,"po":null,"invoiceNumber":null,"requestedBy":"CLERIO","recipientText":"IRINA DRAGANOV","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-1,"date":"2025-08-20","company":null,"po":null,"invoiceNumber":null,"requestedBy":"CLERIO","recipientText":"144 WORCESTER","notes":null,"status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-1,"date":"2026-01-28","company":null,"po":null,"invoiceNumber":null,"requestedBy":"CLERIO","recipientText":"C STUMPO INC: 332 BEACON ST","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-1,"date":"2026-03-16","company":null,"po":null,"invoiceNumber":null,"requestedBy":"CLERIO","recipientText":"C STUMPO ICN : 32 DERNE ST","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-2,"date":"2026-03-31","company":null,"po":null,"invoiceNumber":null,"requestedBy":"MICHAEL","recipientText":"42 LORNA RD","notes":null,"status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-2,"date":"2026-06-26","company":null,"po":null,"invoiceNumber":null,"requestedBy":"CLERIO","recipientText":"C STUMPO 60 LYMAN RD","notes":null,"status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Receiving","quantity":1,"date":"2026-07-29","company":null,"po":null,"invoiceNumber":null,"requestedBy":null,"recipientText":"RETURN - 21 WINSLOW","notes":null,"status":"Completed","enteredBy":"Jaciel Junior"}]},{"name":"JET BLACK","referenceNumber":950,"initialStock":10,"currentStock":3,"stockCheckDate":"2026-05-07","transactions":[{"type":"Withdrawal","quantity":-6,"date":"2024-06-19","company":null,"po":null,"invoiceNumber":"REF#0624-020","requestedBy":"THERENCE","recipientText":"1055 CAMBRIDGE ST","notes":"JAIR JR","status":"Completed","enteredBy":"Deleted member"},{"type":"Withdrawal","quantity":-1,"date":"2024-07-09","company":null,"po":null,"invoiceNumber":null,"requestedBy":"WELLINGTON","recipientText":"17-2 ALVESTON ST- JAMAICA PLAIN","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Deleted member"},{"type":"Withdrawal","quantity":-2,"date":"2025-06-24","company":null,"po":null,"invoiceNumber":"LEON-055","requestedBy":"CLERIO","recipientText":"144 WORCESTER ST","notes":null,"status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Receiving","quantity":8,"date":"2025-07-21","company":null,"po":null,"invoiceNumber":null,"requestedBy":"BARBARA","recipientText":"TEC SPECIALTY PRODUCTS LLC","notes":"JUNIO DRUMOND","status":"Received","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-1,"date":"2025-07-23","company":null,"po":null,"invoiceNumber":null,"requestedBy":"CLERIO","recipientText":"IRINA DRAGANOV","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-2,"date":"2025-09-29","company":null,"po":null,"invoiceNumber":null,"requestedBy":"CLERIO","recipientText":"144 WORCESTER","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-1,"date":"2025-11-12","company":null,"po":null,"invoiceNumber":"24915","requestedBy":"BARBARA","recipientText":"PAUL GRANT:79 SHARIDAN ST. JAMAICA PLAIN","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-1,"date":"2026-03-31","company":null,"po":null,"invoiceNumber":null,"requestedBy":"MICHAEL","recipientText":"42 LORNA RD","notes":null,"status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-1,"date":"2026-05-05","company":null,"po":null,"invoiceNumber":null,"requestedBy":"MICHAEL","recipientText":"21 WINSLOW","notes":null,"status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Receiving","quantity":1,"date":"2026-07-09","company":null,"po":null,"invoiceNumber":null,"requestedBy":null,"recipientText":"RETURN 21 WINSLOW","notes":null,"status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-1,"date":"2026-07-13","company":null,"po":null,"invoiceNumber":null,"requestedBy":null,"recipientText":"60 LYMAN RD.","notes":null,"status":"Completed","enteredBy":"Jaciel Junior"}]},{"name":"LIGHT BRONZE","referenceNumber":947,"initialStock":8,"currentStock":26,"stockCheckDate":"2026-05-07","transactions":[{"type":"Withdrawal","quantity":-2,"date":"2024-11-12","company":null,"po":null,"invoiceNumber":null,"requestedBy":"WELLINGTON","recipientText":"SCIBELLI, VINCENZO","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Deleted member"},{"type":"Withdrawal","quantity":-1,"date":"2025-07-22","company":null,"po":null,"invoiceNumber":"24865","requestedBy":"BARBARA","recipientText":"SHOWROOM","notes":null,"status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Receiving","quantity":20,"date":"2025-08-14","company":null,"po":"29203","invoiceNumber":null,"requestedBy":"BARBARA","recipientText":"RECEIVED - TEC SPECIALTY PRODUCTS","notes":"JUNIO DRUMOND","status":"Received","enteredBy":"Jaciel Junior"},{"type":"Receiving","quantity":1,"date":"2026-05-07","company":null,"po":null,"invoiceNumber":null,"requestedBy":null,"recipientText":"FOUND","notes":null,"status":"Received","enteredBy":"Jaciel Junior"}]},{"name":"STARRY NIGHT","referenceNumber":953,"initialStock":8,"currentStock":7,"stockCheckDate":"2026-05-07","transactions":[{"type":"Withdrawal","quantity":-1,"date":"2024-05-16","company":null,"po":null,"invoiceNumber":null,"requestedBy":"MICHAEL - CLERIO","recipientText":"14  WINSLOW RD","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Deleted member"}]},{"name":"URBAN BRONZE","referenceNumber":966,"initialStock":23,"currentStock":14,"stockCheckDate":"2026-05-07","transactions":[{"type":"Withdrawal","quantity":-1,"date":"2024-11-15","company":null,"po":null,"invoiceNumber":"24678","requestedBy":"BARBARA","recipientText":"SCIBELLI, VINCENZO","notes":null,"status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-1,"date":"2025-07-15","company":null,"po":null,"invoiceNumber":null,"requestedBy":"BARBARA/ CLERIO","recipientText":"C STUMPO INC.:170 ELGIN RD. NEWTON,MA","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-1,"date":"2025-07-23","company":null,"po":null,"invoiceNumber":null,"requestedBy":"CLERIO","recipientText":"IRINA DRAGANOV","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-1,"date":"2025-07-28","company":null,"po":null,"invoiceNumber":null,"requestedBy":null,"recipientText":"SHOWRROM","notes":null,"status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-1,"date":"2025-08-07","company":null,"po":null,"invoiceNumber":"24873","requestedBy":"BARBARA","recipientText":"GREENE, JEFF","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-1,"date":"2025-08-22","company":null,"po":null,"invoiceNumber":"24885","requestedBy":"BARBARA","recipientText":"GREENE, JEFF","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-1,"date":"2025-09-26","company":null,"po":null,"invoiceNumber":"24889","requestedBy":"BARBARA","recipientText":"SLR ARCHITECTURE INC.:57 ELIOT ST. JAMAICA PLAIN","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-1,"date":"2026-01-28","company":null,"po":null,"invoiceNumber":"24956","requestedBy":"BARBARA","recipientText":"57 ELIOT ST - JAMAICA PLAIN, MA - 02130","notes":"JUNIO DRUMOND","status":"Bring to Showroom","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-1,"date":"2026-03-12","company":null,"po":null,"invoiceNumber":"24973","requestedBy":"BARBARA","recipientText":"57 ELIOT ST - JAMAICA PLAIN, MA - 02130","notes":null,"status":"Completed","enteredBy":"Jaciel Junior"}]},{"name":"SANDSTONE BEIGE  (UNSANDED)","referenceNumber":null,"initialStock":3,"currentStock":3,"stockCheckDate":"2026-05-07","transactions":[]},{"name":"CRYSTAL","referenceNumber":null,"initialStock":3,"currentStock":3,"stockCheckDate":"2026-05-07","transactions":[]},{"name":"FROST HELADA UNSANDE N*77","referenceNumber":null,"initialStock":36,"currentStock":35,"stockCheckDate":"2026-05-07","transactions":[{"type":"Withdrawal","quantity":-1,"date":"2025-12-30","company":null,"po":null,"invoiceNumber":null,"requestedBy":"THENRICE","recipientText":"36 MILFORD","notes":"JUNIOR","status":"Completed","enteredBy":"Jaciel Junior"}]},{"name":"FROST HELADA S SANDED N*77","referenceNumber":null,"initialStock":20,"currentStock":17,"stockCheckDate":"2026-05-07","transactions":[{"type":"Withdrawal","quantity":-3,"date":"2025-12-30","company":null,"po":null,"invoiceNumber":null,"requestedBy":"THENRICE","recipientText":"36 MILFORD","notes":"JUNIOR","status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Receiving","quantity":null,"date":null,"company":null,"po":"2026-05-07","invoiceNumber":null,"requestedBy":null,"recipientText":null,"notes":null,"status":"Completed","enteredBy":null}]}]},{"category":"Thinset","materials":[{"name":"REGULAR THINSET 346","referenceNumber":346,"initialStock":170,"currentStock":0,"stockCheckDate":"2024-03-21","transactions":[{"type":"Withdrawal","quantity":-3,"date":"2024-03-26","company":null,"po":null,"invoiceNumber":"24359","requestedBy":"NATTAN","recipientText":"73 FAIRVIEW RD WESTON","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Deleted member"},{"type":"Withdrawal","quantity":-20,"date":"2024-04-03","company":null,"po":null,"invoiceNumber":"24330","requestedBy":"NATTAN","recipientText":"73 FAIRVIEW RD WESTON","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Deleted member"},{"type":"Withdrawal","quantity":-10,"date":"2024-04-05","company":null,"po":null,"invoiceNumber":"24307","requestedBy":"CLERIO","recipientText":"28 FENWAY BOSTON, MA 02215","notes":"JUNIOR","status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-4,"date":"2024-04-12","company":null,"po":null,"invoiceNumber":null,"requestedBy":"NATTAN","recipientText":"100 LINDEN ST -TERRRAZA","notes":"HARLITON","status":"Completed","enteredBy":"Deleted member"},{"type":"Withdrawal","quantity":-15,"date":"2024-04-16","company":null,"po":null,"invoiceNumber":null,"requestedBy":"CLERIO","recipientText":"14 WISLOW RD","notes":"HARLITON","status":"Completed","enteredBy":"Deleted member"},{"type":"Withdrawal","quantity":-15,"date":"2024-04-23","company":null,"po":null,"invoiceNumber":null,"requestedBy":"CLERIO","recipientText":"14 WISLOW RD","notes":"HARLITON","status":"Completed","enteredBy":"Deleted member"},{"type":"Withdrawal","quantity":-5,"date":"2024-06-11","company":null,"po":null,"invoiceNumber":null,"requestedBy":"CLERIO","recipientText":"100 LINDEN ST -TERRRAZA","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Deleted member"},{"type":"Withdrawal","quantity":-10,"date":"2024-08-09","company":null,"po":null,"invoiceNumber":"24599","requestedBy":"BAYAN","recipientText":"DIB SAED","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Deleted member"},{"type":"Withdrawal","quantity":-6,"date":"2024-08-27","company":null,"po":null,"invoiceNumber":null,"requestedBy":"CLERIO","recipientText":"DIB SAED","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Deleted member"},{"type":"Withdrawal","quantity":-4,"date":"2024-09-10","company":null,"po":null,"invoiceNumber":null,"requestedBy":"CLERIO","recipientText":"21 NORWELL AVE SCITUATE - JEREMY HENRY","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Deleted member"},{"type":"Withdrawal","quantity":-2,"date":"2024-09-19","company":null,"po":null,"invoiceNumber":null,"requestedBy":"CLERIO","recipientText":"C STUMPO INC.:152 WOODLAND RD. CHESTNUT HILL","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Deleted member"},{"type":"Withdrawal","quantity":-20,"date":"2024-09-23","company":null,"po":null,"invoiceNumber":"REF#0924-016","requestedBy":"BARBARA","recipientText":"C STUMPO INC.:152 WOODLAND RD. CHESTNUT HILL","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Deleted member"},{"type":"Withdrawal","quantity":-6,"date":"2024-10-02","company":null,"po":null,"invoiceNumber":null,"requestedBy":"CLERIO","recipientText":"C STUMPO INC.:152 WOODLAND RD. CHESTNUT HILL","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Deleted member"},{"type":"Withdrawal","quantity":-30,"date":"2024-10-11","company":null,"po":null,"invoiceNumber":"REF# 1024-014  OR IV 24625","requestedBy":"CLERIO","recipientText":"C STUMPO INC.:119 CABOT STREET, NEWTON","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Deleted member"},{"type":"Withdrawal","quantity":-6,"date":"2024-10-10","company":null,"po":null,"invoiceNumber":"REF#1024-014 OR IV 24625","requestedBy":"CLERIO","recipientText":"C STUMPO INC.:119 CABOT STREET, NEWTON","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Deleted member"},{"type":"Withdrawal","quantity":-5,"date":"2024-10-17","company":null,"po":null,"invoiceNumber":null,"requestedBy":"CLERIO","recipientText":"C STUMPO INC.:119 CABOT STREET, NEWTON","notes":"JUNIOR","status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-6,"date":"2024-10-24","company":null,"po":null,"invoiceNumber":null,"requestedBy":"CLERIO","recipientText":"C STUMPO INC.:119 CABOT STREET, NEWTON","notes":"JUNIOR","status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Stock Check","quantity":-6,"date":"2024-10-24","company":null,"po":null,"invoiceNumber":null,"requestedBy":null,"recipientText":"LOSS","notes":"JUNIOR","status":"STOCK  CHECK","enteredBy":"Jaciel Junior"},{"type":"Receiving","quantity":224,"date":"2024-10-26","company":null,"po":"28971","invoiceNumber":null,"requestedBy":"BARBARA","recipientText":"H.B.FULLER","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Deleted member"},{"type":"Withdrawal","quantity":-8,"date":"2024-11-04","company":null,"po":null,"invoiceNumber":null,"requestedBy":"CLERIO","recipientText":"C STUMPO INC.:119 CABOT STREET, NEWTON","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Deleted member"},{"type":"Withdrawal","quantity":-6,"date":"2024-11-06","company":null,"po":null,"invoiceNumber":"24656","requestedBy":"BARBARA","recipientText":"GOODMAN, DEBRA","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Deleted member"},{"type":"Withdrawal","quantity":-5,"date":"2024-11-12","company":null,"po":null,"invoiceNumber":null,"requestedBy":"WELLINGTON","recipientText":"SCIBELLI, VINCENZO","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Deleted member"},{"type":"Withdrawal","quantity":-5,"date":"2024-11-18","company":null,"po":null,"invoiceNumber":null,"requestedBy":"CLERIO","recipientText":"C STUMPO INC.:119 CABOT STREET, NEWTON","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Deleted member"},{"type":"Withdrawal","quantity":-10,"date":"2024-11-18","company":null,"po":null,"invoiceNumber":"REF# 1124-012","requestedBy":"ELEANDRO","recipientText":"249 COREY PROJECT","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Deleted member"},{"type":"Withdrawal","quantity":-5,"date":"2024-11-19","company":null,"po":null,"invoiceNumber":"Ref# 1124-014","requestedBy":"ELEANDRO","recipientText":"249 COREY PROJECT","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Deleted member"},{"type":"Withdrawal","quantity":-10,"date":"2024-11-21","company":null,"po":null,"invoiceNumber":null,"requestedBy":"CLERIO","recipientText":"145 PINCKNET ST - STEVEN GOODELL","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Deleted member"},{"type":"Withdrawal","quantity":-1,"date":"2024-11-26","company":null,"po":null,"invoiceNumber":"REF# 1124-022","requestedBy":"HALIL","recipientText":"1055 CAMBRIDGE ST","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Deleted member"},{"type":"Withdrawal","quantity":-10,"date":"2024-12-09","company":null,"po":null,"invoiceNumber":"24707","requestedBy":"CLERIO","recipientText":"154 WOODLAND RD CHESTNUT HILL","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Deleted member"},{"type":"Withdrawal","quantity":-7,"date":"2024-12-10","company":null,"po":null,"invoiceNumber":"1224-010","requestedBy":"ELEANDRO","recipientText":"249 COREY PROJECT","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Deleted member"},{"type":"Withdrawal","quantity":-15,"date":"2024-12-16","company":null,"po":null,"invoiceNumber":null,"requestedBy":"CLERIO","recipientText":"154 WOODLAND RD CHESTNUT HILL","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Deleted member"},{"type":"Withdrawal","quantity":-20,"date":"2024-12-18","company":null,"po":null,"invoiceNumber":"IDEAL-8","requestedBy":"CLERIO","recipientText":"154 WOODLAND RD CHESTNUT HILL","notes":null,"status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-4,"date":"2024-12-20","company":null,"po":null,"invoiceNumber":"LEON-007","requestedBy":"ELEANDRO","recipientText":"249 COREY PROJECT","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Deleted member"},{"type":"Withdrawal","quantity":-20,"date":"2025-01-21","company":null,"po":null,"invoiceNumber":"IDEAL-15","requestedBy":"CLERIO","recipientText":"154 WOODLAND RD CHESTNUT HILL","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Deleted member"},{"type":"Withdrawal","quantity":-5,"date":"2025-02-13","company":null,"po":null,"invoiceNumber":null,"requestedBy":"CLERIO","recipientText":"154 WOODLAND RD CHESTNUT HILL","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Deleted member"},{"type":"Withdrawal","quantity":-40,"date":"2025-03-11","company":null,"po":null,"invoiceNumber":"IDEAL - 33","requestedBy":"CLERIO","recipientText":"36 BONNYBROOK RD- WABAN - CLERIO","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-10,"date":"2025-03-31","company":null,"po":null,"invoiceNumber":"24743","requestedBy":"CLERIO","recipientText":"PORTLAND GRP.:SPLASH OF NEWTON:5 HIPPOGRIFFE RD. DENNIS","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-30,"date":"2025-04-04","company":null,"po":null,"invoiceNumber":null,"requestedBy":"CLERIO","recipientText":"C STUMPO INC.:36 BONNYBROOK RD","notes":null,"status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Receiving","quantity":224,"date":"2025-04-23","company":null,"po":null,"invoiceNumber":null,"requestedBy":"BARBARA","recipientText":"TEC SPECIALTY PRODUCTS","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-5,"date":"2025-05-08","company":null,"po":null,"invoiceNumber":null,"requestedBy":"BAYAN","recipientText":"C STUMPO INC.:11 NEWBURY STREET, BOSTON","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-6,"date":"2025-05-19","company":null,"po":null,"invoiceNumber":"LEON-38","requestedBy":"MICHAEL - CLERIO","recipientText":"144 WORCESTER","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-8,"date":"2025-05-30","company":null,"po":null,"invoiceNumber":null,"requestedBy":"MICHAEL - CLERIO","recipientText":"144 WORCESTER","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-20,"date":"2025-06-04","company":null,"po":null,"invoiceNumber":"LEON - 045","requestedBy":"CAROLLINE","recipientText":"144 WORCESTER","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-20,"date":"2025-06-12","company":null,"po":null,"invoiceNumber":null,"requestedBy":"CLERIO","recipientText":"144 WORCESTER","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-15,"date":"2025-07-14","company":null,"po":null,"invoiceNumber":null,"requestedBy":"CLERIO","recipientText":"C STUMPO INC.:154 WOODLAND RD. CHESTNUT HILL","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-15,"date":"2025-07-08","company":null,"po":null,"invoiceNumber":null,"requestedBy":"CLERIO","recipientText":"144 WORCESTER","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-5,"date":"2025-07-15","company":null,"po":null,"invoiceNumber":null,"requestedBy":"BARBARA / CLERIO","recipientText":"C STUMPO INC.:170 ELGIN RD. NEWTON,MA","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-2,"date":"2025-07-22","company":null,"po":null,"invoiceNumber":"24846","requestedBy":"CLERIO","recipientText":"C STUMPO INC.:170 ELGIN RD. NEWTON,MA","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-3,"date":"2025-07-23","company":null,"po":null,"invoiceNumber":null,"requestedBy":"CLERIO","recipientText":"IRINA DRAGANOV","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-6,"date":"2025-08-13","company":null,"po":null,"invoiceNumber":null,"requestedBy":"MICHAEL - CLERIO","recipientText":"154 WOODLAND RD CHESTNUT HILL","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-12,"date":"2025-09-15","company":null,"po":null,"invoiceNumber":null,"requestedBy":"CLERIO","recipientText":"PICK UP : CLERIO  144 WORCESTER","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-15,"date":"2025-09-17","company":null,"po":null,"invoiceNumber":null,"requestedBy":"CLERIO","recipientText":"320 WESLLELEY","notes":"Júnior","status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-8,"date":"2025-10-07","company":null,"po":null,"invoiceNumber":null,"requestedBy":"MICHAEL - CLERIO","recipientText":"144 WORCESTER","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-6,"date":"2025-10-16","company":null,"po":null,"invoiceNumber":null,"requestedBy":"MICHAEL","recipientText":"144 WORCESTER","notes":null,"status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-6,"date":"2025-11-10","company":null,"po":null,"invoiceNumber":null,"requestedBy":"CLERIO","recipientText":"36 MILFORD ST","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-30,"date":"2025-11-20","company":null,"po":null,"invoiceNumber":"REF# LEON 152","requestedBy":"CLERIO","recipientText":"36 MILFORD ST","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-4,"date":"2025-11-26","company":null,"po":null,"invoiceNumber":"REF#  LEON - 153","requestedBy":"CLERIO","recipientText":"249 COREY ST","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-15,"date":"2025-12-05","company":null,"po":null,"invoiceNumber":"24903","requestedBy":"BARBARA / CLERIO","recipientText":"MURRAY, PETER","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-15,"date":"2025-12-29","company":null,"po":null,"invoiceNumber":"24931","requestedBy":"CLERIO","recipientText":"330 BEACON ST","notes":"JUNIOR","status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-10,"date":"2026-01-22","company":null,"po":null,"invoiceNumber":null,"requestedBy":"CLERIO","recipientText":"C STUMPO INC: 332 BEACON ST","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-5,"date":"2026-02-03","company":null,"po":null,"invoiceNumber":null,"requestedBy":"CLERIO","recipientText":"244 NEEDHAM ST NEWTON CLERIO","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-3,"date":"2026-02-09","company":null,"po":null,"invoiceNumber":null,"requestedBy":"CLERIO","recipientText":"152 WOODLAND RD CHESTNUT HILL","notes":null,"status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Receiving","quantity":112,"date":"2026-02-17","company":null,"po":"29290","invoiceNumber":null,"requestedBy":"BARBARA","recipientText":"RECEIVED - TEC SPECIALTY PRODUCTS","notes":null,"status":"Received","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-5,"date":"2026-02-26","company":null,"po":null,"invoiceNumber":null,"requestedBy":"MICHAEL - CLERIO","recipientText":"42 LORNA RD - NEWTON CENTER - MA 02459","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-30,"date":"2026-03-06","company":null,"po":null,"invoiceNumber":"IDEAL-131","requestedBy":"MICHAEL - CLERIO","recipientText":"42 LORNA RD - NEWTON CENTER - MA 02459","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-8,"date":"2026-03-16","company":null,"po":null,"invoiceNumber":null,"requestedBy":"CLERIO","recipientText":"C STUMPO : 32 DERNE ST","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-15,"date":"2026-03-24","company":null,"po":null,"invoiceNumber":null,"requestedBy":"MICHAEL","recipientText":"42 LORNA RD - NEWTON CENTER - MA 02459","notes":null,"status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-5,"date":"2026-04-17","company":null,"po":null,"invoiceNumber":null,"requestedBy":"MICHAEL","recipientText":"21 WINSLOW","notes":null,"status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-20,"date":"2026-04-28","company":null,"po":null,"invoiceNumber":null,"requestedBy":"CLERIO","recipientText":"21 WINSLOW","notes":"JUNIOR","status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-10,"date":"2026-05-06","company":null,"po":null,"invoiceNumber":null,"requestedBy":"MICHAEL","recipientText":"21 WINSLOW","notes":null,"status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-10,"date":"2026-05-21","company":null,"po":null,"invoiceNumber":"LEON 233","requestedBy":"BARBARA","recipientText":"CINDY STUMPO 60 LYMAN","notes":null,"status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-9,"date":"2026-05-28","company":null,"po":null,"invoiceNumber":"LEON-239","requestedBy":"BARBARA","recipientText":"CINDY STUMPO 60 LYMAN","notes":null,"status":"Completed","enteredBy":"Jaciel Junior"}]},{"name":"SLAB THINSET 487","referenceNumber":487,"initialStock":0,"currentStock":24,"stockCheckDate":"2024-03-21","transactions":[{"type":"Receiving","quantity":128,"date":"2024-05-06","company":null,"po":"28804","invoiceNumber":null,"requestedBy":"BARBARA","recipientText":"H.B.FULLER","notes":"HARLITON","status":"Received","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-12,"date":"2024-05-29","company":null,"po":null,"invoiceNumber":"REF#0524-050","requestedBy":"THERENCE","recipientText":"358 MALBOROUGH PROJECT","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Deleted member"},{"type":"Withdrawal","quantity":-10,"date":"2024-06-05","company":null,"po":null,"invoiceNumber":null,"requestedBy":"NATTAN","recipientText":"100 LINDEN ST -TERRAZZA","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-6,"date":"2024-06-06","company":null,"po":null,"invoiceNumber":null,"requestedBy":"NATTAN","recipientText":"100 LINDEN ST -TERRAZZA","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Deleted member"},{"type":"Withdrawal","quantity":-8,"date":"2024-07-11","company":null,"po":null,"invoiceNumber":null,"requestedBy":"CLERIO","recipientText":"33 OLD ROWN RD - WALPOLE","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Deleted member"},{"type":"Withdrawal","quantity":-4,"date":"2024-08-12","company":null,"po":null,"invoiceNumber":null,"requestedBy":"CLERIO","recipientText":"DID SAED","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Deleted member"},{"type":"Withdrawal","quantity":-5,"date":"2024-09-19","company":null,"po":null,"invoiceNumber":null,"requestedBy":"CLERIO","recipientText":"C STUMPO INC.:152 WOODLAND RD. CHESTNUT HILL","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Deleted member"},{"type":"Withdrawal","quantity":-8,"date":"2024-10-10","company":null,"po":null,"invoiceNumber":"REF#1024-014 OR IV 24625","requestedBy":"CLERIO","recipientText":"C STUMPO INC.:119 CABOT STREET, NEWTON","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Deleted member"},{"type":"Withdrawal","quantity":-5,"date":"2024-10-17","company":null,"po":null,"invoiceNumber":null,"requestedBy":"CLERIO","recipientText":"C STUMPO INC.:119 CABOT STREET, NEWTON","notes":"JUNIOR","status":"Completed","enteredBy":"Deleted member"},{"type":"Withdrawal","quantity":-50,"date":"2024-10-28","company":null,"po":null,"invoiceNumber":"REFE#0924-025","requestedBy":"BARBARA","recipientText":"SCIBELLI, VINCENZO","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Deleted member"},{"type":"Stock Check","quantity":-2,"date":"2024-10-25","company":null,"po":null,"invoiceNumber":null,"requestedBy":null,"recipientText":"LOSS","notes":"JUNIO DRUMOND","status":"STOCK  CHECK","enteredBy":"Jaciel Junior"},{"type":"Receiving","quantity":113,"date":"2024-10-29","company":null,"po":null,"invoiceNumber":null,"requestedBy":null,"recipientText":"H.B.FULLER","notes":null,"status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-10,"date":"2024-11-04","company":null,"po":null,"invoiceNumber":null,"requestedBy":"CLERIO","recipientText":"C STUMPO INC.:119 CABOT STREET, NEWTON","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Deleted member"},{"type":"Withdrawal","quantity":-6,"date":"2024-11-05","company":null,"po":null,"invoiceNumber":null,"requestedBy":"CLERIO","recipientText":"C STUMPO INC.:119 CABOT STREET, NEWTON","notes":"JUNIOR","status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-3,"date":"2024-11-12","company":null,"po":null,"invoiceNumber":null,"requestedBy":"WELLINGTON","recipientText":"SCIBELLI, VINCENZO","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Deleted member"},{"type":"Withdrawal","quantity":-4,"date":"2024-12-02","company":null,"po":null,"invoiceNumber":null,"requestedBy":"BAYAN","recipientText":"WHALEN, ELISABETH:7 SMUGGLERS COVE RD. CAPE ELIZABETH","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Deleted member"},{"type":"Withdrawal","quantity":-10,"date":"2024-12-11","company":null,"po":null,"invoiceNumber":null,"requestedBy":"BAYAN","recipientText":"23 ARNOLD RD , WESLLWSLY HILL","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Deleted member"},{"type":"Withdrawal","quantity":-4,"date":"2024-12-20","company":null,"po":null,"invoiceNumber":"LEON-007","requestedBy":"ELEANDRO","recipientText":"249 COREY RD","notes":null,"status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-4,"date":"2025-01-09","company":null,"po":null,"invoiceNumber":null,"requestedBy":"BAYAN/ CLERIO","recipientText":"23 ARNOLD RD , WESLLWSLY HILL","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Deleted member"},{"type":"Withdrawal","quantity":-5,"date":"2025-01-15","company":null,"po":null,"invoiceNumber":null,"requestedBy":"BAYAN/ CLERIO","recipientText":"C STUMPO INC.:154 WOODLAND RD. CHESTNUT HILL","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Deleted member"},{"type":"Withdrawal","quantity":-10,"date":"2025-02-10","company":null,"po":null,"invoiceNumber":null,"requestedBy":"CLERIO","recipientText":"C STUMPO INC.:154 WOODLAND RD. CHESTNUT HILL","notes":"JUNIOR","status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-5,"date":"2025-02-13","company":null,"po":null,"invoiceNumber":null,"requestedBy":"CLERIO","recipientText":"154 WOODLAND RD CHESTNUT HILL","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Deleted member"},{"type":"Withdrawal","quantity":-8,"date":"2025-02-20","company":null,"po":null,"invoiceNumber":"24755","requestedBy":"CLERIO","recipientText":"C STUMPO INC.:154 WOODLAND LOT 3","notes":"JUNIOR","status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-4,"date":"2025-03-11","company":null,"po":null,"invoiceNumber":"24727  / IDEAL-32","requestedBy":"THERENCE","recipientText":"WHALEN, ELISABETH:7 SMUGGLERS COVE RD. CAPE ELIZABETH","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-10,"date":"2025-04-16","company":null,"po":null,"invoiceNumber":null,"requestedBy":"THERENCE","recipientText":"11 NEWBURY PROJECT","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-5,"date":"2025-05-28","company":null,"po":null,"invoiceNumber":"24791","requestedBy":null,"recipientText":"KORN INTERIORS:FRED CHIN","notes":null,"status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-30,"date":"2025-06-18","company":null,"po":null,"invoiceNumber":null,"requestedBy":"CAROLLINE","recipientText":"144 WORCESTER","notes":"JUNIOR","status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-4,"date":"2025-06-26","company":null,"po":null,"invoiceNumber":"24780","requestedBy":"CLERIO","recipientText":"FREN CHIN","notes":"JUNIOR","status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-10,"date":"2025-07-08","company":null,"po":null,"invoiceNumber":"24822","requestedBy":"MICHAEL - CLERIO","recipientText":"24822 - 74 EATON RD.","notes":"JUNIOR","status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Receiving","quantity":128,"date":"2025-08-14","company":null,"po":"29187","invoiceNumber":null,"requestedBy":"BARBARA","recipientText":"RECEIVED - TEC SPECIALTY PRODUCTS","notes":"JUNIO DRUMOND","status":"Received","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-5,"date":"2025-08-25","company":null,"po":null,"invoiceNumber":"24822","requestedBy":"CLERIO","recipientText":"BRIAR DESIGN:74 EATON RD. NEEDHMA","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-12,"date":"2025-08-27","company":null,"po":null,"invoiceNumber":null,"requestedBy":"MICHAEL - CLERIO","recipientText":"144 WORCESTER","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-12,"date":"2025-12-08","company":null,"po":null,"invoiceNumber":"24903","requestedBy":"MICHAEL - CLERIO","recipientText":"MURRAY, PETER","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-2,"date":"2026-01-16","company":null,"po":null,"invoiceNumber":"24903","requestedBy":"CLERIO","recipientText":"MURRAY, PETER","notes":"JUNIOR","status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-5,"date":"2026-02-09","company":null,"po":null,"invoiceNumber":null,"requestedBy":"CLERIO","recipientText":"152 WOODLAND RD CHESTNUT HILL","notes":null,"status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-12,"date":"2026-04-20","company":null,"po":null,"invoiceNumber":null,"requestedBy":"CLERIO","recipientText":"21 WINSLOW","notes":null,"status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-12,"date":"2026-04-23","company":null,"po":null,"invoiceNumber":null,"requestedBy":"CLERIO","recipientText":"21 WINSLOW","notes":null,"status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-8,"date":"2026-04-28","company":null,"po":null,"invoiceNumber":null,"requestedBy":"CLERIO","recipientText":"21 WINSLOW","notes":"JUNIOR","status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-25,"date":"2026-07-09","company":null,"po":null,"invoiceNumber":"25030","requestedBy":"CLERIO","recipientText":"60 LYMAN RD.","notes":"JUNIOR","status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-10,"date":"2026-07-13","company":null,"po":null,"invoiceNumber":"25030","requestedBy":null,"recipientText":"60 LYMAN RD.","notes":null,"status":"Completed","enteredBy":"Jaciel Junior"}]},{"name":"TURKISH THINSET","referenceNumber":null,"initialStock":214,"currentStock":174,"stockCheckDate":"2024-03-21","transactions":[{"type":"Withdrawal","quantity":-10,"date":"2024-04-16","company":null,"po":null,"invoiceNumber":null,"requestedBy":"CLERIO","recipientText":"14 WINSLOW RD","notes":"HARLITON","status":"Completed","enteredBy":"Deleted member"},{"type":"Withdrawal","quantity":-5,"date":"2024-04-23","company":null,"po":null,"invoiceNumber":null,"requestedBy":"CLERIO","recipientText":"14 WINSLOW RD","notes":"HARLITON","status":"Completed","enteredBy":"Deleted member"},{"type":"Withdrawal","quantity":-10,"date":"2024-05-13","company":null,"po":null,"invoiceNumber":null,"requestedBy":"NATTAN","recipientText":"1075 LOWELL RD CONCORD MA","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Deleted member"},{"type":"Withdrawal","quantity":-6,"date":"2024-10-24","company":null,"po":null,"invoiceNumber":null,"requestedBy":"CLERIO","recipientText":"C STUMPO INC.:119 CABOT STREET, NEWTON","notes":"JUNIOR","status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-1,"date":"2024-11-21","company":null,"po":null,"invoiceNumber":null,"requestedBy":"CLERIO","recipientText":"145 PINCKNET ST - STEVEN GOODELL","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Deleted member"},{"type":"Withdrawal","quantity":-3,"date":"2024-12-09","company":null,"po":null,"invoiceNumber":null,"requestedBy":"CLERIO","recipientText":"154 WOODLAND RD CHESTNUT HILL","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Deleted member"},{"type":"Withdrawal","quantity":-5,"date":"2024-12-16","company":null,"po":null,"invoiceNumber":null,"requestedBy":"CLERIO","recipientText":"154 WOODLAND RD CHESTNUT HILL","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Deleted member"}]},{"name":"SELF-LEVELING","referenceNumber":null,"initialStock":0,"currentStock":0,"stockCheckDate":"2024-03-21","transactions":[]},{"name":"PLATINUM PLUS","referenceNumber":null,"initialStock":12,"currentStock":0,"stockCheckDate":"2026-06-24","transactions":[{"type":"Withdrawal","quantity":-3,"date":"2026-07-09","company":null,"po":null,"invoiceNumber":"25030","requestedBy":"CLERIO","recipientText":"60 LYMAN RD.","notes":null,"status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-6,"date":"2026-07-13","company":null,"po":null,"invoiceNumber":"25030","requestedBy":null,"recipientText":"60 LYMAN RD.","notes":null,"status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-3,"date":"2026-08-19","company":"IDEAL","po":null,"invoiceNumber":null,"requestedBy":"CLERIO","recipientText":"208 CABOT ST","notes":null,"status":"Completed","enteredBy":"Jaciel Junior"}]},{"name":"TRI-LITE WHITE","referenceNumber":null,"initialStock":20,"currentStock":0,"stockCheckDate":"2026-06-24","transactions":[{"type":"Withdrawal","quantity":-3,"date":"2026-07-09","company":null,"po":null,"invoiceNumber":"25030","requestedBy":"CLERIO","recipientText":"60 LYMAN RD.","notes":null,"status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-4,"date":"2026-07-29","company":null,"po":null,"invoiceNumber":null,"requestedBy":"CLERIO","recipientText":"121 PROSPECT","notes":null,"status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-13,"date":"2026-08-19","company":"IDEAL","po":null,"invoiceNumber":null,"requestedBy":"CLERIO","recipientText":"208 CABOT ST","notes":null,"status":"Completed","enteredBy":"Jaciel Junior"}]},{"name":"MULTIMAX LITE WHITE","referenceNumber":null,"initialStock":20,"currentStock":0,"stockCheckDate":"2026-06-24","transactions":[{"type":"Withdrawal","quantity":-6,"date":"2026-07-29","company":null,"po":null,"invoiceNumber":null,"requestedBy":"CLERIO","recipientText":"121 PROSPECT","notes":null,"status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-14,"date":"2026-08-19","company":"IDEAL","po":null,"invoiceNumber":null,"requestedBy":"CLERIO","recipientText":"208 CABOT ST","notes":null,"status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Receiving","quantity":null,"date":null,"company":null,"po":"2024-03-21 to 2026-06-24","invoiceNumber":null,"requestedBy":null,"recipientText":null,"notes":null,"status":"Completed","enteredBy":null}]}]},{"category":"Glue","materials":[{"name":"ENDUEGLUE","referenceNumber":null,"initialStock":31,"currentStock":11,"stockCheckDate":"2024-10-25","transactions":[{"type":"Withdrawal","quantity":-20,"date":"2024-11-05","company":null,"po":null,"invoiceNumber":"Ref # 1124-002","requestedBy":"CAROLINE","recipientText":"249 COREY PROJECT","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Deleted member"}]},{"name":"HYBRID FLOORING GLUE (WOOD READY)","referenceNumber":null,"initialStock":120,"currentStock":63,"stockCheckDate":"2024-10-25","transactions":[{"type":"Withdrawal","quantity":-15,"date":"2024-03-22","company":null,"po":null,"invoiceNumber":"REF#0324-043","requestedBy":"CAROLLINE","recipientText":"1055 CAMBRIDGE ST","notes":null,"status":"Completed","enteredBy":"Deleted member"},{"type":"Withdrawal","quantity":-20,"date":"2024-03-28","company":null,"po":null,"invoiceNumber":"REF#0324-056","requestedBy":"CAROLLINE","recipientText":"1055 CAMBRIDGE ST","notes":null,"status":"Completed","enteredBy":"Deleted member"},{"type":"Withdrawal","quantity":-10,"date":"2024-03-29","company":null,"po":null,"invoiceNumber":"REF#0324-062","requestedBy":"THERENCE","recipientText":"1055 CAMBRIDGE ST","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Deleted member"},{"type":"Withdrawal","quantity":-10,"date":"2024-04-01","company":null,"po":null,"invoiceNumber":null,"requestedBy":"JUNIOR","recipientText":"1055 CAMBRIDGE PROJECT","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-36,"date":"2024-06-05","company":null,"po":null,"invoiceNumber":"0624-008","requestedBy":"CAROLLINE","recipientText":"651 4TH AVE PROJECT","notes":null,"status":"Completed","enteredBy":"Deleted member"},{"type":"Withdrawal","quantity":-10,"date":"2024-10-10","company":null,"po":null,"invoiceNumber":"REF # 1024-009","requestedBy":"JOSH / CAROLINE","recipientText":"249 COREY PROJECT","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Deleted member"},{"type":"Withdrawal","quantity":-19,"date":"2024-10-21","company":null,"po":null,"invoiceNumber":"REF#1024-022","requestedBy":"JOSH / CAROLINE","recipientText":"249 COREY PROJECT","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Deleted member"},{"type":"Receiving","quantity":27,"date":"2025-02-07","company":null,"po":null,"invoiceNumber":null,"requestedBy":null,"recipientText":"651 4TH AVE PROJECT RETURN","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-10,"date":"2025-02-10","company":null,"po":null,"invoiceNumber":"LEON-22","requestedBy":"CAROLLINE","recipientText":"249 COREY PROJECT","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Deleted member"},{"type":"Withdrawal","quantity":-12,"date":"2025-05-01","company":null,"po":null,"invoiceNumber":null,"requestedBy":"CAROLLINE","recipientText":"SUMMERSEA PROJECT","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-4,"date":"2025-05-09","company":null,"po":null,"invoiceNumber":null,"requestedBy":"CAROLLINE","recipientText":"SUMMERSEA PROJECT","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Receiving","quantity":64,"date":"2025-08-14","company":null,"po":null,"invoiceNumber":null,"requestedBy":"BARBARA","recipientText":"RECEIVED   - XPO","notes":"JUNIO DRUMOND","status":"Received","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-2,"date":"2026-03-16","company":null,"po":null,"invoiceNumber":null,"requestedBy":"CLERIO/ ANA","recipientText":"249 COREY ST","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Jaciel Junior"}]},{"name":"WOOD SECURE","referenceNumber":null,"initialStock":null,"currentStock":7,"stockCheckDate":null,"transactions":[{"type":"Receiving","quantity":36,"date":"2025-07-21","company":"IDEAL","po":"29167","invoiceNumber":null,"requestedBy":"BARBARA","recipientText":"RECEIVED TEC SPECIALTY PRODUCTS LLC","notes":"JUNIO DRUMOND","status":"Received","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-1,"date":"2025-09-04","company":"LEON","po":"LEON-116","invoiceNumber":null,"requestedBy":"CAROLLINE","recipientText":"144 WORCESTER","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-35,"date":"2025-09-22","company":"LEON","po":"LEON - 123","invoiceNumber":null,"requestedBy":"CAROLLINE","recipientText":"16 STERNS","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Receiving","quantity":96,"date":"2025-09-26","company":"IDEAL","po":"29228","invoiceNumber":null,"requestedBy":"BARBARA","recipientText":"RECEIVED TEC SPECIALTY PRODUCTS LLC","notes":"JUNIO DRUMOND","status":"Received","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-3,"date":"2025-09-29","company":"LEON","po":"LEON-127","invoiceNumber":null,"requestedBy":"CAROLLINE","recipientText":"144 WORCESTER","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-15,"date":"2025-10-02","company":"LEON","po":"LEON-128","invoiceNumber":null,"requestedBy":"CAROLLINE","recipientText":"16 STERNS","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-10,"date":"2025-10-08","company":"LEON","po":"LEON- 129","invoiceNumber":null,"requestedBy":"CAROLLINE","recipientText":"16 STERNS","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-68,"date":"2025-10-10","company":"LEON","po":"LEON-131","invoiceNumber":null,"requestedBy":"CAROLLINE","recipientText":"16 STERNS","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Receiving","quantity":144,"date":"2025-10-20","company":"IDEAL","po":"29233","invoiceNumber":null,"requestedBy":"BARBARA","recipientText":"RECEIVED TEC SPECIALTY PRODUCTS LLC","notes":"JUNIO DRUMOND","status":"Received","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-38,"date":"2025-10-21","company":"LEON","po":"LEON - 137","invoiceNumber":null,"requestedBy":"CAROLLINE","recipientText":"16 STERNS","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-11,"date":"2025-10-27","company":"LEON","po":"LEON-141","invoiceNumber":null,"requestedBy":"CAROLLINE","recipientText":"16 STERNS","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-5,"date":"2025-10-29","company":"LEON","po":"LEON-143","invoiceNumber":null,"requestedBy":"CAROLLINE","recipientText":"16 STERNS","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-1,"date":"2025-12-08","company":"LEON","po":null,"invoiceNumber":null,"requestedBy":"HALIL","recipientText":"144 WORCESTER","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Receiving","quantity":1,"date":"2025-12-10","company":"LEON","po":null,"invoiceNumber":null,"requestedBy":null,"recipientText":"RETURN 144 WORCESTER","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-19,"date":"2025-12-12","company":"LEON","po":null,"invoiceNumber":null,"requestedBy":null,"recipientText":"36 MILFORD","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-1,"date":"2025-12-30","company":"LEON","po":null,"invoiceNumber":null,"requestedBy":"CAROLLINE","recipientText":"144 WORCESTER","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-31,"date":"2026-02-16","company":"LEON","po":"LEON-188","invoiceNumber":null,"requestedBy":null,"recipientText":"240 EAST CENTRAL","notes":null,"status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-2,"date":"2026-02-25","company":"LEON","po":"LEON-192","invoiceNumber":null,"requestedBy":null,"recipientText":"36 MILFORD PROJECT","notes":null,"status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Receiving","quantity":144,"date":"2026-02-26","company":"IDEAL","po":"29249","invoiceNumber":null,"requestedBy":null,"recipientText":"RECEIVED TEC SPECIALTY PRODUCTS LLC","notes":"JUNIO DRUMOND","status":"Received","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-31,"date":"2026-02-27","company":"LEON","po":"LEON-193","invoiceNumber":null,"requestedBy":"JOE","recipientText":"240 EAST CENTRAL","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-31,"date":"2026-03-03","company":"LEON","po":"LEON-194","invoiceNumber":null,"requestedBy":"JOE","recipientText":"240 EAST CENTRAL","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Receiving","quantity":47,"date":"2026-03-12","company":"LEON","po":null,"invoiceNumber":null,"requestedBy":null,"recipientText":"240 EAST CENTRAL RETURN","notes":null,"status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Receiving","quantity":20,"date":"2026-04-14","company":"LEON","po":"RMA-002","invoiceNumber":null,"requestedBy":null,"recipientText":"240 EAST CENTRAL RETURN","notes":null,"status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-20,"date":"2026-04-28","company":"LEON","po":null,"invoiceNumber":null,"requestedBy":null,"recipientText":"21 WINSLOW","notes":null,"status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-4,"date":"2026-05-29","company":"LEON","po":null,"invoiceNumber":null,"requestedBy":null,"recipientText":"21 WINSLOW","notes":null,"status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-2,"date":"2026-06-19","company":"LEON","po":null,"invoiceNumber":null,"requestedBy":"SHOWROOM","recipientText":"21 WINSLOW","notes":null,"status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-1,"date":"2026-06-25","company":"LEON","po":null,"invoiceNumber":null,"requestedBy":null,"recipientText":"21 WINSLOW","notes":null,"status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-34,"date":"2026-06-29","company":"LEON","po":null,"invoiceNumber":"LEON-247","requestedBy":"CAROLLINE","recipientText":"45 BARTLETT","notes":null,"status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-29,"date":"2026-07-13","company":"LEON","po":null,"invoiceNumber":"LEON-253","requestedBy":null,"recipientText":"45 BARTLETT","notes":null,"status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-29,"date":"2026-07-23","company":"LEON","po":null,"invoiceNumber":"LEON-256","requestedBy":null,"recipientText":"45 BARTLETT","notes":null,"status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-60,"date":"2026-08-13","company":"LEON","po":null,"invoiceNumber":"LEON-264","requestedBy":null,"recipientText":"45 BARTLETT","notes":null,"status":"Completed","enteredBy":"Jaciel Junior"}]},{"name":"WOOD MSI  MS007 TRANSITIONAL ADHESIVE","referenceNumber":7,"initialStock":10,"currentStock":7,"stockCheckDate":"2024-11-25","transactions":[{"type":"Withdrawal","quantity":-10,"date":"2024-11-25","company":null,"po":null,"invoiceNumber":null,"requestedBy":"ELEANDRO","recipientText":"249 COREY PROJECT","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Deleted member"},{"type":"Receiving","quantity":7,"date":null,"company":null,"po":null,"invoiceNumber":null,"requestedBy":null,"recipientText":"249 COREY PROJECT-RETURN","notes":null,"status":"Completed","enteredBy":"Jaciel Junior"}]},{"name":"WOOD ULTRABONDE 373","referenceNumber":null,"initialStock":4,"currentStock":4,"stockCheckDate":null,"transactions":[]},{"name":"HYDROBARRIER PLUS","referenceNumber":null,"initialStock":2,"currentStock":2,"stockCheckDate":"2026-06-24","transactions":[{"type":"Receiving","quantity":null,"date":null,"company":null,"po":"2024-10-25 to 2026-06-24","invoiceNumber":null,"requestedBy":null,"recipientText":null,"notes":null,"status":"Completed","enteredBy":null}]}]},{"category":"MEMBRANE - SPACERS","materials":[{"name":"REGULAR MEMBRANE","referenceNumber":null,"initialStock":8,"currentStock":10,"stockCheckDate":null,"transactions":[{"type":"Withdrawal","quantity":-2,"date":"2026-08-13","company":null,"po":null,"invoiceNumber":null,"requestedBy":null,"recipientText":"208 CABOT ST","notes":null,"status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-4,"date":"2024-04-16","company":null,"po":null,"invoiceNumber":null,"requestedBy":"CLERIO","recipientText":"14 WINSLOW RD","notes":"HARLITON","status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-1,"date":"2024-07-08","company":null,"po":null,"invoiceNumber":null,"requestedBy":"CLERIO","recipientText":"33 OLD TOWN RD , WALPOLE","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Deleted member"},{"type":"Withdrawal","quantity":-1,"date":"2024-10-10","company":null,"po":null,"invoiceNumber":"REF#1024-014 OR IV 24625","requestedBy":"CLERIO","recipientText":"C STUMPO INC.:119 CABOT STREET, NEWTON","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Deleted member"},{"type":"Withdrawal","quantity":-2,"date":"2024-10-17","company":null,"po":null,"invoiceNumber":"REFE#1024-021","requestedBy":"BAYAN","recipientText":"SHOWROOM -JASMINE","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Deleted member"},{"type":"Receiving","quantity":12,"date":"2024-10-21","company":null,"po":null,"invoiceNumber":"28970","requestedBy":"BARBARA","recipientText":"PROGRESS PROFILES","notes":"JUNIO DRUMOND","status":"Received","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-1,"date":"2024-11-06","company":null,"po":null,"invoiceNumber":"24656","requestedBy":"BARBARA","recipientText":"GOODMAN, DEBRA","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Deleted member"},{"type":"Withdrawal","quantity":-1,"date":"2024-12-02","company":null,"po":null,"invoiceNumber":"REF#1124-026","requestedBy":"THERENCE","recipientText":"WHALEN, ELISABETH:7 SMUGGLERS COVE RD. CAPE ELIZABETH","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Deleted member"},{"type":"Withdrawal","quantity":-2,"date":"2025-03-11","company":null,"po":null,"invoiceNumber":"IDEAL - 33","requestedBy":"CLERIO","recipientText":"36 BONNYBROOK RD- WABAN - CLERIO","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-1,"date":"2025-03-31","company":null,"po":null,"invoiceNumber":"24743","requestedBy":"CLER","recipientText":"PORTLAND GRP.:SPLASH OF NEWTON:5 HIPPOGRIFFE RD. DENNIS","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-1,"date":"2025-06-13","company":null,"po":null,"invoiceNumber":null,"requestedBy":"BARBARA","recipientText":"SHOWROOM","notes":null,"status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-1,"date":"2025-06-18","company":null,"po":null,"invoiceNumber":null,"requestedBy":"BARBARA","recipientText":"THENRICE","notes":"JUNIOR","status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-1,"date":"2025-06-26","company":null,"po":null,"invoiceNumber":"24843","requestedBy":"THERENCE","recipientText":"THENRICE","notes":null,"status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Receiving","quantity":10,"date":"2025-07-21","company":null,"po":"29176","invoiceNumber":null,"requestedBy":"BARBARA","recipientText":"TEC SPECIALTY PRODUCTS LLC","notes":"JUNIO DRUMOND","status":"Received","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-1,"date":"2025-08-08","company":null,"po":null,"invoiceNumber":"24876","requestedBy":"BARBARA","recipientText":"THENRICE","notes":"JUNIOR","status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-1,"date":"2025-08-20","company":null,"po":null,"invoiceNumber":null,"requestedBy":"CLERIO","recipientText":"144 WORCESTER","notes":null,"status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-1,"date":"2025-09-05","company":null,"po":null,"invoiceNumber":null,"requestedBy":"THERENCE","recipientText":"THENRICE - 5 UPLAND RD - WEKEFIELD MA","notes":null,"status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-1,"date":"2025-09-18","company":null,"po":null,"invoiceNumber":null,"requestedBy":"Thenrice","recipientText":"THENRICE","notes":null,"status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-1,"date":"2025-09-29","company":null,"po":null,"invoiceNumber":null,"requestedBy":"CLERIO","recipientText":"144 WORCESTER","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-1,"date":"2025-12-02","company":null,"po":null,"invoiceNumber":null,"requestedBy":"CLERIO","recipientText":"PICK UP CLERIO","notes":null,"status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-1,"date":"2026-02-26","company":null,"po":null,"invoiceNumber":null,"requestedBy":"MICHAEL - CLERIO","recipientText":"42 LORNA RD - NEWTON CENTER - MA 02459","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-1,"date":"2026-04-17","company":null,"po":null,"invoiceNumber":null,"requestedBy":"MICHAEL","recipientText":"21 WINSLOW","notes":null,"status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-1,"date":"2026-05-15","company":null,"po":null,"invoiceNumber":"LEON-229","requestedBy":"BARBARA","recipientText":"CINDY STUMPO 60 LYMAN","notes":null,"status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-2,"date":"2026-05-21","company":null,"po":null,"invoiceNumber":"LEON - 233","requestedBy":"BARBARA","recipientText":"CINDY STUMPO 60 LYMAN","notes":null,"status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Receiving","quantity":10,"date":"2026-06-02","company":null,"po":null,"invoiceNumber":null,"requestedBy":null,"recipientText":"ROSS EXPRESS","notes":null,"status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-1,"date":"2026-07-29","company":null,"po":null,"invoiceNumber":null,"requestedBy":"CLERIO","recipientText":"121 PROSPECT","notes":null,"status":"Completed","enteredBy":"Jaciel Junior"}]},{"name":"HEAT MEMBRANE","referenceNumber":null,"initialStock":12,"currentStock":8,"stockCheckDate":null,"transactions":[{"type":"Withdrawal","quantity":-2,"date":"2024-04-03","company":null,"po":null,"invoiceNumber":null,"requestedBy":"NATTAN","recipientText":"73 FAIRVIEW RD WESTON","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Deleted member"},{"type":"Withdrawal","quantity":-1,"date":"2024-04-16","company":null,"po":null,"invoiceNumber":null,"requestedBy":"CLERIO","recipientText":"14 WINSLOW RD","notes":"HARLITON","status":"Completed","enteredBy":"Deleted member"},{"type":"Withdrawal","quantity":-1,"date":"2024-05-22","company":null,"po":null,"invoiceNumber":null,"requestedBy":"BARBARA","recipientText":"SHOW ROOM - FRED - DELIVERY","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Deleted member"},{"type":"Withdrawal","quantity":-2,"date":"2024-06-04","company":null,"po":null,"invoiceNumber":null,"requestedBy":"THERENCE","recipientText":"BARBARA ESTA CIENTE","notes":"HARLITON","status":"Completed","enteredBy":"Deleted member"},{"type":"Withdrawal","quantity":-1,"date":"2024-08-21","company":null,"po":null,"invoiceNumber":"24611","requestedBy":"BARBARA","recipientText":"WELLINGTON","notes":"JUNIOR","status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-1,"date":"2024-09-23","company":null,"po":null,"invoiceNumber":"REF#0924-016","requestedBy":"BARBARA","recipientText":"C STUMPO INC.:152 WOODLAND RD. CHESTNUT HILL","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Deleted member"},{"type":"Withdrawal","quantity":-1,"date":"2024-09-30","company":null,"po":null,"invoiceNumber":null,"requestedBy":"CLERIO","recipientText":"C STUMPO INC.:152 WOODLAND RD. CHESTNUT HILL","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Deleted member"},{"type":"Withdrawal","quantity":-1,"date":"2024-10-10","company":null,"po":null,"invoiceNumber":"REF#1024-014 OR IV 24625","requestedBy":"CLERIO","recipientText":"C STUMPO INC.:119 CABOT STREET, NEWTON","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Deleted member"},{"type":"Receiving","quantity":15,"date":"2024-10-21","company":null,"po":null,"invoiceNumber":"28970","requestedBy":"BARBARA","recipientText":"PROGRESS PROFILES","notes":"JUNIO DRUMOND","status":"Received","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-1,"date":"2024-10-28","company":null,"po":null,"invoiceNumber":"REF# 0924-025","requestedBy":"BARBARA","recipientText":"SCIBELLI, VINCENZO","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Deleted member"},{"type":"Withdrawal","quantity":-2,"date":"2024-12-13","company":null,"po":null,"invoiceNumber":"IDEAL-4","requestedBy":"BARBARA","recipientText":"COSTA, WELITON","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Deleted member"},{"type":"Withdrawal","quantity":-3,"date":"2024-12-30","company":null,"po":null,"invoiceNumber":"24724 /  IDEAL -9","requestedBy":"BARBARA","recipientText":"COSTA, WELITON","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Deleted member"},{"type":"Withdrawal","quantity":-1,"date":"2025-01-17","company":null,"po":null,"invoiceNumber":"24732/ IDEAL-13","requestedBy":"BAYAN","recipientText":"COSTA, WELITON","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Deleted member"},{"type":"Withdrawal","quantity":-1,"date":"2025-01-21","company":null,"po":null,"invoiceNumber":"IDEAL-15","requestedBy":"CLERIO","recipientText":"154 WOODLAND RD CHESTNUT HILL","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-1,"date":"2025-03-11","company":null,"po":null,"invoiceNumber":"IDEAL - 33","requestedBy":"CLERIO","recipientText":"36 BONNYBROOK RD- WABAN - CLERIO","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-1,"date":"2025-04-24","company":null,"po":null,"invoiceNumber":null,"requestedBy":"THERENCE","recipientText":"7 HAROLD ST, ARLINGTON","notes":"JUNIOR","status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-2,"date":"2025-05-19","company":null,"po":null,"invoiceNumber":"LEON-38","requestedBy":"MICHAEL - CLERIO","recipientText":"144 WORCESTER","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-1,"date":"2025-06-13","company":null,"po":null,"invoiceNumber":null,"requestedBy":null,"recipientText":"SHOWROOM","notes":null,"status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-2,"date":"2025-06-16","company":null,"po":null,"invoiceNumber":null,"requestedBy":"THERENCE","recipientText":"THENRICE","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-1,"date":"2025-06-16","company":null,"po":null,"invoiceNumber":null,"requestedBy":"CLERIO","recipientText":"144 WORCESTER ST","notes":null,"status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-1,"date":"2025-06-26","company":null,"po":null,"invoiceNumber":null,"requestedBy":"CLERIO","recipientText":"144 WORCETER","notes":"JUNIOR","status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Receiving","quantity":15,"date":"2025-07-21","company":null,"po":"29176","invoiceNumber":null,"requestedBy":"BARBARA","recipientText":"TEC SPECIALTY PRODUCTS LLC","notes":"JUNIO DRUMOND","status":"Received","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-1,"date":"2025-09-05","company":null,"po":null,"invoiceNumber":null,"requestedBy":"THERENCE","recipientText":"THENRICE - 5 UPLAND RD - WEKEFIELD MA","notes":null,"status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-3,"date":"2025-09-18","company":null,"po":null,"invoiceNumber":null,"requestedBy":"Thenrice","recipientText":"THENRICE","notes":null,"status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-1,"date":"2025-11-10","company":null,"po":null,"invoiceNumber":null,"requestedBy":"CLERIO","recipientText":"36 MILFORD ST","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-1,"date":"2025-12-30","company":null,"po":null,"invoiceNumber":null,"requestedBy":"THENRICE","recipientText":"36 MILFORD","notes":"JUNIOR","status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-1,"date":"2025-12-30","company":null,"po":null,"invoiceNumber":null,"requestedBy":"THERENCE","recipientText":"THENRICE","notes":"JUNIOR","status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-1,"date":"2026-02-26","company":null,"po":null,"invoiceNumber":null,"requestedBy":"MICHAEL - CLERIO","recipientText":"42 LORNA RD - NEWTON CENTER - MA 02459","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-1,"date":"2026-04-17","company":null,"po":null,"invoiceNumber":null,"requestedBy":"MICHAEL","recipientText":"21 WINSLOW","notes":null,"status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-1,"date":"2026-05-14","company":null,"po":null,"invoiceNumber":null,"requestedBy":"BARBARA","recipientText":"THENRICE","notes":null,"status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-1,"date":"2026-05-15","company":null,"po":null,"invoiceNumber":"LEON - 229","requestedBy":"BARBARA","recipientText":"CINDY STUMPO 60 LYMAN","notes":null,"status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-1,"date":"2026-05-21","company":null,"po":null,"invoiceNumber":"LEON - 233","requestedBy":"BARBARA","recipientText":"CINDY STUMPO 60 LYMAN","notes":null,"status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-1,"date":"2026-05-21","company":null,"po":null,"invoiceNumber":null,"requestedBy":"BARBARA","recipientText":"SHOWROOM","notes":null,"status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Receiving","quantity":10,"date":"2026-06-02","company":null,"po":null,"invoiceNumber":null,"requestedBy":null,"recipientText":"ROSS EXPRESS","notes":null,"status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-1,"date":"2026-06-24","company":null,"po":null,"invoiceNumber":null,"requestedBy":"JUNIOR","recipientText":"THENRICE","notes":null,"status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-2,"date":"2026-08-13","company":null,"po":null,"invoiceNumber":null,"requestedBy":null,"recipientText":"208 CABOT ST","notes":null,"status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-1,"date":"2026-08-19","company":"IDEAL","po":null,"invoiceNumber":null,"requestedBy":"CLERIO","recipientText":"208 CABOT ST","notes":null,"status":"Completed","enteredBy":"Jaciel Junior"}]},{"name":"INSOLATION MAT","referenceNumber":null,"initialStock":37,"currentStock":3,"stockCheckDate":null,"transactions":[{"type":"Withdrawal","quantity":-20,"date":"2024-03-27","company":null,"po":null,"invoiceNumber":null,"requestedBy":"JUNIOR","recipientText":"143 WASHINGTON","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Deleted member"},{"type":"Withdrawal","quantity":-6,"date":"2024-04-01","company":null,"po":null,"invoiceNumber":"REF#0424-003","requestedBy":"HILAL","recipientText":"143 WASHINGTON ST PROJECT","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Deleted member"},{"type":"Withdrawal","quantity":-2,"date":"2024-05-07","company":null,"po":null,"invoiceNumber":"REF#0524-0012","requestedBy":"JOSE","recipientText":"JOSE BALDERAS","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-6,"date":"2025-03-10","company":null,"po":null,"invoiceNumber":"LEON-025","requestedBy":"JOSE","recipientText":"JOSE BALDERAS","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Jaciel Junior"}]},{"name":"1MM SPACERS","referenceNumber":null,"initialStock":71,"currentStock":0,"stockCheckDate":null,"transactions":[{"type":"Withdrawal","quantity":-2,"date":"2024-03-26","company":null,"po":null,"invoiceNumber":"REF#0324-048","requestedBy":"NATTAN","recipientText":"73 FAIRVIIEW RD - WESTON","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Deleted member"},{"type":"Withdrawal","quantity":-3,"date":"2024-03-26","company":null,"po":null,"invoiceNumber":"REF#0324-047","requestedBy":"NATTAN","recipientText":"1055 CAMBRIGDGE  ST","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Deleted member"},{"type":"Withdrawal","quantity":-10,"date":"2024-04-05","company":null,"po":null,"invoiceNumber":null,"requestedBy":"NATTAN","recipientText":"73 FAIRVIIEW RD - WESTON","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Deleted member"},{"type":"Withdrawal","quantity":-2,"date":"2024-04-12","company":null,"po":null,"invoiceNumber":null,"requestedBy":"NATTAN","recipientText":"100 LINDEN ST-TERRAZA","notes":"HARLITON","status":"Completed","enteredBy":"Deleted member"},{"type":"Withdrawal","quantity":-1,"date":"2024-04-16","company":null,"po":null,"invoiceNumber":null,"requestedBy":"CLERIO","recipientText":"14 WINSLOW RD","notes":"HARLITON","status":"Completed","enteredBy":"Deleted member"},{"type":"Withdrawal","quantity":-4,"date":"2024-04-29","company":null,"po":null,"invoiceNumber":null,"requestedBy":"CLERIO","recipientText":"14 WINSLOW RD","notes":"HARLITON","status":"Completed","enteredBy":"Deleted member"},{"type":"Withdrawal","quantity":-14,"date":"2024-04-29","company":null,"po":null,"invoiceNumber":null,"requestedBy":"THENRECI","recipientText":"55 NORTH MAIN ST","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Deleted member"},{"type":"Withdrawal","quantity":-10,"date":"2024-05-29","company":null,"po":null,"invoiceNumber":"REF#0524-050","requestedBy":"THERENCE","recipientText":"358 MALBOROUGH PROJECT","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Deleted member"},{"type":"Withdrawal","quantity":-5,"date":"2024-06-05","company":null,"po":null,"invoiceNumber":null,"requestedBy":"NATTAN","recipientText":"100 LINDEN ST -TERRRAZA","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Deleted member"},{"type":"Withdrawal","quantity":-4,"date":"2024-07-09","company":null,"po":null,"invoiceNumber":null,"requestedBy":"WELLINGTON","recipientText":"17-2 ALVESTON ST- JAMAICA PLAIN","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Deleted member"},{"type":"Withdrawal","quantity":-10,"date":"2024-09-04","company":null,"po":null,"invoiceNumber":"REF# 0924-001","requestedBy":"JOSH","recipientText":"249 COREY PROJECT","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Jaciel Junior"},{"type":"Withdrawal","quantity":-6,"date":"2024-09-20","company":null,"po":null,"invoiceNumber":"0924-015","requestedBy":"THERENCE","recipientText":"249 COREY PROJECT","notes":"JUNIOR","status":"Completed","enteredBy":"Jaciel Junior"}]},{"name":"2MM SPACERS","referenceNumber":null,"initialStock":30,"currentStock":20,"stockCheckDate":null,"transactions":[{"type":"Withdrawal","quantity":-10,"date":"2024-05-30","company":null,"po":null,"invoiceNumber":"REF#0524-051","requestedBy":"THERENCE","recipientText":"55 N MAIN ST","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Deleted member"},{"type":"Withdrawal","quantity":-2,"date":"2024-10-02","company":null,"po":null,"invoiceNumber":null,"requestedBy":"CLERIO","recipientText":"C STUMPO INC.:152 WOODLAND RD. CHESTNUT HILL","notes":"JUNIO DRUMOND","status":"Completed","enteredBy":"Deleted member"},{"type":"Stock Check","quantity":2,"date":"2025-07-30","company":null,"po":null,"invoiceNumber":null,"requestedBy":null,"recipientText":"STOCK CHECK","notes":null,"status":"STOCK  CHECK","enteredBy":"Jaciel Junior"},{"type":"Receiving","quantity":null,"date":null,"company":null,"po":null,"invoiceNumber":null,"requestedBy":null,"recipientText":null,"notes":null,"status":"Completed","enteredBy":null}]}]},{"category":"Silicone Sealent","materials":[{"name":"133  SILSEA CLEAR","referenceNumber":null,"initialStock":null,"currentStock":1,"stockCheckDate":null,"transactions":[{"type":"Receiving","quantity":1,"date":"2024-04-10","company":null,"po":"28752","invoiceNumber":null,"requestedBy":"BDS","recipientText":"H.B. FULLER","notes":"JUNIO DRUMOND","status":"Received","enteredBy":"Deleted member"},{"type":"Receiving","quantity":null,"date":null,"company":null,"po":null,"invoiceNumber":null,"requestedBy":null,"recipientText":null,"notes":null,"status":"Completed","enteredBy":null}]}]}];
function buildWarehouseImportSeed(groups, warehouseId, importDate) {
  const materials = [];
  const transactions = [];
  groups.forEach(g => {
    g.materials.forEach(m => {
      const importedFrom = { source: 'Monday.com Export', board: 'Asset stock', group: g.category, originalItemRef: m.referenceNumber, importDate };
      const mat = makeWarehouseMaterial({ name: m.name, category: g.category, referenceNumber: m.referenceNumber, currentStock: m.currentStock, initialStock: m.initialStock, warehouseId, importedFrom });
      materials.push(mat);
      (m.transactions || []).forEach(t => {
        transactions.push(makeInventoryTransaction({ materialId: mat.id, type: t.type, quantity: t.quantity, date: t.date, company: t.company, po: t.po, invoiceNumber: t.invoiceNumber, requestedBy: t.requestedBy, recipientText: t.recipientText, notes: t.notes, status: t.status, enteredBy: t.enteredBy, importedFrom }));
      });
    });
  });
  return { materials, transactions };
}
// Real, computed-once validation figures for the imported file (§9) — shown
// verbatim in the Warehouse > Import Validation tab so nothing silently drops.
const IMPORT_VALIDATION_SUMMARY = {
  source: 'Asset_stock_1788266376.xlsx', board: 'Asset stock', importDate: '2026-09-01',
  totalMaterialsImported: 68, totalTransactionsImported: 569, totalRowsImported: 703,
  duplicateRecordsFound: { materialNameDuplicates: 0, note: 'No two materials share the same name. The Company field on transactions resolves to two recognizable vendors (LEON, IDEAL); a handful of rows had a garbled numeric value instead — see dataRequiringManualReview.' },
  missingRequiredFields: {
    materialsMissingReferenceNumber: [{"material": "SANDSTONE BEIGE  (UNSANDED)", "row": 388}, {"material": "CRYSTAL", "row": 389}, {"material": "FROST HELADA UNSANDE N*77", "row": 390}, {"material": "FROST HELADA S SANDED N*77", "row": 393}, {"material": "TURKISH THINSET", "row": 515}, {"material": "SELF-LEVELING", "row": 524}, {"material": "PLATINUM PLUS", "row": 525}, {"material": "TRI-LITE WHITE", "row": 530}, {"material": "MULTIMAX LITE WHITE", "row": 535}, {"material": "ENDUEGLUE", "row": 543}, {"material": "HYBRID FLOORING GLUE (WOOD READY)", "row": 546}, {"material": "WOOD SECURE", "row": 561}, {"material": "WOOD ULTRABONDE 373", "row": 598}, {"material": "HYDROBARRIER PLUS", "row": 599}, {"material": "REGULAR MEMBRANE", "row": 604}, {"material": "HEAT MEMBRANE", "row": 632}, {"material": "INSOLATION MAT", "row": 671}, {"material": "1MM SPACERS", "row": 677}, {"material": "2MM SPACERS", "row": 691}, {"material": "133  SILSEA CLEAR", "row": 700}],
    materialsMissingInitialStock: [{"material": "DOVE GRAY", "row": 125}, {"material": "WOOD SECURE", "row": 561}, {"material": "133  SILSEA CLEAR", "row": 700}],
    transactionsMissingQuantity: [{"material": "FROST HELADA S SANDED N*77", "row": 396}, {"material": "MULTIMAX LITE WHITE", "row": 539}, {"material": "HYDROBARRIER PLUS", "row": 600}, {"material": "2MM SPACERS", "row": 696}, {"material": "133  SILSEA CLEAR", "row": 703}],
  },
  recordsCouldNotBeMapped: [],
  dataRequiringManualReview: [{"material": "FROST HELADA S SANDED N*77", "row": 396, "raw_type": 42292, "raw_status": 867}, {"material": "MULTIMAX LITE WHITE", "row": 539, "raw_type": 833, "raw_status": 198}, {"material": "HYDROBARRIER PLUS", "row": 600, "raw_type": 7, "raw_status": 94}, {"material": "2MM SPACERS", "row": 696, "raw_type": 0, "raw_status": 41}, {"material": "133  SILSEA CLEAR", "row": 703, "raw_type": 0, "raw_status": 1}],
};

// Complete every stage up to `n` and start the one at `n` — clamped to the
// stages the scope actually HAS. Scope templates differ in length now (window
// 13, Supply Only fewer, Labor Only 7), so a fixed index is a crash waiting
// for the next template change.
function advanceTo(scope, n) {
  const last = scope.stages.length - 1;
  const idx = Math.min(n, last);
  for (let i = 0; i < idx; i++) {
    advanceStage(scope, i, scope.stages[i].plannedStart, scope.stages[i].plannedDue);
  }
  if (idx >= 0 && scope.stages[idx]) {
    scope.stages[idx].status = 'In Progress';
    scope.stages[idx].actualStart = scope.stages[idx].plannedStart;
  }
}
function buildSeed() {
  const accounts = [
    { id: uid('acct'), name: 'Harborview Residential Group', contactName: 'Elena Marsh', title: 'Director of Construction', email: 'emarsh@harborviewrg.com', phone: '(305) 555-0142', billingAddress: '410 Bay Front Blvd, Miami, FL' },
    { id: uid('acct'), name: 'Cascade Development Partners', contactName: 'James Okafor', title: 'VP Development', email: 'jokafor@cascadedp.com', phone: '(212) 555-0198', billingAddress: '88 Hudson Yards, New York, NY' },
    { id: uid('acct'), name: 'Whitfield & Cole Architecture', contactName: 'Sarah Whitfield', title: 'Principal', email: 'swhitfield@whitfieldcole.com', phone: '(617) 555-0110', billingAddress: '22 Newbury St, Boston, MA' },
    { id: uid('acct'), name: 'Solmar Coastal Builders', contactName: 'Rafael Nunez', title: 'Project Executive', email: 'rnunez@solmarbuilders.com', phone: '(786) 555-0177', billingAddress: '900 Brickell Ave, Miami, FL' },
  ];

  const vendors = [
    Object.assign(
      makeVendor('Meridian Millwork Supply', 'Carla Jensen', '(704) 555-2210', 'cjensen@meridianmillwork.com', '1200 Industrial Pkwy, Charlotte, NC',
        [{ label: 'Deposit', pct: 50 }, { label: 'Balance on Delivery', pct: 50 }], 'Primary cabinetry box/door supplier — reliable on lead times.'),
      {
        vendorType: 'Manufacturer', city: 'Charlotte', state: 'NC', zip: '28206', country: 'USA', contactTitle: 'Sales Director', website: 'www.meridianmillwork.com',
        contacts: [
          { ...makeVendorContact('Production Contact'), name: 'Owen Reyes', title: 'Production Manager', phone: '(704) 555-2211', mobile: '(704) 555-9012', email: 'oreyes@meridianmillwork.com' },
          { ...makeVendorContact('Accounting Contact'), name: 'Denise Farr', title: 'AP Manager', phone: '(704) 555-2299', mobile: '', email: 'ap@meridianmillwork.com' },
        ],
        billing: { ...makeBillingInfo(), sameAsCompany: false, companyName: 'Meridian Millwork Supply LLC', contact: 'Denise Farr', email: 'ap@meridianmillwork.com', phone: '(704) 555-2299', address: '2 Corporate Center Dr, Suite 400', city: 'Charlotte', state: 'NC', zip: '28202', country: 'USA', paymentTerms: 'Net 30', paymentMethod: 'ACH', currency: 'USD', taxInfo: 'W-9 on file' },
      }
    ),
    Object.assign(makeVendor('Stoneline Fabricators', 'Marco Ianni', '(305) 555-7741', 'mianni@stonelinefab.com', '780 Quarry Rd, Doral, FL',
      [{ label: 'Deposit', pct: 30 }, { label: 'Balance on Delivery', pct: 70 }], ''), { vendorType: 'Local Fabricator', city: 'Doral', state: 'FL', zip: '33122', country: 'USA' }),
    Object.assign(makeVendor('Bright Edge Hardware Co.', 'Kelsey Ann Tran', '(212) 555-9081', 'ktran@brightedgehw.com', '55 Industry Ave, Newark, NJ',
      [{ label: 'Deposit', pct: 50 }, { label: 'Balance on Delivery', pct: 50 }], ''), { vendorType: 'Material Supplier', city: 'Newark', state: 'NJ', zip: '07105', country: 'USA' }),
    Object.assign(makeVendor('Coastal Glass & Glazing', 'Ruben Ortiz', '(786) 555-3320', 'rortiz@coastalglass.com', '410 Harbor Dr, Fort Lauderdale, FL',
      [{ label: 'Deposit', pct: 30 }, { label: 'Prior to Ship', pct: 40 }, { label: 'On Arrival at Port', pct: 30 }], 'Window/glazing systems vendor.'), { vendorType: 'Manufacturer', city: 'Fort Lauderdale', state: 'FL', zip: '33316', country: 'USA' }),
  ];

  // Freight forwarders are a separate directory from material vendors, but
  // follow the exact same shape/workflow (contact info, default payment
  // terms, estimate revisions, two-approval PO issuance).
  const freightForwarders = [
    Object.assign(makeVendor('Atlas Freight Forwarding', 'Denise Okonkwo', '(305) 555-6620', 'dokonkwo@atlasfreight.com', '2200 Port Blvd, Miami, FL',
      [{ label: 'Deposit', pct: 50 }, { label: 'Balance on Delivery', pct: 50 }], 'Primary ocean freight forwarder for FL-origin containers.'), { vendorType: 'Freight / Logistics', city: 'Miami', state: 'FL', zip: '33132', country: 'USA' }),
    Object.assign(makeVendor('Pacific Rim Logistics', 'Tobias Lund', '(714) 555-4471', 'tlund@pacrimlogistics.com', '900 Harbor Way, Long Beach, CA',
      [{ label: 'Deposit', pct: 30 }, { label: 'Balance on Delivery', pct: 70 }], 'West coast freight forwarder, used for Asia-origin imports.'), { vendorType: 'Freight / Logistics', city: 'Long Beach', state: 'CA', zip: '90802', country: 'USA' }),
  ];

  // ---- Subcontractors (§ subcontractor accounts) ----
  // Distinct people from any internal employee (see the Subcontractor login
  // entries near the end of TEAM_DIRECTORY) so usernames never collide.
  const subcontractors = [
    Object.assign(makeSubcontractor({
      companyName: 'Delgado Installation Crew', contactName: 'Manny Delgado', trade: 'Cabinet Installer',
      phone: '(305) 555-8810', mobile: '(305) 555-8811', email: 'mdelgado@delgadoinstall.com',
      address: '1140 SW 27th Ave', city: 'Miami', state: 'FL', zip: '33145',
      emergencyContact: 'Luis Delgado (brother)', emergencyPhone: '(305) 555-8899',
      notes: 'Primary casework install crew for Harborview Tower.',
      username: 'mdelgado.sub',
    }, 'Ivy Marchetti'),
      { billing: { ...makeBillingInfo(), sameAsCompany: false, companyName: 'Delgado Installation Crew LLC', contact: 'Manny Delgado', email: 'billing@delgadoinstall.com', address: '1140 SW 27th Ave', city: 'Miami', state: 'FL', zip: '33145', country: 'USA', paymentTerms: 'Net 15', paymentMethod: 'ACH', currency: 'USD', taxInfo: 'W-9 on file' } }
    ),
    makeSubcontractor({
      companyName: 'Jennings Field Services', contactName: 'Tony Reyes', trade: 'Field Technician',
      phone: '(305) 555-7712', mobile: '(305) 555-7713', email: 'treyes@jenningsfield.com',
      address: '620 NW 15th St', city: 'Miami', state: 'FL', zip: '33136',
      emergencyContact: 'Maria Reyes (spouse)', emergencyPhone: '(305) 555-7799',
      notes: 'Punch list and field QC across active jobs.',
      username: 'treyes.sub',
    }, 'Ivy Marchetti'),
  ].map(s => { s.password = DEMO_SCENARIO_PASSWORD; return s; });

  const projects = [];

  // ---- Project 1: hero / fully fleshed-out Active Job -----------------
  {
    const acct = accounts[0];
    const p = {
      id: uid('proj'),
      projectNumber: 'LI-2026-014',
      name: 'Harborview Tower — Residences 40-52',
      accountId: acct.id,
      department: 'Interior Finishes',
      companyDepartment: ['Interiors'],
      complexity: 'High-End',
      pipelineStatus: 'Active Job',
      displayImageUrl: null,
      address: '410 Bay Front Blvd, Miami, FL 33131',
      projectType: 'Residential',
      sizeSqFt: 184000,
      unitQuantity: 42,
      buildingStories: 52,
      laborType: 'Union',
      notes: 'Client requested accelerated selections timeline for floors 45-52 to align with sales center opening.',
      contacts: {
        'General Contractor': { company: 'Turnbridge Construction', person: 'Mike Talbot', phone: '(305) 555-2200', email: 'mtalbot@turnbridgecx.com' },
        Developer: { company: 'Harborview Residential Group', person: 'Elena Marsh', phone: '(305) 555-0142', email: 'emarsh@harborviewrg.com' },
        Architect: { company: 'Whitfield & Cole Architecture', person: 'Sarah Whitfield', phone: '(617) 555-0110', email: 'swhitfield@whitfieldcole.com' },
        Designer: { company: 'Nine Line Interiors', person: 'Cass Rowe', phone: '(305) 555-3391', email: 'crowe@ninelineinteriors.com' },
        'Billing Contact': { company: 'Harborview Residential Group', person: 'Priya Deshmukh', phone: '(305) 555-0166', email: 'pdeshmukh@harborviewrg.com' },
        'Additional Contact': { company: '', person: '', phone: '', email: '' },
      },
      team: defaultTeamAssignment(),
      scopes: [],
      documents: [],
      drawingSets: [
        { id: uid('dwg'), name: 'Harborview Tower — Architectural Set Rev A.pdf', revision: 'A', dateReceived: '2026-01-08', source: 'Whitfield & Cole Architecture', sharedBy: 'Sarah Whitfield', note: 'Full architectural set shared at lead stage for take-off.' },
        { id: uid('dwg'), name: 'Harborview Tower — Architectural Set Rev B.pdf', revision: 'B', dateReceived: '2026-02-01', source: 'Whitfield & Cole Architecture', sharedBy: 'Sarah Whitfield', note: 'Updated floor plans, floors 50-52 vanity layout revised.' },
      ],
      takeOffs: [
        { id: uid('to'), name: 'Kitchen Cabinetry — Take-Off R1.xlsx', revision: 1, date: '2026-02-14', preparedBy: 'Felix Ndiaye', scopeId: null, note: 'Based on Architectural Set Rev A.' },
      ],
      renders: [],
      quoteRevisions: [],
      quotationFollowUps: [],
      changeOrders: [],
      paymentTerms: [],
      paymentRequisitions: [],
      vendorEstimates: [],
      purchaseOrders: [],
      tasks: [],
      issues: [],
      changeLog: [],
      originalContractValue: 1850000,
      estimatedCost: 1340000,
      actualCostToDate: 812000,
    };

    const s1 = makeScope('Kitchen Cabinetry — Floors 45-52', 'Casework', '2026-03-02', p.complexity, { selectAll: true, quantity: 42, unit: 'Units' });
    const s2 = makeScope('Bathroom Vanities — Floors 45-52', 'Casework', '2026-03-16', p.complexity, { selectAll: true, quantity: 84, unit: 'Units' });
    const s3 = makeScope('Countertops — Kitchens & Baths', 'Countertop', '2026-03-09', p.complexity, { selectAll: true, quantity: 126, unit: 'Units' });
    p.scopes = [s1, s2, s3];

    // Advance scope 1 partway through with real actuals + one reported delay (cascade demo)
    advanceStage(s1, 0, '2026-03-02', '2026-03-04');
    advanceStage(s1, 1, '2026-03-05', '2026-03-11');
    advanceStage(s1, 2, '2026-03-12', '2026-03-19');
    advanceStage(s1, 3, '2026-03-20', '2026-03-24');
    advanceStage(s1, 4, '2026-03-25', '2026-04-03');
    if (s1.stages[5]) s1.stages[5].status = 'In Progress';
    if (s1.stages[5]) s1.stages[5].actualStart = '2026-04-04';
    p.changeLog = p.changeLog || [];

    // Advance scope 2 further, then apply a real delay via cascade to demonstrate the rule
    advanceStage(s2, 0, '2026-03-16', '2026-03-18');
    advanceStage(s2, 1, '2026-03-19', '2026-03-25');
    if (s2.stages[2]) s2.stages[2].status = 'In Progress';
    if (s2.stages[2]) s2.stages[2].actualStart = '2026-03-26';

    // Advance scope 3 into production
    for (let i = 0; i < Math.min(9, s3.stages.length - 1); i++) {
      advanceStage(s3, i, s3.stages[i].plannedStart, s3.stages[i].plannedDue);
    }
    advanceTo(s3, 9);

    p.documents = [
      { id: uid('doc'), tier: 'project', type: 'Quote', name: 'Harborview Tower — Master Quote R3.pdf', revision: 3, dateCreated: '2026-02-20', sharedWithClient: true },
      { id: uid('doc'), tier: 'project', type: 'Contract', name: 'Harborview Tower — Executed Contract.pdf', revision: 1, dateCreated: '2026-02-26', sharedWithClient: true },
    ];
    s1.documents = [
      { id: uid('doc'), tier: 'scope', type: 'Shop Drawings', name: 'Kitchen Cabinetry — Shop Dwgs R1.pdf', revision: 1, dateCreated: '2026-03-24', sharedWithClient: true },
    ];
    p.renders = [
      { id: uid('render'), scopeId: s1.id, revision: 1, name: 'Kitchen Cabinetry — Concept Render', imageUrl: null, date: '2026-03-05', note: 'Initial concept render for client review.', sharedWithClient: true },
      { id: uid('render'), scopeId: s1.id, revision: 2, name: 'Kitchen Cabinetry — Concept Render', imageUrl: null, date: '2026-03-22', note: 'Updated with client-selected hardware.', sharedWithClient: true },
      { id: uid('render'), scopeId: s3.id, revision: 1, name: 'Countertop — Waterfall Detail Render', imageUrl: null, date: '2026-03-15', note: '', sharedWithClient: false },
    ];

    p.quoteRevisions = [
      { id: uid('qr'), revision: 1, amount: 1720000, date: '2026-01-15', clientFile: 'Quote_R1_Client.pdf', internalAnalysisFile: 'Quote_R1_Internal_Analysis.xlsx', note: 'Initial quote based on preliminary drawings.' },
      { id: uid('qr'), revision: 2, amount: 1795000, date: '2026-02-02', clientFile: 'Quote_R2_Client.pdf', internalAnalysisFile: 'Quote_R2_Internal_Analysis.xlsx', note: 'Revised for scope addition — bathroom vanities floors 50-52.' },
      { id: uid('qr'), revision: 3, amount: 1850000, date: '2026-02-18', clientFile: 'Quote_R3_Client.pdf', internalAnalysisFile: 'Quote_R3_Internal_Analysis.xlsx', note: 'Final revision after countertop material upgrade.' },
    ];
    p.quotationFollowUps = [
      { id: uid('fu'), date: '2026-01-20', method: 'Call', note: 'Discussed timeline for floors 50-52 addition.', nextFollowUp: '2026-01-27' },
      { id: uid('fu'), date: '2026-02-05', method: 'Email', note: 'Sent revised quote R2 for review.', nextFollowUp: '2026-02-12' },
      { id: uid('fu'), date: '2026-02-19', method: 'Meeting', note: 'Walked through R3 with Elena and GC on site.', nextFollowUp: null },
    ];

    p.changeOrders = [
      { id: uid('co'), number: 'CO-1', type: 'Change Order', amount: 42000, date: '2026-03-05', file: 'CO-1_Vanity_Upgrade.pdf', description: 'Upgrade vanity countertop material for floors 50-52 to Statuario marble.', status: 'Approved' },
      { id: uid('co'), number: 'BC-1', type: 'Back Charge', amount: -8500, date: '2026-03-20', file: 'BC-1_Damaged_Panels.pdf', description: 'GC-caused damage to 6 cabinet panels during framing — cost of replacement panels back-charged.', status: 'Approved' },
      { id: uid('co'), number: 'CO-2', type: 'Change Order', amount: 15500, date: '2026-04-01', file: 'CO-2_Hardware_Change.pdf', description: 'Client-requested hardware upgrade to integrated pulls, floors 45-49.', status: 'Pending' },
    ];

    // Payment terms computed against revised contract value below (after helper defined)
    p.paymentTermDefs = [
      { label: 'Deposit', pct: 30, trigger: 'Contract Signing' },
      { label: 'Progress Payment', pct: 40, trigger: 'Production Release' },
      { label: 'Final Payment', pct: 30, trigger: 'Installation Complete' },
    ];
    p.paymentTerms = [
      { id: uid('pt'), label: 'Deposit', pct: 50, trigger: 'Contract Signing', status: 'Paid' },
      { id: uid('pt'), label: 'Prior to Ship from Manufacture', pct: 40, trigger: 'Production Release', status: 'Due' },
      { id: uid('pt'), label: 'Upon Delivery to Jobsite', pct: 10, trigger: 'Delivery to Jobsite', status: 'Not Due' },
    ];
    p.retainagePct = 5;

    p.paymentRequisitions = [
      { id: uid('req'), revision: 1, date: '2026-02-27', amount: 925000, retainageHeld: 46250, file: 'PayReq_Deposit_R1.pdf', status: 'Approved', type: 'Milestone', reference: 'Deposit', note: 'Initial deposit — 50% of R3 contract.' },
      { id: uid('req'), revision: 1, date: '2026-03-06', amount: 42000, retainageHeld: 0, file: 'PayReq_CO1_R1.pdf', status: 'Approved', type: 'Change Order', reference: 'CO-1', note: 'Billed with progress draw.' },
      { id: uid('req'), revision: 1, date: '2026-03-22', amount: -8500, retainageHeld: 0, file: 'PayReq_BC1_R1.pdf', status: 'Approved', type: 'Back Charge', reference: 'BC-1', note: 'Deducted from progress draw.' },
      { id: uid('req'), revision: 1, date: '2026-04-05', amount: 707800, retainageHeld: 35390, file: 'PayReq_Progress_R1.pdf', status: 'Submitted', type: 'Milestone', reference: 'Prior to Ship from Manufacture', note: 'Progress payment on production release.' },
    ];

    p.vendorEstimates = [
      {
        id: uid('ve'), vendorId: vendors[0].id, vendorName: vendors[0].name, category: 'Original Order', description: 'Cabinet boxes + doors — Kitchen scope', amount: 410000, date: '2026-02-10',
        pmApproved: true, pmApprovedBy: 'Rachel Kim', pmApprovedDate: '2026-02-12', ownerApproved: true, ownerApprovedBy: 'Ivy Marchetti', ownerApprovedDate: '2026-02-14', poId: null,
        revisions: [
          { id: uid('ver'), revision: 1, amount: 425000, date: '2026-02-08', file: 'Meridian_Quote_R1.pdf', note: 'Initial quote.' },
          { id: uid('ver'), revision: 2, amount: 410000, date: '2026-02-10', file: 'Meridian_Quote_R2.pdf', note: 'Revised after value engineering on hardware.' },
        ],
      },
      {
        id: uid('ve'), vendorId: vendors[1].id, vendorName: vendors[1].name, category: 'Original Order', description: 'Countertop fabrication — Kitchen + Bath', amount: 265000, date: '2026-02-15',
        pmApproved: true, pmApprovedBy: 'Rachel Kim', pmApprovedDate: '2026-02-17', ownerApproved: false, ownerApprovedBy: null, ownerApprovedDate: null, poId: null,
        revisions: [{ id: uid('ver'), revision: 1, amount: 265000, date: '2026-02-15', file: 'Stoneline_Quote_R1.pdf', note: '' }],
      },
      {
        id: uid('ve'), vendorId: vendors[2].id, vendorName: vendors[2].name, category: 'Samples', description: 'Cabinet hardware — full scope', amount: 38500, date: '2026-03-01',
        pmApproved: false, pmApprovedBy: null, pmApprovedDate: null, ownerApproved: false, ownerApprovedBy: null, ownerApprovedDate: null, poId: null,
        revisions: [{ id: uid('ver'), revision: 1, amount: 38500, date: '2026-03-01', file: 'BrightEdge_Quote_R1.pdf', note: '' }],
      },
      {
        id: uid('ve'), vendorId: vendors[0].id, vendorName: vendors[0].name, category: 'Damaged Item', scopeId: s1.id, description: 'Replacement panels — 6 base cabinet doors damaged in transit', amount: 6200, date: '2026-03-22',
        pmApproved: true, pmApprovedBy: 'Rachel Kim', pmApprovedDate: '2026-03-23', ownerApproved: true, ownerApprovedBy: 'Ivy Marchetti', ownerApprovedDate: '2026-03-24', poId: null,
        unplannedReason: 'Damage', recoverability: 'Recoverable from Vendor',
        revisions: [{ id: uid('ver'), revision: 1, amount: 6200, date: '2026-03-22', file: 'Meridian_DamageClaim_R1.pdf', note: 'Transit damage — vendor to credit or replace at their cost.' }],
      },
    ];
    // Issue a PO for the fully-approved vendor estimate
    const poVe = p.vendorEstimates[0];
    const po = {
      id: uid('po'), poNumber: 'PO-10001', vendorEstimateId: poVe.id, vendorName: poVe.vendorName, amount: poVe.amount, issuedDate: '2026-02-14',
      paymentTerms: [
        { id: uid('pot'), label: 'Deposit', pct: 50, status: 'Paid' },
        { id: uid('pot'), label: 'Balance on Delivery', pct: 50, status: 'Not Due' },
      ],
    };
    p.purchaseOrders = [po];
    poVe.poId = po.id;

    p.tasks = [
      { id: uid('task'), title: 'Confirm floor 50-52 vanity selections with client', assigneeId: personIdByName('Marisol Chen'), dueDate: '2026-04-10', status: 'Open', scopeId: s2.id, priority: 'High' },
      { id: uid('task'), title: 'Route shop drawing R1 for architect sign-off', assigneeId: personIdByName('Grace Liu'), dueDate: '2026-04-02', status: 'Open', scopeId: s1.id, priority: 'Medium' },
      { id: uid('task'), title: 'Follow up on Stoneline ownership approval', assigneeId: personIdByName('Ivy Marchetti'), dueDate: '2026-03-28', status: 'Overdue', scopeId: null, priority: 'High' },
      { id: uid('task'), title: 'Submit progress payment requisition', assigneeId: personIdByName('Nina Osei'), dueDate: '2026-04-05', status: 'Completed', scopeId: null, priority: 'Medium' },
      { id: uid('task'), title: 'Schedule warehouse receiving walkthrough', assigneeId: personIdByName('Hugo Reyes'), dueDate: '2026-05-01', status: 'Open', scopeId: s3.id, priority: 'Low' },
    ];

    p.issues = [
      { id: uid('issue'), title: 'Panel damage from GC framing crew', description: 'Six cabinet panels damaged during wall framing on floor 47; replacement panels ordered and back-charged (BC-1).', status: 'Resolved', severity: 'Medium', scopeId: s1.id, dateRaised: '2026-03-18', dateResolved: '2026-03-20' },
      { id: uid('issue'), title: 'Countertop material lead time risk', description: 'Statuario slab lead time running 2 weeks over vendor estimate; monitoring for schedule impact on floors 50-52.', status: 'Open', severity: 'High', scopeId: s3.id, dateRaised: '2026-04-08', dateResolved: null },
    ];

    p.deliveries = [
      { id: uid('del'), scopeId: s1.id, description: 'Kitchen cabinetry boxes — floors 45-48 partial delivery', date: '2026-03-28', slipFile: 'Delivery_Slip_0328.pdf', jobsitePhoto: null, notes: 'Received by site super, staged in unit 4502 storage.' },
    ];

    p.exportDocuments = [
      { id: uid('exp'), scopeId: s1.id, step: 'po_contract', name: 'Meridian Millwork — PO Confirmation.pdf', file: 'Meridian_PO_Confirmation.pdf', date: '2026-02-14', trackingNumber: '', containerNumber: '', note: '' },
      { id: uid('exp'), scopeId: s1.id, step: 'commercial_invoice', name: 'Meridian Millwork — Commercial Invoice.pdf', file: 'Meridian_Commercial_Invoice.pdf', date: '2026-03-20', trackingNumber: '', containerNumber: '', note: '' },
    ];
    p.freightEstimates = [
      {
        id: uid('fre'), scopeId: s1.id, forwarderId: freightForwarders[0].id, carrier: freightForwarders[0].name, category: 'Original Order',
        description: 'Ocean freight — Meridian cabinetry container', amount: 18500, date: '2026-03-10',
        exportApproved: true, exportApprovedBy: 'Beatriz Souza', exportApprovedDate: '2026-03-11', adminApproved: false, adminApprovedBy: null, adminApprovedDate: null, poId: null,
        revisions: [{ id: uid('ver'), revision: 1, amount: 18500, date: '2026-03-10', file: 'Atlas_Freight_Quote_R1.pdf', note: '' }],
      },
    ];
    p.freightPOs = [];

    p.installationRecords = [
      { id: uid('inst'), scopeId: s1.id, building: 'Tower A', floor: '45', unit: '4502', room: 'Kitchen', item: 'Base + Wall Cabinetry', assignedCrew: 'Crew 2 — Jennings', scheduledStart: '2026-05-05', actualStart: null, scheduledCompletion: '2026-05-08', actualCompletion: null, status: 'Ready', pctComplete: 0, notes: '', photos: [], qcStatus: 'Not Started', signOff: null },
    ];
    p.dailyFieldReports = [];
    p.fieldIssues = [];
    p.materialReceipts = [
      { id: uid('mr'), poId: po.id, description: 'Meridian Millwork — Kitchen cabinet boxes, floors 45-48', date: '2026-03-28', outcome: 'Received', photos: [], note: 'Full count verified against packing list.' },
    ];
    p.punchItems = [];

    p.sov = [
      makeSovCategory('Kitchen Cabinetry — Floors 45-52', [
        makeSovItem('Cabinet Boxes & Doors', 410000),
        makeSovItem('Installation Labor', 140000),
      ]),
      makeSovCategory('Bathroom Vanities — Floors 45-52', [
        makeSovItem('Vanity Casework', 300000),
        makeSovItem('Installation Labor', 100000),
      ]),
      makeSovCategory('Countertops — Kitchens & Baths', [
        makeSovItem('Fabrication & Material', 265000),
        makeSovItem('Installation Labor', 85000),
      ]),
      makeSovCategory('General Conditions & Overhead', [
        makeSovItem('Project Management & Coordination', 300000),
        makeSovItem('Freight & Logistics', 250000),
      ]),
    ];
    const sovItem = (catIdx, itemIdx) => p.sov[catIdx].items[itemIdx];
    const app1Lines = {};
    p.sov.forEach(cat => cat.items.forEach(item => {
      app1Lines[item.id] = { previous: 0, current: Math.round(item.scheduledValue * 0.3), stored: 0, inputType: 'Amount', formulaText: '', retainageOverride: null };
    }));
    const app2Lines = {};
    p.sov.forEach(cat => cat.items.forEach(item => {
      app2Lines[item.id] = { previous: Math.round(item.scheduledValue * 0.3), current: 0, stored: 0, inputType: 'Amount', formulaText: '', retainageOverride: null };
    }));
    app2Lines[sovItem(0, 0).id] = { previous: app1Lines[sovItem(0, 0).id].current, current: 0, stored: 0, inputType: 'Formula', formulaText: '=S*50%-P', retainageOverride: null };
    app2Lines[sovItem(0, 1).id] = { previous: app1Lines[sovItem(0, 1).id].current, current: 0, stored: 0, inputType: 'Percent', formulaText: '', percent: 45, retainageOverride: null };
    app2Lines[sovItem(1, 0).id] = { previous: app1Lines[sovItem(1, 0).id].current, current: 60000, stored: 0, inputType: 'Amount', formulaText: '', retainageOverride: 0 };

    p.applications = [
      {
        id: uid('app'), number: 1, periodTo: '2026-03-15', preparedBy: 'Nina Osei', status: 'Certified', retainageReleased: false,
        lines: app1Lines, coLines: {},
        payment: { status: 'Paid', amount: 527250, reference: 'Check #1042', date: '2026-03-20' },
        certifiedAmount: 527250, createdDate: '2026-03-15',
      },
      {
        id: uid('app'), number: 2, periodTo: '2026-04-15', preparedBy: 'Nina Osei', status: 'Draft', retainageReleased: false,
        lines: app2Lines,
        coLines: {
          [p.changeOrders[0].id]: { previous: 0, current: p.changeOrders[0].amount, stored: 0, inputType: 'Amount', formulaText: '', retainageOverride: null },
          [p.changeOrders[1].id]: { previous: 0, current: p.changeOrders[1].amount, stored: 0, inputType: 'Amount', formulaText: '', retainageOverride: null },
        },
        payment: { status: 'Unpaid', amount: 0, reference: '', date: null },
        certifiedAmount: null, createdDate: '2026-04-15',
      },
    ];

    // ---- Production: Project -> Scope -> Vendor -> Production Record ----
    const prodRecord1 = makeProductionRecord(s1.id, vendors[0].id, vendors[0].name, 'Andre Boone');
    prodRecord1.status = 'Quality Check';
    prodRecord1.drawings = [
      { id: uid('pd'), revisionNumber: 1, revisionDate: '2026-03-02', uploadDate: '2026-03-02', uploadedBy: 'Grace Liu', status: 'Superseded', notes: 'Initial production drawing set.', file: 'Meridian_KitchenCab_ProdDwg_RevA.pdf', fileUrl: null },
      { id: uid('pd'), revisionNumber: 2, revisionDate: '2026-03-18', uploadDate: '2026-03-18', uploadedBy: 'Grace Liu', status: 'Current', notes: 'Revised per hardware change order (CO-1).', file: 'Meridian_KitchenCab_ProdDwg_RevB.pdf', fileUrl: null },
    ];
    prodRecord1.photos = [
      { id: uid('pp'), revisionNumber: 1, date: '2026-03-20', uploadedBy: 'Andre Boone', unit: '', floor: '45', area: 'Shop Floor', item: 'Base Cabinets — First Article', notes: 'First article inspection photos.', file: 'Meridian_FirstArticle_0320.jpg', fileUrl: null, status: 'Current' },
      { id: uid('pp'), revisionNumber: 2, date: '2026-03-27', uploadedBy: 'Andre Boone', unit: '', floor: '45-48', area: 'Shop Floor', item: 'Base + Wall Cabinets — Batch 1', notes: 'Batch 1 production progress.', file: 'Meridian_Batch1_0327.jpg', fileUrl: null, status: 'Current' },
    ];
    prodRecord1.qcReports = [
      {
        id: uid('qc'), revisionNumber: 1, revisionDate: '2026-03-21', uploadDate: '2026-03-21', uploadedBy: 'Andre Boone', status: 'Current',
        inspectionDate: '2026-03-21', inspector: 'Andre Boone', result: 'Fail',
        issuesFound: 'Finish inconsistency on 6 door fronts; espresso stain uneven vs. approved sample.',
        correctiveActionRequired: 'Re-finish affected door fronts to match approved sample; vendor to submit corrected pieces for reinspection.',
        correctiveActionDueDate: '2026-04-02', reinspectionRequired: true, reinspectionDate: null,
        finalApprovalStatus: 'Not Approved', notes: 'Held at vendor shop — do not ship affected pieces.',
        file: 'Meridian_QC_Report_0321.pdf', fileUrl: null,
        supportingPhotos: [{ id: uid('qcp'), file: 'QC_FinishIssue_0321_1.jpg', fileUrl: null }, { id: uid('qcp'), file: 'QC_FinishIssue_0321_2.jpg', fileUrl: null }],
      },
    ];

    const prodRecord2 = makeProductionRecord(s3.id, vendors[1].id, vendors[1].name, 'Andre Boone');
    prodRecord2.status = 'Complete';
    prodRecord2.drawings = [
      { id: uid('pd'), revisionNumber: 1, revisionDate: '2026-03-05', uploadDate: '2026-03-05', uploadedBy: 'Sam Petrov', status: 'Current', notes: 'Approved for fabrication.', file: 'Stoneline_Countertop_ProdDwg_RevA.pdf', fileUrl: null },
    ];
    prodRecord2.photos = [
      { id: uid('pp'), revisionNumber: 1, date: '2026-03-28', uploadedBy: 'Andre Boone', unit: '', floor: '45', area: 'Fabrication Shop', item: 'Slab Templates — Kitchens', notes: 'Templating complete, matched to approved sample.', file: 'Stoneline_Templates_0328.jpg', fileUrl: null, status: 'Current' },
    ];
    prodRecord2.qcReports = [
      {
        id: uid('qc'), revisionNumber: 1, revisionDate: '2026-04-02', uploadDate: '2026-04-02', uploadedBy: 'Andre Boone', status: 'Current',
        inspectionDate: '2026-04-02', inspector: 'Andre Boone', result: 'Pass',
        issuesFound: '', correctiveActionRequired: '', correctiveActionDueDate: null,
        reinspectionRequired: false, reinspectionDate: null,
        finalApprovalStatus: 'Approved', notes: 'Fabrication matches approved sample and shop drawing Rev A. Cleared for shipping.',
        file: 'Stoneline_QC_Report_0402.pdf', fileUrl: null, supportingPhotos: [],
      },
    ];

    p.productionRecords = [prodRecord1, prodRecord2];

    // ---- Shop Drawing / Submittal + Client Response demo (§1) ----
    const kitchenSubmittal = makeSubmittalThread(s1.id, 'Shop Drawing / Submittal', 'Kitchen Cabinetry — Shop Drawing Submittal', vendors[0].id, vendors[0].name, 'Grace Liu');
    kitchenSubmittal.status = 'Approved as Noted';
    kitchenSubmittal.revisions = [
      { id: uid('sr'), revisionNumber: 0, date: '2026-03-10', status: 'Superseded', file: 'Kitchen_ShopDwg_Rev0.pdf', fileUrl: null, notes: 'Initial shop drawing submission for GC/architect review.', responsiblePerson: 'Grace Liu' },
      { id: uid('sr'), revisionNumber: 1, date: '2026-03-25', status: 'Approved as Noted', file: 'Kitchen_ShopDwg_Rev1.pdf', fileUrl: null, notes: 'Revised per architect markup — door style and hardware finish updated.', responsiblePerson: 'Grace Liu' },
    ];
    const kitchenResponse1 = makeSubmittalThread(s1.id, 'Client Response / Submittal Response', 'Architect Markup — Kitchen Shop Drawing Rev. 0', null, 'Whitfield & Cole Architecture', 'Grace Liu');
    kitchenResponse1.respondingToSubmittalId = kitchenSubmittal.id;
    kitchenResponse1.respondingToRevisionNumber = 0;
    kitchenResponse1.status = 'Revise & Resubmit';
    kitchenResponse1.revisions = [
      { id: uid('sr'), revisionNumber: 0, date: '2026-03-18', status: 'Revise & Resubmit', file: 'Kitchen_ShopDwg_Rev0_Markup.pdf', fileUrl: null, notes: 'Architect requested door style change to Slim Shaker and hardware finish change to Brushed Brass.', responsiblePerson: 'Grace Liu' },
    ];
    const kitchenResponse2 = makeSubmittalThread(s1.id, 'Client Response / Submittal Response', 'Architect Approval — Kitchen Shop Drawing Rev. 1', null, 'Whitfield & Cole Architecture', 'Grace Liu');
    kitchenResponse2.respondingToSubmittalId = kitchenSubmittal.id;
    kitchenResponse2.respondingToRevisionNumber = 1;
    kitchenResponse2.status = 'Approved as Noted';
    kitchenResponse2.revisions = [
      { id: uid('sr'), revisionNumber: 0, date: '2026-04-02', status: 'Approved as Noted', file: 'Kitchen_ShopDwg_Rev1_Approval.pdf', fileUrl: null, notes: 'Approved as noted — proceed to production drawings.', responsiblePerson: 'Grace Liu' },
    ];
    s1.submittals = [kitchenSubmittal];
    s1.clientResponses = [kitchenResponse1, kitchenResponse2];

    // ---- Projected Profitability demo (§6-8) ----
    s1.profitability = {
      salesValue: 950000,
      costs: { vendorCost: 416200, oceanFreight: 22000, domesticFreight: 8000, tariffs: 15000, dutiesCustoms: 6000, installation: 45000, warehousing: 5000, overhead: 20000, other: 3000 },
      actual: { vendorCost: 416200, oceanFreight: 23400, domesticFreight: 8000, tariffs: 15000, dutiesCustoms: 6000, installation: 0, warehousing: 0, overhead: 20000, other: 0 },
      targetMarginPct: 30,
      baseline: { salesValue: 950000, costs: { vendorCost: 410000, oceanFreight: 20000, domesticFreight: 8000, tariffs: 15000, dutiesCustoms: 6000, installation: 45000, warehousing: 5000, overhead: 20000, other: 3000 }, targetMarginPct: 30, lockedDate: '2026-02-16', lockedBy: 'Ivy Marchetti' },
    };
    s3.profitability = {
      salesValue: 620000,
      costs: { vendorCost: 265000, oceanFreight: 14000, domesticFreight: 5000, tariffs: 9000, dutiesCustoms: 3500, installation: 28000, warehousing: 3000, overhead: 12000, other: 1500 },
      actual: { vendorCost: 0, oceanFreight: 0, domesticFreight: 0, tariffs: 0, dutiesCustoms: 0, installation: 0, warehousing: 0, overhead: 0, other: 0 },
      targetMarginPct: 30,
      baseline: { salesValue: 620000, costs: { vendorCost: 265000, oceanFreight: 14000, domesticFreight: 5000, tariffs: 9000, dutiesCustoms: 3500, installation: 28000, warehousing: 3000, overhead: 12000, other: 1500 }, targetMarginPct: 30, lockedDate: '2026-02-16', lockedBy: 'Ivy Marchetti' },
    };

    p.changeLog = [
      { id: uid('log'), date: '2026-02-14', role: 'Admin', user: 'Ivy Marchetti', action: 'Approved vendor estimate (Ownership) — Meridian Millwork Supply. PO auto-issued.' },
      { id: uid('log'), date: '2026-03-05', role: 'Senior Associate', user: 'Marisol Chen', action: 'Added Change Order CO-1 — Vanity countertop upgrade ($42,000).' },
      { id: uid('log'), date: '2026-03-20', role: 'Project Coordinator', user: 'Rachel Kim', action: 'Added Back Charge BC-1 — Damaged panels (-$8,500).' },
      { id: uid('log'), date: '2026-04-06', role: 'Project Coordinator', user: 'Rachel Kim', action: 'Reported delay on Bathroom Vanities — Floors 45-52, stage "Quote Preparation": 6 days (Client Decision Pending). Downstream stages shifted.' },
    ];

    subcontractors[0].projectIds.push(p.id);
    subcontractors[1].projectIds.push(p.id);

    // ---- Accounts Payable demo invoices (§ vendor-billing-AP request) ----
    const apInv1 = makeApInvoice({ partyType: 'Vendor', vendorId: vendors[0].id, vendorName: vendors[0].name, projectId: p.id, scopeId: s1.id, invoiceNumber: 'INV-8842', invoiceDate: '2026-03-18', dueDate: '2026-04-17', poReference: 'PO-10001', amount: 205000, description: 'Progress billing — kitchen cabinetry, production release.', approvalStatus: 'Approved' }, 'Grace Liu');
    apInv1.approvedBy = 'Ivy Marchetti'; apInv1.approvalDate = '2026-03-20';
    apInv1.payments = [
      makeApPayment({ date: '2026-03-25', amount: 100000, method: 'ACH', reference: 'ACH-55219' }, 'Nina Osei'),
      makeApPayment({ date: '2026-04-10', amount: 50000, method: 'ACH', reference: 'ACH-55340' }, 'Nina Osei'),
    ];
    apInv1.paymentStatus = 'Partially Paid';
    apInv1.history.push(makeApHistoryEntry('Ivy Marchetti', 'Approved.'), makeApHistoryEntry('Nina Osei', 'Payment of $100,000 recorded (ACH-55219).'), makeApHistoryEntry('Nina Osei', 'Payment of $50,000 recorded (ACH-55340).'));

    const apInv2 = makeApInvoice({ partyType: 'Freight', vendorId: freightForwarders[0].id, vendorName: freightForwarders[0].name, projectId: p.id, scopeId: s1.id, invoiceNumber: 'ATL-2291', invoiceDate: '2026-04-01', dueDate: '2026-05-01', poReference: 'FPO-20001', amount: 18500, description: 'Ocean freight — kitchen cabinetry container, Charlotte to Miami port.', approvalStatus: 'Approved' }, 'Hugo Reyes');
    apInv2.approvedBy = 'Ivy Marchetti'; apInv2.approvalDate = '2026-04-03';
    apInv2.history.push(makeApHistoryEntry('Ivy Marchetti', 'Approved.'));

    const apInv3 = makeApInvoice({ partyType: 'Subcontractor', vendorId: subcontractors[0].id, vendorName: subcontractors[0].companyName, projectId: p.id, scopeId: s1.id, invoiceNumber: 'DEL-0142', invoiceDate: '2026-04-05', dueDate: '2026-04-20', poReference: '', amount: 12400, description: 'Cabinet installation labor — floors 45-47.', servicePeriod: '2026-03-25 to 2026-04-04', approvalStatus: 'Pending Approval' }, 'Manny Delgado');

    const apInv4 = makeApInvoice({ partyType: 'Subcontractor', vendorId: subcontractors[1].id, vendorName: subcontractors[1].companyName, projectId: p.id, scopeId: s1.id, invoiceNumber: 'JFS-0087', invoiceDate: '2026-03-10', dueDate: '2026-03-25', poReference: '', amount: 3200, description: 'Field QC and punch walk, floors 45-48.', servicePeriod: '2026-03-05 to 2026-03-09', approvalStatus: 'Approved' }, 'Tony Reyes');
    apInv4.approvedBy = 'Ivy Marchetti'; apInv4.approvalDate = '2026-03-11';
    apInv4.payments = [makeApPayment({ date: '2026-03-18', amount: 3200, method: 'Check', reference: 'CHK-4471' }, 'Nina Osei')];
    apInv4.paymentStatus = 'Paid';
    apInv4.history.push(makeApHistoryEntry('Ivy Marchetti', 'Approved.'), makeApHistoryEntry('Nina Osei', 'Payment of $3,200 recorded (CHK-4471) — invoice paid in full.'));

    p.apInvoices = [apInv1, apInv2, apInv3, apInv4];

    projects.push(p);

    // Apply the delay described in the change log via the real cascade function,
    // once lib.jsx has loaded (see initDelayDemo below).
    p.__pendingDelayDemo = { scopeId: s2.id, stageIndex: 2, days: 6, reason: 'Client Decision Pending', note: 'Client slow to confirm vanity countertop selections for floors 50-52.' };
  }

  // ---- Project 2: Active Job, mid-stream, Medium complexity -----------
  {
    const acct = accounts[1];
    const p = {
      id: uid('proj'),
      projectNumber: 'LI-2026-021',
      name: 'Cascade Riverline Lofts — Model Units',
      accountId: acct.id,
      department: 'Interior Finishes',
      complexity: 'Medium',
      pipelineStatus: 'Active Job',
      address: '88 Hudson Yards, New York, NY 10001',
      displayImageUrl: null,
      notes: '',
      contacts: {
        'General Contractor': { company: 'Baseline Builders', person: 'Ken Ashworth', phone: '(212) 555-4410', email: 'kashworth@baselinebuilders.com' },
        Developer: { company: 'Cascade Development Partners', person: 'James Okafor', phone: '(212) 555-0198', email: 'jokafor@cascadedp.com' },
        Architect: { company: 'Vantage Architecture Group', person: 'LenaFord', phone: '(212) 555-7712', email: 'lford@vantagearch.com' },
        Designer: { company: '', person: '', phone: '', email: '' },
      },
      team: defaultTeamAssignment(),
      scopes: [],
      documents: [],
      quoteRevisions: [{ id: uid('qr'), revision: 1, amount: 640000, date: '2026-01-08', file: 'Quote_R1.pdf', note: 'Initial quote for 4 model units.' }],
      quotationFollowUps: [],
      changeOrders: [],
      paymentTerms: [],
      paymentRequisitions: [{ id: uid('req'), revision: 1, date: '2026-01-25', amount: 192000, file: 'PayReq_Deposit.pdf', status: 'Approved', type: 'Milestone', reference: 'Deposit', note: '' }],
      vendorEstimates: [],
      purchaseOrders: [],
      tasks: [],
      issues: [],
      changeLog: [],
      originalContractValue: 640000,
      estimatedCost: 455000,
      actualCostToDate: 260000,
    };
    const s1 = makeScope('Closets — Model Units A-D', 'Casework', '2026-02-01', p.complexity, { selectAll: true });
    advanceStage(s1, 0, '2026-02-01', '2026-02-03');
    advanceStage(s1, 1, '2026-02-04', '2026-02-11');
    if (s1.stages[2]) s1.stages[2].status = 'In Progress';
    if (s1.stages[2]) s1.stages[2].actualStart = '2026-02-12';
    p.scopes = [s1];
    p.paymentTerms = [
      { id: uid('pt'), label: 'Deposit', pct: 30, trigger: 'Contract Signing', status: 'Paid' },
      { id: uid('pt'), label: 'Progress Payment', pct: 40, trigger: 'Production Release', status: 'Not Due' },
      { id: uid('pt'), label: 'Final Payment', pct: 30, trigger: 'Installation Complete', status: 'Not Due' },
    ];
    p.tasks = [
      { id: uid('task'), title: 'Confirm closet finish for Unit C', assigneeId: personIdByName('Diego Alvarez'), dueDate: '2026-04-12', status: 'Open', scopeId: s1.id, priority: 'Medium' },
    ];
    projects.push(p);
  }

  // ---- Project 3: Active Quotation --------------------------------------
  {
    const acct = accounts[2];
    const p = {
      id: uid('proj'),
      projectNumber: 'LI-2026-033',
      name: 'Whitfield Studio HQ Renovation',
      accountId: acct.id,
      department: 'Mixed',
      complexity: 'Basic',
      pipelineStatus: 'Active Quotation',
      address: '22 Newbury St, Boston, MA 02116',
      displayImageUrl: null,
      notes: 'Awaiting client budget sign-off before proceeding to contract.',
      contacts: {
        'General Contractor': { company: 'TBD', person: '', phone: '', email: '' },
        Developer: { company: 'Whitfield & Cole Architecture', person: 'Sarah Whitfield', phone: '(617) 555-0110', email: 'swhitfield@whitfieldcole.com' },
        Architect: { company: 'Whitfield & Cole Architecture', person: 'Sarah Whitfield', phone: '(617) 555-0110', email: 'swhitfield@whitfieldcole.com' },
        Designer: { company: '', person: '', phone: '', email: '' },
      },
      team: defaultTeamAssignment(),
      scopes: [],
      documents: [],
      quoteRevisions: [
        { id: uid('qr'), revision: 1, amount: 210000, date: '2026-03-01', file: 'Quote_R1.pdf', note: 'Initial reception + conference room casework quote.' },
        { id: uid('qr'), revision: 2, amount: 198500, date: '2026-03-14', file: 'Quote_R2.pdf', note: 'Value-engineered per client budget feedback.' },
      ],
      quotationFollowUps: [
        { id: uid('fu'), date: '2026-03-15', method: 'Email', note: 'Sent R2 with VE notes.', nextFollowUp: '2026-03-22' },
        { id: uid('fu'), date: '2026-03-22', method: 'Call', note: 'Sarah reviewing internally with partners.', nextFollowUp: '2026-04-05' },
      ],
      changeOrders: [],
      paymentTerms: [],
      paymentRequisitions: [],
      vendorEstimates: [],
      purchaseOrders: [],
      tasks: [
        { id: uid('task'), title: 'Follow up on R2 budget approval', assigneeId: personIdByName('Marisol Chen'), dueDate: '2026-04-05', status: 'Open', scopeId: null, priority: 'High' },
      ],
      issues: [],
      changeLog: [],
      originalContractValue: 198500,
      estimatedCost: 142000,
      actualCostToDate: 0,
    };
    const s1 = makeScope('Reception Casework', 'Casework', '2026-03-01', p.complexity, {});
    p.scopes = [s1];
    projects.push(p);
  }

  // ---- Project 4: Lead --------------------------------------------------
  {
    const acct = accounts[3];
    const p = {
      id: uid('proj'),
      projectNumber: 'LI-2026-041',
      name: 'Solmar Coastal — Clubhouse Millwork',
      accountId: acct.id,
      department: 'Interior Finishes',
      complexity: 'Medium',
      pipelineStatus: 'Lead',
      address: '900 Brickell Ave, Miami, FL 33131',
      displayImageUrl: null,
      notes: 'Referral from Whitfield & Cole. Initial site walk scheduled.',
      contacts: {
        'General Contractor': { company: 'Solmar Coastal Builders', person: 'Rafael Nunez', phone: '(786) 555-0177', email: 'rnunez@solmarbuilders.com' },
        Developer: { company: 'Solmar Coastal Builders', person: 'Rafael Nunez', phone: '(786) 555-0177', email: 'rnunez@solmarbuilders.com' },
        Architect: { company: '', person: '', phone: '', email: '' },
        Designer: { company: '', person: '', phone: '', email: '' },
      },
      team: defaultTeamAssignment(),
      scopes: [],
      documents: [],
      quoteRevisions: [],
      quotationFollowUps: [
        { id: uid('fu'), date: '2026-04-02', method: 'Meeting', note: 'Initial site walk with Rafael, discussed clubhouse bar + lounge casework.', nextFollowUp: '2026-04-16' },
      ],
      changeOrders: [],
      paymentTerms: [],
      paymentRequisitions: [],
      vendorEstimates: [],
      purchaseOrders: [],
      tasks: [
        { id: uid('task'), title: 'Prepare take-off for clubhouse bar casework', assigneeId: personIdByName('Felix Ndiaye'), dueDate: '2026-04-20', status: 'Open', scopeId: null, priority: 'Medium' },
      ],
      issues: [],
      changeLog: [],
      originalContractValue: 0,
      estimatedCost: 0,
      actualCostToDate: 0,
    };
    p.scopes = [];
    projects.push(p);
  }

  // ---- Project 5: Lost Job -----------------------------------------------
  {
    const acct = accounts[1];
    const p = {
      id: uid('proj'),
      projectNumber: 'LI-2026-009',
      name: 'Cascade North Point — Amenity Deck',
      accountId: acct.id,
      department: 'Interior Finishes',
      complexity: 'Basic',
      pipelineStatus: 'Lost Job',
      address: '150 Riverside Blvd, New York, NY 10069',
      displayImageUrl: null,
      notes: 'Client selected a competitor bid — cited a 4-week faster lead time on SPC flooring.',
      contacts: {
        'General Contractor': { company: 'Baseline Builders', person: 'Ken Ashworth', phone: '(212) 555-4410', email: 'kashworth@baselinebuilders.com' },
        Developer: { company: 'Cascade Development Partners', person: 'James Okafor', phone: '(212) 555-0198', email: 'jokafor@cascadedp.com' },
        Architect: { company: '', person: '', phone: '', email: '' },
        Designer: { company: '', person: '', phone: '', email: '' },
      },
      team: defaultTeamAssignment(),
      scopes: [],
      documents: [],
      quoteRevisions: [
        { id: uid('qr'), revision: 1, amount: 96000, date: '2026-01-10', file: 'Quote_R1.pdf', note: 'SPC flooring, amenity deck level.' },
      ],
      quotationFollowUps: [
        { id: uid('fu'), date: '2026-02-01', method: 'Email', note: 'Client informed us they selected another vendor.', nextFollowUp: null },
      ],
      changeOrders: [],
      paymentTerms: [],
      paymentRequisitions: [],
      vendorEstimates: [],
      purchaseOrders: [],
      tasks: [],
      issues: [],
      changeLog: [],
      originalContractValue: 96000,
      estimatedCost: 71000,
      actualCostToDate: 0,
    };
    projects.push(p);
  }

  // ---- Project 6: Active Job, Basic complexity, further along -----------
  {
    const acct = accounts[2];
    const p = {
      id: uid('proj'),
      projectNumber: 'LI-2026-018',
      name: 'Newbury Row Townhomes — Unit Finishes',
      accountId: acct.id,
      department: 'Windows',
      complexity: 'Basic',
      pipelineStatus: 'Active Job',
      address: '145 Newbury St, Boston, MA 02116',
      displayImageUrl: null,
      notes: '',
      contacts: {
        'General Contractor': { company: 'Copley Construction', person: 'Dan Reyes', phone: '(617) 555-9012', email: 'dreyes@copleyconstruction.com' },
        Developer: { company: 'Whitfield & Cole Architecture', person: 'Sarah Whitfield', phone: '(617) 555-0110', email: 'swhitfield@whitfieldcole.com' },
        Architect: { company: 'Whitfield & Cole Architecture', person: 'Sarah Whitfield', phone: '(617) 555-0110', email: 'swhitfield@whitfieldcole.com' },
        Designer: { company: '', person: '', phone: '', email: '' },
      },
      team: defaultTeamAssignment(),
      scopes: [],
      documents: [],
      quoteRevisions: [{ id: uid('qr'), revision: 1, amount: 310000, date: '2025-12-05', file: 'Quote_R1.pdf', note: '' }],
      quotationFollowUps: [],
      changeOrders: [],
      paymentTerms: [],
      paymentRequisitions: [
        { id: uid('req'), revision: 1, date: '2025-12-20', amount: 93000, file: 'PayReq_Deposit.pdf', status: 'Approved', type: 'Milestone', reference: 'Deposit', note: '' },
        { id: uid('req'), revision: 1, date: '2026-02-15', amount: 124000, file: 'PayReq_Progress.pdf', status: 'Approved', type: 'Milestone', reference: 'Progress Payment', note: '' },
      ],
      vendorEstimates: [],
      purchaseOrders: [],
      tasks: [
        { id: uid('task'), title: 'Confirm window frame color for Units 5-8', assigneeId: personIdByName('Diego Alvarez'), dueDate: '2026-04-01', status: 'Overdue', scopeId: null, priority: 'High' },
      ],
      issues: [],
      changeLog: [],
      originalContractValue: 310000,
      estimatedCost: 224000,
      actualCostToDate: 218000,
    };
    const s1 = makeScope('Window Systems — Units 1-8', 'Window Systems', '2025-12-08', p.complexity, { selectAll: true });
    advanceTo(s1, 15);
    p.paymentTerms = [
      { id: uid('pt'), label: 'Deposit', pct: 30, trigger: 'Contract Signing', status: 'Paid' },
      { id: uid('pt'), label: 'Progress Payment', pct: 40, trigger: 'Production Release', status: 'Paid' },
      { id: uid('pt'), label: 'Final Payment', pct: 30, trigger: 'Installation Complete', status: 'Due' },
    ];
    p.scopes = [s1];
    projects.push(p);
  }

  projects.forEach(normalizeProject);

  // A couple of demo scope quantities on other active jobs for realism
  const p2 = projects.find(p => p.projectNumber === 'LI-2026-021');
  if (p2 && p2.scopes[0]) { p2.scopes[0].quantity = 4; p2.scopes[0].unit = 'Units'; }
  const p6 = projects.find(p => p.projectNumber === 'LI-2026-018');
  if (p6 && p6.scopes[0]) { p6.scopes[0].quantity = 64; p6.scopes[0].unit = 'Each'; }

  const documentLibrary = [
    { id: uid('lib'), name: 'Employee Handbook 2026.pdf', category: 'HR & Policies', file: 'Employee_Handbook_2026.pdf', uploadedBy: 'Ivy Marchetti', date: '2026-01-05', note: 'Annual policy update.' },
    { id: uid('lib'), name: 'Standard Exclusions — Casework & Countertops.pdf', category: 'Standards & Specifications', file: 'Standard_Exclusions.pdf', uploadedBy: 'Rachel Kim', date: '2026-01-10', note: 'Reference for quote preparation.' },
    { id: uid('lib'), name: 'Daily Report Template.xlsx', category: 'Templates & Forms', file: 'Daily_Report_Template.xlsx', uploadedBy: 'Rachel Kim', date: '2026-01-10', note: '' },
    { id: uid('lib'), name: 'Jobsite Safety Checklist.pdf', category: 'Safety', file: 'Jobsite_Safety_Checklist.pdf', uploadedBy: 'Carl Jennings', date: '2026-01-15', note: 'Required before every installation start.' },
    { id: uid('lib'), name: 'Change Order Request Form.docx', category: 'Templates & Forms', file: 'Change_Order_Request_Form.docx', uploadedBy: 'Nina Osei', date: '2026-02-01', note: '' },
    ...DEFAULT_LIBRARY_DOCS,
  ];

  // ---- Material Specification Library (§2, §4, §14-22) ----
  const flooring = makeMaterial({
    name: 'European White Oak Engineered Flooring', category: 'Engineered Wood Flooring',
    manufacturer: 'Nordic Floors Co.', vendorId: vendors[0].id, vendorName: vendors[0].name, productCode: 'EWF-EU-OAK-712',
    unit: 'Imperial',
    specs: { species: 'European Oak', finish: 'Wire-Brushed Oil', overallThickness: '15 mm', wearLayer: '4 mm', plankWidth: '7.5"', plankLength: '86"', lengthType: 'Fixed', installationMethod: 'Glue-Down', pattern: 'Wide Plank' },
    notes: 'Standard flooring spec for Harborview-tier residential units.',
  }, 'Grace Liu');
  flooring.documents = [
    makeMaterialDocument('Technical Data Sheet', 'EWF_EuroOak_TechDataSheet.pdf', 'EWF_EuroOak_TechDataSheet.pdf', null, 'Grace Liu'),
    makeMaterialDocument('Care & Maintenance', 'EWF_EuroOak_CareMaintenance.pdf', 'EWF_EuroOak_CareMaintenance.pdf', null, 'Grace Liu'),
    makeMaterialDocument('Cleaning Instructions', 'EWF_EuroOak_CleaningInstructions.pdf', 'EWF_EuroOak_CleaningInstructions.pdf', null, 'Grace Liu'),
    makeMaterialDocument('Manufacturer Warranty', 'EWF_EuroOak_ManufacturerWarranty.pdf', 'EWF_EuroOak_ManufacturerWarranty.pdf', null, 'Grace Liu'),
    makeMaterialDocument('Installation Instructions', 'EWF_EuroOak_InstallInstructions.pdf', 'EWF_EuroOak_InstallInstructions.pdf', null, 'Grace Liu'),
  ];

  const countertop = makeMaterial({
    name: 'Calacatta Gold Quartz Countertop', category: 'Countertop / Stone',
    manufacturer: 'Stoneline Fabricators', vendorId: vendors[1].id, vendorName: vendors[1].name, productCode: 'QTZ-CALG-3CM',
    unit: 'Imperial',
    specs: { material: 'Quartz', look: 'Calacatta Gold', finish: 'Polished', actualThickness: '3 cm', edgeProfile: 'Eased', backsplashHeight: '4"' },
    notes: 'Primary kitchen + bath countertop selection.',
  }, 'Sam Petrov');
  countertop.documents = [
    makeMaterialDocument('Technical Data Sheet', 'QTZ_CalacattaGold_TechDataSheet.pdf', 'QTZ_CalacattaGold_TechDataSheet.pdf', null, 'Sam Petrov'),
    makeMaterialDocument('Care & Maintenance', 'QTZ_CalacattaGold_CareMaintenance.pdf', 'QTZ_CalacattaGold_CareMaintenance.pdf', null, 'Sam Petrov'),
    makeMaterialDocument('Product Warranty', 'QTZ_CalacattaGold_ProductWarranty.pdf', 'QTZ_CalacattaGold_ProductWarranty.pdf', null, 'Sam Petrov'),
  ];

  const materialLibrary = [flooring, countertop];

  // ---- Warehouse Hub — imported from the Monday.com "Asset stock" board (§4-11) ----
  const warehouses = [makeWarehouse('Main Warehouse', 'Boston, MA')];
  const whImport = buildWarehouseImportSeed(WAREHOUSE_IMPORT_GROUPS, warehouses[0].id, IMPORT_VALIDATION_SUMMARY.importDate);
  const warehouseMaterials = whImport.materials;
  const inventoryTransactions = whImport.transactions;
  const materialAllocations = [];
  const warehouseReleases = [];
  const packingLists = [];
  const personalItems = [];
  const logisticsClaims = [];
  const tariffLibrary = [];
  const tariffLines = [];
  const trucks = [];

  // ---- What a browser with NO stored state actually gets ------------------
  //
  // Every collection resolves as `(persisted && persisted.X) || SEED.X`, so
  // this is not merely "example data" — it is what the app shows the FIRST
  // time anybody opens it. Twenty-six people were about to sign in to the live
  // Hub and be met by six fictional projects and four fictional clients, and
  // conclude the thing was a mock-up.
  //
  // The test-data clear-down of 2026-09-06 removed those records from ONE
  // browser's localStorage. That could never have been enough: the seed is
  // per-browser by construction, so every new person re-created the demo
  // company on first load. It is fixed at the source here instead.
  //
  // KEPT, because they are real and imported:
  //   warehouses / warehouseMaterials (68) / inventoryTransactions (569)
  //     — the Monday.com "Asset stock" import
  //   documentLibrary — DEFAULT_LIBRARY_DOCS only. The five entries above it
  //     have no file at all and are filed by demo people deleted from the
  //     roster in the 2026-09-04 wipe.
  //
  // EMPTIED, because they are invented:
  //   accounts / projects / vendors / freightForwarders / subcontractors /
  //   materialLibrary
  //
  // The ten SUPPLIER vendors are unaffected — they are created by
  // seedSupplierVendors from SUPPLIER_VENDOR_SEED, not here, so emptying this
  // list removes the four demo vendors and leaves the catalogs orderable.
  //
  // Demonstrating the app has not been lost: buildDemoScenario() is the
  // deliberate, one-click, fully-resettable demo, which is what that job
  // should have been doing all along.
  //
  // The construction above still runs and its result is discarded. That is on
  // purpose for now — unpicking ~700 interleaved lines carries more risk than
  // the milliseconds it costs, and the demo records are the reference for what
  // a fully-populated project looks like.
  return {
    accounts: [], projects: [], vendors: [], freightForwarders: [],
    subcontractors: [], materialLibrary: [],
    documentLibrary: [...DEFAULT_LIBRARY_DOCS],
    warehouses, warehouseMaterials, inventoryTransactions,
    materialAllocations, warehouseReleases, packingLists, personalItems,
    logisticsClaims, tariffLibrary, tariffLines, trucks,
  };
}

function advanceStage(scope, index, actualStart, actualCompletion) {
  const st = scope.stages[index];
  st.actualStart = actualStart;
  st.actualCompletion = actualCompletion;
  st.status = 'Completed';
}

// Shift every seeded ISO date by a fixed offset so the demo data's relative
// timeline (delays, overdue items, health signals) stays meaningful no matter
// what "today" actually is when this prototype is opened.
const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
function shiftDatesDeep(node, offsetDays) {
  if (Array.isArray(node)) {
    node.forEach(item => shiftDatesDeep(item, offsetDays));
  } else if (node && typeof node === 'object') {
    Object.keys(node).forEach(key => {
      const val = node[key];
      if (typeof val === 'string' && ISO_DATE_RE.test(val)) {
        node[key] = addDays(val, offsetDays);
      } else if (val && typeof val === 'object') {
        shiftDatesDeep(val, offsetDays);
      }
    });
  }
}

const SEED = buildSeed();
const SEED_ANCHOR_DATE = '2026-04-08'; // most recent date referenced in the seed narrative
const SEED_OFFSET_DAYS = daysBetween(SEED_ANCHOR_DATE, todayISO());
shiftDatesDeep(SEED.projects, SEED_OFFSET_DAYS);

// ---------------------------------------------------------------------------
// Demo Scenario builder (§ team walkthrough request) — one complete,
// realistic project touching every major module: an Account and Vendor and
// Subcontractor, the full vendor Estimate -> PO -> PI chain, an Export
// Container handed off to Logistics and sitting "Arrived" (so receiving it
// live is the demo's hands-on moment — the exact flow that was
// verified/fixed earlier), a pending Delivery request, a pending
// Installation record, an approved Change Order, AP Invoices in a mix of
// paid/pending states, a Shop Drawing submittal with a revision history,
// a Task/Meeting/Jobsite Visit, and both a Client Portal and a
// Subcontractor Portal login. Dates are written relative to
// DEMO_ANCHOR_DATE below and shifted to "today" the same way SEED.projects
// is above, so the story stays current no matter when it's loaded.
// Pure data construction only — never calls a ctx function — so it's
// synchronous and has none of the "read a just-created id back out of
// still-updating React state" problems chaining the real ctx functions
// would have. ctx.loadDemoScenario (app.jsx) just merges the returned
// records into state in one shot per collection.
const DEMO_ANCHOR_DATE = '2026-08-25';
function buildDemoScenario({ teamDirectory, scopeLibrary, warehouseId }) {
  const d = days => addDays(DEMO_ANCHOR_DATE, days);
  // Pick the demo's people BY WHAT THEY DO, not by name. The original demo
  // named three specific employees; when the real roster replaced the demo one
  // every name fell through to teamDirectory[0] and the whole scenario was
  // assigned to the same person. Resolving by job role survives any roster
  // change, including someone leaving.
  const byJob = (jobRole, securityRole) =>
    teamDirectory.find(p => p.active && (p.roles || []).includes(jobRole))
    || teamDirectory.find(p => p.active && p.securityRole === securityRole)
    || teamDirectory[0];
  const salesPerson = byJob('Sales Person', 'Associates');
  const pc = byJob('Project Coordinator', 'Project Coordinator');
  const accounting = byJob('Accounting Manager', 'Accounting');

  // ---- Account + Client Portal login ----
  const account = {
    id: uid('acct'), name: 'Meridian Bay Holdings', accountType: 'Owner / Developer',
    contactName: 'Daniel Ruiz', title: 'Director of Development', email: 'druiz@meridianbayholdings.com', phone: '(617) 555-0199',
    contactMobile: '(617) 555-0198', website: 'www.meridianbayholdings.com',
    billingAddress: '88 Harbor View Drive, Boston, MA 02210', notes: 'Demo account for team walkthroughs.',
    logoUrl: null, attachments: [], activityLog: [], createdDate: d(-95),
    contacts: [
      { id: uid('ac'), role: 'Property Manager', name: 'Elena Cho', title: 'Property Manager', phone: '(617) 555-0177', mobile: '', email: 'echo@meridianbayholdings.com', notes: '', preferredContactMethod: 'Email', photoUrl: null, createdDate: d(-90) },
    ],
  };
  const clientLogin = {
    id: uid('person'), name: 'Daniel Ruiz', roles: [], securityRole: 'Client', title: '', phone: '', mobile: '', photoUrl: null,
    email: account.email, username: 'druiz.demo', password: DEMO_SCENARIO_PASSWORD, active: true, accountId: account.id,
    permissionOverrides: {}, pictures: [], bio: '', education: '', experience: '', aspirations: '', birthday: null, reportsToId: null,
  };

  // ---- Vendor ----
  const vendor = {
    ...makeVendor('Coastal Cabinetry Supply Co.', 'Owen Marsh', '(704) 555-3300', 'sales@coastalcabinetry.com', '410 Millwork Way, Charlotte, NC 28202',
      [{ label: 'Deposit', pct: 50, trigger: 'PO Issuance' }, { label: 'Balance', pct: 50, trigger: 'Prior to Shipment' }],
      'Preferred casework supplier — demo vendor for team walkthroughs.'),
    contactTitle: 'Sales Director', city: 'Charlotte', state: 'NC', zip: '28202', country: 'USA',
  };
  vendor.contacts = [
    { id: uid('vc'), role: 'Primary Contact', name: 'Owen Marsh', title: 'Sales Director', phone: '(704) 555-3300', mobile: '(704) 555-3301', email: 'owen@coastalcabinetry.com', notes: '', preferredContactMethod: 'Email', createdDate: d(-90) },
  ];

  // ---- Subcontractor + Subcontractor Portal login ----
  const subcontractor = {
    ...makeSubcontractor({
      companyName: 'Apex Installation Group', contactName: 'Marcus Bell', trade: 'Cabinet Installer',
      phone: '(305) 555-4420', mobile: '(305) 555-4421', email: 'marcus@apexinstallgroup.com',
      address: '220 SW 8th St', city: 'Miami', state: 'FL', zip: '33130',
      notes: 'Demo subcontractor for team walkthroughs.', username: 'mbell.demo', password: DEMO_SCENARIO_PASSWORD,
    }, salesPerson.name),
    registrationStatus: 'Approved', submittedDate: d(-88), submittedBy: 'Marcus Bell',
    approvedDate: d(-85), approvedBy: accounting.name,
  };
  const subcontractorLogin = {
    id: uid('person'), name: 'Marcus Bell', roles: [], securityRole: 'Subcontractor', title: '', phone: '', mobile: '', photoUrl: null,
    email: subcontractor.email, username: 'mbell.demo', password: DEMO_SCENARIO_PASSWORD, active: true, subcontractorId: subcontractor.id,
    permissionOverrides: {}, pictures: [], bio: '', education: '', experience: '', aspirations: '', birthday: null, reportsToId: null,
  };

  // ---- Project + Scope ----
  const projectId = uid('proj');
  const scopeId = uid('scope');
  subcontractor.projectIds = [projectId];

  const chronology = instantiateStages(d(-95), 'Medium', PROJECT_CHRONOLOGY_STAGE_DEFS);
  [['Completed', -95, -92], ['Completed', -92, -87], ['Completed', -87, -85], ['Completed', -85, -84]]
    .forEach(([status, s, e], i) => { chronology[i].status = status; chronology[i].actualStart = d(s); chronology[i].actualCompletion = d(e); });

  const scopeStages = instantiateStages(d(-84), 'Medium', STAGE_DEFS);
  const scopeTimeline = [
    ['Completed', -84, -78], ['Completed', -78, -73], ['Completed', -73, -70], ['Completed', -70, -60],
    ['Completed', -60, -55], ['Completed', -55, -47], ['Completed', -47, -43], ['Completed', -43, -40],
    ['Completed', -40, -23], ['Completed', -23, -20], ['Completed', -20, -15],
    ['In Progress', -6, null],
    ['Not Started', null, null], ['Not Started', null, null], ['Not Started', null, null],
    ['Not Started', null, null], ['Not Started', null, null], ['Not Started', null, null],
  ];
  scopeTimeline.forEach(([status, s, e], i) => {
    scopeStages[i].status = status;
    scopeStages[i].actualStart = s === null ? null : d(s);
    scopeStages[i].actualCompletion = e === null ? null : d(e);
  });

  const family = scopeLibrary.find(f => f.name === 'Casework');
  const selections = {};
  if (family) family.categories.forEach(c => { selections[c.id] = null; });

  const scope = {
    id: scopeId, name: 'Kitchen Cabinetry', familyName: 'Casework',
    selections, materialLinks: {}, selectionAreas: [], selectionRevisions: [],
    stages: scopeStages, stageHistory: [], documents: [], submittals: [], clientResponses: [],
    profitability: makeScopeProfitability(), quantity: 20, unit: 'Units',
  };

  // ---- Vendor Estimate -> PO -> PI ----
  const piLine1 = makePiMaterialLine({ description: 'Base Cabinets — Shaker White, Soft-Close', quantity: 12, unit: 'Units', unitCost: 850, itemCode: 'BC-SW-SC' });
  const piLine2 = makePiMaterialLine({ description: 'Wall Cabinets — Shaker White, Soft-Close', quantity: 8, unit: 'Units', unitCost: 620, itemCode: 'WC-SW-SC' });
  const estimateAmount = piLine1.quantity * piLine1.unitCost + piLine2.quantity * piLine2.unitCost; // 15,160

  const vendorEstimateId = uid('ve');
  const poId = uid('po');
  const piId = uid('pi');
  const vendorEstimate = {
    id: vendorEstimateId, estimateNumber: 'VE-DEMO-1', vendorId: vendor.id, vendorName: vendor.name,
    category: 'Original Order', scopeId, currency: 'USD', status: 'Converted to PO',
    description: 'Kitchen cabinetry — base + wall cabinets, Shaker White, soft-close.', amount: estimateAmount, date: d(-43),
    unplannedReason: null, recoverability: null,
    pmApproved: true, pmApprovedBy: pc.name, pmApprovedDate: d(-42),
    ownerApproved: true, ownerApprovedBy: accounting.name, ownerApprovedDate: d(-41), poId,
    revisions: [{ id: uid('ver'), revision: 1, amount: estimateAmount, date: d(-43), file: null, fileUrl: null, note: '' }],
  };
  const purchaseOrder = {
    id: poId, poNumber: 'PO-DEMO-1001', vendorEstimateId, vendorId: vendor.id, vendorName: vendor.name,
    category: 'Original Order', scopeId, currency: 'USD', amount: estimateAmount, issuedDate: d(-41), status: 'Converted to PI',
    revisions: [], deliveryTerms: 'FOB Charlotte, NC', requiredDate: d(-25), notes: '',
    paymentTerms: [{ id: uid('popt'), label: 'Deposit', pct: 50, trigger: 'PO Issuance', status: 'Paid' }, { id: uid('popt'), label: 'Balance on Delivery', pct: 50, trigger: 'Prior to Shipment', status: 'Due' }],
  };
  const proformaInvoice = makeProformaInvoice({
    piNumber: 'PI-DEMO-1', partyType: 'Vendor', poId, estimateId: vendorEstimateId,
    vendorId: vendor.id, vendorName: vendor.name, projectId, scopeId,
    piDate: d(-40), currency: 'USD', amount: estimateAmount,
    paymentTerms: '50% Deposit / 50% Prior to Shipment', depositRequirement: '50%', balanceRequirement: '50%',
    freight: 0, taxes: 0, duties: 0, otherCharges: 0, notes: 'Demo PI — kitchen cabinetry.',
    materialLines: [piLine1, piLine2],
  }, pc.name);

  // ---- Export Container — Arrived + handed off to Logistics, NOT yet
  // received, so "Receive at Warehouse" is the live, hands-on moment of the
  // demo (the same flow verified/fixed earlier this project). ----
  const containerDocs = {};
  EXPORT_WORKFLOW_STEPS.forEach(step => { containerDocs[step.key] = { file: `${step.name}.pdf`, fileUrl: `data:text/plain,Demo%20document%20%E2%80%94%20${encodeURIComponent(step.name)}`, date: d(-18), note: '' }; });
  const container = {
    ...makeExportContainer({
      containerNumber: 'DEMO-CONT-1',
      shipments: [{ projectId, scopeIds: [scopeId] }],
      materialLines: [
        makeContainerMaterialLine({ piId, piLineId: piLine1.id, materialId: null, description: piLine1.description, quantity: piLine1.quantity, unit: piLine1.unit }),
        makeContainerMaterialLine({ piId, piLineId: piLine2.id, materialId: null, description: piLine2.description, quantity: piLine2.quantity, unit: piLine2.unit }),
      ],
      fromPort: 'Charlotte, NC', toPort: 'Boston, MA', etd: d(-20), eta: d(-3),
      shipmentType: 'Domestic Truck', containerType: 'Domestic Truck', carrier: 'Coastal Freight Lines', bookingNumber: 'BK-DEMO-1',
      customsStatus: 'Not Started', warehouseDestination: 'Main Warehouse', jobsiteDestination: 'Meridian Bay Residences — Unit 12B',
      responsibleParty: 'Logistics', handoffDate: d(-4), handoffBy: pc.name,
      assigneeIds: [pc.id],
    }),
    status: 'Arrived', documents: containerDocs,
  };

  // ---- Project (mirrors addProject's literal, app.jsx:333, plus the
  // apInvoices:[] field that literal is missing) ----
  const project = {
    id: projectId, projectNumber: 'LI-DEMO-001', name: 'Meridian Bay Residences — Unit 12B', accountId: account.id,
    deleteRequest: null, department: '', companyDepartment: ['Interiors'], complexity: 'Medium', pipelineStatus: 'Active Job',
    displayImageUrl: null, address: '88 Harbor View Drive, Unit 12B, Boston, MA 02210', projectType: 'Residential',
    sizeSqFt: 2400, unitQuantity: 1, buildingStories: 1, laborType: 'Standard', notes: 'Demo project for team walkthroughs — safe to reset via LEON Collection.',
    contacts: {
      'General Contractor': { company: '', person: '', phone: '', email: '' },
      Owner: { company: 'Meridian Bay Holdings', person: 'Daniel Ruiz', phone: account.phone, email: account.email },
      Developer: { company: '', person: '', phone: '', email: '' },
      Architect: { company: '', person: '', phone: '', email: '' },
      Designer: { company: '', person: '', phone: '', email: '' },
      'Billing Contact': { company: '', person: '', phone: '', email: '' },
      'Additional Contact': { company: '', person: '', phone: '', email: '' },
    },
    // Per-DEPARTMENT teams. This used to set the legacy flat `team`, which the
    // department split replaced — normalizeProject only migrates that on old
    // persisted state, so a freshly built demo project came out with no team at
    // all and teamMemberFor returned null for every role.
    teams: {
      Interiors: { 'Sales Person': salesPerson.id, 'Project Coordinator': pc.id, 'Accounting Manager': accounting.id },
      Windows: {},
    },
    additionalContacts: [], meetings: [], jobsiteVisits: [], chronology, scopes: [scope], documents: [], drawingSets: [], takeOffs: [], renderSets: [],
    deliveries: [], exportDocuments: [], freightEstimates: [], freightPOs: [], installationRecords: [], dailyFieldReports: [], fieldIssues: [],
    materialReceipts: [], punchItems: [], fieldMeasurements: [], fieldMeasurementNA: {}, financialIssues: [], applianceInstances: [], fixtureInstances: [],
    proformaInvoices: [proformaInvoice], sov: [], applications: [], aiaHeaderDefaults: makeAiaHeaderDefaults(), productionRecords: [],
    quoteRevisions: [{ id: uid('qr'), revision: 1, amount: 48500, date: d(-78), note: 'Kitchen cabinetry, delivery, and installation — full scope.', isFinal: true, clientFile: null, clientFileUrl: null, internalAnalysisFile: null, internalAnalysisFileUrl: null }],
    quotationFollowUps: [],
    contractFile: 'Meridian_Bay_Unit12B_Contract.pdf', contractFileUrl: null, contractSignedDate: d(-73),
    changeOrders: [{ id: uid('co'), number: 'CO-1', type: 'Change Order', amount: 2500, date: d(-30), file: null, fileUrl: null, description: 'Upgrade all cabinet hinges to soft-close hardware throughout.', status: 'Approved' }],
    paymentTerms: [
      { id: uid('pt'), label: 'Deposit', pct: 50, trigger: 'Contract Signing', status: 'Paid' },
      { id: uid('pt'), label: 'Prior to Ship from Manufacture', pct: 40, trigger: 'Production Release', status: 'Paid' },
      { id: uid('pt'), label: 'Upon Delivery to Jobsite', pct: 10, trigger: 'Delivery to Jobsite', status: 'Due' },
    ],
    retainagePct: 0, paymentRequisitions: [],
    vendorEstimates: [vendorEstimate], purchaseOrders: [purchaseOrder],
    tasks: [{ id: uid('task'), status: 'Open', title: 'Confirm installer crew availability for install week', assigneeId: pc.id, dueDate: d(4), priority: 'Medium', scopeId }],
    issues: [],
    changeLog: [{ id: uid('log'), date: d(-95), role: 'Admin', user: salesPerson.name, action: 'Project created. Document storage folder created. Team notified.' }],
    originalContractValue: 48500, estimatedCost: 0, actualCostToDate: 0, health: 'Green',
    apInvoices: [
      { ...makeApInvoice({ partyType: 'Vendor', vendorId: vendor.id, vendorName: vendor.name, projectId, scopeId, invoiceNumber: 'CCS-DEMO-1', invoiceDate: d(-41), dueDate: d(-31), poReference: purchaseOrder.poNumber, amount: estimateAmount / 2, description: 'Cabinetry deposit — 50%.', servicePeriod: '', approvalStatus: 'Approved' }, accounting.name),
        approvedBy: accounting.name, approvalDate: d(-40), paymentStatus: 'Paid', payments: [makeApPayment({ date: d(-39), amount: estimateAmount / 2, method: 'ACH', reference: 'DEMO-PAY-1' }, accounting.name)] },
      makeApInvoice({ partyType: 'Vendor', vendorId: vendor.id, vendorName: vendor.name, projectId, scopeId, invoiceNumber: 'CCS-DEMO-2', invoiceDate: d(-5), dueDate: d(10), poReference: purchaseOrder.poNumber, amount: estimateAmount / 2, description: 'Cabinetry balance — prior to shipment.', servicePeriod: '' }, accounting.name),
      // Lands at Pending Sales Approval with the job's salesperson named as the
      // approver, so the demo actually shows that gate working end to end.
      makeApInvoice({ partyType: 'Subcontractor', vendorId: subcontractor.id, vendorName: subcontractor.companyName, projectId, scopeId, invoiceNumber: 'APEX-DEMO-1', invoiceDate: d(-8), dueDate: d(7), amount: 450, description: 'Pre-installation site visit and field measure.', servicePeriod: '', salesApproverId: salesPerson.id }, subcontractor.companyName),
    ],
  };

  // ---- Shop Drawing submittal (revision history) ----
  const submittal = makeSubmittalThread(scopeId, 'Shop Drawing / Submittal', 'Kitchen Cabinetry — Shop Drawing Package', vendor.id, vendor.name, pc.name);
  submittal.status = 'Approved as Noted';
  submittal.revisions = [
    { id: uid('sr'), revisionNumber: 0, date: d(-45), status: 'Revise & Resubmit', file: 'Kitchen_ShopDwg_Rev0.pdf', fileUrl: null, notes: 'Revise upper cabinet heights per field measure.', responsiblePerson: vendor.contactPerson },
    { id: uid('sr'), revisionNumber: 1, date: d(-35), status: 'Approved as Noted', file: 'Kitchen_ShopDwg_Rev1.pdf', fileUrl: null, notes: 'Approved — proceed to production.', responsiblePerson: pc.name },
  ];
  scope.submittals = [submittal];

  // ---- Delivery (pending — a live-approvable moment) ----
  const delivery = {
    id: uid('del'), deliveryNumber: 'DEL-DEMO-1', scopeId, description: 'Kitchen cabinetry — full load', quantity: 20, unit: 'Units',
    areas: 'Unit 12B Kitchen', notes: '', wantsWarehouseAllocation: true, date: d(6), deliveryTime: '',
    slipFile: null, slipFileUrl: null, jobsitePhoto: null, packingListId: null, warehouseReleaseId: null,
    carrier: '', driver: '', vehicleInfo: '', trackingBolNumber: '', jobsiteContact: 'Elena Cho', deliveryAddress: project.address,
    deliveryStatus: 'Pending', proofOfDeliveryUrl: null, deliveryPictures: [], signedPackingListUrl: null, billOfLadingUrl: null, damageShortageNotes: '',
    approvalStatus: 'Pending Approval', requestedBy: pc.name, requestedDate: d(-2),
    approvedBy: null, approvedDate: null, receiverName: '', receiverPhone: '', clientSignatureUrl: null,
    lines: [], approverId: pc.id, driverId: null, helperId: null, durationMinutes: null,
    outcome: null, cancelReason: null, driverNotes: '', createdBy: pc.name, createdDate: d(-2),
  };
  project.deliveries = [delivery];

  // ---- Installation (pending installer approval — another live moment) ----
  const installationRecord = {
    id: uid('inst'), status: 'Not Ready', pctComplete: 0, photos: [], qcStatus: 'Not Started', signOff: null,
    actualStart: null, actualCompletion: null, notes: '', outcome: null, completionNotes: '', returnVisit: null,
    scopeId, building: 'Unit 12B', floor: '1', unit: '12B', room: 'Kitchen', item: 'Base + wall cabinetry install',
    assignedCrew: 'Apex Installation Group', assignedSubcontractorId: subcontractor.id,
    scheduledStart: d(9), scheduledTime: '08:00', durationMinutes: 480, scheduledCompletion: d(11),
    approvalStatus: 'Pending Installer Approval', rescheduleRequest: null,
  };
  project.installationRecords = [installationRecord];

  // ---- Meeting + Jobsite Visit ----
  project.meetings = [{ id: uid('mtg'), date: d(-50), time: '10:00', durationMinutes: 30, title: 'Kickoff / Production Update Call',
    notes: 'Reviewed shop drawing approval and production schedule with the client.', attendeeIds: [salesPerson.id, pc.id, accounting.id],
    externalAttendees: 'Daniel Ruiz (Meridian Bay Holdings)', loggedBy: pc.name, loggedDate: d(-50), attachments: [] }];
  project.jobsiteVisits = [makeJobsiteVisit({ date: d(-5), time: '09:00', durationMinutes: 45, assigneeId: pc.id, purpose: 'Pre-delivery site verification', notes: 'Confirmed elevator reservation and unit access for delivery week.' }, pc.name)];

  return { account, clientLogin, vendor, subcontractor, subcontractorLogin, project, container };
}

// ---------------------------------------------------------------------------
// Public holidays
// ---------------------------------------------------------------------------
// LEON buys, makes and ships across six countries, and a factory shut for Tet
// or a port closed for National Day moves a delivery date whether or not the
// schedule knows about it. So holidays are shown to EVERYONE on the calendar,
// not held by one department.
//
// Two honest limits, both surfaced in the UI rather than hidden:
//  1. There is no backend, so this is a table shipped with the app, not a live
//     feed. It needs extending each year.
//  2. Fixed-date holidays are certain. LUNAR ones (Tet, Chinese New Year) and
//     ISLAMIC ones (Eid) move, and Islamic dates depend on the moon sighting
//     and differ country to country — those carry `approx: true` and must be
//     confirmed with the local team before anyone schedules against them.
// Everything here is editable, which is the real answer to both.
const HOLIDAY_COUNTRIES = ['Brazil', 'USA', 'Vietnam', 'China', 'Türkiye', 'United Arab Emirates'];
const HOLIDAY_COUNTRY_FLAGS = {
  Brazil: '🇧🇷', USA: '🇺🇸', Vietnam: '🇻🇳', China: '🇨🇳',
  'Türkiye': '🇹🇷', 'United Arab Emirates': '🇦🇪',
};
function makeHoliday(data) {
  return {
    id: uid('hol'), country: data.country, date: data.date, name: data.name || '',
    // A shutdown is more than a day off: a factory or port is closed for the
    // whole run, which is what actually moves a ship date.
    endDate: data.endDate || null,
    // Two different facts, deliberately separate. A public holiday is a date;
    // whether OUR office in that country actually shuts is a decision, and
    // some holidays close the office while others are worked through.
    officeClosed: data.officeClosed !== undefined ? !!data.officeClosed : true,
    // And a closure rarely costs only the days it covers — a factory coming
    // back from Tet is not at full speed on the first morning. `bufferDays` is
    // the extra time the team reserves either side, so a schedule built around
    // this holiday allows for the real loss, not the calendar one.
    bufferDays: Number(data.bufferDays) || 0,
    // Three levels, because "the country is on holiday" and "the factory is
    // shut" are different operational facts:
    //   holiday   — reserved. Nothing runs.
    //   elevated  — approvals, inspections and factory visits will slip.
    //   awareness — work continues, but hours and response times are affected.
    level: ['holiday', 'elevated', 'awareness'].includes(data.level) ? data.level : 'holiday',
    approx: !!data.approx, active: true, note: data.note || '',
    // Which generation of the shipped table this row came from. A row the team
    // added or edited carries no seedVersion (or is flagged edited), which is
    // what lets a regenerated table replace the old rows without touching
    // anyone's corrections.
    seedVersion: data.seedVersion || null, edited: !!data.edited,
  };
}
// ---------------------------------------------------------------------------
// Ten years of holidays, GENERATED rather than typed
// ---------------------------------------------------------------------------
// Six countries over ten years is ~600 dates. Typed by hand that is a list of
// plausible-looking mistakes; computed from the actual rules, most of it is
// correct by construction and the rest is honestly labelled.
//
// Three tiers, and the UI shows which is which:
//   CERTAIN   fixed dates, "nth Monday" rules, and everything derived from
//             Easter (computed with the Gregorian Computus).
//   FIRM DATE, POLICY RUN  Lunar New Year — the astronomical date is fixed and
//             published, but how many days each government closes for is
//             announced year by year.
//   APPROXIMATE  every Islamic date (they follow the moon sighting and differ
//             country to country) and the other lunar festivals, which are
//             derived from Lunar New Year rather than a full lunar calendar.
const HOLIDAY_YEARS_FROM = 2026;
const HOLIDAY_YEARS_TO = 2036;

function ymd(y, m, d) {
  return `${y}-${String(m).padStart(2, '0')}-${String(d).padStart(2, '0')}`;
}
// Gregorian Computus (Meeus/Jones/Butcher). Easter drives Carnival, Good
// Friday and Corpus Christi, so all four are exact once this is.
function easterSunday(y) {
  const a = y % 19, b = Math.floor(y / 100), c = y % 100;
  const d = Math.floor(b / 4), e = b % 4;
  const f = Math.floor((b + 8) / 25), g = Math.floor((b - f + 1) / 3);
  const h = (19 * a + b - d - g + 15) % 30;
  const i = Math.floor(c / 4), k = c % 4;
  const l = (32 + 2 * e + 2 * i - h - k) % 7;
  const m = Math.floor((a + 11 * h + 22 * l) / 451);
  const month = Math.floor((h + l - 7 * m + 114) / 31);
  const day = ((h + l - 7 * m + 114) % 31) + 1;
  return ymd(y, month, day);
}
// nth weekday of a month; n = -1 means the last one.
function nthWeekday(y, month, weekday, n) {
  if (n > 0) {
    const first = new Date(Date.UTC(y, month - 1, 1)).getUTCDay();
    const day = 1 + ((weekday - first + 7) % 7) + (n - 1) * 7;
    return ymd(y, month, day);
  }
  const lastDay = new Date(Date.UTC(y, month, 0)).getUTCDate();
  const last = new Date(Date.UTC(y, month - 1, lastDay)).getUTCDay();
  return ymd(y, month, lastDay - ((last - weekday + 7) % 7));
}
// US federal rule: a holiday on Saturday is observed the Friday before, on
// Sunday the Monday after.
function usObserved(dateStr) {
  const dow = new Date(dateStr + 'T00:00:00Z').getUTCDay();
  if (dow === 6) return addDays(dateStr, -1);
  if (dow === 0) return addDays(dateStr, 1);
  return dateStr;
}

// Lunar New Year, 2026-2036. Astronomically determined and published well in
// advance, so the DATE is firm; the length of each government's holiday is
// announced year by year, which is why the run carries `approx`.
const LUNAR_NEW_YEAR = {
  2026: '2026-02-17', 2027: '2027-02-06', 2028: '2028-01-26', 2029: '2029-02-13',
  2030: '2030-02-03', 2031: '2031-01-23', 2032: '2032-02-11', 2033: '2033-01-31',
  2034: '2034-02-19', 2035: '2035-02-08', 2036: '2036-01-28',
};
// The official holiday week is not the operational reality. Factories, hauliers,
// ports and labour around Lunar New Year are disrupted for a fortnight either
// side — staff travel home early and come back late, and capacity does not
// return on day one. So the reserved run is the announced week, and the RISK
// WINDOW that milestones are checked against is ±14 days.
const LUNAR_NEW_YEAR_RISK_DAYS = 14;

// Ramadan and Eid al-Fitr, 2026-2036, keyed by the year Ramadan BEGINS.
// Islamic months follow the moon sighting, so every one of these is projected
// and can move by a day. Note 2030 holds two Ramadans — the Islamic year is
// ~11 days shorter than the Gregorian one, so one occasionally happens twice.
const RAMADAN = [
  { start: '2026-02-18', eid: '2026-03-20' },
  { start: '2027-02-08', eid: '2027-03-09' },
  { start: '2028-01-28', eid: '2028-02-26' },
  { start: '2029-01-16', eid: '2029-02-14' },
  { start: '2030-01-05', eid: '2030-02-04' },
  { start: '2030-12-26', eid: '2031-01-24' },
  { start: '2031-12-15', eid: '2032-01-14' },
  { start: '2032-12-04', eid: '2033-01-02' },
  { start: '2033-11-23', eid: '2033-12-23' },
  { start: '2034-11-12', eid: '2034-12-12' },
  { start: '2035-11-01', eid: '2035-12-01' },
  { start: '2036-10-20', eid: '2036-11-19' },
];
// Eid al-Adha falls on 10 Dhu al-Hijjah, about 69 days after Eid al-Fitr.
// Derived rather than tabulated, and projected like everything else here.
const EID_AL_ADHA_OFFSET = 69;

function generateHolidays(fromYear, toYear) {
  const out = [];
  const add = (country, date, name, opts) => {
    if (!date) return;
    out.push({ country, date, name, ...(opts || {}) });
  };
  // Every Ramadan that touches this Gregorian year — 2030 holds two.
  const ramadanTouching = y => RAMADAN.filter(r =>
    r.start.startsWith(String(y)) || r.eid.startsWith(String(y)));
  // Ramadan is not one flat block. The month affects hours and response times;
  // the last ten days are when approvals and inspections actually stall; Eid
  // is the shutdown. Three entries, three levels, so a schedule can tell them
  // apart instead of blocking out a whole month or ignoring it entirely.
  function addRamadan(country, r, y, eidName) {
    const lastTen = addDays(r.eid, -10);
    if (r.start.startsWith(String(y)) || lastTen.startsWith(String(y))) {
      add(country, r.start, 'Ramadan', {
        endDate: addDays(lastTen, -1), level: 'awareness', approx: true, officeClosed: false,
        note: 'Working hours, approvals and response times are affected. Work continues.',
      });
      add(country, lastTen, 'Ramadan — final ten days', {
        endDate: addDays(r.eid, -1), level: 'elevated', approx: true, officeClosed: false,
        note: 'Approvals, inspections and factory visits are most likely to slip here.',
      });
    }
    if (r.eid.startsWith(String(y))) {
      add(country, r.eid, eidName, { endDate: addDays(r.eid, 3), approx: true, bufferDays: 1 });
    }
  }
  for (let y = fromYear; y <= toYear; y++) {
    const easter = easterSunday(y);
    const lny = LUNAR_NEW_YEAR[y];

    // ---- USA — every rule here is exact ----
    add('USA', usObserved(ymd(y, 1, 1)), "New Year's Day");
    add('USA', nthWeekday(y, 1, 1, 3), 'Martin Luther King Jr. Day');
    add('USA', nthWeekday(y, 2, 1, 3), "Presidents' Day");
    add('USA', nthWeekday(y, 5, 1, -1), 'Memorial Day');
    add('USA', usObserved(ymd(y, 6, 19)), 'Juneteenth');
    add('USA', usObserved(ymd(y, 7, 4)), 'Independence Day');
    add('USA', nthWeekday(y, 9, 1, 1), 'Labor Day');
    add('USA', nthWeekday(y, 10, 1, 2), 'Columbus Day');
    add('USA', usObserved(ymd(y, 11, 11)), 'Veterans Day');
    add('USA', nthWeekday(y, 11, 4, 4), 'Thanksgiving');
    add('USA', usObserved(ymd(y, 12, 25)), 'Christmas Day');

    // ---- Brazil — Carnival, Good Friday and Corpus Christi follow Easter ----
    add('Brazil', ymd(y, 1, 1), 'Confraternização Universal');
    add('Brazil', addDays(easter, -48), 'Carnaval', { endDate: addDays(easter, -47) });
    add('Brazil', addDays(easter, -2), 'Sexta-feira Santa');
    add('Brazil', ymd(y, 4, 21), 'Tiradentes');
    add('Brazil', ymd(y, 5, 1), 'Dia do Trabalho');
    add('Brazil', addDays(easter, 60), 'Corpus Christi');
    add('Brazil', ymd(y, 9, 7), 'Independência do Brasil');
    add('Brazil', ymd(y, 10, 12), 'Nossa Senhora Aparecida');
    add('Brazil', ymd(y, 11, 2), 'Finados');
    add('Brazil', ymd(y, 11, 15), 'Proclamação da República');
    add('Brazil', ymd(y, 11, 20), 'Consciência Negra');
    add('Brazil', ymd(y, 12, 25), 'Natal');

    // ---- Vietnam ----
    add('Vietnam', ymd(y, 1, 1), "New Year's Day");
    if (lny) {
      add('Vietnam', addDays(lny, -3), 'Tết Nguyên Đán (Lunar New Year)',
        { endDate: addDays(lny, 5), approx: true, bufferDays: LUNAR_NEW_YEAR_RISK_DAYS,
          note: 'Date is firm; the holiday length is announced each year. Capacity is disrupted for about a fortnight either side.' });
      // 10th day of the 3rd lunar month, derived from Lunar New Year.
      add('Vietnam', addDays(lny, 68), 'Hùng Kings Commemoration', { approx: true });
    }
    add('Vietnam', ymd(y, 4, 30), 'Reunification Day');
    add('Vietnam', ymd(y, 5, 1), 'Labour Day');
    add('Vietnam', ymd(y, 9, 2), 'National Day');

    // ---- China ----
    add('China', ymd(y, 1, 1), "New Year's Day");
    if (lny) {
      add('China', addDays(lny, -2), 'Spring Festival (Chinese New Year)',
        { endDate: addDays(lny, 4), approx: true, bufferDays: LUNAR_NEW_YEAR_RISK_DAYS,
          note: 'Date is firm; the holiday length is announced each year. Factories, hauliers and ports are disrupted for about a fortnight either side.' });
      add('China', addDays(lny, 122), 'Dragon Boat Festival', { approx: true });
      add('China', addDays(lny, 221), 'Mid-Autumn Festival', { approx: true });
    }
    add('China', ymd(y, 4, y % 4 === 0 ? 4 : 5), 'Qingming Festival', { approx: true });
    add('China', ymd(y, 5, 1), 'Labour Day', { endDate: ymd(y, 5, 5) });
    add('China', ymd(y, 10, 1), 'National Day / Golden Week', { endDate: ymd(y, 10, 7), bufferDays: 2 });

    // ---- Türkiye — the two Bayram runs follow the Islamic calendar ----
    add('Türkiye', ymd(y, 1, 1), 'Yılbaşı (New Year)');
    add('Türkiye', ymd(y, 4, 23), "National Sovereignty & Children's Day");
    add('Türkiye', ymd(y, 5, 1), 'Labour Day');
    add('Türkiye', ymd(y, 5, 19), 'Commemoration of Atatürk, Youth & Sports Day');
    add('Türkiye', ymd(y, 7, 15), 'Democracy & National Unity Day');
    add('Türkiye', ymd(y, 8, 30), 'Victory Day');
    add('Türkiye', ymd(y, 10, 29), 'Republic Day');
    ramadanTouching(y).forEach(r => {
      addRamadan('Türkiye', r, y, 'Ramazan Bayramı (Eid al-Fitr)');
      const adha = addDays(r.eid, EID_AL_ADHA_OFFSET);
      if (adha.startsWith(String(y))) {
        add('Türkiye', adha, 'Kurban Bayramı (Eid al-Adha)', { endDate: addDays(adha, 3), approx: true });
      }
    });

    // ---- United Arab Emirates — every Islamic date follows the moon ----
    add('United Arab Emirates', ymd(y, 1, 1), "New Year's Day");
    add('United Arab Emirates', ymd(y, 12, 1), 'Commemoration Day');
    add('United Arab Emirates', ymd(y, 12, 2), 'National Day', { endDate: ymd(y, 12, 3) });
    ramadanTouching(y).forEach(r => {
      addRamadan('United Arab Emirates', r, y, 'Eid al-Fitr');
      const adha = addDays(r.eid, EID_AL_ADHA_OFFSET);
      if (adha.startsWith(String(y))) {
        add('United Arab Emirates', addDays(adha, -1), 'Arafat Day & Eid al-Adha', { endDate: addDays(adha, 3), approx: true });
      }
    });
    // Both derived from Eid al-Fitr rather than a second calendar: 1 Muharram
    // is three lunar months after 1 Shawwal (~89 days) and 12 Rabi' al-Awwal
    // ~70 days after that. Projected, like every Islamic date here.
    RAMADAN.forEach(r => {
      const newYear = addDays(r.eid, 89);
      if (newYear.startsWith(String(y))) add('United Arab Emirates', newYear, 'Islamic New Year', { approx: true });
      const mawlid = addDays(r.eid, 159);
      if (mawlid.startsWith(String(y))) add('United Arab Emirates', mawlid, "Prophet Muhammad's Birthday", { approx: true });
    });
  }
  // A Ramadan that starts in one Gregorian year and ends in the next is
  // returned for both years, so its awareness and final-ten entries get built
  // twice. Dedupe on the identity of a holiday — country, date and name.
  const seen = new Set();
  return out.filter(x => {
    const k = `${x.country}|${x.date}|${x.name}`;
    if (seen.has(k)) return false;
    seen.add(k);
    return true;
  });
}
// Bump when the generated table changes shape or corrects dates: the old
// generated rows are then replaced, and only those. Rows the team added or
// edited are never touched.
const HOLIDAY_SEED_VERSION = 3;
const DEFAULT_HOLIDAY_SEED = generateHolidays(HOLIDAY_YEARS_FROM, HOLIDAY_YEARS_TO)
  .map(x => makeHoliday({ ...x, seedVersion: HOLIDAY_SEED_VERSION }));


// Forward-merge, same contract as the other seed libraries: a holiday added to
// the seed later appears without clobbering a date the team has corrected.
// Matched on country + date + name, because that triple is the holiday.
function mergeNewHolidays(persisted) {
  if (!persisted) return DEFAULT_HOLIDAY_SEED;
  const key = x => `${x.country}|${x.date}|${x.name}`;
  // Anything the team added or edited is theirs and survives untouched. Rows
  // from an OLDER generation of the shipped table are replaced wholesale —
  // otherwise a corrected date arrives alongside the wrong one it replaces,
  // and the calendar shows both.
  const mine = persisted.filter(x => x.edited || !x.seedVersion);
  const current = persisted.filter(x => !x.edited && x.seedVersion === HOLIDAY_SEED_VERSION);
  const haveDay = new Set([...mine, ...current].map(x => `${x.country}|${x.date}`));
  const have = new Set([...mine, ...current].map(key));
  const missing = DEFAULT_HOLIDAY_SEED.filter(x => !have.has(key(x)) && !haveDay.has(`${x.country}|${x.date}`));
  // Backfill fields added after a row was saved — officeClosed and bufferDays
  // did not exist in the first version, and a row missing them renders as an
  // unticked box and a blank buffer, which reads as a decision nobody made.
  const fill = x => ({
    ...x,
    officeClosed: x.officeClosed === undefined ? true : x.officeClosed,
    bufferDays: x.bufferDays === undefined ? 0 : x.bufferDays,
    approx: x.approx === undefined ? false : x.approx,
  });
  return [...mine.map(fill), ...current.map(fill), ...missing];
}

// Every holiday covering a given day, for the countries asked about. A holiday
// with an endDate covers the whole run, which is the case that actually stops
// a factory.
// The window a schedule should actually keep clear: the holiday plus whatever
// buffer the team reserves either side of it.
function holidayWindow(x) {
  const b = Number(x.bufferDays) || 0;
  return { from: addDays(x.date, -b), to: addDays(x.endDate || x.date, b) };
}
function holidaysOn(holidays, dateStr, countries) {
  return (holidays || []).filter(x => x.active !== false
    && (!countries || !countries.length || countries.includes(x.country))
    && dateStr >= x.date && dateStr <= (x.endDate || x.date));
}
