// portal-data.jsx — données démo (miroir de la base Airtable) + store localStorage.

const GREETINGS = { IT: "Ciao", US: "Hello", HU: "Szia", ES: "¡Hola!", NL: "Hallo", RU: "Привет", PL: "Cześć", KR: "안녕하세요", AT: "Hallo", FR: "Bonjour", CN: "你好", CZ: "Ahoj", DE: "Hallo", BE: "Bonjour", CA: "Hello", UK: "Hello", PT: "Olá", RS: "Ahoj", SU: "Hej" };

const STATUS = {
  "En discussion":             { en: "In discussion",        fg: K.gray,   bg: K.grayBg },
  "Prod Planifiée":            { en: "Scheduled",            fg: K.gray,   bg: K.grayBg },
  "Pré-production":            { en: "Pre-production",       fg: K.blueS,  bg: K.blueBg },
  "En attente fichiers":       { en: "Waiting for files",    fg: K.amber,  bg: K.amberBg },
  "Fichiers validés":          { en: "Files approved",       fg: K.green,  bg: K.greenBg, dot: "✓" },
  "En attente eProofs":        { en: "Waiting for eProofs",  fg: K.amber,  bg: K.amberBg },
  "Validation eProofs":        { en: "eProofs review needed",fg: K.amber,  bg: K.amberBg, dot: "⏳" },
  "eProofs OK":                { en: "eProofs approved",     fg: K.green,  bg: K.greenBg, dot: "✓" },
  "Validation MPC":            { en: "MPC approval needed",  fg: K.amber,  bg: K.amberBg, dot: "⏳" },
  "MPC validé":                { en: "MPC approved",         fg: K.green,  bg: K.greenBg, dot: "✓" },
  "Modifications des fichiers":{ en: "Files being updated",  fg: K.red,    bg: K.redBg,   dot: "✎" },
  "En production":             { en: "In production",        fg: K.blueS,  bg: K.blueBg },
  "Packing List envoyée":      { en: "Packing list sent",    fg: K.teal,   bg: K.tealBg },
  "Transport en Cours":        { en: "In transit",           fg: K.teal,   bg: K.tealBg, dot: "⛴" },
  "Livraison partielle":       { en: "Partial delivery",     fg: K.teal,   bg: K.tealBg },
  "🧾 Finaliser facturation":  { en: "Finalizing invoicing", fg: K.purple, bg: K.purpleBg },
  "Terminé":                   { en: "Completed",            fg: K.gray,   bg: K.grayBg, dot: "✓" },
};

const BASIC_FIELDS = [
  { group: "Identification", key: "ean13", label: "EAN 13", type: "text" },
  { group: "Identification", key: "ean14", label: "EAN 14", type: "text" },
  { group: "Game", key: "gameWeight", label: "Unit weight", unit: "kg", type: "number" },
  { group: "Game", key: "peWeight", label: "PE film weight / game", unit: "g", type: "number" },
  { group: "Game", key: "gameL", label: "Length", unit: "mm", type: "number" },
  { group: "Game", key: "gameW", label: "Width", unit: "mm", type: "number" },
  { group: "Game", key: "gameH", label: "Height", unit: "mm", type: "number" },
  { group: "Carton", key: "unitsPerCarton", label: "Units per carton", type: "number" },
  { group: "Carton", key: "cartonFull", label: "Carton weight (full)", unit: "kg", type: "number" },
  { group: "Carton", key: "cartonEmpty", label: "Carton weight (empty)", unit: "kg", type: "number" },
  { group: "Carton", key: "cartonL", label: "Carton length", unit: "cm", type: "number" },
  { group: "Carton", key: "cartonW", label: "Carton width", unit: "cm", type: "number" },
  { group: "Carton", key: "cartonH", label: "Carton height", unit: "cm", type: "number" },
  { group: "Palletization", key: "palletL", label: "Pallet length", unit: "cm", type: "number" },
  { group: "Palletization", key: "palletW", label: "Pallet width", unit: "cm", type: "number" },
  { group: "Palletization", key: "palletH", label: "Pallet height", unit: "cm", type: "number" },
  { group: "Palletization", key: "gamesPerPallet", label: "Games per pallet", type: "number" },
  { group: "Palletization", key: "cartonsPerPallet", label: "Cartons per pallet", type: "number" },
  { group: "Palletization", key: "cartonsPerLayer", label: "Cartons per layer", type: "number" },
  { group: "Palletization", key: "layersPerPallet", label: "Layers per pallet", type: "number" },
];

const consigneeCranio = `CONSIGNEE: Cranio Creations S.r.l.
Via Ettore Romagnoli 1, 20146 Milano, Italy
VAT IT08628820969

NOTIFY: Same as consignee
Contact: logistics@craniocreations.it`;

const DEFAULT_DB = {
  partners: [
    { id: "cranio", name: "CRANIO - IT 🇮🇹", company: "Cranio Creations S.r.l.", country: "Italy", cc: "IT", flag: "🇮🇹", token: "pt_cranio_8f3k2",
      consignee: consigneeCranio,
      preprod: `Cranio Creations — Attn. Laura Bianchi\nVia Ettore Romagnoli 1\n20146 Milano, Italy\n+39 02 1234 5678`,
      confirmedOn: "2025-07-02", confirmedBy: "L. Bianchi" },
    { id: "luckyduck", name: "LUCKY DUCK - US 🇺🇸", company: "Lucky Duck Games LLC", country: "USA", cc: "US", flag: "🇺🇸", token: "pt_luckyduck_2m9x7",
      consignee: `CONSIGNEE: Lucky Duck Games LLC\n2750 Eastman Ave, Ventura CA 93003, USA\nEIN 84-2210331\n\nNOTIFY: Flexport Inc. — ops@flexport.com`,
      preprod: `Lucky Duck Games — Attn. Mike Gray\n2750 Eastman Ave\nVentura CA 93003, USA`,
      confirmedOn: "2026-03-18", confirmedBy: "M. Gray" },
    { id: "gemklub", name: "GÉMKLUB - HU 🇭🇺", company: "Gémklub Kft.", country: "Hungary", cc: "HU", flag: "🇭🇺", token: "pt_gemklub_5r2w1",
      consignee: `CONSIGNEE: Gémklub Kft.\nMészáros u. 58/B, 1016 Budapest, Hungary\nVAT HU12095853\n\nNOTIFY: Same as consignee`,
      preprod: `Gémklub — Attn. Eszter Nagy\nMészáros u. 58/B\n1016 Budapest, Hungary`,
      confirmedOn: "2026-01-12", confirmedBy: "E. Nagy" },
    { id: "tranjis", name: "TRANJIS - ES 🇪🇸", company: "Tranjis Games S.L.", country: "Spain", cc: "ES", flag: "🇪🇸", token: "pt_tranjis_9k4p3",
      consignee: `CONSIGNEE: Tranjis Games S.L.\nCalle Rosa de Lima 1, 28290 Las Rozas, Madrid, Spain\nVAT ESB87401512\n\nNOTIFY: Same as consignee`,
      preprod: `Tranjis Games — Attn. Pablo Cebrián\nCalle Rosa de Lima 1\n28290 Las Rozas, Madrid, Spain`,
      confirmedOn: "2026-02-02", confirmedBy: "P. Cebrián" },
    { id: "999games", name: "999 GAMES - NL 🇳🇱", company: "999 Games B.V.", country: "Netherlands", cc: "NL", flag: "🇳🇱", token: "pt_999games_7h6t8",
      consignee: `CONSIGNEE: 999 Games B.V.\nDe Trompet 1915, 1967 DB Heemskerk, Netherlands\nVAT NL801121222B01\n\nNOTIFY: Rotterdam Freight BV — import@rfbv.nl`,
      preprod: `999 Games — Attn. Joost de Vries\nDe Trompet 1915\n1967 DB Heemskerk, Netherlands`,
      confirmedOn: "2025-11-20", confirmedBy: "J. de Vries" },
    { id: "crowdgames", name: "CROWD GAMES - RU 🇷🇺", company: "Crowd Games LLC", country: "Russia", cc: "RU", flag: "🇷🇺", token: "pt_crowdgames_3v8n4",
      consignee: "", preprod: `Crowd Games — Attn. Maxim Istomin\nVolgogradsky prospekt 42\nMoscow, Russia`,
      confirmedOn: null, confirmedBy: null },
    { id: "galakta", name: "GALAKTA - PL 🇵🇱", company: "Galakta Sp. z o.o.", country: "Poland", cc: "PL", flag: "🇵🇱", token: "pt_galakta_6d1q9",
      consignee: `CONSIGNEE: Galakta Sp. z o.o.\nul. Łagiewnicka 39, 30-417 Kraków, Poland\nVAT PL6793041341\n\nNOTIFY: Same as consignee`,
      preprod: `Galakta — Attn. Marek Mydel\nul. Łagiewnicka 39\n30-417 Kraków, Poland`,
      confirmedOn: "2026-04-05", confirmedBy: "M. Mydel" },
    { id: "piatnik", name: "PIATNIK - AT 🇦🇹", company: "Wiener Spielkartenfabrik Ferd. Piatnik & Söhne", country: "Austria", cc: "AT", flag: "🇦🇹", token: "pt_piatnik_4j7s5",
      consignee: `CONSIGNEE: Piatnik & Söhne GmbH\nHütteldorfer Str. 229-231, 1140 Wien, Austria\nVAT ATU14930507\n\nNOTIFY: Same as consignee`,
      preprod: `Piatnik — Attn. Stefan Maier\nHütteldorfer Str. 229-231\n1140 Wien, Austria`,
      confirmedOn: "2026-02-27", confirmedBy: "S. Maier" },
    { id: "koreabg", name: "KOREA BG - KR 🇰🇷", company: "Korea Boardgames Co., Ltd.", country: "South Korea", cc: "KR", flag: "🇰🇷", token: "pt_koreabg_1z5c6",
      consignee: `CONSIGNEE: Korea Boardgames Co., Ltd.\n12 Hwangsaeul-ro 200beon-gil, Bundang-gu, Seongnam-si, Korea\nBRN 129-86-26743\n\nNOTIFY: Same as consignee`,
      preprod: `Korea Boardgames — Attn. Hyun-jin Park\n12 Hwangsaeul-ro 200beon-gil\nBundang-gu, Seongnam-si, Korea`,
      confirmedOn: "2026-05-01", confirmedBy: "H. Park" },
  ],

  prints: [
    { id: "p7-moustache", game: "Moustache", label: "Print #7", gameId: "moustache", cartonMarksUrl: "https://drive.google.com/drive/folders/1kDm-moustache-p7-carton-marks" },
    { id: "p6-moustache", game: "Moustache", label: "Print #6", gameId: "moustache", cartonMarksUrl: "https://drive.google.com/drive/folders/1kDm-moustache-p6-carton-marks" },
    { id: "p2-alice", game: "Alice", label: "Print #2", gameId: "alice", cartonMarksUrl: "" },
  ],

  games: [
    { id: "moustache", name: "Moustache", pctDone: 100, catalogue: "2025 - Cat #3", releaseDate: "2025-05-09", yearFR: "2025", ppttc: 17, ppht: 14.17,
      note: "", image: null, quoteFiles: [], presseFr: "https://drive.google.com/drive/folders/1presse-moustache-fr", presseEn: "", bgg: "https://boardgamegeek.com/boardgame/000000/moustache" },
    { id: "alice", name: "Alice", pctDone: 85, catalogue: "2026 - Cat #1", releaseDate: "2026-10-02", yearFR: "2026", ppttc: 24.9, ppht: 20.75,
      note: "Localisation DE en discussion avec Piatnik.", image: null, quoteFiles: [], presseFr: "", presseEn: "", bgg: "" },
  ],

  productions: [
    // ——— Moustache · Print #7 ———
    { id: "m7-cranio", printId: "p7-moustache", partnerId: "cranio", qty: 3000, wantsPallets: null, statut: "Validation MPC", validation: null,
      prodStart: "2026-07-01", prodEnd: "2026-08-22", mpcOk: false, mpcBy: null, mpcOn: null, mpcTracking: "SF1392204 8871", mpcTrackingOn: "2026-06-05", mpcTracking2: "", comment: "", files: [], transportIds: [] },
    { id: "m7-luckyduck", incoterm: "FOB", printId: "p7-moustache", partnerId: "luckyduck", qty: 10000, wantsPallets: false, mpcVideoUrl: "https://drive.google.com/file/d/1vMpc-moustache-p7-luckyduck", statut: "MPC validé", validation: "MPC + Vidéo",
      prodStart: "2026-07-01", prodEnd: "2026-08-22", mpcOk: true, mpcBy: "M. Gray", mpcOn: "2026-06-02", mpcTracking: "SF1392204 8864", mpcTrackingOn: "2026-05-28", mpcTracking2: "", comment: "", files: [], transportIds: [] },
    { id: "m7-gemklub", incoterm: "EXW", printId: "p7-moustache", partnerId: "gemklub", qty: 2000, wantsPallets: true, palletCost: 14, statut: "MPC validé", validation: "MPC",
      prodStart: "2026-07-01", prodEnd: "2026-08-22", mpcOk: true, mpcBy: "E. Nagy", mpcOn: "2026-06-04", mpcTracking: "SF1392204 8852", mpcTrackingOn: "2026-05-29", mpcTracking2: "", comment: "", files: [], transportIds: [] },
    { id: "m7-tranjis", incoterm: "FOB", printId: "p7-moustache", partnerId: "tranjis", qty: 3000, wantsPallets: null, statut: "MPC validé", validation: "MPC",
      prodStart: "2026-07-01", prodEnd: "2026-08-22", mpcOk: true, mpcBy: "P. Cebrián", mpcOn: "2026-06-01", mpcTracking: "SF1392204 8849", mpcTrackingOn: "2026-05-27", mpcTracking2: "", comment: "", files: [], transportIds: [] },
    { id: "m7-999games", incoterm: "FOB", printId: "p7-moustache", partnerId: "999games", qty: 5000, wantsPallets: false, mpcVideoUrl: "https://drive.google.com/file/d/1vMpc-moustache-p7-999games", statut: "MPC validé", validation: "MPC + Vidéo",
      prodStart: "2026-07-01", prodEnd: "2026-08-22", mpcOk: true, mpcBy: "J. de Vries", mpcOn: "2026-06-05", mpcTracking: "SF1392204 8836", mpcTrackingOn: "2026-05-30", mpcTracking2: "", comment: "", files: [], transportIds: [] },
    { id: "m7-crowdgames", printId: "p7-moustache", partnerId: "crowdgames", qty: 2500, wantsPallets: null, statut: "Modifications des fichiers", validation: "MPC",
      prodStart: "2026-07-01", prodEnd: "2026-08-22", mpcOk: false, mpcBy: null, mpcOn: null, mpcTracking: "SF1392204 8823", mpcTrackingOn: "2026-05-28", mpcTracking2: "",
      comment: "Rulebook p.12 — typo in Russian edition (\"карта\" misspelled). Please fix before printing.", files: [], transportIds: [] },
    { id: "m7-galakta", incoterm: "EXW", printId: "p7-moustache", partnerId: "galakta", qty: 2000, wantsPallets: true, statut: "MPC validé", validation: "MPC",
      prodStart: "2026-07-01", prodEnd: "2026-08-22", mpcOk: true, mpcBy: "M. Mydel", mpcOn: "2026-06-06", mpcTracking: "SF1392204 8810", mpcTrackingOn: "2026-05-30", mpcTracking2: "", comment: "", files: [], transportIds: [] },
    { id: "m7-piatnik", incoterm: "EXW", printId: "p7-moustache", partnerId: "piatnik", qty: 1500, wantsPallets: true, statut: "MPC validé", validation: "MPC",
      prodStart: "2026-07-01", prodEnd: "2026-08-22", mpcOk: true, mpcBy: "S. Maier", mpcOn: "2026-06-03", mpcTracking: "SF1392204 8807", mpcTrackingOn: "2026-05-28", mpcTracking2: "", comment: "", files: [], transportIds: [] },
    { id: "m7-koreabg", incoterm: "FOB", printId: "p7-moustache", partnerId: "koreabg", qty: 2000, wantsPallets: true, palletCost: 14, mpcVideoUrl: "https://drive.google.com/file/d/1vMpc-moustache-p7-koreabg", statut: "MPC validé", validation: "MPC + Vidéo",
      prodStart: "2026-07-01", prodEnd: "2026-08-22", mpcOk: true, mpcBy: "H. Park", mpcOn: "2026-06-07", mpcTracking: "SF1392204 8794", mpcTrackingOn: "2026-06-01", mpcTracking2: "", comment: "", files: [], transportIds: [] },
    // ——— Alice · Print #2 ———
    { id: "a2-cranio", incoterm: "FOB", printId: "p2-alice", partnerId: "cranio", qty: 5000, wantsPallets: true, palletCost: 18, statut: "En production", validation: "MPC",
      prodStart: "2026-05-15", prodEnd: "2026-07-30", mpcOk: true, mpcBy: "L. Bianchi", mpcOn: "2026-05-12", mpcTracking: "SF1391108 2241", mpcTrackingOn: "2026-05-06", mpcTracking2: "",
      comment: "", files: [{ name: "PackingList_Alice_P2_CRANIO.pdf", date: "2026-06-01", kind: "Packing list" }], transportIds: [] },
    { id: "a2-999games", incoterm: "FOB", printId: "p2-alice", partnerId: "999games", qty: 4000, wantsPallets: false, statut: "En production", validation: "MPC",
      prodStart: "2026-05-15", prodEnd: "2026-07-30", mpcOk: true, mpcBy: "J. de Vries", mpcOn: "2026-05-13", mpcTracking: "SF1391108 2258", mpcTrackingOn: "2026-05-06", mpcTracking2: "", comment: "", files: [], transportIds: [] },
    { id: "a2-gemklub", incoterm: "EXW", printId: "p2-alice", partnerId: "gemklub", qty: 1500, wantsPallets: true, palletCost: 18, statut: "Transport en Cours", validation: "MPC",
      prodStart: "2026-04-01", prodEnd: "2026-05-20", mpcOk: true, mpcBy: "E. Nagy", mpcOn: "2026-03-28", mpcTracking: "SF1391108 2230", mpcTrackingOn: "2026-03-22", mpcTracking2: "",
      madeIn: "🇨🇳", prixProd: 4.05, prixExport: 9.96, caPost: 14940, benefPost: 8865, margePost: 0.27, margeReelle: null, factStatut: "Unpaid", numFact: "", facEuros: null, facDollars: null, virement: null, notesFact: "", factureFiles: [], suiviMpc: "",
      comment: "", files: [{ name: "PackingList_Alice_P2_GEMKLUB.pdf", date: "2026-05-22", kind: "Packing list" }], transportIds: ["tr-alice-gemklub"] },
    // ——— Moustache · Print #6 (terminé) ———
    { id: "m6-cranio", printId: "p6-moustache", partnerId: "cranio", qty: 4000, wantsPallets: true, palletCost: 12, statut: "Terminé", validation: "MPC",
      prodStart: "2025-09-01", prodEnd: "2025-10-20", mpcOk: true, mpcBy: "L. Bianchi", mpcOn: "2025-08-25", mpcTracking: "SF1387765 1102", mpcTrackingOn: "2025-08-19", mpcTracking2: "",
      madeIn: "🇨🇳", prixProd: 1.61, prixExport: 4.22, caPost: 16880, benefPost: 3290, margePost: 0.195, margeReelle: 0.238, factStatut: "paid", numFact: "WZG-LJ20251029A", facEuros: 5830.4, facDollars: 6720, virement: "2025-11-15",
      notesFact: "", factureFiles: [{ name: "WZG-LJ20251029A--Moustache (Print#6) payment invoice.pdf", date: "2025-10-29", kind: "Facture" }], suiviMpc: "",
      comment: "", files: [{ name: "PackingList_Moustache_P6_CRANIO.pdf", date: "2025-10-22", kind: "Packing list" }], transportIds: ["tr-m6-cranio"] },
    { id: "m6-999games", printId: "p6-moustache", partnerId: "999games", qty: 6000, wantsPallets: false, statut: "Terminé", validation: "MPC",
      prodStart: "2025-09-01", prodEnd: "2025-10-20", mpcOk: true, mpcBy: "J. de Vries", mpcOn: "2025-08-26", mpcTracking: "SF1387765 1119", mpcTrackingOn: "2025-08-19", mpcTracking2: "",
      madeIn: "🇨🇳", prixProd: 1.61, prixExport: 4.22, caPost: 25320, benefPost: 4935, margePost: 0.195, margeReelle: 0.221, factStatut: "paid", numFact: "WZG-LJ20251029B", facEuros: 8745.6, facDollars: 10080, virement: "2025-11-15",
      notesFact: "", factureFiles: [], suiviMpc: "",
      comment: "", files: [{ name: "PackingList_Moustache_P6_999GAMES.pdf", date: "2025-10-22", kind: "Packing list" }], transportIds: [] },
  ],

  transports: [
    { id: "tr-alice-gemklub", name: "ALICE P2 → GÉMKLUB (Budapest)", status: "Transport en cours", departure: "2026-05-28", arrival: "2026-07-08",
      ref: "MSCU-7781934", payement: "À payer", cost: 2140, notes: "TRAIN ?", type: ["🚢"], docs: [{ name: "BL_MSCU7781934.pdf", date: "2026-05-29", kind: "B/L" }] },
    { id: "tr-m6-cranio", name: "MOUSTACHE P6 → CRANIO (São Paulo)", status: "Done", departure: "2025-10-29", arrival: "2025-12-18",
      ref: "W1-BG202500453", payement: "Payé", cost: 3480, notes: "", type: ["🚢"], docs: [{ name: "W1-BG202500453-PACKINGLIST.xls", date: "2025-10-27", kind: "Packing list" }] },
  ],

  basicData: {
    moustache: { ean13: "3770013734076", ean14: "13770013734073", gameWeight: 0.62, peWeight: 8, gameL: 200, gameW: 200, gameH: 50,
      unitsPerCarton: 12, cartonFull: 8.1, cartonEmpty: 0.55, cartonL: 42, cartonW: 41, cartonH: 27,
      palletL: 120, palletW: 80, palletH: 190, gamesPerPallet: 720, cartonsPerPallet: 60, cartonsPerLayer: 10, layersPerPallet: 6 },
    alice: null, // pas encore de ligne BASIC DATA → l'usine peut la créer
  },

  log: [
    { ts: "2026-06-07 09:14", actor: "H. Park (Korea BG)", action: "MPC approved", detail: "Moustache · Print #7" },
    { ts: "2026-06-06 16:40", actor: "M. Mydel (Galakta)", action: "MPC approved", detail: "Moustache · Print #7" },
    { ts: "2026-06-05 11:02", actor: "Whatz Games", action: "MPC tracking added", detail: "Moustache · Print #7 → 999 GAMES" },
    { ts: "2026-06-04 10:21", actor: "Maxim (Crowd Games)", action: "Changes requested", detail: "Moustache · Print #7 — rulebook typo p.12" },
    { ts: "2026-06-01 08:55", actor: "Whatz Games", action: "Packing list uploaded", detail: "Alice · Print #2 → CRANIO" },
  ],
};

// Options des selects de la fiche jeu (miroir des choix Airtable)
const GAME_CATALOGUES = ["Cat #1 - 2024", "Cat #2 - 2024", "Cat #3 - 2024", "2025 - Cat #1", "2025 - Cat #2", "2025 - Cat #3", "2026 - Cat #1", "2026 - Cat #2", "2026 - Cat #3", "2027 - Cat #1", "2027 - Cat #2", "2027 - Cat #3"];
const GAME_YEARS = ["2018", "2020", "2021", "2022", "2023", "2024", "2025", "2026", "2027"];
// Choix Airtable — 🏭 Statut Facturation, Made in, Payement transport
const FACT_STATUTS = ["Unpaid", "paid", "Acompte 50%", "URGENT", "En prod'"];
const MADE_IN_OPTIONS = ["🇪🇺", "🇨🇳"];
const TRANSPORT_PAYEMENT = ["À payer", "Payé"];

// ——— Store : merge défauts + localStorage, sauvegarde à chaque update ———
const KSTORE_KEY = "kodama-portal-demo-v9";
function loadDb() {
  try {
    const raw = localStorage.getItem(KSTORE_KEY);
    if (raw) { const saved = JSON.parse(raw); if (saved && saved.__v === 1) return saved.db; }
  } catch (e) {}
  return JSON.parse(JSON.stringify(DEFAULT_DB));
}
function usePortalDb() {
  const [db, setDb] = React.useState(loadDb);
  const [live, setLive] = React.useState(false);
  const dbRef = React.useRef(null);
  dbRef.current = db;

  // Au montage : détecter le mode (jeton direct / proxy Vercel / démo) et charger Airtable si possible
  React.useEffect(() => {
    let cancelled = false;
    (async () => {
      try {
        const m = await AirtableSync.detectMode();
        if (m === "demo" || cancelled) return;
        window.dispatchEvent(new CustomEvent("kodama-sync", { detail: { state: "loading", msg: "Chargement depuis Airtable…" } }));
        const remote = await AirtableSync.loadAll();
        if (cancelled || !remote) return;
        setLive(true);
        setDb(remote);
        window.dispatchEvent(new CustomEvent("kodama-sync", { detail: { state: "ok", msg: "Données Airtable en direct" } }));
      } catch (e) {
        console.error("[portal] chargement Airtable échoué — mode démo conservé", e);
        window.dispatchEvent(new CustomEvent("kodama-sync", { detail: { state: "error", msg: "Airtable inaccessible : " + e.message } }));
      }
    })();
    return () => { cancelled = true; };
  }, []);

  const update = (fn) => setDb((prev) => {
    const next = JSON.parse(JSON.stringify(prev));
    fn(next);
    if (live || AirtableSync.getMode() === "direct" || AirtableSync.getMode() === "proxy") {
      AirtableSync.pushDiff(prev, next); // asynchrone, fire & forget
    } else {
      try { localStorage.setItem(KSTORE_KEY, JSON.stringify({ __v: 1, db: next })); } catch (e) {}
    }
    return next;
  });
  const reset = async () => {
    if (live) {
      window.dispatchEvent(new CustomEvent("kodama-sync", { detail: { state: "loading", msg: "Rechargement…" } }));
      try {
        const remote = await AirtableSync.loadAll();
        if (remote) { setDb(remote); window.dispatchEvent(new CustomEvent("kodama-sync", { detail: { state: "ok", msg: "Données rechargées" } })); }
      } catch (e) {
        window.dispatchEvent(new CustomEvent("kodama-sync", { detail: { state: "error", msg: e.message } }));
      }
      return;
    }
    try { localStorage.removeItem(KSTORE_KEY); } catch (e) {}
    setDb(JSON.parse(JSON.stringify(DEFAULT_DB)));
  };
  return [db, update, reset, live];
}

// Petit badge d'état de synchro (live / démo / erreur), affiché dans la barre prototype
function SyncBadge() {
  const [s, setS] = React.useState({ state: "idle", msg: "" });
  React.useEffect(() => {
    const onEvt = (e) => setS(e.detail);
    window.addEventListener("kodama-sync", onEvt);
    AirtableSync.onStatus((st) => { if (st.state !== "idle") setS(st); });
    return () => window.removeEventListener("kodama-sync", onEvt);
  }, []);
  const styles = {
    idle:    { bg: "rgba(255,255,255,0.12)", fg: "rgba(255,255,255,0.6)", label: "● Démo locale" },
    loading: { bg: "rgba(255,200,80,0.25)", fg: "#FFD98A", label: "⧗ " + (s.msg || "Chargement…") },
    saving:  { bg: "rgba(255,200,80,0.25)", fg: "#FFD98A", label: "⧗ Enregistrement…" },
    ok:      { bg: "rgba(80,200,120,0.22)", fg: "#9FE2B5", label: "● Airtable · live" },
    error:   { bg: "rgba(230,90,70,0.25)", fg: "#FFB3A6", label: "● " + (s.msg || "Erreur de synchro") },
  };
  const st = styles[s.state] || styles.idle;
  return (
    <span title={s.msg || ""} style={{ marginLeft: "auto", fontFamily: "'IBM Plex Mono', monospace", fontSize: 10.5, padding: "4px 10px", borderRadius: 99, background: st.bg, color: st.fg, maxWidth: 380, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
      {st.label}
    </span>
  );
}

function nowStamp() {
  const d = new Date();
  const p = (n) => String(n).padStart(2, "0");
  return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
}
function todayIso() { return nowStamp().slice(0, 10); }
function fmtDate(iso) {
  if (!iso) return "—";
  const [y, m, d] = iso.slice(0, 10).split("-").map(Number);
  return ["", "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"][m] + " " + d + ", " + y;
}
function monthsAgo(iso) {
  if (!iso) return Infinity;
  return (Date.now() - new Date(iso).getTime()) / (1000 * 3600 * 24 * 30.4);
}
// Cartons / palettes estimés à partir des BASIC DATA du jeu — coût : 50 $ par palette entamée
const PALLET_COST_USD = 50;
function palletInfo(qty, basic) {
  if (!basic || !basic.unitsPerCarton) return null;
  const cartons = Math.ceil(qty / basic.unitsPerCarton);
  const pallets = basic.cartonsPerPallet ? Math.round((cartons / basic.cartonsPerPallet) * 10) / 10 : null;
  const cost = pallets ? Math.ceil(pallets) * PALLET_COST_USD : null;
  return { cartons, pallets, cost };
}

// Un print est « actif » si au moins une de ses productions n'est pas terminée
function activePrintIds(db) {
  const s = new Set();
  db.productions.forEach((p) => { if (p.statut !== "Terminé") s.add(p.printId); });
  return s;
}

Object.assign(window, { GREETINGS, STATUS, BASIC_FIELDS, DEFAULT_DB, usePortalDb, SyncBadge, nowStamp, todayIso, fmtDate, monthsAgo, palletInfo, PALLET_COST_USD, activePrintIds, KSTORE_KEY, GAME_CATALOGUES, GAME_YEARS, FACT_STATUTS, MADE_IN_OPTIONS, TRANSPORT_PAYEMENT });
