/* ===========================================================
   DTF.uk · Ops — sample data
   window.DTF.{ customers, orders, threads, products, blogPages, helpers }
   =========================================================== */
(function () {
  const TODAY = "2026-06-18"; // Thu

  // ---------- catalogue (Shopify products to attach) ----------
  const products = [
    { id: "p_dtf",   title: "Custom DTF Transfer — by size",   handle: "/custom",                 price: "£4.50+",  type: "Transfer" },
    { id: "p_gang",  title: "Gang Sheet Builder — A3",          handle: "/gang-sheets/a3",         price: "£14.95",  type: "Gang sheet" },
    { id: "p_gangxl",title: "Gang Sheet — DTF XL (60×100cm)",   handle: "/gang-sheets/xl",         price: "£32.00",  type: "Gang sheet" },
    { id: "p_tee",   title: "Heavyweight Tee 240gsm — White",   handle: "/printshop/tee-heavy",    price: "£8.00",   type: "Blank" },
    { id: "p_hood",  title: "Pullover Hoodie 350gsm — Black",   handle: "/printshop/hoodie",       price: "£18.50",  type: "Blank" },
    { id: "p_tote",  title: "Organic Cotton Tote — Natural",    handle: "/printshop/tote",         price: "£5.20",   type: "Blank" },
    { id: "p_uv",    title: "UV DTF Stickers — Sheet",          handle: "/uv-dtf",                 price: "£9.95",   type: "Sticker" },
    { id: "p_cap",   title: "5-Panel Cap — Stone",              handle: "/printshop/cap",          price: "£7.40",   type: "Blank" },
    { id: "p_press", title: "A4 Heat Press — Clamshell",        handle: "/machinery/press-a4",     price: "£189.00", type: "Machinery" },
    { id: "p_starter",title:"Starter Bundle — Press + 10 sheets",handle: "/bundles/starter",        price: "£229.00", type: "Bundle" },
  ];

  // ---------- blog / journal pages to attach ----------
  const blogPages = [
    { id: "b_size",  title: "DTF Transfer Size Chart — Inches & Cm by Garment", handle: "/blog/dtf-transfer-size-chart" },
    { id: "b_apply", title: "How to Apply a DTF Transfer (Temp, Time, Pressure)", handle: "/blog/how-to-apply-dtf" },
    { id: "b_wash",  title: "Caring for DTF Prints — Wash & Last 60+ Cycles",   handle: "/blog/caring-for-dtf-prints" },
    { id: "b_vs",    title: "DTF vs Screen Print — Which Is Right For You?",     handle: "/blog/dtf-vs-screen-print" },
    { id: "b_gang",  title: "Gang Sheet Tips — Fit More, Waste Less",            handle: "/blog/gang-sheet-tips" },
    { id: "b_cold",  title: "Cold Peel vs Hot Peel Explained",                   handle: "/blog/cold-vs-hot-peel" },
  ];

  // ---------- customers ----------
  const customers = [
    { id: "c1", name: "Hannah Whitfield", email: "hannah@thelittleprintco.co.uk", subscribed: true,  location: "Leeds, UK",        country: "GB", orders: 23, spent: 4218.40, channel: "Trade", since: "2023-02-14", phone: "+44 7700 900118", tags: ["VIP","Trade","Reseller"], note: "Runs The Little Print Co. — weekly gang sheet orders. Wants Friday cutoff reminders." },
    { id: "c2", name: "Marcus Bell",       email: "marcus.bell@gmail.com",          subscribed: true,  location: "Manchester, UK",   country: "GB", orders: 9,  spent: 642.10,  channel: "Shopify", since: "2024-06-03", phone: "+44 7700 900241", tags: ["Repeat"], note: "" },
    { id: "c3", name: "Priya Nair",        email: "priya.nair@outlook.com",         subscribed: false, location: "Birmingham, UK",   country: "GB", orders: 4,  spent: 198.75,  channel: "Etsy", since: "2025-01-22", phone: "", tags: [], note: "" },
    { id: "c4", name: "Tom Hargreaves",    email: "tom@hargreavesapparel.com",      subscribed: true,  location: "Sheffield, UK",    country: "GB", orders: 41, spent: 9120.00, channel: "Trade", since: "2022-09-30", phone: "+44 7700 900387", tags: ["VIP","Trade","Net-30"], note: "Net-30 account. Bulk hoodie press runs. Escalate any delay > 24h." },
    { id: "c5", name: "Sofia Romano",      email: "sofia.romano@gmail.com",         subscribed: true,  location: "London, UK",       country: "GB", orders: 2,  spent: 54.90,   channel: "Shopify", since: "2025-11-08", phone: "", tags: ["New"], note: "" },
    { id: "c6", name: "Daniel Okoye",      email: "dan.okoye@kitlocker.io",         subscribed: true,  location: "Nottingham, UK",   country: "GB", orders: 15, spent: 2870.30, channel: "Trade", since: "2023-07-19", phone: "+44 7700 900512", tags: ["Trade","Sports kit"], note: "Team kit numbers — names + numbers gang sheets." },
    { id: "c7", name: "Eleanor Pryce",     email: "ellie.pryce@gmail.com",          subscribed: false, location: "Cardiff, UK",      country: "GB", orders: 6,  spent: 311.20,  channel: "Etsy", since: "2024-03-11", phone: "", tags: ["Repeat"], note: "" },
    { id: "c8", name: "James Sutcliffe",   email: "j.sutcliffe@phone-order.dtf.uk", subscribed: false, location: "Huddersfield, UK", country: "GB", orders: 3,  spent: 127.50,  channel: "Phone", since: "2025-04-27", phone: "+44 7700 900644", tags: ["Local","Collection"], note: "Collects in person from the studio." },
    { id: "c9", name: "Aisha Khan",        email: "aisha@brightthreads.co",         subscribed: true,  location: "Bradford, UK",     country: "GB", orders: 12, spent: 1980.65, channel: "Shopify", since: "2023-11-02", phone: "+44 7700 900733", tags: ["Repeat","Reseller"], note: "" },
    { id: "c10",name: "Lewis Carter",      email: "lewis.carter@gmail.com",         subscribed: true,  location: "Glasgow, UK",      country: "GB", orders: 7,  spent: 489.00,  channel: "Shopify", since: "2024-09-15", phone: "", tags: ["Repeat"], note: "" },
    { id: "c11",name: "Megan Lloyd",       email: "megan.lloyd@etsy-buyer.com",     subscribed: false, location: "Swansea, UK",      country: "GB", orders: 1,  spent: 22.40,   channel: "Etsy", since: "2026-05-30", phone: "", tags: ["New"], note: "First order — chase review after delivery." },
    { id: "c12",name: "Oliver Grant",      email: "oliver@grantworkwear.co.uk",     subscribed: true,  location: "Newcastle, UK",    country: "GB", orders: 18, spent: 5340.10, channel: "Trade", since: "2022-12-08", phone: "+44 7700 900821", tags: ["VIP","Trade","Workwear"], note: "Hi-vis + logo press. Always Special Delivery." },
    { id: "c13",name: "Chloe Bennett",     email: "chloe.bennett@gmail.com",        subscribed: true,  location: "Bristol, UK",      country: "GB", orders: 5,  spent: 274.80,  channel: "Shopify", since: "2024-12-19", phone: "", tags: ["Repeat"], note: "" },
    { id: "c14",name: "Raj Patel",         email: "raj@patelprints.com",            subscribed: true,  location: "Leicester, UK",    country: "GB", orders: 28, spent: 6105.55, channel: "Trade", since: "2023-01-05", phone: "+44 7700 900956", tags: ["VIP","Trade"], note: "Refund issued on #DTF-2841 (misprint). Watch QC on dark garments." },
  ];

  // ---------- recently viewed (per customer) ----------
  const viewed = {
    c1: { products: [ {id:"p_gangxl", t:"2h ago"}, {id:"p_gang", t:"Yesterday"}, {id:"p_dtf", t:"2 days ago"} ],
          pages:    [ {id:"b_gang", t:"3h ago"}, {id:"b_size", t:"Yesterday"} ] },
    c2: { products: [ {id:"p_dtf", t:"5h ago"}, {id:"p_tee", t:"Yesterday"} ],
          pages:    [ {id:"b_apply", t:"5h ago"}, {id:"b_size", t:"2 days ago"} ] },
    c4: { products: [ {id:"p_hood", t:"1h ago"}, {id:"p_gangxl", t:"4h ago"}, {id:"p_press", t:"Yesterday"} ],
          pages:    [ {id:"b_vs", t:"1h ago"}, {id:"b_wash", t:"2 days ago"} ] },
    c6: { products: [ {id:"p_dtf", t:"30m ago"}, {id:"p_tee", t:"3h ago"} ],
          pages:    [ {id:"b_size", t:"30m ago"} ] },
    c12:{ products: [ {id:"p_dtf", t:"Just now"}, {id:"p_cap", t:"2h ago"}, {id:"p_hood", t:"Yesterday"} ],
          pages:    [ {id:"b_apply", t:"2h ago"}, {id:"b_cold", t:"Yesterday"} ] },
    c14:{ products: [ {id:"p_gangxl", t:"1h ago"}, {id:"p_dtf", t:"Yesterday"} ],
          pages:    [ {id:"b_gang", t:"1h ago"}, {id:"b_vs", t:"3 days ago"} ] },
  };
  const _defViewed = { products: [ {id:"p_dtf", t:"Recently"} ], pages: [ {id:"b_size", t:"Recently"} ] };

  // ---------- orders ----------
  // status: new | in-progress | printing | dispatched | delivered | closed
  // pay: paid | pending | refunded | partial
  // fulfil: unfulfilled | in-progress | fulfilled
  // delivery: not-shipped | label-printed | in-transit | delivered | attempted
  // method: SD (Special Delivery 1pm) | RM24 | RM48 | DPD | Collection
  function li(id, name, variant, qty, price) { return { id, name, variant, qty, price }; }

  const orders = [
    // ===== Dispatch TODAY · Thu 18 Jun =====
    { id:"DTF-2910", customerId:"c12", date:"2026-06-18 08:12", fulfillBy:TODAY, channel:"Trade", method:"SD",
      total:412.00, pay:"paid", fulfil:"in-progress", delivery:"not-shipped", status:"printing",
      flags:["vip"], tags:["Workwear","Rush"], packed:false, dispatched:false, delivered:false,
      items:[ li("p_dtf","Custom DTF — Logo 25cm","Hot peel ×120",120,2.90), li("p_dtf","Custom DTF — Back 30cm","Hot peel ×60",60,3.40) ],
      address:"Grant Workwear, Unit 4 Quayside, Newcastle NE1 3DX",
      timeline:[ {t:"Order placed",m:"18 Jun 08:12 · Trade portal",on:true}, {t:"Payment captured",m:"18 Jun 08:12 · £412.00",on:true}, {t:"Sent to print queue",m:"18 Jun 08:40 · Studio 02",on:true}, {t:"Printing",m:"In progress · 1 of 2 sheets",on:true}, {t:"Packed",m:"Pending",on:false}, {t:"Dispatched",m:"Pending",on:false} ] },

    { id:"DTF-2911", customerId:"c4", date:"2026-06-18 07:55", fulfillBy:TODAY, channel:"Trade", method:"SD",
      total:1280.00, pay:"paid", fulfil:"in-progress", delivery:"not-shipped", status:"in-progress",
      flags:["vip","note"], tags:["Bulk","Net-30"], packed:false, dispatched:false, delivered:false,
      items:[ li("p_hood","Pullover Hoodie — Black","S–XXL ×40",40,18.50), li("p_dtf","Custom DTF — Chest + back","Cold peel ×40",40,5.20) ],
      address:"Hargreaves Apparel, 12 Mill Lane, Sheffield S1 2BJ",
      timeline:[ {t:"Order placed",m:"18 Jun 07:55 · Trade portal",on:true}, {t:"Payment terms",m:"Net-30 invoice raised",on:true}, {t:"Blanks pulled",m:"18 Jun 09:10 · 40 hoodies",on:true}, {t:"Printing",m:"Queued · Studio 04",on:false}, {t:"Packed",m:"Pending",on:false} ] },

    { id:"DTF-2912", customerId:"c1", date:"2026-06-18 09:20", fulfillBy:TODAY, channel:"Trade", method:"RM24",
      total:148.95, pay:"paid", fulfil:"in-progress", delivery:"not-shipped", status:"printing",
      flags:["vip","repeat"], tags:["Gang sheet"], packed:false, dispatched:false, delivered:false,
      items:[ li("p_gangxl","Gang Sheet XL 60×100cm","Mixed artwork ×4",4,32.00), li("p_gang","Gang Sheet A3","Logos ×1",1,14.95) ],
      address:"The Little Print Co., 88 Kirkgate, Leeds LS2 7DJ",
      timeline:[ {t:"Order placed",m:"18 Jun 09:20",on:true}, {t:"Payment captured",m:"£148.95",on:true}, {t:"Printing",m:"In progress · gang sheet 2 of 5",on:true}, {t:"Packed",m:"Pending",on:false} ] },

    { id:"DTF-2908", customerId:"c6", date:"2026-06-17 16:40", fulfillBy:TODAY, channel:"Trade", method:"RM24",
      total:326.50, pay:"paid", fulfil:"in-progress", delivery:"not-shipped", status:"new",
      flags:["repeat"], tags:["Sports kit","Names+numbers"], packed:false, dispatched:false, delivered:false,
      items:[ li("p_dtf","Custom DTF — Names + numbers","Football kit ×22",22,4.50), li("p_gang","Gang Sheet A3","Crests ×3",3,14.95) ],
      address:"Bright Threads c/o D. Okoye, 5 Castle Rd, Nottingham NG1 6AA",
      timeline:[ {t:"Order placed",m:"17 Jun 16:40",on:true}, {t:"Payment captured",m:"£326.50",on:true}, {t:"Artwork check",m:"Awaiting names list confirmation",on:false} ] },

    { id:"DTF-2905", customerId:"c9", date:"2026-06-17 11:05", fulfillBy:TODAY, channel:"Shopify", method:"RM48",
      total:89.40, pay:"paid", fulfil:"fulfilled", delivery:"not-shipped", status:"in-progress",
      flags:["repeat"], tags:["Reseller"], packed:true, dispatched:false, delivered:false,
      items:[ li("p_dtf","Custom DTF — Assorted","Cold peel ×30",30,2.98) ],
      address:"Bright Threads, 19 Sunbridge Rd, Bradford BD1 2AY",
      timeline:[ {t:"Order placed",m:"17 Jun 11:05",on:true}, {t:"Payment captured",m:"£89.40",on:true}, {t:"Printed",m:"17 Jun 18:20",on:true}, {t:"Packed",m:"18 Jun 08:05 · packing list printed",on:true}, {t:"Dispatched",m:"Pending RM48 collection",on:false} ] },

    // ===== Dispatch Tomorrow · Fri 19 Jun =====
    { id:"DTF-2914", customerId:"c2", date:"2026-06-18 10:31", fulfillBy:"2026-06-19", channel:"Shopify", method:"RM24",
      total:34.50, pay:"paid", fulfil:"unfulfilled", delivery:"not-shipped", status:"new",
      flags:[], tags:[], packed:false, dispatched:false, delivered:false,
      items:[ li("p_dtf","Custom DTF — A4 print","Hot peel ×6",6,4.50), li("p_uv","UV DTF Stickers","Sheet ×1",1,9.95) ],
      address:"Marcus Bell, 14 Oak Ave, Manchester M14 5RT",
      timeline:[ {t:"Order placed",m:"18 Jun 10:31",on:true}, {t:"Payment captured",m:"£34.50",on:true}, {t:"In print queue",m:"Scheduled for 19 Jun",on:false} ] },

    { id:"DTF-2915", customerId:"c14", date:"2026-06-18 11:48", fulfillBy:"2026-06-19", channel:"Trade", method:"DPD",
      total:560.20, pay:"paid", fulfil:"unfulfilled", delivery:"not-shipped", status:"new",
      flags:["vip","note"], tags:["Trade","QC-watch"], packed:false, dispatched:false, delivered:false,
      items:[ li("p_gangxl","Gang Sheet XL 60×100cm","Dark garment set ×10",10,32.00), li("p_dtf","Custom DTF — White underbase","×80",80,3.00) ],
      address:"Patel Prints, 3 Belgrave Gate, Leicester LE1 3GQ",
      timeline:[ {t:"Order placed",m:"18 Jun 11:48",on:true}, {t:"Payment captured",m:"£560.20",on:true}, {t:"QC note",m:"Watch opacity on dark garments",on:false} ] },

    { id:"DTF-2916", customerId:"c10", date:"2026-06-18 12:10", fulfillBy:"2026-06-19", channel:"Shopify", method:"RM48",
      total:62.00, pay:"paid", fulfil:"unfulfilled", delivery:"not-shipped", status:"new",
      flags:[], tags:[], packed:false, dispatched:false, delivered:false,
      items:[ li("p_dtf","Custom DTF — Band merch","Hot peel ×20",20,3.10) ],
      address:"Lewis Carter, 22 Argyle St, Glasgow G2 8BG",
      timeline:[ {t:"Order placed",m:"18 Jun 12:10",on:true}, {t:"Payment captured",m:"£62.00",on:true} ] },

    { id:"DTF-2913", customerId:"c8", date:"2026-06-18 09:02", fulfillBy:"2026-06-19", channel:"Phone", method:"Collection",
      total:48.00, pay:"pending", fulfil:"unfulfilled", delivery:"not-shipped", status:"new",
      flags:["note"], tags:["Local","Collection"], packed:false, dispatched:false, delivered:false,
      items:[ li("p_dtf","Custom DTF — A3 print","Cold peel ×10",10,4.80) ],
      address:"Collection — studio counter, Huddersfield HD1 2RA",
      timeline:[ {t:"Phone order taken",m:"18 Jun 09:02 · by Priya",on:true}, {t:"Payment",m:"Pay on collection — pending",on:false} ] },

    // ===== Dispatch Mon 22 Jun =====
    { id:"DTF-2917", customerId:"c5", date:"2026-06-18 13:22", fulfillBy:"2026-06-22", channel:"Shopify", method:"RM48",
      total:27.45, pay:"paid", fulfil:"unfulfilled", delivery:"not-shipped", status:"new",
      flags:[], tags:["New customer"], packed:false, dispatched:false, delivered:false,
      items:[ li("p_dtf","Custom DTF — Single A5","Hot peel ×3",3,4.50), li("p_tee","Heavyweight Tee — White","M ×1",1,8.00) ],
      address:"Sofia Romano, 7 Bethnal Green Rd, London E1 6LA",
      timeline:[ {t:"Order placed",m:"18 Jun 13:22",on:true}, {t:"Payment captured",m:"£27.45",on:true} ] },

    { id:"DTF-2918", customerId:"c3", date:"2026-06-18 14:05", fulfillBy:"2026-06-22", channel:"Etsy", method:"RM48",
      total:41.20, pay:"paid", fulfil:"unfulfilled", delivery:"not-shipped", status:"new",
      flags:[], tags:["Etsy"], packed:false, dispatched:false, delivered:false,
      items:[ li("p_uv","UV DTF Stickers","Sheet ×4",4,9.95) ],
      address:"Priya Nair, 41 Soho Rd, Birmingham B21 9SP",
      timeline:[ {t:"Order placed",m:"18 Jun 14:05 · Etsy",on:true}, {t:"Payment captured",m:"£41.20",on:true} ] },

    // ===== Already dispatched / delivered (history) =====
    { id:"DTF-2901", customerId:"c1", date:"2026-06-16 09:14", fulfillBy:"2026-06-16", channel:"Trade", method:"RM24",
      total:204.80, pay:"paid", fulfil:"fulfilled", delivery:"delivered", status:"delivered",
      flags:["vip"], tags:["Gang sheet"], packed:true, dispatched:true, delivered:true,
      items:[ li("p_gangxl","Gang Sheet XL 60×100cm","×6",6,32.00) ],
      address:"The Little Print Co., 88 Kirkgate, Leeds LS2 7DJ",
      timeline:[ {t:"Order placed",m:"16 Jun 09:14",on:true}, {t:"Printed",m:"16 Jun 12:40",on:true}, {t:"Dispatched",m:"16 Jun 16:00 · RM24 ND123456GB",on:true}, {t:"Delivered",m:"17 Jun 11:22 · signed Whitfield",on:true} ] },

    { id:"DTF-2899", customerId:"c12", date:"2026-06-15 08:40", fulfillBy:"2026-06-15", channel:"Trade", method:"SD",
      total:388.00, pay:"paid", fulfil:"fulfilled", delivery:"delivered", status:"closed",
      flags:["vip"], tags:["Workwear"], packed:true, dispatched:true, delivered:true,
      items:[ li("p_dtf","Custom DTF — Hi-vis logo","×100",100,3.20), li("p_dtf","Custom DTF — Back","×100",100,0.68) ],
      address:"Grant Workwear, Unit 4 Quayside, Newcastle NE1 3DX",
      timeline:[ {t:"Order placed",m:"15 Jun 08:40",on:true}, {t:"Dispatched",m:"15 Jun 15:30 · SD by 1pm next day",on:true}, {t:"Delivered",m:"16 Jun 11:48",on:true}, {t:"Closed",m:"16 Jun · reorder reminder set",on:true} ] },

    { id:"DTF-2895", customerId:"c7", date:"2026-06-14 19:02", fulfillBy:"2026-06-16", channel:"Etsy", method:"RM48",
      total:58.70, pay:"paid", fulfil:"fulfilled", delivery:"in-transit", status:"dispatched",
      flags:["repeat"], tags:["Etsy"], packed:true, dispatched:true, delivered:false,
      items:[ li("p_dtf","Custom DTF — Floral set","×16",16,3.40) ],
      address:"Eleanor Pryce, 9 Queen St, Cardiff CF10 2BU",
      timeline:[ {t:"Order placed",m:"14 Jun 19:02 · Etsy",on:true}, {t:"Printed",m:"15 Jun 13:10",on:true}, {t:"Dispatched",m:"16 Jun 16:10 · RM48",on:true}, {t:"In transit",m:"Expected 18–19 Jun",on:true}, {t:"Delivered",m:"Pending",on:false} ] },

    { id:"DTF-2890", customerId:"c13", date:"2026-06-13 10:15", fulfillBy:"2026-06-15", channel:"Shopify", method:"RM24",
      total:73.10, pay:"paid", fulfil:"fulfilled", delivery:"delivered", status:"closed",
      flags:[], tags:[], packed:true, dispatched:true, delivered:true,
      items:[ li("p_dtf","Custom DTF — Logo pack","×24",24,2.80), li("p_tote","Organic Tote — Natural","×1",1,5.20) ],
      address:"Chloe Bennett, 3 Park St, Bristol BS1 5NH",
      timeline:[ {t:"Order placed",m:"13 Jun 10:15",on:true}, {t:"Dispatched",m:"15 Jun 16:00 · RM24",on:true}, {t:"Delivered",m:"16 Jun 10:05",on:true} ] },

    { id:"DTF-2841", customerId:"c14", date:"2026-06-05 09:30", fulfillBy:"2026-06-06", channel:"Trade", method:"DPD",
      total:0.00, pay:"refunded", fulfil:"fulfilled", delivery:"delivered", status:"closed",
      flags:["note"], tags:["Refunded","Misprint"], packed:true, dispatched:true, delivered:true,
      items:[ li("p_gangxl","Gang Sheet XL — REPRINT","×8 (no charge)",8,0.00) ],
      address:"Patel Prints, 3 Belgrave Gate, Leicester LE1 3GQ",
      timeline:[ {t:"Order placed",m:"05 Jun 09:30",on:true}, {t:"Misprint reported",m:"06 Jun · opacity on dark",on:true}, {t:"Full refund",m:"06 Jun · £258.40 returned",on:true}, {t:"Free reprint dispatched",m:"07 Jun · DPD",on:true}, {t:"Delivered",m:"08 Jun",on:true} ] },

    { id:"DTF-2880", customerId:"c11", date:"2026-05-30 21:14", fulfillBy:"2026-06-02", channel:"Etsy", method:"RM48",
      total:22.40, pay:"paid", fulfil:"fulfilled", delivery:"delivered", status:"closed",
      flags:[], tags:["New customer"], packed:true, dispatched:true, delivered:true,
      items:[ li("p_dtf","Custom DTF — Small logo","×8",8,2.80) ],
      address:"Megan Lloyd, 12 Wind St, Swansea SA1 1DP",
      timeline:[ {t:"Order placed",m:"30 May 21:14 · Etsy",on:true}, {t:"Dispatched",m:"02 Jun · RM48",on:true}, {t:"Delivered",m:"04 Jun",on:true} ] },
  ];

  // ---------- email threads (Gmail) linked to customer ----------
  const threads = [
    { id:"t1", customerId:"c12", orderId:"DTF-2910", subject:"Re: Order DTF-2910 — can this make the 1pm cutoff?", channel:"gmail",
      labels:["Order","Urgent"], status:"open", unread:true, assignedTo:"You", updated:"18 Jun 10:42",
      messages:[
        { from:"customer", name:"Oliver Grant", addr:"oliver@grantworkwear.co.uk", time:"18 Jun 10:42", body:"Morning,\n\nWe've got a site delivery first thing tomorrow and the lads need the hi-vis transfers on. Order DTF-2910 went in this morning — can you still get it out on Special Delivery for 1pm tomorrow?\n\nIf it's tight I can split it: get the 120 chest logos out today and the backs can follow.\n\nCheers,\nOliver", atts:[] },
      ] },
    { id:"t2", customerId:"c1", orderId:"DTF-2912", subject:"Weekly gang sheet — sizing question", channel:"gmail",
      labels:["Order","Question"], status:"open", unread:true, assignedTo:"You", updated:"18 Jun 09:25",
      messages:[
        { from:"customer", name:"Hannah Whitfield", addr:"hannah@thelittleprintco.co.uk", time:"18 Jun 09:25", body:"Hi team,\n\nQuick one on this week's XL gang sheets (DTF-2912) — I've packed them fairly tight. Could you confirm the minimum gap you need between designs so nothing gets clipped at the cutter? Want to get more on each sheet next week.\n\nAlso — is your size chart still accurate for the new heavyweight tees? Left chest looked a touch small on the last batch.\n\nThanks!\nHannah", atts:[] },
        { from:"us", name:"Priya (DTF.uk)", addr:"hello@dtf.uk", time:"18 Jun 09:48", body:"Hi Hannah,\n\nGreat to hear the batches are getting bigger! Passing this to the studio for exact spacing — back to you shortly. Will send the updated size chart over too.\n\nPriya", atts:[] },
      ] },
    { id:"t3", customerId:"c4", orderId:"DTF-2911", subject:"Net-30 invoice + this week's hoodie run", channel:"gmail",
      labels:["Order","Billing"], status:"open", unread:false, assignedTo:"You", updated:"18 Jun 08:05",
      messages:[
        { from:"customer", name:"Tom Hargreaves", addr:"tom@hargreavesapparel.com", time:"18 Jun 08:05", body:"Hi,\n\nDTF-2911 is in — 40 black hoodies, chest + back. Usual Net-30 please. Can you confirm it'll dispatch today on Special Delivery? Customer event is Saturday.\n\nTom", atts:[] },
      ] },
    { id:"t4", customerId:"c5", orderId:"DTF-2917", subject:"First order — how do I apply these?", channel:"gmail",
      labels:["Support"], status:"open", unread:true, assignedTo:"Unassigned", updated:"18 Jun 13:40",
      messages:[
        { from:"customer", name:"Sofia Romano", addr:"sofia.romano@gmail.com", time:"18 Jun 13:40", body:"Hello!\n\nJust placed my first order (DTF-2917) — so excited. I've never used DTF before. What temperature and how long do I press for, and do I peel hot or cold? I've got a basic heat press at home.\n\nThank you so much,\nSofia", atts:[] },
      ] },
    { id:"t5", customerId:"c14", orderId:"DTF-2915", subject:"Re: Dark garment opacity", channel:"gmail",
      labels:["Order","QC"], status:"open", unread:false, assignedTo:"You", updated:"18 Jun 12:01",
      messages:[
        { from:"customer", name:"Raj Patel", addr:"raj@patelprints.com", time:"18 Jun 12:01", body:"Hi,\n\nNew order DTF-2915 going on black & navy. After last time can you make sure the white underbase is solid? Happy to pay for the heavier white if it helps. These are for a paying client so they need to be spot on.\n\nRaj", atts:[] },
        { from:"us", name:"You (DTF.uk)", addr:"hello@dtf.uk", time:"18 Jun 12:18", body:"Hi Raj,\n\nAbsolutely — we've flagged DTF-2915 for a double white-underbase pass and a QC check before it leaves. No charge for the heavier white on this one given last time. I'll send a photo of the first sheet for sign-off before we run the rest.\n\nBest,\nThe DTF.uk team", atts:[] },
      ] },
    { id:"t6", customerId:"c7", orderId:"DTF-2895", subject:"Where's my parcel? (DTF-2895)", channel:"gmail",
      labels:["Delivery"], status:"open", unread:true, assignedTo:"Unassigned", updated:"18 Jun 11:15",
      messages:[
        { from:"customer", name:"Eleanor Pryce", addr:"ellie.pryce@gmail.com", time:"18 Jun 11:15", body:"Hi there,\n\nMy order DTF-2895 was meant to arrive by now — the tracking hasn't updated since it was dispatched on the 16th. Could you check what's happening? I need them for the weekend market.\n\nThanks,\nEllie", atts:[] },
      ] },
    { id:"t7", customerId:"c2", orderId:null, subject:"Bulk pricing for monthly orders?", channel:"gmail",
      labels:["Sales"], status:"open", unread:false, assignedTo:"You", updated:"17 Jun 16:20",
      messages:[
        { from:"customer", name:"Marcus Bell", addr:"marcus.bell@gmail.com", time:"17 Jun 16:20", body:"Hi,\n\nI've ordered a few times now and I'm starting to do this more seriously — maybe 200–300 transfers a month. Do you have trade pricing or a subscription? Would love to set up a regular order.\n\nMarcus", atts:[] },
      ] },
    { id:"t8", customerId:"c11", orderId:"DTF-2880", subject:"Re: How did we do? ★", channel:"gmail",
      labels:["Review"], status:"closed", unread:false, assignedTo:"You", updated:"15 Jun 09:00",
      messages:[
        { from:"us", name:"DTF.uk", addr:"hello@dtf.uk", time:"08 Jun 09:00", body:"Hi Megan,\n\nHope your first DTF transfers landed well! If you have a spare minute we'd hugely appreciate a review — it really helps a small UK studio like ours.\n\nThanks,\nThe DTF.uk team", atts:[] },
        { from:"customer", name:"Megan Lloyd", addr:"megan.lloyd@etsy-buyer.com", time:"15 Jun 09:00", body:"Just left you 5 stars — pressed perfectly first try and the colours are so bright! Will definitely order again. x", atts:[] },
      ] },
  ];

  // ---------- helpers ----------
  const byId = (arr, id) => arr.find(x => x.id === id);
  const fmtGBP = n => "£" + Number(n).toLocaleString("en-GB", { minimumFractionDigits: 2, maximumFractionDigits: 2 });

  // ---- DTF material metres (linear metres of roll a line item consumes) ----
  // Products encode length differently:
  //   • Gang-sheet builders (Create/Upload DTF Gang Sheet, UV builder, Name&Numbers)
  //     carry an "N M" variant (1M…10M) → N metres per unit.
  //   • "Custom Size DTF Transfers" carries a "Height (cm)" property → cm/100.
  //   • "Let Our AI …" and the UV builder encode a "W x L" sheet size; the length
  //     is the 2nd dimension in cm (e.g. 57 x 83.78 → 0.84m).
  //   • "Upload DTF Gang Sheet Transfers" carries a "Design length" (e.g. "1 m").
  //   • "WeTransfer DTF Gang Sheet Transfers" is 1 m each (no variant/props).
  // Per-unit length is multiplied by the ordered quantity.
  function itemMetres(it) {
    if (!it) return 0;
    const name = it.name || "";
    const qty = it.qty || 0;
    if (!qty) return 0;
    const props = it.properties || [];
    const prop = k => { const p = props.find(p => (p.name || "").toLowerCase() === k.toLowerCase()); return p ? p.value : null; };
    const firstNum = s => { const m = String(s == null ? "" : s).match(/-?\d+(?:\.\d+)?/); return m ? parseFloat(m[0]) : null; };
    // length (2nd dimension, cm) from a "W x L" string — try the sheet-size prop, then the variant
    const wlOf = s => { const m = String(s == null ? "" : s).match(/(\d+(?:\.\d+)?)\s*[x×]\s*(\d+(?:\.\d+)?)/i); return m ? parseFloat(m[2]) : null; };
    const wl = wlOf(prop("sheet_size") || prop("Sheet size")) != null
      ? wlOf(prop("sheet_size") || prop("Sheet size"))
      : wlOf(it.variant);

    let per = null; // metres per unit
    if (/let our ai/i.test(name) && wl != null) per = wl / 100;
    else if (prop("Design length") != null) per = firstNum(prop("Design length"));      // "1 m"
    else if (prop("Height (cm)") != null) per = (firstNum(prop("Height (cm)")) || 0) / 100; // custom size
    else if (/^\s*\d+(?:\.\d+)?\s*m\s*$/i.test(it.variant || "")) per = firstNum(it.variant); // "3M"
    else if (wl != null) per = wl / 100;                                                 // "57x50cm"
    else if (/wetransfer/i.test(name)) per = 1;                                           // 1 m each

    if (per == null || !isFinite(per) || per <= 0) return 0;
    return per * qty;
  }
  // If a "to print" artwork is marked for the order, its actual sheet length wins
  // over the per-line-item estimate (that's the real material being printed).
  const orderMetres = o => {
    const pm = o && DTF._printMetres && DTF._printMetres[o.id];
    if (pm > 0) return pm;
    return ((o && o.items) ? o.items : []).reduce((s, it) => s + itemMetres(it), 0);
  };
  const fmtMetres = m => { const n = Math.round((m || 0) * 10) / 10; return (Number.isInteger(n) ? n : n.toFixed(1)) + "m"; };

  // Classify one line for shipping. Weight rules (per the ops team):
  //   • DTF gang-sheet material  → 150 g per linear metre
  //   • custom-size transfer     → posts as a Large Letter, 200 g each
  //   • t-shirt / blank garment  → 200 g each
  // Anything else falls back to a small per-unit weight so postage still buys.
  // Fee / surcharge lines (e.g. "Customisation Print Fee") are not physical goods —
  // they carry a fake quantity (pence hack) so they must never add weight/postage.
  function isFeeLine(it) {
    if (!it) return false;
    if (/\bfee\b|surcharge/i.test(it.name || "")) return true;
    const props = it.properties || [];
    return props.some(p => /^_customizer_fee|_fee_pence$/i.test(p.name || ""));
  }
  // Does a line carry the customizer's fee marker (either the "Customisation …
  // Fee" line itself, or a garment line linked to a customization fee)? This is
  // the reliable "this is a customized print/press job" signal — independent of
  // whatever the garment product happens to be named.
  function isCustomFeeLine(it) {
    if (!it) return false;
    const props = it.properties || [];
    if (props.some(p => /_customizer_fee|_fee_pence|customer_fee/i.test(p.name || ""))) return true;
    return /custom/i.test(it.name || "") && /\bfee\b/i.test(it.name || "");
  }
  function orderHasCustomFee(o) {
    return ((o && o.items) || []).some(it => !it.removed && isCustomFeeLine(it));
  }
  function itemShip(it) {
    if (!it) return { grams: 0, kind: "other" };
    if (it.removed || isFeeLine(it)) return { grams: 0, kind: "fee" };
    const name = (it.name || "").toLowerCase();
    const qty = it.qty || 0;
    if (!qty) return { grams: 0, kind: "other" };
    const props = it.properties || [];
    const hasProp = k => props.some(p => (p.name || "").toLowerCase() === k.toLowerCase());
    const isCustom = hasProp("Height (cm)");                       // custom-size transfer → Large Letter 200 g
    const isShirt = /t[\s-]?shirt|tee\b|hoodie|sweat|garment|apparel|polo|vest|jumper|crewneck/.test(name);
    if (isCustom) return { grams: 200 * qty, kind: "letter" };
    if (isShirt)  return { grams: 200 * qty, kind: "parcel" };
    const metres = itemMetres(it);
    if (metres > 0) return { grams: Math.round(metres * 150), kind: "parcel" };
    return { grams: 100 * qty, kind: "other" };
  }
  // Suggested { weightGrams, format } for a whole order — drives the label modal
  // pre-fill (both remain editable). A parcel item anywhere → smallParcel; an
  // order of only custom-size transfers → largeLetter (Letter service applies).
  function shipEstimate(o) {
    const items = ((o && o.items) || []).filter(it => !it.removed && !isFeeLine(it));
    let grams = 0, hasParcel = false, hasLetter = false;
    for (const it of items) {
      const s = itemShip(it);
      grams += s.grams;
      if (s.kind === "parcel") hasParcel = true;
      else if (s.kind === "letter") hasLetter = true;
    }
    if (grams <= 0) grams = 100;
    // Large Letter tops out at 750 g; above that Royal Mail needs a parcel.
    const format = hasParcel ? "smallParcel" : (hasLetter && grams <= 750 ? "largeLetter" : "smallParcel");
    return { weightGrams: grams, format };
  }
  // "5m" / "1h19m" / "45s" — for print elapsed & ETA.
  const fmtDuration = sec => { sec = Math.max(0, Math.round(sec || 0)); const h = Math.floor(sec/3600), m = Math.floor((sec%3600)/60); return h ? h+"h"+m+"m" : m ? m+"m" : sec+"s"; };
  const fmtClock = iso => { const d = new Date(iso); if (isNaN(d)) return ""; const p = n => String(n).padStart(2,"0"); return p(d.getHours())+":"+p(d.getMinutes()); };

  const methodMeta = {
    SD:        { label:"Special Delivery 1pm", short:"SD 1pm", urg:"sd",      rank:0 },
    RM24:      { label:"Royal Mail 24",        short:"RM24",   urg:"rm24",    rank:1 },
    RM48:      { label:"Royal Mail 48",        short:"RM48",   urg:"rm48",    rank:3 },
    DPD:       { label:"DPD",                  short:"DPD",    urg:"dpd",     rank:2 },
    Collection:{ label:"Collection",           short:"Collect",urg:"collect", rank:4 },
    Free:      { label:"Free delivery",        short:"Free",   urg:"free",    rank:5 },
  };
  const statusMeta = {
    "new":         { label:"New",          cls:"badge--blue",   dot:"var(--c-blue)" },
    "held":        { label:"Held",         cls:"badge--grey",   dot:"var(--c-grey)" },
    "in-progress": { label:"In progress",  cls:"badge--amber",  dot:"var(--c-amber)" },
    "printing":    { label:"Printing",     cls:"badge--violet", dot:"var(--c-violet)" },
    "dispatched":  { label:"Dispatched",   cls:"badge--teal",   dot:"var(--c-teal)" },
    // local production stages
    "awaiting_artwork": { label:"Awaiting artwork", cls:"badge--amber",  dot:"var(--c-amber)" },
    "awaiting_garments":{ label:"Awaiting garments",cls:"badge--amber",  dot:"var(--c-amber)" },
    "design_queue":     { label:"Design services",  cls:"badge--violet", dot:"var(--c-violet)" },
    "rip_queue":        { label:"RIP queue",        cls:"badge--teal",   dot:"var(--c-teal)" },
    "print_queue":      { label:"Sent to print",    cls:"badge--blue",   dot:"var(--c-blue)" },
    "printed":          { label:"Printed",          cls:"badge--teal",   dot:"var(--c-teal)" },
    "press_queue":      { label:"Press queue",      cls:"badge--blue",   dot:"var(--c-blue)" },
    "ready_dispatch":   { label:"Ready to dispatch",cls:"badge--mint",   dot:"var(--c-green)" },
    "ready-collection": { label:"Ready for collection", cls:"badge--amber", dot:"var(--c-amber)" },
    "collected":   { label:"Collected",    cls:"badge--green",  dot:"var(--c-green)" },
    "delivered":   { label:"Delivered",    cls:"badge--green",  dot:"var(--c-green)" },
    "refunded":    { label:"Refunded",     cls:"badge--grey",   dot:"var(--c-grey)" },
    "cancelled":   { label:"Cancelled",    cls:"badge--grey",   dot:"var(--c-grey)" },
    "closed":      { label:"Closed",       cls:"badge--grey",   dot:"var(--c-grey)" },
  };
  const payMeta = {
    paid:     { label:"Paid",     cls:"badge--green" },
    pending:  { label:"Pending",  cls:"badge--amber" },
    refunded: { label:"Refunded", cls:"badge--grey" },
    partial:  { label:"Partial",  cls:"badge--amber" },
  };
  const fulfilMeta = {
    unfulfilled:  { label:"Unfulfilled", cls:"badge--ghost" },
    "in-progress":{ label:"In progress", cls:"badge--amber" },
    fulfilled:    { label:"Fulfilled",   cls:"badge--mint" },
  };
  const deliveryMeta = {
    "not-shipped":   { label:"Not shipped", cls:"badge--ghost" },
    "label-printed": { label:"Label ready", cls:"badge--blue" },
    "in-transit":    { label:"In transit",  cls:"badge--teal" },
    "ready-collection": { label:"Ready for collection", cls:"badge--amber" },
    collected:       { label:"Collected",   cls:"badge--green" },
    delivered:       { label:"Delivered",   cls:"badge--green" },
    attempted:       { label:"Attempted",   cls:"badge--amber" },
  };
  const flagMeta = {
    risk:   { cls:"flag--risk",   glyph:"!", title:"At risk" },
    vip:    { cls:"flag--vip",    glyph:"★", title:"VIP customer" },
    note:   { cls:"flag--note",   glyph:"✎", title:"Has note" },
    repeat: { cls:"flag--repeat", glyph:"↻", title:"Repeat customer" },
  };
  const channelMeta = {
    Shopify: { label:"Shopify", cls:"badge--green" },
    Etsy:    { label:"Etsy",    cls:"badge--amber" },
    Trade:   { label:"Trade",   cls:"badge--violet" },
    Phone:   { label:"Phone",   cls:"badge--grey" },
    Email:   { label:"Email",   cls:"badge--grey" },
  };

  function initials(name){ return name.split(/\s+/).map(w=>w[0]).slice(0,2).join("").toUpperCase(); }

  // fulfillment step index for progress UI
  const STEP_ORDER = ["new","in-progress","printing","dispatched","delivered"];

  // The static `customers` / `orders` arrays above stay as self-contained sample
  // data for the Inbox demo (email threads, AI composer, attach catalogue).
  // The live board/customer views are populated from the backend by DTF.load().
  const inboxCustomers = customers;
  const inboxOrders = orders;

  /* ===========================================================
     Live data layer — fetch /api/* and map Shopify's normalized
     shape onto the rich shape the components expect.
     =========================================================== */
  // UK business clock — the "placed" time, the 1pm dispatch cutoff and "today"
  // are all UK rules, so they must be computed in Europe/London regardless of the
  // viewer's browser timezone (an operator abroad would otherwise see wrong days).
  const LONDON_TZ = "Europe/London";
  const _londonFmt = new Intl.DateTimeFormat("en-GB", { timeZone: LONDON_TZ, year:"numeric", month:"2-digit", day:"2-digit", hour:"2-digit", minute:"2-digit", hour12:false });
  function londonParts(iso){
    const d = iso == null ? new Date() : new Date(iso); if (isNaN(d)) return null;
    const o = {}; for (const part of _londonFmt.formatToParts(d)) if (part.type !== "literal") o[part.type] = part.value;
    let hour = parseInt(o.hour, 10); if (hour === 24) hour = 0; // some engines emit "24" at midnight
    return { year:+o.year, month:+o.month, day:+o.day, hour, minute:+o.minute };
  }
  const _pad = n => String(n).padStart(2,"0");
  function todayYMD(){
    const p = londonParts(); return `${p.year}-${_pad(p.month)}-${_pad(p.day)}`;
  }
  function fmtDateTime(iso){
    const p = londonParts(iso); if (!p) return iso || "";
    return `${p.year}-${_pad(p.month)}-${_pad(p.day)} ${_pad(p.hour)}:${_pad(p.minute)}`;
  }
  // Cutoff-aware dispatch day: orders placed at/after the 1pm Special Delivery
  // cutoff roll to the next day; before the cutoff they dispatch the same day.
  const CUTOFF_HOUR = 13; // 1pm UK time
  function dispatchByYMD(iso){
    const p = londonParts(iso); if (!p) return "";
    // Anchor to noon UTC on the London calendar day (noon dodges any DST edge that
    // could nudge the date), then roll forward one day if placed past the cutoff.
    const base = new Date(Date.UTC(p.year, p.month - 1, p.day, 12));
    if (p.hour >= CUTOFF_HOUR) base.setUTCDate(base.getUTCDate() + 1);
    return `${base.getUTCFullYear()}-${_pad(base.getUTCMonth()+1)}-${_pad(base.getUTCDate())}`;
  }

  const PAY_MAP = {
    PAID:"paid", PARTIALLY_PAID:"partial", PENDING:"pending", AUTHORIZED:"pending",
    PARTIALLY_REFUNDED:"refunded", REFUNDED:"refunded", VOIDED:"refunded", EXPIRED:"pending",
  };
  const FULFIL_MAP = {
    FULFILLED:"fulfilled", PARTIALLY_FULFILLED:"in-progress", IN_PROGRESS:"in-progress",
    UNFULFILLED:"unfulfilled", ON_HOLD:"unfulfilled", SCHEDULED:"unfulfilled",
    PENDING_FULFILLMENT:"unfulfilled", OPEN:"unfulfilled", RESTOCKED:"unfulfilled",
  };
  const mapPay = s => PAY_MAP[s] || "pending";
  const mapFulfil = s => FULFIL_MAP[s] || "unfulfilled";
  const deriveStatus = fulfil => fulfil==="fulfilled" ? "dispatched" : fulfil==="in-progress" ? "in-progress" : "new";

  function mapMethod(title, type, shipPrice){
    // Shopify's delivery method type is authoritative for pickup/collection, but
    // reading it needs the fulfillment-orders scope. As a reliable fallback for
    // this store, the local-pickup rate is named after the brand ("DTF.UK").
    const t = (title || "").trim().toLowerCase();
    const isCollectionTitle = t === "dtf.uk" || /collect|pick ?up|in[- ]store|local/.test(t);
    // A PAID, non-collection shipping line (e.g. "RM24" £5.95) means the merchant is
    // POSTING this order — honour it even when Shopify still marks it local pickup.
    // Builder / gang-sheet orders default to pickup at checkout, but get shipped once
    // a Royal Mail line is added. Without a paid postal line, trust the pickup flag.
    const paidShipping = Number(shipPrice) > 0 && !!t && !isCollectionTitle;
    if ((type === "PICK_UP" || type === "RETAIL") && !paidShipping) return "Collection";
    if (isCollectionTitle) return "Collection";
    if (/special delivery|guaranteed|by 1\s*pm|\bsd\b/.test(t)) return "SD";
    if (/dpd|courier|express/.test(t)) return "DPD";
    if (/24|next[- ]day|first class|tracked 24/.test(t)) return "RM24";
    if (/free|no charge|£0|complimentary/.test(t)) return "Free";
    return "RM48"; // default / 48 / standard / second class
  }
  function deriveChannel(tags){
    const j = (tags || []).join(" ").toLowerCase();
    if (/trade|wholesale|net-?30|b2b/.test(j)) return "Trade";
    if (/etsy/.test(j)) return "Etsy";
    if (/phone/.test(j)) return "Phone";
    return "Shopify";
  }
  function fmtAddress(a){
    if (!a) return "—";
    return [a.name, a.address1, a.address2, a.city, a.zip].filter(Boolean).join(", ") || "—";
  }
  const isVip = c => !!c && ((c.spent || 0) >= 1000 || (c.tags || []).some(t => /vip/i.test(t)));

  function mapCustomer(c){
    return {
      id: c.id,
      name: c.name || c.email || "—",
      email: c.email || "",
      subscribed: c.subscribed === true,
      location: c.location || "—",
      country: c.country || "GB",
      orders: c.ordersCount || 0,
      spent: c.totalSpent || 0,
      channel: deriveChannel(c.tags),
      since: (c.createdAt || "").slice(0,10),
      lastOrderAt: c.lastOrderAt || null,
      phone: c.phone || "",
      tags: c.tags || [],
      note: c.note || "",
    };
  }
  function supplementalCustomer(cu, addr){
    const loc = cu.location || (addr ? [addr.city, addr.country].filter(Boolean).join(", ") : "");
    return {
      id: cu.id, name: cu.name || cu.email || "—", email: cu.email || "",
      subscribed:false,
      location: loc || "—",
      country: (cu.location ? cu.location.split(", ").pop() : null) || addr?.country || "GB",
      orders: cu.ordersCount || 0, spent: cu.totalSpent || 0,
      channel:"Shopify", since:"", phone: cu.phone || "", tags:[], note:"",
    };
  }
  // "Lapsed regular": a customer who used to order often but has gone quiet. We
  // flag them when they've ordered ≥4 times and their silence now runs past ~2.5×
  // their own average gap between orders (min 45 days), capped at 2 years so
  // ancient one-offs aren't nagged. Returns the days-since + their usual cadence.
  function lapsedInfo(c){
    const orders = (c && c.orders) || 0;
    const last = c && c.lastOrderAt ? new Date(c.lastOrderAt) : null;
    if (!last || isNaN(last)) return { lapsed:false, daysSince:null, avgInterval:null, regular: orders >= 4 };
    const DAY = 86400000;
    const daysSince = Math.round((Date.now() - last.getTime()) / DAY);
    const since = c.since ? new Date(c.since) : null;
    let avgInterval = null;
    if (since && !isNaN(since) && orders > 1) {
      const span = Math.max(1, (last.getTime() - since.getTime()) / DAY);
      avgInterval = Math.round(span / (orders - 1));
    }
    const regular = orders >= 4;
    const threshold = Math.max(45, Math.round(2.5 * (avgInterval || 30)));      // fully lapsed
    const riskThreshold = Math.max(30, Math.round(1.5 * (avgInterval || 30)));  // overdue → at risk
    const inWindow = daysSince <= 730;
    const lapsed = regular && daysSince > threshold && inWindow;
    const atRisk = regular && daysSince > riskThreshold && inWindow;            // includes lapsed
    return { lapsed, atRisk, daysSince, avgInterval, threshold, riskThreshold, regular };
  }
  function buildTimeline(o, dateStr, pay, fulfil, isCollection){
    return [
      { t:"Order placed", m: dateStr, on:true },
      { t:"Payment", m: pay==="paid" ? "Captured · "+fmtGBP(o.total) : (payMeta[pay] ? payMeta[pay].label : "Pending"), on: pay==="paid" },
      { t:"Fulfilment", m: fulfilMeta[fulfil] ? fulfilMeta[fulfil].label : "Unfulfilled", on: fulfil!=="unfulfilled" },
      isCollection
        ? { t:"Collected", m: fulfil==="fulfilled" ? "Collected from DTF.uk" : "Awaiting collection", on: fulfil==="fulfilled" }
        : { t:"Dispatched", m: fulfil==="fulfilled" ? "Fulfilled in Shopify" : "Pending", on: fulfil==="fulfilled" },
    ];
  }
  function mapOrder(o, custIndex){
    const dateStr = fmtDateTime(o.createdAt);
    const pay = mapPay(o.financialStatus);
    const fulfil = mapFulfil(o.fulfillmentStatus);
    const method = mapMethod(o.shippingTitle, o.deliveryMethodType, o.shipping);
    const isCollection = method === "Collection";
    const cancelled = !!o.cancelledAt;
    const refunded = o.financialStatus === "REFUNDED";
    // Shopify leaves non-physical lines (e.g. "Customisation Print Fee") unfulfilled,
    // so an order whose goods have shipped reads as PARTIALLY_FULFILLED forever.
    // Trust a CLOSED fulfilment order carrying real tracking as dispatched
    // (collections never carry tracking, so they're unaffected).
    const shipped = !isCollection && o.fulfillmentOrderStatus === "CLOSED" && !!(o.tracking && o.tracking.number);
    // Same fee-line problem for click-&-collect: a CLOSED pick-up fulfilment order
    // means it was handed over, even while the fee line keeps the order PARTIALLY_
    // FULFILLED. Trust that as collected (no tracking on pickups, so key off status).
    const collected = isCollection && o.fulfillmentOrderStatus === "CLOSED";
    const dispatched = fulfil === "fulfilled" || shipped || collected;
    const wfo = (DTF._workflow && DTF._workflow.orders && DTF._workflow.orders[o.name]) || null;
    const localStage = wfo && wfo.stage || null;
    // "Hold until": while the hold date is still in the future the order is parked
    // (status "held", off the active boards); on that day it falls through to its
    // natural status again — i.e. re-appears in New orders.
    const holdUntil = (wfo && wfo.holdUntil) || null;
    const heldActive = holdUntil && (todayYMD() < holdUntil);
    const cust = o.customer ? custIndex.get(o.customer.id) : null;
    const flags = [];
    if (isVip(cust)) flags.push("vip");
    if (o.note) flags.push("note");
    if (cust && cust.orders > 1) flags.push("repeat");
    const items = (o.lineItems || []).map(li => {
      // currentQuantity reflects order edits/refunds — a removed line drops to 0
      // while li.quantity keeps what was originally ordered. `qty` is the effective
      // quantity that drives metres/weight/packing; removed lines still render but
      // struck-through (see below) and contribute nothing.
      const orderedQty = li.quantity || 0;
      const curQty = li.currentQuantity != null ? li.currentQuantity : orderedQty;
      return {
        id: li.id, name: li.title,
        variant: li.variantTitle || "",
        sku: li.sku || "",
        qty: curQty,
        orderedQty,
        removed: !!li.removed || curQty === 0,
        price: orderedQty ? li.total / orderedQty : (li.total || 0),
        image: li.image || null,
        productLegacyId: li.productLegacyId || null,
        productUrl: li.productUrl || null,
        properties: li.properties || [],
      };
    });
    return {
      id: o.name,
      adminId: o.legacyId || null,
      customerId: o.customer ? o.customer.id : null,
      date: dateStr,
      fulfillBy: dispatchByYMD(o.createdAt),
      channel: deriveChannel(o.tags),
      method,
      total: o.total || 0,
      subtotal: (o.subtotal != null ? o.subtotal : null),   // Shopify current subtotal (after edits)
      tax: (o.tax != null ? o.tax : null),                  // Shopify current tax (0 on VAT-inclusive stores)
      originalTotal: (o.originalTotal != null ? o.originalTotal : null),
      amountPaid: (o.amountPaid != null ? o.amountPaid : (o.total || 0)),
      shipping: o.shipping || 0,
      shippingTitle: o.shippingTitle || null,   // requested delivery service (checkout)
      note: o.note || "",
      // Postal order with no shipping address (e.g. a builder order placed as pickup
      // then shipped) → fall back to the billing / customer's saved address so a
      // Royal Mail label can still be created.
      ship: o.shippingAddress || (!isCollection ? (o.billingAddress || (o.customer && o.customer.defaultAddress) || null) : null),
      tracking: o.tracking || null,
      pay, fulfil,
      localStage,
      holdUntil,
      groupId: (wfo && wfo.groupId) || null,
      printerId: (wfo && wfo.printerId) || null,
      garmentReceived: !!(wfo && wfo.garmentReceived),

      // Precedence: refunded/cancelled (Shopify) → fulfilled (Shopify) → held (until
      // its date) → local production stage → collection/derived.
      status: refunded ? "refunded"
        : cancelled ? "cancelled"
        : dispatched ? (isCollection ? "collected" : "dispatched")
        : heldActive ? "held"
        : localStage ? localStage
        : isCollection
          ? (o.fulfillmentOrderStatus==="IN_PROGRESS" ? "ready-collection" : deriveStatus(fulfil))
          : deriveStatus(fulfil),
      delivery: dispatched
        ? (isCollection ? "collected" : "in-transit")
        : (isCollection && o.fulfillmentOrderStatus==="IN_PROGRESS" ? "ready-collection" : "not-shipped"),
      flags,
      tags: o.tags || [],
      packed: dispatched || fulfil!=="unfulfilled",
      dispatched,
      delivered: false,
      items,
      address: fmtAddress(o.shippingAddress),
      timeline: buildTimeline(o, dateStr, pay, fulfil, isCollection),
    };
  }

  async function fetchJSON(url, opts){
    const res = await fetch(url, opts);
    if (!res.ok){
      let m; try { m = (await res.json()).error; } catch (e) {}
      throw new Error(m || (res.status + " " + res.statusText));
    }
    return res.json();
  }

  // Internal stock (our own blank-garment inventory, CRM-owned — separate from
  // the Shopify inventory fed by Rallawise). Thin wrappers over /api/stock.
  const _json = { "Content-Type": "application/json" };
  async function stockList(){ return fetchJSON("/api/stock"); }
  async function stockCreate(item){ const r = await fetchJSON("/api/stock", { method:"POST", headers:_json, body: JSON.stringify(item||{}) }); return r.item; }
  async function stockUpdate(id, patch){ const r = await fetchJSON("/api/stock/" + encodeURIComponent(id), { method:"PATCH", headers:_json, body: JSON.stringify(patch||{}) }); return r.item; }
  async function stockAdjust(id, delta, reason){ const r = await fetchJSON("/api/stock/" + encodeURIComponent(id) + "/adjust", { method:"POST", headers:_json, body: JSON.stringify({ delta, reason }) }); return r.item; }
  async function stockDelete(id){ const r = await fetchJSON("/api/stock/" + encodeURIComponent(id), { method:"DELETE" }); return !!r.ok; }
  // Live Shopify (Rallawise-fed) levels for the linked SKUs → { sku:{available,product} }.
  async function stockShopify(){ const r = await fetchJSON("/api/stock/shopify"); return r.levels || {}; }
  // Per-location levels for a specific set of SKUs → { sku:{ralawise,local,total,adminUrl} }.
  async function stockLevels(skus){ const list=(skus||[]).filter(Boolean); if(!list.length) return {}; const r = await fetchJSON("/api/stock/levels?skus=" + encodeURIComponent(list.join(","))); return r.levels || {}; }
  // Type-ahead SKU search (min 2 chars) → [{sku,product,colour,size,qty}], grouped by product in the UI.
  async function stockSkuSearch(q){ const r = await fetchJSON("/api/stock/skus?q=" + encodeURIComponent(q||"")); return r.skus || []; }
  // Ralawise supplier: trigger the import-products / stock-sync GitHub Actions.
  async function ralawiseStatus(){ return fetchJSON("/api/stock/ralawise/status"); }
  async function ralawiseImport(codes, refresh){ return fetchJSON("/api/stock/ralawise/import", { method:"POST", headers:_json, body: JSON.stringify({ codes, refresh: !!refresh }) }); }
  async function ralawiseSync(codes){ return fetchJSON("/api/stock/ralawise/sync", { method:"POST", headers:_json, body: JSON.stringify({ codes: codes||"" }) }); }
  // Append SKUs+qtys to the Rallawise basket. lines = [{sku, qty}].
  async function ralawiseBasket(lines){ return fetchJSON("/api/stock/ralawise/basket", { method:"POST", headers:_json, body: JSON.stringify({ lines: lines||[] }) }); }
  // Re-Stock forecast: garment sales (7/14/30d) + per-location stock + AI order qty
  // to cover the next `cover` days.
  async function stockForecast(cover){ const r = await fetchJSON("/api/stock/forecast?cover=" + (Number(cover)||14)); return r; }

  // Local-pickup actions: mirror Shopify's "Ready for pickup" → "Picked up" flow
  // and write the change to Shopify. We update the local order in place; callers
  // bump React state to re-render.
  function _orderPath(orderId){ return "/api/orders/" + encodeURIComponent(String(orderId).replace(/^#/, "")); }
  async function markReadyForCollection(orderId){
    const res = await fetchJSON(_orderPath(orderId) + "/ready-for-collection", { method:"POST" });
    const o = DTF.getOrder(orderId);
    if (o) { o.status = "ready-collection"; o.delivery = "ready-collection"; o.packed = true; }
    return res;
  }
  // Search Shopify customers (server-side, by name/email) — used by the link picker.
  async function searchShopifyCustomers(query){
    const q = (query || "").trim();
    if (!q) return [];
    const res = await fetchJSON("/api/customers?all=0&first=20&query=" + encodeURIComponent(q));
    return (res.customers || []).map(mapCustomer);
  }

  // Link an email address to a real Shopify customer and persist the alias.
  async function linkCustomerAlias(email, customer){
    const addr = String(email || "").trim().toLowerCase();
    const res = await fetchJSON("/api/aliases", {
      method: "POST", headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ email: addr, customerId: customer.id }),
    });
    DTF._aliases = res.aliases || DTF._aliases;
    // Make sure the linked customer resolves, and re-point existing mail threads.
    if (!byId(DTF.customers, customer.id) && !byId(DTF._orderCustomers, customer.id)) DTF._orderCustomers.push(customer);
    const mailId = "mail:" + addr;
    (DTF.liveThreads || []).forEach(t => { if (t.customerId === mailId) t.customerId = customer.id; });
    return res;
  }

  // Mark an inbox thread closed/open (persisted) so handled conversations leave the inbox.
  async function setThreadClosed(id, closed){
    if (!DTF._closedThreads) DTF._closedThreads = new Set();
    if (closed) DTF._closedThreads.add(id); else DTF._closedThreads.delete(id);
    const apply = t => (t.id === id ? { ...t, status: closed ? "closed" : "open" } : t);
    if (DTF.liveThreads) DTF.liveThreads = DTF.liveThreads.map(apply);
    try {
      await fetchJSON("/api/threads/closed", { method:"POST", headers:{ "Content-Type":"application/json" }, body: JSON.stringify({ id, closed }) });
    } catch (e) { /* keep optimistic local state */ }
  }

  // Assign an inbox thread to a specific Shopify customer (system/no-reply emails).
  async function assignThread(threadId, customer){
    if (!DTF._threadAssign) DTF._threadAssign = {};
    const cid = customer ? customer.id : null;
    if (cid) DTF._threadAssign[threadId] = cid; else delete DTF._threadAssign[threadId];
    if (customer && !byId(DTF.customers, cid) && !byId(DTF._orderCustomers, cid)) DTF._orderCustomers.push(customer);
    // re-point the loaded thread so it shows under the new customer everywhere this session
    (DTF.liveThreads || []).forEach(t => { if (t.id === threadId) { t.customerId = cid || t.customerId; } });
    try { await fetchJSON("/api/threads/assign", { method:"POST", headers:{ "Content-Type":"application/json" }, body: JSON.stringify({ id: threadId, customerId: cid }) }); } catch(e){}
  }

  // ---- production workflow (local stages + printers) ----
  function _applyStage(name, stage, printerId){
    if (!DTF._workflow) DTF._workflow = { printers: [], orders: {}, groups: {} };
    const _gid = (DTF._workflow.orders[name] && DTF._workflow.orders[name].groupId) || null;
    if (stage == null) { if (_gid) DTF._workflow.orders[name] = { groupId: _gid }; else delete DTF._workflow.orders[name]; }
    else DTF._workflow.orders[name] = { stage, printerId: printerId || null, updatedAt: Date.now(), groupId: _gid };
    const o = DTF.getOrder(name);
    if (o) { o.localStage = stage || null; o.printerId = printerId || null;
      if (o.fulfil !== "fulfilled" && o.status !== "refunded" && o.status !== "cancelled")
        o.status = stage || (o.method === "Collection" ? o.status : "new"); }
    // Notify the app so the topbar prev/next counter reflects the new list.
    try { window.dispatchEvent(new CustomEvent("dtf:workflow")); } catch(e){}
  }
  async function setOrderStage(name, stage, printerId){
    _applyStage(name, stage, printerId);
    try { const wf = await fetchJSON("/api/workflow/order", { method:"PUT", headers:{ "Content-Type":"application/json" }, body: JSON.stringify({ name, stage, printerId }) }); DTF._workflow = { printers: wf.printers||[], orders: wf.orders||{}, groups: wf.groups||{} }; } catch(e){}
  }
  // Cancel a Shopify fulfilment and drop the order back onto a production stage
  // (default RIP queue). Used when an order reads as dispatched but wasn't really
  // printed/shipped (e.g. combined order whose tracking sits on a sibling). Refetches
  // the order so its status reflects the revert (now unfulfilled + staged).
  async function cancelFulfilment(name, stage){
    const st = stage === undefined ? "rip_queue" : stage;
    const res = await fetchJSON("/api/orders/" + encodeURIComponent(String(name).replace(/^#/, "")) + "/cancel-fulfilment", {
      method:"POST", headers:{ "Content-Type":"application/json" }, body: JSON.stringify({ stage: st })
    });
    if (res && res.workflow) DTF._workflow = { printers: res.workflow.printers||[], orders: res.workflow.orders||{}, groups: res.workflow.groups||{} };
    await fetchOrderByName(name);
    try { window.dispatchEvent(new CustomEvent("dtf:workflow")); } catch(e){}
    return res;
  }
  async function setOrdersStage(names, stage, printerId){
    (names||[]).forEach(n => _applyStage(n, stage, printerId));
    try { const wf = await fetchJSON("/api/workflow/orders", { method:"PUT", headers:{ "Content-Type":"application/json" }, body: JSON.stringify({ names, stage, printerId }) }); DTF._workflow = { printers: wf.printers||[], orders: wf.orders||{}, groups: wf.groups||{} }; } catch(e){}
  }
  // Hold an order until a date (YYYY-MM-DD) — parks it off the boards; it re-appears
  // in New orders on that day. Passing null releases it. Optimistic + persisted.
  async function holdOrder(name, holdUntil){
    if (!DTF._workflow) DTF._workflow = { printers: [], orders: {}, groups: {} };
    const prev = DTF._workflow.orders[name] || {};
    if (holdUntil) DTF._workflow.orders[name] = { ...prev, holdUntil, stage: null, printerId: null, updatedAt: Date.now() };
    else { const { holdUntil:_d, ...rest } = prev; if (rest.groupId || rest.stage) DTF._workflow.orders[name] = { ...rest }; else delete DTF._workflow.orders[name]; }
    const o = DTF.getOrder(name);
    if (o && o.fulfil !== "fulfilled" && o.status !== "refunded" && o.status !== "cancelled") {
      o.holdUntil = holdUntil || null;
      o.localStage = null;
      o.status = (holdUntil && todayYMD() < holdUntil) ? "held" : (o.method === "Collection" ? o.status : "new");
    }
    try { window.dispatchEvent(new CustomEvent("dtf:workflow")); } catch(e){}
    try { const wf = await fetchJSON("/api/workflow/order/hold", { method:"PUT", headers:{ "Content-Type":"application/json" }, body: JSON.stringify({ name, holdUntil: holdUntil||null }) }); DTF._workflow = { printers: wf.printers||[], orders: wf.orders||{}, groups: wf.groups||{} }; } catch(e){}
  }
  // Mark a customizable order's blank garment as received (or not). Optimistic +
  // persisted; drives the pressing board's Awaiting-garment ↔ Ready-to-press split.
  async function setGarmentReceived(name, on){
    if (!DTF._workflow) DTF._workflow = { printers: [], orders: {}, groups: {} };
    const prev = DTF._workflow.orders[name] || {};
    if (on) DTF._workflow.orders[name] = { ...prev, garmentReceived: true, updatedAt: Date.now() };
    else { const { garmentReceived:_g, ...rest } = prev; if (rest.groupId || rest.stage || rest.holdUntil) DTF._workflow.orders[name] = { ...rest }; else delete DTF._workflow.orders[name]; }
    const o = DTF.getOrder(name); if (o) o.garmentReceived = !!on;
    try { window.dispatchEvent(new CustomEvent("dtf:workflow")); } catch(e){}
    try { const wf = await fetchJSON("/api/workflow/order/garment", { method:"PUT", headers:{ "Content-Type":"application/json" }, body: JSON.stringify({ name, received: !!on }) }); DTF._workflow = { printers: wf.printers||[], orders: wf.orders||{}, groups: wf.groups||{} }; } catch(e){}
  }
  async function addPrinter(name){
    const wf = await fetchJSON("/api/workflow/printer", { method:"POST", headers:{ "Content-Type":"application/json" }, body: JSON.stringify({ name }) });
    DTF._workflow = { printers: wf.printers||[], orders: wf.orders||{}, groups: wf.groups||{} }; return wf;
  }
  async function updatePrinter(id, patch){
    const wf = await fetchJSON("/api/workflow/printer/"+encodeURIComponent(id), { method:"PUT", headers:{ "Content-Type":"application/json" }, body: JSON.stringify(patch) });
    DTF._workflow = { printers: wf.printers||[], orders: wf.orders||{}, groups: wf.groups||{} }; return wf;
  }
  async function removePrinter(id, reassignTo){
    const qs = reassignTo ? ("?reassignTo=" + encodeURIComponent(reassignTo)) : "";
    const wf = await fetchJSON("/api/workflow/printer/"+encodeURIComponent(id)+qs, { method:"DELETE" });
    DTF._workflow = { printers: wf.printers||[], orders: wf.orders||{}, groups: wf.groups||{} }; return wf;
  }

  // ---- order groups (combine several orders → one parent drives dispatch) ----
  // The group endpoints return only the changed group, so after any mutation we
  // refetch the whole workflow to keep DTF._workflow (and each order's groupId) true.
  async function _syncWorkflow(){
    try {
      const wf = await fetchJSON("/api/workflow");
      DTF._workflow = { printers: wf.printers||[], orders: wf.orders||{}, groups: wf.groups||{} };
    } catch(e){}
    // Re-stamp groupId on every loaded order so the tree re-renders correctly.
    (DTF.orders||[]).forEach(o => { const w = DTF._workflow.orders[o.id]; o.groupId = (w && w.groupId) || null; });
    try { window.dispatchEvent(new CustomEvent("dtf:refreshed")); } catch(e){}
  }
  async function createGroup(parent, children){
    const g = await fetchJSON("/api/groups", { method:"POST", headers:{ "Content-Type":"application/json" }, body: JSON.stringify({ parent, children: children||[] }) });
    await _syncWorkflow();
    return g;
  }
  async function updateGroup(id, patch){
    const g = await fetchJSON("/api/groups/"+encodeURIComponent(id), { method:"PUT", headers:{ "Content-Type":"application/json" }, body: JSON.stringify(patch||{}) });
    await _syncWorkflow();
    return g;
  }
  async function dissolveGroup(id){
    // The server reverses any refunded shipping credit first; it returns
    // { ok:false, dissolved:false, failed:[…] } if a debit failed (group kept).
    const res = await fetchJSON("/api/groups/"+encodeURIComponent(id), { method:"DELETE" });
    await _syncWorkflow();
    return res || { ok:true };
  }
  // Refund a child order's paid shipping to the customer's store credit (opt-in,
  // reversible on ungroup). Amount is computed server-side.
  async function creditShipping(childId){
    const res = await fetchJSON(_orderPath(childId) + "/credit-shipping", { method:"POST", headers:{ "Content-Type":"application/json" }, body: "{}" });
    await _syncWorkflow();
    return res;
  }
  // Total shipping already credited within a group (from the per-order records).
  function groupCreditTotal(group){
    if (!group || !DTF._workflow) return 0;
    return [group.parent, ...(group.children||[])].reduce((s, id) => {
      const w = DTF._workflow.orders[id];
      return s + ((w && w.shippingCredit && w.shippingCredit.amount) || 0);
    }, 0);
  }
  // Read helpers. `order` may be an order object or an order id ("#3872").
  function groupOf(order){
    const id = typeof order === "string" ? order : (order && order.id);
    if (!id || !DTF._workflow) return null;
    const w = DTF._workflow.orders[id];
    const gid = w && w.groupId;
    return gid ? (DTF._workflow.groups[gid] || null) : null;
  }
  // Ordered member ids of a group (parent first).
  function groupMembers(groupId){
    const g = DTF._workflow && DTF._workflow.groups[groupId];
    return g ? [g.parent, ...g.children] : [];
  }
  // Auto-suggest: ungrouped, still-open orders from the same customer that share
  // no group yet — the operator can one-click combine them. Returns
  // [{ customerId, name, orderIds:[...] }] only where ≥2 candidates exist.
  // Key that identifies orders shippable as ONE parcel: the normalized delivery
  // destination (name + address + postcode), falling back to the Shopify customer
  // id when there's no address. Address-based so duplicate customer profiles for
  // the same person/address (two Shopify accounts, guest re-checkout) still pair.
  function combineKey(o){
    const s = (o && o.ship) || {};
    const norm = v => String(v == null ? "" : v).trim().toLowerCase().replace(/[.,]/g, "").replace(/\s+/g, " ");
    const a1 = norm(s.address1), zip = norm(s.zip);
    // Key on the DESTINATION (address1 + postcode) only — not the recipient name,
    // city or address2, which customers enter inconsistently across orders (e.g.
    // "warren day" vs "day", town in city on one order and address2 on another).
    // Two orders to the same door are the combine candidate; the operator confirms.
    if (a1 && zip) return "addr:" + a1 + "|" + zip;
    return o && o.customerId ? "cust:" + o.customerId : null;
  }
  function groupSuggestions(list){
    const byKey = new Map();
    (list||[]).forEach(o => {
      if (groupOf(o)) return;                 // already grouped
      const k = combineKey(o); if (!k) return;
      if (!byKey.has(k)) byKey.set(k, []);
      byKey.get(k).push(o);
    });
    const out = [];
    for (const [k, os] of byKey) {
      if (os.length < 2) continue;
      const c = DTF.getCustomer(os[0].customerId);
      out.push({ key: k, customerId: os[0].customerId, name: (os[0].ship && os[0].ship.name) || (c && c.name) || "Customer", orderIds: os.map(o=>o.id) });
    }
    return out;
  }

  // --- Order artwork (uploaded print files) ----------------------------
  // Order name is "#3657"; the API path uses the bare number (server re-adds #).
  // `on` may be an order name (string) OR an owner object {type:'draft'|'order', id}
  // so the SAME artwork helpers work on orders and draft orders (quotes).
  function _baseFor(on){
    if (on && typeof on === "object")
      return on.type === "draft"
        ? "/api/draft-orders/" + encodeURIComponent(String(on.id).replace(/\D/g, ""))
        : "/api/orders/" + encodeURIComponent(String(on.id || "").replace(/^#/, ""));
    return "/api/orders/" + encodeURIComponent(String(on || "").replace(/^#/, ""));
  }
  // Thumb query segment (?order= / ?draft=) for an owner-or-name.
  function _thumbQS(on){
    if (on && typeof on === "object")
      return (on.type === "draft" ? "draft=" : "order=") + encodeURIComponent(String(on.id || "").replace(on.type === "draft" ? /\D/g : /^#/, ""));
    return "order=" + encodeURIComponent(String(on || "").replace(/^#/, ""));
  }
  function _artBase(name){ return _baseFor(name) + "/artwork"; }
  // Cache artwork lists so the production table doesn't re-fetch every row on
  // each re-render (was firing the same request many times → glitchy).
  const _artCache = {};
  const _artInflight = {};
  function artworkCached(name){ return _artCache[name] || null; }
  async function artworkFor(name, fresh){
    if (!fresh && _artCache[name]) return _artCache[name];
    if (_artInflight[name]) return _artInflight[name];
    _artInflight[name] = (async () => {
      try { const r = await fetchJSON(_artBase(name)); _artCache[name] = r.files || []; return _artCache[name]; }
      catch(e){ return []; }
      finally { delete _artInflight[name]; }
    })();
    return _artInflight[name];
  }
  function artworkUrl(name, id, download){
    return _artBase(name) + "/" + encodeURIComponent(id) + (download ? "?download=1" : "");
  }
  // Preview page: the artwork shown on a checkerboard so transparency is visible.
  // Drafts have no /preview route, so fall back to the raw file there.
  function artworkPreviewUrl(name, id){
    const isDraft = name && typeof name === "object" && name.type === "draft";
    return _artBase(name) + "/" + encodeURIComponent(id) + (isDraft ? "" : "/preview");
  }
  // Same checkerboard preview, but for an EXTERNAL image URL (e.g. a Design Preview).
  function previewUrl(src){ return "/api/preview?src=" + encodeURIComponent(String(src || "")); }
  // Cached, downscaled thumbnails (w = longest side px) so lists don't pull
  // multi-MB print files / design previews at full resolution. The /api/thumb
  // route falls back to the original on any failure, so these never break.
  function thumbUrl(src, w){ return "/api/thumb?w=" + (w || 160) + "&src=" + encodeURIComponent(src); }
  function artThumbUrl(name, id, w){ return "/api/thumb?w=" + (w || 160) + "&" + _thumbQS(name) + "&id=" + encodeURIComponent(id); }
  async function uploadArtwork(name, file, onProgress){
    // Cloudflare rejects any single request body over ~100MB before it reaches the
    // origin, so large print files are split into ≤80MB chunks and reassembled
    // server-side (POST …/artwork/chunk) — same as uploadArtworkOwner. Smaller files
    // post in one shot. XHR gives us upload progress for big files.
    const base = _artBase(name);
    const ct = file.type || "application/octet-stream";
    const CHUNK_THRESHOLD = 90 * 1024 * 1024;
    const CHUNK_SIZE = 80 * 1024 * 1024;
    let rec;
    if (file.size <= CHUNK_THRESHOLD) {
      rec = await _xhrPost(base + "?filename=" + encodeURIComponent(file.name || "artwork"), file, ct, onProgress);
    } else {
      const uploadId = Date.now().toString(36) + Math.random().toString(36).slice(2, 10);
      const total = Math.ceil(file.size / CHUNK_SIZE);
      let last = null;
      for (let i = 0; i < total; i++) {
        const blob = file.slice(i * CHUNK_SIZE, Math.min((i + 1) * CHUNK_SIZE, file.size));
        const qs = "?uploadId=" + encodeURIComponent(uploadId) + "&index=" + i + "&total=" + total + "&filename=" + encodeURIComponent(file.name || "artwork");
        last = await _xhrPost(base + "/chunk" + qs, blob, ct, (frac) => { if (onProgress) onProgress((i + frac) / total); });
      }
      rec = (last && last.file) ? last.file : last;
    }
    DTF._artworkCounts = DTF._artworkCounts || {};
    DTF._artworkCounts[name] = (DTF._artworkCounts[name] || 0) + 1;
    delete _artCache[name]; // refetch fresh next time
    return rec;
  }
  async function deleteArtwork(name, id){
    const r = await fetchJSON(_artBase(name) + "/" + encodeURIComponent(id), { method:"DELETE" });
    DTF._artworkCounts = DTF._artworkCounts || {};
    DTF._artworkCounts[name] = (r.files || []).length;
    _artCache[name] = r.files || [];
    return r.files || [];
  }
  // Rename an artwork's display filename (exact — no order prefix re-added). Works for
  // orders (name = order id) and drafts (name = "draft-<id>", via _baseFor).
  async function renameArtwork(name, id, filename){
    const r = await fetchJSON(_artBase(name) + "/" + encodeURIComponent(id) + "/rename", { method:"PUT", headers:{ "Content-Type":"application/json" }, body: JSON.stringify({ filename }) });
    _artCache[name] = r.files || [];
    return r.files || [];
  }
  function artworkCount(name){ return (DTF._artworkCounts && DTF._artworkCounts[name]) || 0; }

  // Studio: toggle whether an artwork is marked "to print" so the partner knows which
  // uploads to send to the printer. Multiple files can be marked. Returns the fresh list.
  async function setPrintFile(name, id, on){
    const r = await fetchJSON(_artBase(name) + "/" + encodeURIComponent(id) + "/print", { method:"POST", headers:{ "Content-Type":"application/json" }, body: JSON.stringify({ on: on !== false }) });
    _artCache[name] = r.files || [];
    // Keep the metres override in sync: sum every marked sheet's height ÷ DPI.
    const m = Math.round((r.files || []).filter(f => f.print).reduce((s, f) => s + ((f.height && f.dpi) ? f.height / f.dpi * 0.0254 : 0), 0) * 1000) / 1000;
    DTF._printMetres = DTF._printMetres || {};
    if (m > 0) DTF._printMetres[name] = m; else delete DTF._printMetres[name];
    return r.files || [];
  }

  // Process a design-preview image into a print-ready artwork file (server runs:
  // bria bg-removal → Real-ESRGAN ×scale upscale → trim → 300 DPI) and attach it
  // to the order. Can be slow (Replicate), so callers should show progress.
  // Attach a client-rendered PNG blob to an order's artwork, optionally flattening to a
  // palette + trimming + stamping DPI server-side. Used by the vectorise/recolour Save,
  // which rasterises the SVG in the browser (correct where rsvg drops recraft outlines).
  async function attachPng(name, blob, filename, opts = {}){
    const qs = new URLSearchParams();
    qs.set("filename", filename || "artwork");
    qs.set("dpi", String(opts.dpi || 300));
    if (opts.trim) qs.set("trim", "1");
    if (opts.keepColors && opts.keepColors.length) qs.set("keep", opts.keepColors.join(","));
    if (opts.hideColors && opts.hideColors.length) qs.set("hide", opts.hideColors.join(","));
    const url = _artBase(name).replace(/\/artwork$/, "/attach-png") + "?" + qs.toString();
    const r = await _xhrPost(url, blob, "image/png", opts.onProgress);
    DTF._artworkCounts = DTF._artworkCounts || {}; DTF._artworkCounts[name] = (DTF._artworkCounts[name] || 0) + 1;
    delete _artCache[name];
    return r && r.file;
  }
  async function processArtwork(name, imageUrl, opts){
    const procBase = _artBase(name).replace(/\/artwork$/, "/process-artwork");
    const start = await fetchJSON(procBase, {
      method: "POST", headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ imageUrl, fileId: opts && opts.fileId, scale: (opts && opts.scale) || 10, dpi: (opts && opts.dpi) || 300, removeBg: !(opts && opts.removeBg === false), upscale: !(opts && opts.upscale === false), erode: (opts && opts.erode) || 0, harden: (opts && opts.harden) || 0, outline: (opts && opts.outline) || 0, colorkey: (opts && opts.colorkey) || "", colortol: (opts && opts.colortol) || 0, highres: (opts && opts.highres) || "", highresPrompt: (opts && opts.highresPrompt) || "", recraftbg: !!(opts && opts.recraftbg), setdpi: !!(opts && opts.setdpi), trim: !!(opts && opts.trim), recolor: (opts && opts.recolor) || "", wandmode: (opts && opts.wandmode) || "", wandseeds: (opts && opts.wandseeds) || [] }),
    });
    if (!start.jobId) throw new Error(start.error || "Couldn't start processing");
    const onStep = opts && opts.onStep;
    // Poll the job (the pipeline runs server-side for ~60–90s).
    for (let i = 0; i < 180; i++) {
      await new Promise(r => setTimeout(r, 4000));
      let j; try { j = await fetchJSON(procBase + "/" + start.jobId); } catch { continue; }
      if (j.step && onStep) onStep(j.step);
      if (j.status === "done") {
        DTF._artworkCounts = DTF._artworkCounts || {};
        DTF._artworkCounts[name] = (DTF._artworkCounts[name] || 0) + 1;
        delete _artCache[name];
        return j.file;
      }
      if (j.status === "error") throw new Error(j.error || "Processing failed");
      if (j.status === "unknown") throw new Error("Processing job was lost — try again");
    }
    throw new Error("Processing timed out");
  }

  // Vectorise an uploaded raster artwork file to SVG (recraft) — returns { svg, base }
  // so the UI can extract the palette, recolour, then rasterise via svgToPng. Fast.
  async function vectorize(name, fileId, crop, engine){
    const base = _artBase(name).replace(/\/artwork$/, "/vectorize");
    const r = await fetchJSON(base, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ fileId, crop: crop || null, engine: engine || "recraft" }) });
    // The server also saves the vectorised SVG as a new artwork file — bust the cache
    // so a refresh reveals it (and it can be recoloured later without more credits).
    if (r && r.file) { DTF._artworkCounts = DTF._artworkCounts || {}; DTF._artworkCounts[name] = (DTF._artworkCounts[name] || 0) + 1; delete _artCache[name]; }
    return r;
  }
  // Rasterise an SVG to a print-ready PNG at a fixed physical width (cm) — height
  // stays proportional — stamped at 300 DPI and attached to the order's artwork.
  // `src` is a URL string, or { svg, filename } to rasterise inline SVG text (e.g. a
  // recoloured vector). Runs as a background job, so poll for completion. Returns the file.
  async function svgToPng(name, src, widthCm, onStep){
    const base = _artBase(name).replace(/\/artwork$/, "/svg-to-png");
    const body = typeof src === "string" ? { url: src, widthCm, dpi: 300 } : { ...src, widthCm, dpi: 300 };
    const start = await fetchJSON(base, {
      method: "POST", headers: { "Content-Type": "application/json" },
      body: JSON.stringify(body),
    });
    if (!start.jobId) throw new Error(start.error || "Couldn't start conversion");
    for (let i = 0; i < 150; i++) {
      await new Promise(r => setTimeout(r, 2000));
      let j; try { j = await fetchJSON(base + "/" + start.jobId); } catch { continue; }
      if (j.step && onStep) onStep(j.step);
      if (j.status === "done") {
        DTF._artworkCounts = DTF._artworkCounts || {};
        DTF._artworkCounts[name] = (DTF._artworkCounts[name] || 0) + 1;
        delete _artCache[name];
        return j.file;
      }
      if (j.status === "error") throw new Error(j.error || "Conversion failed");
      if (j.status === "unknown") throw new Error("Conversion job was lost — try again");
    }
    throw new Error("Conversion timed out");
  }
  // Rasterise a PDF into a 300-DPI print-ready PNG attached to the order. `src` is
  // either a URL string (Design Preview line item) or { fileId } for an uploaded PDF
  // artwork file. Same async-job pattern as svgToPng.
  async function pdfToPng(name, src, onStep){
    const base = _artBase(name).replace(/\/artwork$/, "/pdf-to-png");
    const body = typeof src === "string" ? { url: src, dpi: 300 } : { ...src, dpi: 300 };
    const start = await fetchJSON(base, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) });
    if (!start.jobId) throw new Error(start.error || "Couldn't start conversion");
    // A full-length gang-sheet PDF (e.g. 6 m at 300 DPI ≈ 0.5 gigapixel) takes several
    // minutes to render + upload, so poll for up to ~30 min to match the server timeout.
    for (let i = 0; i < 900; i++) {
      await new Promise(r => setTimeout(r, 2000));
      let j; try { j = await fetchJSON(base + "/" + start.jobId); } catch { continue; }
      if (j.step && onStep) onStep(j.step);
      if (j.status === "done") {
        DTF._artworkCounts = DTF._artworkCounts || {};
        DTF._artworkCounts[name] = (DTF._artworkCounts[name] || 0) + 1;
        delete _artCache[name];
        return j.file;
      }
      if (j.status === "error") throw new Error(j.error || "Conversion failed");
      if (j.status === "unknown") throw new Error("Conversion job was lost — try again");
    }
    throw new Error("Conversion timed out");
  }

  // --- Artwork owners (order or draft) + gang sheet --------------------------
  // Orders and draft-orders both hold artwork; an "owner" is { type, id } where
  // type is "order" (id = "#3657" / number) or "draft" (id = draft legacy id).
  // These generic helpers let the same UI (list / upload / delete / thumb / gang
  // sheet) work against either.
  function ownerBase(o){
    return o && o.type === "draft"
      ? "/api/draft-orders/" + encodeURIComponent(o.id) + "/artwork"
      : "/api/orders/" + encodeURIComponent(String(o.id || "").replace(/^#/, "")) + "/artwork";
  }
  async function artworkForOwner(o){ try { const r = await fetchJSON(ownerBase(o)); return r.files || []; } catch(e){ return []; } }
  function artworkUrlOwner(o, id, download){ return ownerBase(o) + "/" + encodeURIComponent(id) + (download ? "?download=1" : ""); }
  function artThumbUrlOwner(o, id, w, fmt){
    const q = o && o.type === "draft" ? "draft=" + encodeURIComponent(o.id) : "order=" + encodeURIComponent(String(o.id).replace(/^#/, ""));
    return "/api/thumb?w=" + (w || 160) + "&" + q + "&id=" + encodeURIComponent(id) + (fmt === "png" ? "&fmt=png" : "");
  }
  function _xhrPost(url, body, contentType, onProgress){
    return new Promise((resolve, reject) => {
      const xhr = new XMLHttpRequest();
      xhr.open("POST", url);
      xhr.setRequestHeader("Content-Type", contentType || "application/octet-stream");
      if (xhr.upload && onProgress) xhr.upload.onprogress = (e) => { if (e.lengthComputable) onProgress(e.loaded / e.total); };
      xhr.onload = () => { try { const j = JSON.parse(xhr.responseText || "{}"); if (xhr.status >= 200 && xhr.status < 300) resolve(j); else reject(new Error(j.error || ("HTTP " + xhr.status))); } catch(err){ reject(err); } };
      xhr.onerror = () => reject(new Error("Upload failed"));
      xhr.send(body);
    });
  }
  // Cloudflare's Free plan rejects any single request body over ~100MB before it
  // reaches the origin, so large print files are split into ≤80MB chunks and
  // reassembled server-side (POST …/artwork/chunk). Smaller files post in one shot.
  async function uploadArtworkOwner(o, file, onProgress){
    const base = ownerBase(o);
    const ct = file.type || "application/octet-stream";
    const CHUNK_THRESHOLD = 90 * 1024 * 1024;
    const CHUNK_SIZE = 80 * 1024 * 1024;
    if (file.size <= CHUNK_THRESHOLD) {
      return _xhrPost(base + "?filename=" + encodeURIComponent(file.name || "artwork"), file, ct, onProgress);
    }
    const uploadId = Date.now().toString(36) + Math.random().toString(36).slice(2, 10);
    const total = Math.ceil(file.size / CHUNK_SIZE);
    let last = null;
    for (let i = 0; i < total; i++) {
      const blob = file.slice(i * CHUNK_SIZE, Math.min((i + 1) * CHUNK_SIZE, file.size));
      const qs = "?uploadId=" + encodeURIComponent(uploadId) + "&index=" + i + "&total=" + total + "&filename=" + encodeURIComponent(file.name || "artwork");
      last = await _xhrPost(base + "/chunk" + qs, blob, ct, (frac) => { if (onProgress) onProgress((i + frac) / total); });
    }
    return (last && last.file) ? last.file : last;
  }
  async function deleteArtworkOwner(o, id){ const r = await fetchJSON(ownerBase(o) + "/" + encodeURIComponent(id), { method:"DELETE" }); return r.files || []; }

  // --- Per-customer artwork library (staff) ----------------------------------
  // cid = a Shopify customer gid OR legacy numeric id (the server accepts either).
  function libBase(cid){ return "/api/customers/" + encodeURIComponent(String(cid)) + "/library"; }
  async function customerLibrary(cid){ return fetchJSON(libBase(cid)); }                     // { assets, folders }
  async function libUpload(cid, file, folderId, onProgress){
    const qs = "?filename=" + encodeURIComponent(file.name || "artwork") + (folderId ? "&folderId=" + encodeURIComponent(folderId) : "");
    return _xhrPost(libBase(cid) + "/upload" + qs, file, file.type || "application/octet-stream", onProgress); // returns the new asset
  }
  async function libUpdateAsset(cid, id, patch){ return fetchJSON(libBase(cid) + "/assets/" + encodeURIComponent(id), { method:"PUT", headers:{ "Content-Type":"application/json" }, body: JSON.stringify(patch) }); }
  async function libDeleteAsset(cid, id){ return fetchJSON(libBase(cid) + "/assets/" + encodeURIComponent(id), { method:"DELETE" }); }
  async function libCreateFolder(cid, name){ return fetchJSON(libBase(cid) + "/folders", { method:"POST", headers:{ "Content-Type":"application/json" }, body: JSON.stringify({ name }) }); }
  async function libRenameFolder(cid, id, name){ return fetchJSON(libBase(cid) + "/folders/" + encodeURIComponent(id), { method:"PUT", headers:{ "Content-Type":"application/json" }, body: JSON.stringify({ name }) }); }
  async function libDeleteFolder(cid, id){ return fetchJSON(libBase(cid) + "/folders/" + encodeURIComponent(id), { method:"DELETE" }); }
  async function libEmbroider(cid, id){ return fetchJSON(libBase(cid) + "/assets/" + encodeURIComponent(id) + "/embroidery", { method:"POST" }); } // returns new asset
  // Copy an order/draft artwork INTO the customer's library.
  async function libFromArtwork(cid, owner, artworkId, folderId){
    return fetchJSON(libBase(cid) + "/from-artwork", { method:"POST", headers:{ "Content-Type":"application/json" },
      body: JSON.stringify({ ownerType: owner && owner.type === "draft" ? "draft" : "order", ownerId: String(owner.id).replace(/^#/, ""), artworkId, folderId: folderId || null }) });
  }
  // Add a library asset ONTO an order/draft as production artwork → updated files list.
  async function libAddToOrder(owner, cid, assetId){
    const isDraft = owner && owner.type === "draft";
    const base = isDraft
      ? "/api/draft-orders/" + encodeURIComponent(owner.id) + "/artwork/from-library"
      : "/api/orders/" + encodeURIComponent(String(owner.id).replace(/^#/, "")) + "/artwork/from-library";
    const r = await fetchJSON(base, { method:"POST", headers:{ "Content-Type":"application/json" }, body: JSON.stringify({ customerId: cid, assetId }) });
    const files = r.files || [];
    // Keep the ORDERS artwork cache fresh so the order's list updates instantly (the
    // cached artworkFor would otherwise return the stale pre-add list). Drafts use an
    // un-cached loader, so no bust needed there.
    if (!isDraft && owner) _artCache[owner.id] = files;
    return files;
  }

  // --- Global font library ---------------------------------------------------
  async function listFonts(){ try { const r = await fetchJSON("/api/fonts"); return r.fonts || []; } catch { return []; } }
  async function uploadFont(file, name){
    const url = "/api/fonts?name=" + encodeURIComponent(name || file.name || "Font") + "&filename=" + encodeURIComponent(file.name || "font.ttf");
    return _xhrPost(url, file, file.type || "font/ttf");   // returns the new font record
  }
  async function deleteFont(id){ const r = await fetchJSON("/api/fonts/" + encodeURIComponent(id), { method:"DELETE" }); return r.fonts || []; }
  function fontFileUrl(id){ return "/api/fonts/" + encodeURIComponent(id) + "/file"; }

  // Render a line of text (font+colour) to a print-ready PNG at an exact cm size and
  // attach it to the owner's artwork. opts: { text, fontId, color, widthCm, heightCm, mode }.
  async function createTextArtwork(owner, opts){
    const base = ownerBase(owner).replace(/\/artwork$/, "/text-artwork");
    const r = await fetchJSON(base, { method:"POST", headers:{ "Content-Type":"application/json" }, body: JSON.stringify(opts) });
    return r.file;
  }

  // Pack a set of artworks onto the roll (server does the packing) — returns the
  // layout used to draw the live preview. items: [{ id, dim:'w'|'h', mm, qty }].
  async function gangSheetPlan(owner, items, opts){
    return fetchJSON("/api/gang-sheet/plan", {
      method: "POST", headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ owner, items, marginHmm: opts.marginHmm, marginVmm: opts.marginVmm, rotate: opts.rotate, layout: opts.layout }),
    });
  }
  // Render the gang sheet to a PNG and attach it to the owner's artwork list.
  // Runs as a background job (a big sheet takes tens of seconds); polls to done.
  async function gangSheetCreate(owner, items, opts, onStep){
    const start = await fetchJSON("/api/gang-sheet/create", {
      method: "POST", headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ owner, items, marginHmm: opts.marginHmm, marginVmm: opts.marginVmm, rotate: opts.rotate, layout: opts.layout, burnCutLines: opts.burnCutLines, filename: opts.filename, customerId: opts.customerId || null }),
    });
    if (!start.jobId) throw new Error(start.error || "Couldn't start gang sheet");
    // Persistent queue: the job survives restarts and may sit "queued" behind other
    // renders before it goes "running" then "done".
    for (let i = 0; i < 600; i++) {
      await new Promise(r => setTimeout(r, 2000));
      let j; try { j = await fetchJSON("/api/gang-sheet/create/" + start.jobId); } catch { continue; }
      if (onStep && j.status === "queued") onStep("queued…");
      if (onStep && j.status === "running") onStep("rendering…");
      if (j.status === "done") {
        if (owner && owner.type === "order") {
          delete _artCache[owner.id];
          DTF._artworkCounts = DTF._artworkCounts || {};
          DTF._artworkCounts[owner.id] = (DTF._artworkCounts[owner.id] || 0) + 1;
        }
        // Enrich the artwork file with the gang-sheet hash / preview URL / design id.
        return { ...(j.file || {}), hash: j.hash || null, previewUrl: j.previewUrl || null, designId: j.designId || null };
      }
      if (j.status === "error") throw new Error(j.error || "Gang sheet failed");
      if (j.status === "unknown") throw new Error("Gang sheet job was lost — try again");
    }
    throw new Error("Gang sheet timed out");
  }
  // Recent gang-sheet jobs (queue / history) for an owner or store-wide.
  async function gangSheetQueue(owner){ const q = owner ? ("?owner=" + encodeURIComponent(owner.id) + "&type=" + (owner.type||"order")) : ""; const r = await fetchJSON("/api/gang-sheet/queue" + q); return r.jobs || []; }
  async function gangSheetRetry(id){ return fetchJSON("/api/gang-sheet/queue/" + encodeURIComponent(id) + "/retry", { method:"POST" }); }
  async function gangComposition(owner, fileId){ return fetchJSON("/api/gang-sheet/composition?owner=" + encodeURIComponent(owner.id) + "&type=" + (owner.type||"order") + "&fileId=" + encodeURIComponent(fileId)); }
  async function gangSheetDismiss(id){ const r = await fetchJSON("/api/gang-sheet/queue/" + encodeURIComponent(id), { method:"DELETE" }); return !!r.ok; }
  // Top-bar notification centre.
  async function notifications(){ return fetchJSON("/api/notifications"); }
  async function notificationsRead(id){ return fetchJSON("/api/notifications/read", { method:"POST", headers:_json, body: JSON.stringify({ id: id || "all" }) }); }
  async function notificationsClear(){ const r = await fetchJSON("/api/notifications", { method:"DELETE" }); return !!r.ok; }

  // Backend (Shopify) order search — covers ALL orders, not just the loaded page.
  // Returns mapped orders + a cursor for paging more results. Does not mutate DTF.orders.
  async function searchOrders(query, after){
    const url = "/api/orders?first=50&query=" + encodeURIComponent(query) + (after ? "&after=" + encodeURIComponent(after) : "");
    const res = await fetchJSON(url);
    const custIndex = new Map(DTF.customers.map(c => [c.id, c]));
    (DTF._orderCustomers || []).forEach(sc => custIndex.set(sc.id, sc));
    const mapped = (res.orders || []).map(o => {
      if (o.customer && !custIndex.has(o.customer.id)) {
        const sc = supplementalCustomer(o.customer, o.shippingAddress);
        custIndex.set(sc.id, sc); DTF._orderCustomers.push(sc);
      }
      return mapOrder(o, custIndex);
    });
    return { orders: mapped, cursor: res.pageInfo?.endCursor || null, hasMore: !!res.pageInfo?.hasNextPage };
  }

  // Fetch the next page of orders (cursor-based) and append, de-duped.
  async function loadMoreOrders(){
    if (!DTF._ordersHasMore || !DTF._ordersCursor) return { done: true, added: 0 };
    const res = await fetchJSON("/api/orders?first=50&after=" + encodeURIComponent(DTF._ordersCursor));
    const custIndex = new Map(DTF.customers.map(c => [c.id, c]));
    (DTF._orderCustomers || []).forEach(sc => custIndex.set(sc.id, sc));
    const mapped = (res.orders || []).map(o => {
      if (o.customer && !custIndex.has(o.customer.id)) {
        const sc = supplementalCustomer(o.customer, o.shippingAddress);
        custIndex.set(sc.id, sc); DTF._orderCustomers.push(sc);
      }
      return mapOrder(o, custIndex);
    });
    const seen = new Set(DTF.orders.map(o => o.id));
    const fresh = mapped.filter(o => !seen.has(o.id));
    DTF.orders.push(...fresh);
    DTF._ordersCursor = res.pageInfo?.endCursor || null;
    DTF._ordersHasMore = !!res.pageInfo?.hasNextPage;
    return { done: !DTF._ordersHasMore, added: fresh.length };
  }

  async function markCollected(orderId){
    const res = await fetchJSON(_orderPath(orderId) + "/collected", {
      method:"POST", headers:{ "Content-Type":"application/json" }, body: JSON.stringify({ notify:true }),
    });
    const o = DTF.getOrder(orderId);
    if (o) { o.status = "collected"; o.delivery = "collected"; o.fulfil = "fulfilled"; o.packed = true; o.dispatched = true; }
    return res;
  }

  // ---- order list nav context (prev/next traverse the list you came from) ----
  // Captured when you open an order from a list (board / all / search / filtered),
  // persisted so it survives a reload of the order page and back/forward.
  function setOrderNav(ids, meta){
    DTF._orderNav = { ids: (ids || []).slice(), meta: meta || {} };
    try { sessionStorage.setItem("dtf_ordernav", JSON.stringify(DTF._orderNav)); } catch(e){}
  }
  function getOrderNav(){
    if (DTF._orderNav) return DTF._orderNav;
    try { DTF._orderNav = JSON.parse(sessionStorage.getItem("dtf_ordernav") || "null"); } catch(e){ DTF._orderNav = null; }
    return DTF._orderNav;
  }
  // Prev/next stepper for the topbar, LIVE-filtered to the source list so orders
  // that have left it (e.g. moved to a production stage off the dispatch board)
  // stop being counted. Returns { pos, len, prevId, nextId, src } or null.
  const NAV_TERMINAL = ["closed","delivered","dispatched","collected","refunded","cancelled"];
  function navStepper(currentId){
    const nav = getOrderNav();
    const ids = (nav && nav.ids) || [];
    if (ids.length < 2) return null;
    const route = nav && nav.meta && nav.meta.route;
    const board = (route && route.view === "orders") ? route.board : null;
    const nonDispatch = NAV_TERMINAL.concat(DTF.PRODUCTION_STAGES || [], DTF.HOLD_STAGES || []);
    const stillIn = (id) => {
      const o = DTF.getOrder(id);
      if (!o) return true;                                  // not loaded → can't judge, keep
      if (board === "board") return !nonDispatch.includes(o.status) && o.status !== "new"; // dispatch board
      if (board === "new")   return o.status === "new";     // new-orders board
      return true;                                          // all/customer/inbox/… → no status filter
    };
    const valid = ids.filter(stillIn);
    if (valid.length < 1) return null;
    const src = nav.meta && nav.meta.source;
    const idx = valid.indexOf(currentId);
    if (idx >= 0) {
      return { pos: idx + 1, len: valid.length, prevId: idx > 0 ? valid[idx-1] : null, nextId: idx < valid.length-1 ? valid[idx+1] : null, src };
    }
    // current order has left the source list — map to the nearest still-valid neighbours
    const orig = ids.indexOf(currentId);
    if (orig < 0) return null;
    const before = ids.slice(0, orig).filter(stillIn);
    const after = ids.slice(orig + 1).filter(stillIn);
    return {
      pos: Math.min(valid.length, Math.max(1, before.length)),
      len: valid.length,
      prevId: before.length ? before[before.length-1] : null,
      nextId: after.length ? after[0] : null,
      src, movedOut: true,
    };
  }

  // ---- image metadata (actual px dimensions + DPI of an uploaded design) ----
  const _imgMetaCache = new Map();
  async function fetchImageMeta(url){
    if (_imgMetaCache.has(url)) return _imgMetaCache.get(url);
    const p = fetchJSON("/api/image-meta?src=" + encodeURIComponent(url)).catch(() => null);
    _imgMetaCache.set(url, p);
    return p;
  }

  // ---- customer store credit ----
  async function fetchCustomerCredit(custId){
    const legacy = String(custId).split("/").pop();
    return fetchJSON("/api/customers/" + encodeURIComponent(legacy) + "/credit");
  }

  // ---- per-item fulfillment ----
  async function fetchOrderFulfillment(orderId){
    return fetchJSON(_orderPath(orderId) + "/fulfillment");
  }
  async function fulfillItems(orderId, items, notify){
    const res = await fetchJSON(_orderPath(orderId) + "/fulfill", {
      method:"POST", headers:{ "Content-Type":"application/json" }, body: JSON.stringify({ items, notify: !!notify }),
    });
    return res;
  }
  // Combined dispatch: fulfil every remaining item on a child order with the
  // parent's tracking (child ships inside the parent's parcel).
  async function fulfillOrderAll(orderId, { tracking, notify } = {}){
    return fetchJSON(_orderPath(orderId) + "/fulfill-all", {
      method:"POST", headers:{ "Content-Type":"application/json" }, body: JSON.stringify({ tracking: tracking || null, notify: !!notify }),
    });
  }

  // ---- Royal Mail: per-customer defaults + void-with-revert ----
  async function getCustomerShipping(customerId){
    return fetchJSON("/api/settings/customer-shipping/" + encodeURIComponent(String(customerId)));
  }
  async function getCustomerShippingMany(ids){
    const list = (ids || []).filter(Boolean);
    if (!list.length) return {};
    const r = await fetchJSON("/api/settings/customer-shipping?ids=" + encodeURIComponent(list.join(",")));
    return (r && r.defaults) || {};
  }
  async function saveCustomerShipping(customerId, { serviceId, format } = {}){
    return fetchJSON("/api/settings/customer-shipping/" + encodeURIComponent(String(customerId)), {
      method:"PUT", headers:{ "Content-Type":"application/json" }, body: JSON.stringify({ serviceId: serviceId || null, format: format || null }),
    });
  }
  // Void a label AND revert the dispatch (un-fulfil in Shopify, clear tracking).
  async function voidShipment(orderIdentifier, orderReference){
    return fetchJSON("/api/shipments/" + encodeURIComponent(orderIdentifier) + "/void", {
      method:"POST", headers:{ "Content-Type":"application/json" }, body: JSON.stringify({ orderReference: orderReference || "" }),
    });
  }

  let _loadPromise = null;
  function load(){
    if (DTF.loaded) return Promise.resolve();
    if (_loadPromise) return _loadPromise;
    _loadPromise = (async () => {
      // Shopify can be down/expired (401) — don't let that block Settings/Inbox.
      // Capture the error and carry on with empty data instead of failing the load.
      const [ordersRes, custRes, health, aliasRes, closedRes, wfRes, assignRes, artRes, plogRes, openRes, threadOrderRes] = await Promise.all([
        fetchJSON("/api/orders?first=50").catch(e => ({ _err: e.message, orders: [] })),
        fetchJSON("/api/customers").catch(e => ({ _err: e.message, customers: [] })),
        fetchJSON("/api/health").catch(() => ({})),
        fetchJSON("/api/aliases").catch(() => ({ aliases: {} })),
        fetchJSON("/api/threads/closed").catch(() => ({ ids: [] })),
        fetchJSON("/api/workflow").catch(() => ({ printers: [], orders: {} })),
        fetchJSON("/api/threads/assignments").catch(() => ({ assignments: {} })),
        fetchJSON("/api/artwork/counts").catch(() => ({ counts: {} })),
        fetchJSON("/api/printing/log?limit=500").catch(() => ({ log: [] })),
        // ALL open/unfulfilled orders (any age) — so a still-to-process order older
        // than the latest-50 window (and held orders) load instantly instead of only
        // after "load more". Covers held orders too (they're unfulfilled).
        fetchJSON("/api/orders/open").catch(() => ({ orders: [] })),
        // thread id -> order name, for threads MANUALLY linked to an order (even when
        // the order number is nowhere in the subject/body).
        fetchJSON("/api/threads/orders").catch(() => ({ orders: {} })),
      ]);
      // Merge open/to-process orders the latest-50 page missed (dedup by order name).
      {
        const have = new Set((ordersRes.orders || []).map(o => o.name));
        const extra = (openRes.orders || []).filter(o => o && !have.has(o.name));
        if (extra.length) ordersRes.orders = (ordersRes.orders || []).concat(extra);
      }
      // Latest completed print per order (log is newest-first) → shown on the order + production list.
      const printResults = {};
      for (const r of (plogRes.log || [])) { if (r.event === "completed" && !printResults[r.order]) printResults[r.order] = r; }
      DTF._printResults = printResults;
      DTF.loadError = ordersRes._err || custRes._err || null;
      DTF._aliases = aliasRes.aliases || {};
      DTF._closedThreads = new Set(closedRes.ids || []);
      DTF._workflow = { printers: wfRes.printers || [], orders: wfRes.orders || {}, groups: wfRes.groups || {} };
      DTF._threadAssign = assignRes.assignments || {};
      DTF._threadOrders = (threadOrderRes && threadOrderRes.orders) || {};
      DTF._artworkCounts = (artRes && artRes.counts) || {};
      DTF._printMetres = (artRes && artRes.printMetres) || {};
      DTF.shop = { adminUrl: health.adminUrl || null, storeUrl: health.storeUrl || "" };
      DTF.mode = ordersRes.mode || custRes.mode || "seed";
      const liveCustomers = (custRes.customers || []).map(mapCustomer);
      const custIndex = new Map(liveCustomers.map(c => [c.id, c]));
      // customers referenced by orders but outside the first customers page
      const orderCustomers = [];
      (ordersRes.orders || []).forEach(o => {
        if (o.customer && !custIndex.has(o.customer.id)) {
          const sc = supplementalCustomer(o.customer, o.shippingAddress);
          custIndex.set(sc.id, sc); orderCustomers.push(sc);
        }
      });
      DTF.customers = liveCustomers;
      DTF._orderCustomers = orderCustomers;
      DTF.orders = (ordersRes.orders || []).map(o => mapOrder(o, custIndex));
      DTF._ordersCursor = ordersRes.pageInfo?.endCursor || null;
      DTF._ordersHasMore = !!ordersRes.pageInfo?.hasNextPage;
      DTF.loaded = true;
    })().catch(err => { _loadPromise = null; throw err; });
    return _loadPromise;
  }

  // Try to spot an order reference (e.g. "#3635") in an email's subject/body.
  function detectOrderId(text){
    const m = /#\d{3,6}/.exec(text || "");
    return (m && DTF.getOrder(m[0])) ? m[0] : null;
  }

  // Fetch real emails from /api/inbox (Gmail IMAP) and map them onto the thread
  // shape the Inbox view expects. Senders are matched to CRM customers by email;
  // unknown senders get a lightweight pseudo-customer so the UI still resolves.
  const normSubject = s => (s || "").replace(/^((re|fwd|fw)\s*:\s*)+/i, "").trim();

  // Group raw messages into Gmail-style conversations by Message-ID/References
  // chains (subject alone over-merges distinct conversations that share a title).
  // When customerEmail is given, messages from that address are "customer" and
  // everything else (our replies) is "us"; otherwise every message is inbound.
  function buildThreadsFromRaw(rawMsgs, customerEmail){
    const cem = (customerEmail || "").toLowerCase();
    const pool = [...DTF.customers, ...(DTF._orderCustomers || [])];
    const list = (rawMsgs || []).slice();

    // union-find over message ids + their references
    const parent = {};
    const ensure = x => { if (parent[x] === undefined) parent[x] = x; };
    const find = x => { let r = x; while (parent[r] && parent[r] !== r) r = parent[r]; while (parent[x] && parent[x] !== r) { const n = parent[x]; parent[x] = r; x = n; } return r; };
    const union = (a, b) => { ensure(a); ensure(b); const ra = find(a), rb = find(b); if (ra !== rb) parent[ra] = rb; };
    list.forEach((m, i) => {
      m._self = m.messageId || ("uid:" + (m.uid != null ? m.uid : i));
      ensure(m._self);
      (m.refs || []).forEach(r => { if (r) union(m._self, r); });
    });

    const groups = new Map();
    list.forEach(m => {
      const hasRef = m.messageId || (m.refs && m.refs.length);
      const key = hasRef
        ? "ref:" + find(m._self)
        : "sub:" + (normSubject(m.subject).toLowerCase() || "(no subject)") + "|" + ((m.from && m.from.addr || "").toLowerCase());
      if (!groups.has(key)) groups.set(key, []);
      groups.get(key).push(m);
    });

    const out = [];
    for (const [key, msgs] of groups) {
      msgs.sort((a,b) => new Date(a.date || 0) - new Date(b.date || 0)); // oldest first
      const latest = msgs[msgs.length - 1];
      let custAddr = cem;
      if (!custAddr) {
        const inbound = msgs.find(m => m.from && m.from.addr);
        custAddr = (inbound && inbound.from.addr || "").toLowerCase();
      }
      let match = custAddr && pool.find(c => (c.email || "").toLowerCase() === custAddr);
      // Manual alias: link an email to a real Shopify customer (e.g. after a merge).
      if (!match && custAddr && DTF._aliases && DTF._aliases[custAddr]) {
        match = pool.find(c => c.id === DTF._aliases[custAddr]);
      }
      let customerId, custName;
      if (match) { customerId = match.id; custName = match.name; }
      else {
        customerId = "mail:" + (custAddr || key);
        const inb = msgs.find(m => ((m.from && m.from.addr || "").toLowerCase()) === custAddr) || msgs[0];
        custName = (inb && inb.from && inb.from.name) || custAddr || "Unknown sender";
        if (!byId(DTF._mailCustomers, customerId)) {
          DTF._mailCustomers.push({
            id: customerId, name: custName, email: custAddr || "", subscribed:false,
            location:"—", country:"", orders:0, spent:0, channel:"Email", since:"", phone:"", tags:[], note:"",
          });
        }
      }
      // Manual thread→customer assignment (e.g. system / no-reply emails) wins.
      const _tidA = "thr:" + (custAddr || "x") + "|" + key;
      if (DTF._threadAssign && DTF._threadAssign[_tidA]) {
        const ac = (pool.find(c => c.id === DTF._threadAssign[_tidA])) || byId(DTF.customers, DTF._threadAssign[_tidA]);
        if (ac) { customerId = ac.id; custName = ac.name; }
      }
      const messages = msgs.map(m => {
        const fa = (m.from && m.from.addr || "").toLowerCase();
        const isCustomer = custAddr ? (fa === custAddr) : true;
        return {
          from: isCustomer ? "customer" : "us",
          name: (m.from && m.from.name) || (m.from && m.from.addr) || (isCustomer ? custName : "DTF.uk"),
          addr: (m.from && m.from.addr) || "",
          time: m.date ? fmtDateTime(m.date) : "",
          body: m.body || "", html: m.html || null, emailAtts: m.attachments || [], atts: [],
          messageId: m.messageId || null,
        };
      });
      // Stable id (participant + conversation key) → survives reloads (deep links)
      // and dedupes inbox-load vs. mailbox-search of the same conversation.
      const _tid = "thr:" + (custAddr || "x") + "|" + key;
      // A manual thread→order link wins over auto-detection, so a thread linked from
      // the inbox shows under its order even without the order number in the text.
      const _linkedOrder = (DTF._threadOrders && DTF._threadOrders[_tid]) || null;
      out.push({
        id: _tid,
        customerId,
        orderId: _linkedOrder || detectOrderId(msgs.map(m => (m.subject || "") + " " + (m.body || "")).join(" ")),
        subject: normSubject(latest.subject) || latest.subject || "(no subject)",
        channel: "gmail", labels: [], status: (DTF._closedThreads && DTF._closedThreads.has(_tid)) ? "closed" : "open",
        unread: msgs.some(m => m.unread),
        assignedTo: "Unassigned",
        updated: latest.date ? fmtDateTime(latest.date) : "",
        messages,
      });
    }
    out.sort((a,b) => a.updated < b.updated ? 1 : -1);
    return out;
  }

  async function loadInbox(){
    const res = await fetchJSON("/api/inbox");
    if (!res.configured) return { configured:false, threads:[] };
    DTF._mailCustomers = [];
    return { configured:true, threads: buildThreadsFromRaw(res.threads || [], null) };
  }

  // Resolve a deep-linked thread (id "mail:<uid>") that isn't loaded yet: look up
  // the message's sender, then search their conversation to rebuild the thread.
  async function resolveThread(id){
    if (!id) return false;
    let email = null;
    const thr = /^thr:([^|]+)\|/.exec(id);   // stable id carries the participant email
    if (thr) {
      email = thr[1];
    } else {
      const uid = /^mail:(\d+)$/.exec(id);   // legacy uid-based id → look up its sender
      if (uid) {
        try {
          const res = await fetchJSON("/api/inbox/message?uid=" + encodeURIComponent(uid[1]));
          if (res.configured && res.from) email = res.from;
        } catch {}
      }
    }
    if (!email) return false;
    await searchMail(email); // merges the rebuilt thread into DTF.liveThreads
    return DTF.liveThreads.some(t => t.id === id);
  }

  // Search the whole mailbox (Gmail All Mail) for a customer's full conversation.
  async function searchMail(email){
    if (!email) return [];
    let res;
    try { res = await fetchJSON("/api/inbox/search?email=" + encodeURIComponent(email)); }
    catch { return []; }
    if (!res.configured) return [];
    const mapped = buildThreadsFromRaw(res.threads || [], email);
    DTF.mergeLiveThreads(mapped); // make them openable from the Inbox too
    return mapped;
  }

  // Fetch a single order by its number (e.g. "#3409") when it isn't in the loaded
  // set; cache it into DTF.orders so links and lookups work afterwards.
  async function fetchOrderByName(name){
    let res;
    try { res = await fetchJSON("/api/order?name=" + encodeURIComponent(name)); }
    catch { return null; }
    if (!res.order) return null;
    // Enrich with the known customer so VIP/repeat flags survive the single-order map.
    const idx = new Map();
    const cid = res.order.customer && res.order.customer.id;
    const known = cid && DTF.getCustomer(cid); if (known) idx.set(cid, known);
    const o = mapOrder(res.order, idx);
    if (o) {
      // REPLACE any stale cached copy so an order changed elsewhere (e.g. dispatched
      // in Click & Drop / Shopify) reflects its current status — not just push when new.
      const i = DTF.orders.findIndex(x => x.id === o.id);
      if (i >= 0) DTF.orders[i] = o; else DTF.orders.push(o);
    }
    return o;
  }

  // Fetch a customer's full order history from Shopify (not just the loaded page).
  async function fetchCustomerOrders(custId){
    const legacy = String(custId || "").split("/").pop();
    if (!legacy) return [];
    let res;
    try { res = await fetchJSON("/api/customers/" + encodeURIComponent(legacy) + "/orders"); }
    catch { return []; }
    const idx = new Map();
    const c = DTF.getCustomer(custId); if (c) idx.set(custId, c);
    return (res.orders || []).map(o => mapOrder(o, idx)).sort((a,b) => a.date < b.date ? 1 : -1);
  }

  // --- Draft orders (server already returns the client order shape) ---
  async function fetchDraftOrders(status){
    const q = status && status !== "all" ? "?status=" + encodeURIComponent(status) : "";
    try { const r = await fetchJSON("/api/draft-orders" + q); return { drafts: r.drafts || [], scopeMissing: !!r.scopeMissing }; }
    catch (e) { return { drafts: [], error: e.message || "Failed to load draft orders" }; }
  }
  async function fetchDraftOrder(id){
    try { return await fetchJSON("/api/draft-orders/" + encodeURIComponent(id)) || null; }
    catch { return null; }
  }
  function saveDraftOrder(id, patch){
    return fetchJSON("/api/draft-orders/" + encodeURIComponent(id), { method:"PUT", headers:{ "Content-Type":"application/json" }, body: JSON.stringify(patch || {}) });
  }
  function completeDraftOrder(id, paymentPending){
    return fetchJSON("/api/draft-orders/" + encodeURIComponent(id) + "/complete", { method:"POST", headers:{ "Content-Type":"application/json" }, body: JSON.stringify({ paymentPending: !!paymentPending }) });
  }
  async function searchProductVariants(q){
    try { const r = await fetchJSON("/api/product-search?q=" + encodeURIComponent(q || "")); return (r && r.variants) || []; }
    catch { return []; }
  }
  async function createShopifyCustomer(fields){
    const c = await fetchJSON("/api/customers", { method:"POST", headers:{ "Content-Type":"application/json" }, body: JSON.stringify(fields || {}) });
    if (c && c.id && !byId(DTF.customers, c.id) && !byId(DTF._orderCustomers || [], c.id)) (DTF._orderCustomers = DTF._orderCustomers || []).push(c);
    return c;
  }
  async function fetchCustomerAddress(id){
    try { const r = await fetchJSON("/api/customers/" + encodeURIComponent(id) + "/address"); return (r && r.address) || null; }
    catch { return null; }
  }

  window.DTF = {
    TODAY: todayYMD(),
    mode: "seed", loaded: false, load, loadInbox, searchMail, resolveThread, fetchCustomerOrders, fetchOrderByName,
    fetchDraftOrders, fetchDraftOrder, saveDraftOrder, completeDraftOrder, searchProductVariants, createShopifyCustomer, fetchCustomerAddress,
    markReadyForCollection, markCollected, fetchOrderFulfillment, fulfillItems, fulfillOrderAll, loadMoreOrders, searchOrders,
    getCustomerShipping, getCustomerShippingMany, saveCustomerShipping, voidShipment,
    setOrderNav, getOrderNav, navStepper, fetchCustomerCredit, fetchImageMeta,
    searchShopifyCustomers, linkCustomerAlias, _aliases: {},
    stockList, stockCreate, stockUpdate, stockAdjust, stockDelete, stockShopify, stockLevels, stockSkuSearch,
    ralawiseStatus, ralawiseImport, ralawiseSync, ralawiseBasket, stockForecast,
    setThreadClosed, _closedThreads: new Set(),
    assignThread, _threadAssign: {},
    setOrderStage, setOrdersStage, cancelFulfilment, holdOrder, setGarmentReceived, addPrinter, updatePrinter, removePrinter,
    createGroup, updateGroup, dissolveGroup, groupOf, groupMembers, groupSuggestions, combineKey, creditShipping, groupCreditTotal,
    artworkFor, artworkCached, artworkUrl, artworkPreviewUrl, previewUrl, thumbUrl, artThumbUrl, uploadArtwork, deleteArtwork, renameArtwork, setPrintFile, processArtwork, attachPng, svgToPng, pdfToPng, vectorize, artworkCount, _artworkCounts: {},
    artworkForOwner, artworkUrlOwner, artThumbUrlOwner, uploadArtworkOwner, deleteArtworkOwner, gangSheetPlan, gangSheetCreate, gangSheetQueue, gangSheetRetry, gangComposition, gangSheetDismiss, notifications, notificationsRead, notificationsClear,
    customerLibrary, libUpload, libUpdateAsset, libDeleteAsset, libCreateFolder, libRenameFolder, libDeleteFolder, libEmbroider, libFromArtwork, libAddToOrder,
    listFonts, uploadFont, deleteFont, fontFileUrl, createTextArtwork,
    _workflow: { printers: [], orders: {}, groups: {} },
    PRODUCTION_STAGES: ["awaiting_artwork","design_queue","rip_queue","print_queue","printing","printed","press_queue"],
    // On-hold stages: the order is PARKED (postponed off the dispatch board and not
    // counted as in-production) until an operator moves it to another status. Kept
    // separate from PRODUCTION_STAGES so held orders don't inflate production counts.
    HOLD_STAGES: ["awaiting_garments", "held"],
    STAGE_ROUTES: [
      { stage: "ready_dispatch",   label: "Ready to dispatch" },
      { stage: "rip_queue",        label: "RIP queue" },
      { stage: "print_queue",      label: "Print queue" },
      { stage: "press_queue",      label: "Press queue" },
      { stage: "design_queue",     label: "Design services" },
      { stage: "awaiting_artwork", label: "Awaiting artwork" },
      { stage: "awaiting_garments",label: "Awaiting garments" },
    ],
    shop: {},
    // Force a full re-fetch of orders, customers and the inbox.
    reload: async () => {
      DTF.loaded = false; _loadPromise = null;
      await load();
      DTF.liveThreads = []; DTF._mailCustomers = [];
      try { const r = await loadInbox(); if (r && r.configured) DTF.setLiveThreads(r.threads); } catch (e) {}
    },
    // Lighter reload of just the order/customer/workflow data (no inbox) — used
    // to refresh a section's content when it's opened.
    reloadData: async () => { DTF.loaded = false; _loadPromise = null; await load(); },
    // Live-sync: poll a cheap revision endpoint; when server data changes (e.g. the
    // printer cron moves a job), refresh data and notify views — throttled so it
    // reloads at most once per window even if updates arrive in bursts.
    live: {
      timer: null, lastRev: null, lastReload: 0, busy: false,
      POLL: 12000, THROTTLE: 8000,
      async tick() {
        const L = DTF.live;
        if (document.hidden || L.busy) return;       // skip when tab hidden / mid-reload
        let j; try { j = await fetchJSON("/api/sync"); } catch { return; }
        const rev = j && j.rev;
        if (rev == null) return;
        if (L.lastRev == null) { L.lastRev = rev; return; }   // first sample = baseline
        if (rev === L.lastRev) return;                        // nothing changed
        if (Date.now() - L.lastReload < L.THROTTLE) return;   // throttle; retry next tick (lastRev unchanged)
        L.busy = true; L.lastReload = Date.now();
        try { await DTF.reloadData(); L.lastRev = rev; window.dispatchEvent(new CustomEvent("dtf:refreshed")); }
        catch (e) {} finally { L.busy = false; }
      },
      start() { const L = DTF.live; if (L.timer) return; L.timer = setInterval(() => L.tick(), L.POLL); },
      stop() { const L = DTF.live; if (L.timer) { clearInterval(L.timer); L.timer = null; } },
    },
    // Frontend version watch — polls the server's asset fingerprint and flags when
    // a newer build has been deployed, so the app can prompt a reload. The version
    // sampled at load is the baseline; any change means new code is live.
    _updateReady: false,
    versionWatch: {
      timer: null, POLL: 30000, base: null,
      async tick(){
        const V = DTF.versionWatch;
        if (document.hidden || DTF._updateReady) return;
        let j; try { j = await fetchJSON("/api/version"); } catch { return; }
        const v = j && j.version; if (!v) return;
        if (V.base == null) { V.base = v; return; }       // baseline = version at first sample
        if (v !== V.base) {
          DTF._updateReady = true;
          try { window.dispatchEvent(new CustomEvent("dtf:update-available", { detail:{ version:v } })); } catch(e){}
          V.stop();                                        // no need to keep polling once flagged
        }
      },
      start(){
        const V = DTF.versionWatch; if (V.timer) return;
        V.tick(); V.timer = setInterval(() => V.tick(), V.POLL);
        // Re-check the instant the tab regains focus (polling pauses while hidden),
        // so someone returning to a long-open tab is told promptly.
        if (!V._vis) { V._vis = () => { if (!document.hidden) V.tick(); }; document.addEventListener("visibilitychange", V._vis); }
      },
      stop(){
        const V = DTF.versionWatch;
        if (V.timer) { clearInterval(V.timer); V.timer = null; }
        if (V._vis) { document.removeEventListener("visibilitychange", V._vis); V._vis = null; }
      },
    },
    // Live print telemetry — fast, lightweight poll of /api/printing/live (separate
    // from the coarse 12s data sync so per-second progress never full-reloads).
    _printing: [], _printResults: {},
    fmtDuration, fmtClock,
    fmtMMSS: sec => { sec = Math.max(0, Math.round(sec || 0)); const m = Math.floor(sec / 60), s = sec % 60; return String(m).padStart(2, "0") + ":" + String(s).padStart(2, "0"); },
    printingFor: id => { const k = "#" + String(id == null ? "" : id).replace(/^#/, ""); return (DTF._printing || []).find(p => p.order === k) || null; },
    printResultFor: id => { const k = "#" + String(id == null ? "" : id).replace(/^#/, ""); return (DTF._printResults || {})[k] || null; },
    printLive: {
      timer: null, POLL: 5000,
      async tick() {
        if (document.hidden) return;
        let j; try { j = await fetchJSON("/api/printing/live"); } catch { return; }
        const next = (j && j.printing) || [];
        DTF._printing = next;
        window.dispatchEvent(new CustomEvent("dtf:print-live"));
      },
      start() {
        const P = DTF.printLive; if (P.timer) return;
        P.tick(); P.timer = setInterval(() => P.tick(), P.POLL);
        // Refresh the instant the tab becomes visible again — the interval pauses
        // work while hidden, so without this a returning tab shows stale/empty
        // progress until the next 5s tick.
        if (!P._vis) { P._vis = () => { if (!document.hidden) P.tick(); }; document.addEventListener("visibilitychange", P._vis); }
      },
      stop() {
        const P = DTF.printLive;
        if (P.timer) { clearInterval(P.timer); P.timer = null; }
        if (P._vis) { document.removeEventListener("visibilitychange", P._vis); P._vis = null; }
      },
    },
    _mailCustomers: [], liveThreads: [],
    setLiveThreads: arr => { DTF.liveThreads = arr || []; },
    mergeLiveThreads: arr => {
      const map = new Map((DTF.liveThreads || []).map(t => [t.id, t]));
      (arr || []).forEach(t => map.set(t.id, t)); // overwrite → a refresh replaces stale copies
      DTF.liveThreads = [...map.values()];
      return DTF.liveThreads;
    },
    products, blogPages, threads, viewed, _defViewed,
    customers: [], orders: [],            // live — filled by load()
    _inboxCustomers: inboxCustomers, _inboxOrders: inboxOrders, _orderCustomers: [],
    methodMeta, statusMeta, payMeta, fulfilMeta, deliveryMeta, flagMeta, channelMeta,
    STEP_ORDER,
    byId, fmtGBP, initials, isVip, itemMetres, orderMetres, fmtMetres, itemShip, isFeeLine, orderHasCustomFee, shipEstimate,
    getCustomer: id => byId(DTF.customers, id) || byId(DTF._orderCustomers, id) || byId(DTF._mailCustomers, id) || byId(inboxCustomers, id) || null,
    lapsedInfo,
    getOrder: id => byId(DTF.orders, id) || byId(inboxOrders, id) || null,
    getProduct: id => byId(products, id),
    getPage: id => byId(blogPages, id),
    ordersForCustomer: cid => DTF.orders.filter(o => o.customerId === cid).concat(inboxOrders.filter(o => o.customerId === cid)),
    getThreads: () => (DTF.liveThreads && DTF.liveThreads.length ? DTF.liveThreads : threads),
    collectAttachments: (ts) => {
      const out = [];
      (ts || []).forEach(t => (t.messages || []).forEach(m => (m.emailAtts || []).forEach(a => {
        out.push({ ...a, threadId: t.id, subject: t.subject, time: m.time, sender: m.name, customerId: t.customerId });
      })));
      return out;
    },
    threadsForCustomer: cid => (DTF.liveThreads && DTF.liveThreads.length ? DTF.liveThreads : threads).filter(t => t.customerId === cid),
    viewedFor: cid => viewed[cid] || _defViewed,
  };
})();
