/* global React, Icon, PROJECTS, sectorById, sectorLabel, projectName, projectCountries, projectPhase, getPortDakarData, getPlanFor, PDPlanning */
const { useState: useStatePD, useMemo: useMemoPD } = React;

// Sector-specific narratives for header subtitle + per-sector talking points
const SECTOR_NARRATIVES = {
  sante:       { fr: "Renforcement des soins de santé primaires — maternité, vaccination, chaîne du froid.", en: "Primary care strengthening — maternity, vaccination, cold chain." },
  nutrition:   { fr: "Lutte contre la malnutrition aiguë et chronique — dépistage, supplémentation, cantines.", en: "Tackling acute and chronic malnutrition — screening, supplementation, canteens." },
  vih:         { fr: "Prévention, dépistage et accompagnement TARV des jeunes urbains et populations clés.", en: "Prevention, testing and ART support for urban youth and key populations." },
  education:   { fr: "Qualité des apprentissages, équipement numérique des collèges et formation enseignants.", en: "Learning quality, digital equipment in middle schools and teacher training." },
  peche:       { fr: "Modernisation pêche artisanale, gestion des stocks halieutiques et valorisation littorale.", en: "Artisanal fisheries modernization, fish-stock management and coastal value addition." },
  port:        { fr: "Modernisation portuaire, terminaux à conteneurs, dragage, performance opérationnelle et recettes douanières.", en: "Port modernization, container terminals, dredging, operational performance and customs revenue." },
  agriculture: { fr: "Filières agricoles, irrigation, vulgarisation et résilience climatique des exploitations.", en: "Agricultural value chains, irrigation, extension services and climate resilience." },
  finances:    { fr: "Modernisation de l'administration fiscale et budgétaire — mobilisation des recettes.", en: "Fiscal and budget administration modernization — revenue mobilization." },
  gouvernance: { fr: "Décentralisation, redevabilité sociale et performance des services publics locaux.", en: "Decentralization, social accountability and local public service performance." },
  eau:         { fr: "Accès à l'eau potable, assainissement et hygiène dans les centres de services publics.", en: "Access to safe water, sanitation and hygiene in public facility centers." },
  energie:     { fr: "Mini-réseaux solaires, électrification rurale et accès aux services énergétiques.", en: "Solar mini-grids, rural electrification and access to energy services." },
  genre:       { fr: "Autonomisation économique des femmes, lutte contre les violences et inclusion.", en: "Women economic empowerment, gender-based violence reduction and inclusion." },
};

// ==================== PROJECT DETAIL (drill-down) ====================
function ProjectDetail({ t, lang, projectId, selectedProject, onOpen, onBack, isSuperAdmin, isAdmin, canEditIndicators, hasPerm }) {
  const pid = projectId || selectedProject || "P-001";
  const { currency } = window.melr.useCurrency();
  // fmtM accepts an optional source currency (defaults to EUR for fixture projects).
  const fmtM = (v, src) => window.melr.formatAmount(v, src || "EUR", currency, lang);
  // All React hooks must be called unconditionally before any early
  // return (rules of hooks). Previously useStatePD(tab) and usePlan(...)
  // came AFTER the supaLoading early return, which made the number of
  // hooks vary between renders and crashed the component.
  const { project: supaProject, loading: supaLoading, refresh: refreshSupa } = window.melr.useProjectDetail(pid);
  const { programmes: livePrograms } = window.melr.usePrograms();
  const [tab, setTab] = useStatePD("overview");
  const [progEditing, setProgEditing] = useStatePD(false);
  const [progBusy, setProgBusy] = useStatePD(false);
  // Modal d'edition complete du projet (code, nom, dates, budget, etc.).
  // Remplace le placeholder alert() qui annoncait "edition libre a venir".
  const [editProjectOpen, setEditProjectOpen] = useStatePD(false);
  const { plan: livePlan, refresh: refreshPlan } = window.melr.usePlan(supaProject && supaProject.uuid);
  if (supaLoading) {
    return <div className="page"><div className="page-body" style={{ padding: 40 }}>{lang === "fr" ? "Chargement du projet…" : "Loading project…"}</div></div>;
  }

  // Click handler for the progress edit button (live projects only).
  const onEditProgress = async () => {
    if (!supaProject || !supaProject.uuid) return;
    const raw = window.prompt(
      lang === "fr" ? "Nouveau pourcentage d'avancement (0-100) :" : "New progress (0-100):",
      String(supaProject.progress || 0),
    );
    if (raw === null) return;
    const v = Math.max(0, Math.min(100, parseInt(raw, 10) || 0));
    try {
      await window.melr.updateProject(supaProject.uuid, { progress: v });
      await refreshSupa();
    } catch (e) {
      window.alert((lang === "fr" ? "Erreur : " : "Error: ") + e.message);
    }
  };
  // Use database row when available; fall back to legacy fixture for
  // projects that aren't seeded yet (e.g. Port de Dakar demo).
  const base = supaProject || (PROJECTS && PROJECTS.find((x) => x.id === pid)) || (PROJECTS && PROJECTS[0]);
  const sectorObj = base ? sectorById(base.sector) : null;
  const sectorNote = (base && SECTOR_NARRATIVES[base.sector]) || SECTOR_NARRATIVES.sante;
  const isPortDakar = pid === "P-612" && typeof getPortDakarData === "function";
  const PORT = isPortDakar ? getPortDakarData(lang) : null;

  const PROJECT = {
    id: base ? base.id : "P-241",
    name: base ? projectName(base, lang) : "Renforcement santé primaire Sahel",
    short: lang === "fr" ? sectorNote.fr : sectorNote.en,
    phase: base ? projectPhase(base, lang) : "Mise en œuvre",
    // Mapping status (project_status enum) -> phase step (0..4) :
    //   appraisal    -> 0 (Évaluation)
    //   inception    -> 1 (Démarrage)
    //   active       -> 2 (Mise en œuvre)
    //   paused       -> 2 (en pause mais reste en "Mise en œuvre")
    //   closing      -> 3 (Clôture)
    //   closed       -> 4 (Évaluation finale)
    // Aussi : si progress >= 100 alors on saute à l'étape finale même si
    // le status n'est pas encore mis à "closed" — utile pour la cohérence
    // visuelle quand l'agent oublie de fermer le projet manuellement.
    phaseStep: (() => {
      if (!base) return 2;
      if (base.progress != null && base.progress >= 100) return 4;
      switch (base.status) {
        case "appraisal":    return 0;
        case "inception":    return 1;
        case "active":       return 2;
        case "paused":       return 2;
        case "closing":      return 3;
        case "closed":       return 4;
        default:             return 2;
      }
    })(),
    countries: base ? projectCountries(base, lang) : "Mali · Burkina Faso · Niger",
    sector: sectorObj ? sectorLabel(sectorObj, lang) : "—",
    sectorObj,
    donor: base ? base.donor : "AFD · Union européenne · Gavi",
    startDate: "01/03/2024",
    endDate: "28/02/2027",
    sites: base ? base.sites : 42,
    beneficiaries: base ? (base.sites * 9750).toLocaleString("fr-FR") : "412 800",
    staff: 18,
    progress: base ? base.progress : 64,
    budget: base ? base.budget : 3.36,
    disbursed: base ? base.disbursed : 2.15,
    committed: base ? (base.disbursed + base.budget * 0.18).toFixed(2) * 1 : 2.84,
    burn: base ? base.progress : 64,
    plan: base ? Math.min(95, base.progress + 6) : 58,
    irr: 14.2, npv: 1.27, dscr: 1.86,
    nativeCurrency: (base && base.nativeCurrency) || "EUR",
    risk: base ? base.risk : "warn",
    lead: base ? base.lead : "Aïssata Diallo",
    leadRole: lang === "fr" ? "Chef de projet" : "Project lead",
    meLead: "Souleymane Touré",
    programmeId:    (base && base.programmeId) || null,
    programmeCode:  (base && base.programmeCode) || null,
    programmeName:  (base && base.programmeName) || null,
  };

  // Live-edit handler for the project's programme link. Only available
  // when this is a database row (supaProject.uuid present).
  const onChangeProgramme = async (newProgrammeId) => {
    if (!supaProject || !supaProject.uuid) return;
    setProgBusy(true);
    try {
      await window.melr.updateProject(supaProject.uuid, {
        programme_id: newProgrammeId || null,
      });
      await refreshSupa();
      setProgEditing(false);
    } catch (e) {
      window.alert((lang === "fr" ? "Erreur : " : "Error: ") + e.message);
    } finally {
      setProgBusy(false);
    }
  };

  // Outcome / output indicators with sparkline data
  const INDICATORS = PORT ? PORT.indicators : [
    { code: "OC-01", level: "outcome", name: lang === "fr" ? "Couverture vaccinale Penta-3 (12–23 mois)" : "Penta-3 immunization coverage (12–23 m)", base: 65.1, target: 90, cur: 78.4, unit: "%", trend: [65,67,68,70,71,73,74,75,76,77,78,78.4], status: "ok", dir: "up" },
    { code: "OC-02", level: "outcome", name: lang === "fr" ? "Accouchements assistés par personnel qualifié" : "Births attended by skilled personnel", base: 48.2, target: 75, cur: 67.8, unit: "%", trend: [48,52,55,57,60,62,63,65,66,67,67.5,67.8], status: "ok", dir: "up" },
    { code: "OC-03", level: "outcome", name: lang === "fr" ? "Mortalité maternelle (pour 100 000 NV)" : "Maternal mortality (per 100k live births)", base: 642, target: 380, cur: 487, unit: "‰", trend: [642,624,610,580,560,540,525,510,500,495,490,487], status: "warn", dir: "down" },
    { code: "OP-04", level: "output", name: lang === "fr" ? "CSCom dotés en chaîne du froid fonctionnelle" : "CSCom with functioning cold chain", base: 12, target: 42, cur: 38, unit: "", trend: [12,15,19,22,26,29,32,34,36,37,38,38], status: "ok", dir: "up" },
    { code: "OP-05", level: "output", name: lang === "fr" ? "Agents de santé formés (cumul)" : "Health workers trained (cumulative)", base: 0, target: 420, cur: 287, unit: "", trend: [0,28,62,98,134,168,201,224,247,268,279,287], status: "ok", dir: "up" },
    { code: "OP-06", level: "output", name: lang === "fr" ? "Référencements obstétricaux d'urgence" : "Emergency obstetric referrals", base: 184, target: 600, cur: 312, unit: "", trend: [184,202,221,238,251,266,281,290,298,304,309,312], status: "warn", dir: "up" },
    { code: "OP-07", level: "output", name: lang === "fr" ? "Stock de rupture en MEG (jours/an)" : "MEG stockout (days/year)", base: 71, target: 14, cur: 38, unit: "j", trend: [71,68,62,58,53,49,45,42,40,39,38,38], status: "warn", dir: "down" },
  ];

  const MILESTONES = PORT ? PORT.milestones : [
    { d: "2024-03-01", t: lang === "fr" ? "Démarrage projet" : "Project kick-off", state: "done" },
    { d: "2024-05-15", t: lang === "fr" ? "Baseline finalisée — 42 sites" : "Baseline complete — 42 sites", state: "done" },
    { d: "2024-09-30", t: lang === "fr" ? "Cohorte 1 formation (140 agents)" : "Training cohort 1 (140 staff)", state: "done" },
    { d: "2025-01-15", t: lang === "fr" ? "Installation chaîne du froid 30 sites" : "Cold chain deployed 30 sites", state: "done" },
    { d: "2025-06-30", t: lang === "fr" ? "Évaluation mi-parcours" : "Mid-term evaluation", state: "done" },
    { d: "2026-03-15", t: lang === "fr" ? "Cohorte 2 formation — EN COURS" : "Training cohort 2 — IN PROGRESS", state: "cur" },
    { d: "2026-09-30", t: lang === "fr" ? "Audit DQA externe" : "External DQA audit", state: "todo" },
    { d: "2027-02-28", t: lang === "fr" ? "Clôture & évaluation finale" : "Closure & final evaluation", state: "todo" },
  ];

  const ACTIVITY = PORT ? PORT.activity : [
    { who: "Souleymane Touré", role: "S&E", a: lang === "fr" ? "a validé 11 mises à jour d'indicateurs" : "validated 11 indicator updates", w: lang === "fr" ? "il y a 2 h" : "2 h ago", t: "check", tone: "green" },
    { who: "Aïssata Diallo", role: lang === "fr" ? "Chef projet" : "Project lead", a: lang === "fr" ? "a soumis le rapport Q1 2026 pour validation" : "submitted Q1 2026 report for validation", w: lang === "fr" ? "hier" : "yesterday", t: "send", tone: "accent" },
    { who: "Karim Bensaad", role: lang === "fr" ? "Audit qualité" : "Quality audit", a: lang === "fr" ? "a lancé un échantillonnage DQA sur 4 sites" : "launched DQA sampling on 4 sites", w: "2 j", t: "shieldCheck", tone: "violet" },
    { who: "Bintou Tall", role: lang === "fr" ? "Saisie terrain" : "Field", a: lang === "fr" ? "a synchronisé 87 fiches (Tombouctou)" : "synced 87 forms (Timbuktu)", w: "3 j", t: "refresh", tone: "accent" },
    { who: "Modou Sarr", role: lang === "fr" ? "Bailleur" : "Donor", a: lang === "fr" ? "a téléchargé le dashboard portefeuille" : "downloaded portfolio dashboard", w: "5 j", t: "download", tone: "amber" },
  ];

  const RISKS = PORT ? PORT.risks : [
    { l: "H", c: lang === "fr" ? "Insécurité Nord Mali — 4 sites" : "Insecurity North Mali — 4 sites", m: lang === "fr" ? "Rotation des équipes · escortes UN" : "Team rotation · UN escorts", p: 4, i: 5 },
    { l: "M", c: lang === "fr" ? "Rupture MEG saison pluies" : "MEG stockout rainy season", m: lang === "fr" ? "Stocks tampons préfectoraux" : "Buffer stocks at district level", p: 3, i: 4 },
    { l: "M", c: lang === "fr" ? "Volatilité FCFA / EUR" : "XOF / EUR volatility", m: lang === "fr" ? "Achats groupés trimestriels" : "Quarterly bulk procurement", p: 4, i: 3 },
    { l: "L", c: lang === "fr" ? "Rotation personnel formé" : "Trained staff rotation", m: lang === "fr" ? "Primes rétention 18 mois" : "18-month retention bonus", p: 3, i: 2 },
  ];

  const FILES = PORT ? PORT.files : [
    { n: "MPR-P241-revised-v3.xlsx", s: "284 KB", w: "Karim B.", d: lang === "fr" ? "il y a 3 j" : "3 d ago", t: "spreadsheet" },
    { n: "Baseline-rapport-final.pdf", s: "4.2 MB", w: "Souleymane T.", d: "12 j", t: "fileText" },
    { n: "Cartographie-42-sites.geojson", s: "892 KB", w: "Bintou T.", d: "1 m", t: "map" },
    { n: "Manuel-procedures-DQA.pdf", s: "1.8 MB", w: "Aïssata D.", d: "2 m", t: "fileText" },
    { n: "Budget-2026-detail.xlsx", s: "156 KB", w: "Aïssata D.", d: "2 m", t: "spreadsheet" },
  ];

  // Live plan from database if the project is in DB AND has phases seeded;
  // otherwise fall back to the in-memory fixture (data-plans.jsx).
  const fixturePlan = typeof getPlanFor === "function" ? getPlanFor(pid, base) : null;
  let plan = fixturePlan;
  if (livePlan && livePlan.phases && livePlan.phases.length > 0) {
    const dates = livePlan.actions
      .flatMap((a) => [a.start_date, a.end_date])
      .filter(Boolean)
      .sort();
    plan = {
      start: dates[0] || "2024-01-01",
      end:   dates[dates.length - 1] || "2027-12-31",
      phases: livePlan.phases.map((ph) => ({
        uuid: ph.id,                                                 // database UUID for createPlanAction
        id: ph.code,
        code: ph.code,
        name: { fr: ph.name_fr, en: ph.name_en || ph.name_fr },
        color: ph.color || "oklch(0.55 0.13 230)",
      })),
      actions: livePlan.actions.map((a) => ({
        uuid: a.id,                                                 // database UUID, needed for updatePlanAction
        phase_id: a.phase_id,                                        // for EditActionModal
        id: a.wbs || a.id,
        wbs: a.wbs || "",
        phase: (livePlan.phases.find((ph) => ph.id === a.phase_id) || {}).code,
        name: { fr: a.name_fr, en: a.name_en || a.name_fr },
        name_fr: a.name_fr,
        name_en: a.name_en,
        owner: base && base.lead ? base.lead : "—",
        start: a.start_date,
        end: a.end_date,
        progress: a.progress || 0,
        status: a.status || "planned",
        milestone: !!a.milestone,
        dep: [],
      })),
    };
  }
  // Onglets du detail projet. Chaque onglet a son propre HUE OKLCH pour
  // une coloration distincte (border-bottom + text quand actif). L'ordre
  // suit le cycle de vie : Vue d'ensemble -> Definition (4 onglets de
  // cadrage) -> Execution (Gantt, Indicateurs, Sites, Budget) -> Suivi
  // (Equipe, Documents, Risques, Journal).
  const TABS = [
    { k: "overview",          l: lang === "fr" ? "Vue d'ensemble" : "Overview", hue: 230 },                      // bleu
    { k: "objectives",        l: lang === "fr" ? "Objectifs" : "Objectives", hue: 260 },                          // violet-bleu
    { k: "toc",               l: lang === "fr" ? "Théorie du changement" : "Theory of change", hue: 285 },        // violet
    { k: "stakeholders_init", l: lang === "fr" ? "Parties prenantes" : "Stakeholders", hue: 315 },                // magenta
    { k: "assumptions",       l: lang === "fr" ? "Hypothèses & conditions" : "Assumptions & conditions", hue: 350 }, // rose
    { k: "planning",          l: lang === "fr" ? "Gantt" : "Gantt", c: plan ? plan.actions.length : 0, hue: 25 },  // orange
    { k: "indicators",        l: PORT ? (lang === "fr" ? "Indicateurs CNUCED" : "UNCTAD indicators") : (lang === "fr" ? "Indicateurs" : "Indicators"), c: INDICATORS.length, hue: 55 }, // jaune-orange
    { k: "sites",             l: PORT ? (lang === "fr" ? "Postes à quai" : "Berths") : (lang === "fr" ? "Sites" : "Sites"), c: PORT ? PORT.sites.length : 42, hue: 145 }, // vert
    { k: "budget",            l: lang === "fr" ? "Budget" : "Budget", hue: 170 },                                  // teal
    { k: "team",              l: lang === "fr" ? "Équipe" : "Team", c: PORT ? PORT.team.length : 18, hue: 195 },  // cyan
    { k: "files",             l: lang === "fr" ? "Documents" : "Documents", c: FILES.length, hue: 215 },           // bleu
    { k: "risks",             l: lang === "fr" ? "Risques" : "Risks", c: RISKS.length, hue: 10 },                  // rouge
    // "Journal" (au lieu de "Activité") pour eviter la confusion avec le
    // module top-level "Activités" qui contient les interventions
    // operationnelles (formations, visites...).
    { k: "activity",          l: lang === "fr" ? "Journal" : "Journal", hue: 250 },                                // bleu-gris
  ];

  return (
    <div className="page pd-page">
      {/* Header */}
      <div className="pd-header">
        <button className="pd-back" onClick={onBack}>
          <Icon.chevronLeft />
          <span>{lang === "fr" ? "Portefeuille" : "Portfolio"}</span>
        </button>
        <div className="pd-title-row">
          <div style={{ flex: 1, minWidth: 0 }}>
            <div className="row gap-sm" style={{ marginBottom: 6 }}>
              <span className="mono text-faint" style={{ fontSize: 11.5 }}>{PROJECT.id}</span>
              <span className="dotsep"></span>
              {PROJECT.sectorObj && (
                <span className="sector-chip" style={{ background: PROJECT.sectorObj.bg, color: PROJECT.sectorObj.color, borderColor: PROJECT.sectorObj.color }}>{PROJECT.sector}</span>
              )}
              <span className="dotsep"></span>
              {PROJECT.risk === "ok" && <span className="pill green dot">OK</span>}
              {PROJECT.risk === "warn" && <span className="pill amber dot">{lang === "fr" ? "Vigilance" : "Watch"}</span>}
              {PROJECT.risk === "bad" && <span className="pill red dot">{lang === "fr" ? "Élevé" : "High"}</span>}
              <span className="dotsep"></span>
              <span className="muted" style={{ fontSize: 11.5 }}>{PROJECT.phase}</span>
              <span className="dotsep"></span>
              {/* Programme parent chip — clickable when set, with inline edit dropdown for live projects */}
              {PROJECT.programmeId ? (
                <span className="row gap-xs" style={{ alignItems: "center" }}>
                  <span
                    onClick={() => onOpen && onOpen("prog:" + PROJECT.programmeId)}
                    title={lang === "fr" ? "Ouvrir le programme parent" : "Open parent programme"}
                    style={{
                      padding: "2px 8px", borderRadius: 999, background: "var(--bg-sunken)",
                      color: "var(--text)", fontSize: 11, fontWeight: 500, cursor: "pointer",
                      border: "1px solid var(--line-faint)",
                    }}>
                    ◇ {PROJECT.programmeCode}{PROJECT.programmeName ? " — " + PROJECT.programmeName : ""}
                  </span>
                </span>
              ) : (
                <span className="text-faint" style={{ fontSize: 11 }}>
                  ◇ {lang === "fr" ? "Sans programme" : "No programme"}
                </span>
              )}
              {/* Inline edit: only for live (database) projects with a uuid */}
              {supaProject && supaProject.uuid && (
                <>
                  {!progEditing ? (
                    <button className="btn xs ghost" onClick={() => setProgEditing(true)}
                      title={lang === "fr" ? "Changer le programme parent" : "Change parent programme"}
                      style={{ fontSize: 10, padding: "1px 6px" }}>
                      <Icon.edit />
                    </button>
                  ) : (
                    <span className="row gap-xs" style={{ alignItems: "center" }}>
                      <select
                        autoFocus
                        defaultValue={PROJECT.programmeId || ""}
                        disabled={progBusy}
                        onChange={(e) => onChangeProgramme(e.target.value)}
                        style={{ fontSize: 11, padding: "2px 6px", borderRadius: 4, border: "1px solid var(--line)" }}>
                        <option value="">— {lang === "fr" ? "aucun programme" : "no programme"} —</option>
                        {(livePrograms || []).map((pg) => (
                          <option key={pg.id} value={pg.id}>
                            {pg.code} — {lang === "en" ? (pg.name_en || pg.name_fr) : pg.name_fr}
                          </option>
                        ))}
                      </select>
                      <button className="btn xs ghost" onClick={() => setProgEditing(false)}
                        disabled={progBusy} style={{ fontSize: 10, padding: "1px 6px" }}>
                        {lang === "fr" ? "Annuler" : "Cancel"}
                      </button>
                    </span>
                  )}
                </>
              )}
            </div>
            <h1 className="page-title" style={{ marginBottom: 4 }}>
              {PROJECT.name}
              {PROJECT.nativeCurrency && PROJECT.nativeCurrency !== currency && (
                <span style={{
                  marginLeft: 10, display: "inline-block", padding: "2px 8px", borderRadius: 999,
                  background: "var(--bg-sunken)", color: "var(--text-faint)",
                  border: "1px solid var(--line-faint)",
                  fontSize: 11, fontWeight: 500, verticalAlign: "middle",
                }} title={lang === "fr" ? "Devise native du projet (les montants sont convertis pour l'affichage)" : "Project's native currency (amounts are converted for display)"}>
                  {lang === "fr" ? "Native : " : "Native: "}{PROJECT.nativeCurrency}
                </span>
              )}
            </h1>
            <div className="page-sub" style={{ maxWidth: 760 }}>{PROJECT.short}</div>
          </div>
          <div className="pd-header-actions">
            {/* "Suivre" / "Discussion" removed — placeholders with no
                backing feature confused beginners. "Exporter" now
                produces a CSV with the project's headline data so the
                button does something concrete. "Modifier" deep-links
                to the Projects screen where the existing edit flow
                lives (transfer org, set lead, rename, etc.). */}
            <button className="btn sm"
              onClick={() => {
                if (!window.melr || !window.melr.exportCSV) return;
                const date = new Date().toISOString().slice(0, 10);
                window.melr.exportCSV("project-" + (PROJECT.id || "x") + "-" + date + ".csv", [{
                  code:        PROJECT.id,
                  name:        projectName(PROJECT, lang),
                  sector:      (() => { const s = sectorById(PROJECT.sector); return s ? (lang === "en" ? s.en : s.fr) : ""; })(),
                  countries:   projectCountries(PROJECT, lang),
                  lead:        PROJECT.lead,
                  budget:      PROJECT.budget,
                  disbursed:   PROJECT.disbursed,
                  progress:    Math.round(PROJECT.progress || 0),
                  phase:       lang === "en" ? PROJECT.phaseEn : PROJECT.phaseFr,
                  risk:        PROJECT.risk,
                  sites:       PROJECT.sites,
                  indicators:  PROJECT.indic,
                  donors:      PROJECT.donor || "",
                }], [
                  { key: "code",       label: "Code" },
                  { key: "name",       label: lang === "fr" ? "Nom" : "Name" },
                  { key: "sector",     label: lang === "fr" ? "Secteur" : "Sector" },
                  { key: "countries",  label: lang === "fr" ? "Pays" : "Countries" },
                  { key: "lead",       label: lang === "fr" ? "Responsable" : "Lead" },
                  { key: "budget",     label: lang === "fr" ? "Budget (M)" : "Budget (M)" },
                  { key: "disbursed",  label: lang === "fr" ? "Décaissé (M)" : "Disbursed (M)" },
                  { key: "progress",   label: lang === "fr" ? "Avancement %" : "Progress %" },
                  { key: "phase",      label: lang === "fr" ? "Phase" : "Phase" },
                  { key: "risk",       label: lang === "fr" ? "Risque" : "Risk" },
                  { key: "sites",      label: "Sites" },
                  { key: "indicators", label: lang === "fr" ? "Indicateurs" : "Indicators" },
                  { key: "donors",     label: lang === "fr" ? "Bailleurs" : "Donors" },
                ]);
              }}>
              <Icon.download /> {lang === "fr" ? "Exporter CSV" : "Export CSV"}
            </button>
            {/* Document projet complet (Phase exports · 2026-05) :
                Word ou PDF avec toutes les sections de cadrage
                (Objectifs, ToC, Parties prenantes, Hypotheses). */}
            {supaProject && supaProject.uuid && (
              <button className="btn sm" onClick={() => exportProjectDocument(PROJECT, supaProject.uuid, lang, "docx")}
                title={lang === "fr" ? "Exporter le document de cadrage (Word)" : "Export framing document (Word)"}>
                <Icon.fileText /> Doc · Word
              </button>
            )}
            {supaProject && supaProject.uuid && (
              <button className="btn sm" onClick={() => exportProjectDocument(PROJECT, supaProject.uuid, lang, "pdf")}
                title={lang === "fr" ? "Exporter le document de cadrage (PDF)" : "Export framing document (PDF)"}>
                <Icon.fileText /> Doc · PDF
              </button>
            )}
            {supaProject && supaProject.uuid && (
              <button className="btn sm" onClick={onEditProgress} title={lang === "fr" ? "Modifier l'avancement" : "Update progress"}>
                <Icon.edit /> {lang === "fr" ? "Avancement" : "Progress"}
              </button>
            )}
            {supaProject && supaProject.uuid && (
              <button className="btn sm primary" onClick={() => setEditProjectOpen(true)}
                title={lang === "fr" ? "Modifier les attributs du projet" : "Edit project attributes"}>
                <Icon.edit /> {lang === "fr" ? "Modifier" : "Edit"}
              </button>
            )}
          </div>
        </div>

        {/* Phase progress */}
        <div className="pd-phases">
          {[
            { k: "appraisal", l: lang === "fr" ? "Évaluation ex ante" : "Ex-ante appraisal" },
            { k: "inception", l: lang === "fr" ? "Démarrage" : "Inception" },
            { k: "implementation", l: lang === "fr" ? "Mise en œuvre" : "Implementation" },
            { k: "closing", l: lang === "fr" ? "Clôture" : "Closing" },
            { k: "evaluation", l: lang === "fr" ? "Évaluation finale" : "Final evaluation" },
          ].map((p, i) => (
            <div key={p.k} className={"pd-phase " + (i < PROJECT.phaseStep ? "done" : i === PROJECT.phaseStep ? "cur" : "todo")}>
              <div className="pd-phase-bar"></div>
              <div className="pd-phase-label">{p.l}</div>
            </div>
          ))}
        </div>

        {/* Tabs · chaque tab porte un --tab-hue OKLCH pour la coloration */}
        <div className="pd-tabs">
          {TABS.map((tb) => (
            <button key={tb.k}
              className={"pd-tab" + (tab === tb.k ? " active" : "")}
              onClick={() => setTab(tb.k)}
              data-hue={tb.hue != null ? tb.hue : undefined}
              style={tb.hue != null ? { "--tab-hue": tb.hue } : undefined}>
              {tb.l} {tb.c !== undefined && <span className="pd-tab-c">{tb.c}</span>}
            </button>
          ))}
        </div>
      </div>

      {tab === "overview" && <PDOverview P={PROJECT} INDICATORS={INDICATORS} MILESTONES={MILESTONES} ACTIVITY={ACTIVITY} lang={lang} kpisOverride={PORT && PORT.kpis} fmtM={fmtM} />}

      {/* Phase 2C : 4 onglets de cadrage avec saisie/edition complete.
          Chacun branche sur sa table SQL respective via les hooks dedies
          du data layer. */}
      {tab === "objectives" && <PDObjectives lang={lang} projectUuid={supaProject && supaProject.uuid} />}
      {tab === "toc" && <PDToc lang={lang} projectUuid={supaProject && supaProject.uuid} />}
      {tab === "stakeholders_init" && <PDStakeholdersInit lang={lang} projectUuid={supaProject && supaProject.uuid} />}
      {tab === "assumptions" && <PDAssumptions lang={lang} projectUuid={supaProject && supaProject.uuid} />}

      {tab === "planning" && <PDPlanning project={base} plan={plan} lang={lang} projectUuid={supaProject && supaProject.uuid} onPlanChanged={refreshPlan} />}
      {tab === "indicators" && (PORT
        ? <PDIndicatorsCNUCED INDICATORS={INDICATORS} lang={lang} projectCode={base && base.id} onChanged={() => refreshSupa && refreshSupa()} />
        : <PDIndicators INDICATORS={INDICATORS} lang={lang} projectCode={base && base.id}
            onChanged={() => refreshSupa && refreshSupa()}
            isSuperAdmin={isSuperAdmin} isAdmin={isAdmin}
            canEditIndicators={canEditIndicators} hasPerm={hasPerm} />)}
      {tab === "sites" && <PDSites lang={lang} SITES={PORT && PORT.sites} />}
      {tab === "budget" && <PDBudget P={PROJECT} lang={lang} LINES={PORT && PORT.budgetLines} fmtM={fmtM} projectUuid={supaProject && supaProject.uuid} />}
      {tab === "team" && <PDTeam lang={lang} TEAM={PORT && PORT.team} projectUuid={supaProject && supaProject.uuid} projectOrgId={supaProject && supaProject.organizationId} />}
      {tab === "files" && <PDFiles FILES={FILES} lang={lang} projectUuid={supaProject && supaProject.uuid} />}
      {tab === "risks" && <PDRisks RISKS={RISKS} lang={lang} projectUuid={supaProject && supaProject.uuid} />}
      {tab === "activity" && <PDActivity ACTIVITY={ACTIVITY} lang={lang} projectUuid={supaProject && supaProject.uuid} />}

      {/* Modal · edition complete des attributs du projet */}
      {editProjectOpen && supaProject && (
        <EditProjectModal
          lang={lang}
          project={supaProject}
          onClose={() => setEditProjectOpen(false)}
          onSaved={async () => { setEditProjectOpen(false); if (refreshSupa) await refreshSupa(); }}
        />
      )}
    </div>
  );
}

// =====================================================================
// EditProjectModal · édition complète des attributs du projet
// ---------------------------------------------------------------------
// Remplace l'ancien alert() placeholder. Tous les champs principaux
// modifiables : code, nom (fr/en), nom court, secteur, statut, risque,
// progression, dates, budget/decaissé/engagé (en millions natifs ->
// raw au save), devise.
//
// Champs intentionnellement omis (gérés ailleurs) :
//   - organization_id : transfert d'org → écran Projets
//   - programme_id    : transfert de programme → écran Projets
//   - lead_user_id    : bouton "Définir le responsable" sur la liste
//   - me_lead_id      : pas d'UI dédiée pour l'instant
// =====================================================================
function EditProjectModal({ lang, project, onClose, onSaved }) {
  const Modal = window.Modal;
  const { useState } = React;
  // project ici est le shape mappé par mapProjectRow (uuid, id=code, nameFr,
  // budget en M<devise>, etc.). On l'utilise pour pré-remplir et on
  // construit un patch sql-friendly (snake_case) au save.
  const [code,     setCode]     = useState(project.id || "");
  const [nameFr,   setNameFr]   = useState(project.nameFr || "");
  const [nameEn,   setNameEn]   = useState(project.nameEn || "");
  const [shortFr,  setShortFr]  = useState(project.shortFr || "");
  const [shortEn,  setShortEn]  = useState(project.shortEn || "");
  const [sectorId, setSectorId] = useState(project.sector || "");
  const [status,   setStatus]   = useState(project.status || "inception");
  const [risk,     setRisk]     = useState(project.risk || "ok");
  const [progress, setProgress] = useState(project.progress != null ? String(project.progress) : "0");
  // budget/disbursed/committed sont en MILLIONS de la devise native dans
  // le shape mappé. On les édite tels quels puis on multiplie par 1M au save.
  const [budgetM,    setBudgetM]    = useState(project.budget    != null ? String(project.budget)    : "0");
  const [disbursedM, setDisbursedM] = useState(project.disbursed != null ? String(project.disbursed) : "0");
  const [committedM, setCommittedM] = useState(project.committed != null ? String(project.committed) : "0");
  const [currency, setCurrency]     = useState(project.currency || project.nativeCurrency || "EUR");
  const [startDate, setStartDate]   = useState(project.startDate || "");
  const [endDate,   setEndDate]     = useState(project.endDate   || "");
  const [busy, setBusy] = useState(false);
  const [err,  setErr]  = useState(null);

  // Liste des secteurs pour le dropdown. Le hook useSectors() retourne
  // { data, loading, refresh } où data = [{ id, fr, en, color, bg, icon,
  // is_builtin, organization_id }] (champs deja mappes — PAS name_fr/en).
  // SELECT est RLS-public, donc tout authentifie peut lire.
  // On appelle le hook a chaque render (regle des hooks) avec fallback
  // identite si jamais window.melr n'est pas pret (cas extreme).
  const _useSectorsHook = (window.melr && window.melr.useSectors) || (() => ({ data: [] }));
  const { data: sectors } = _useSectorsHook();

  const STATUSES = [
    { v: "appraisal",      fr: "Évaluation ex ante", en: "Appraisal" },
    { v: "inception",      fr: "Démarrage",          en: "Inception" },
    { v: "active",         fr: "En cours",           en: "Active" },
    { v: "paused",         fr: "En pause",           en: "Paused" },
    { v: "closing",        fr: "Clôture",            en: "Closing" },
    { v: "closed",         fr: "Clos",               en: "Closed" },
  ];
  const RISKS = [
    { v: "ok",    fr: "OK",         en: "OK" },
    { v: "warn",  fr: "Attention",  en: "Warning" },
    { v: "bad",   fr: "Critique",   en: "Critical" },
  ];

  const save = async () => {
    setErr(null);
    if (!code.trim())   { setErr(lang === "fr" ? "Code requis." : "Code required.");   return; }
    if (!nameFr.trim()) { setErr(lang === "fr" ? "Nom français requis." : "French name required."); return; }
    setBusy(true);
    try {
      const num = (s) => { const n = parseFloat(String(s).replace(",", ".")); return isNaN(n) ? 0 : n; };
      const patch = {
        code:       code.trim(),
        name_fr:    nameFr.trim(),
        name_en:    nameEn.trim() || null,
        short_fr:   shortFr.trim() || null,
        short_en:   shortEn.trim() || null,
        sector_id:  sectorId || null,
        status,
        risk,
        progress:   Math.max(0, Math.min(100, parseInt(progress, 10) || 0)),
        // Conversion millions -> raw (×1_000_000) pour l'écriture en base.
        budget:     Math.round(num(budgetM)    * 1_000_000),
        disbursed:  Math.round(num(disbursedM) * 1_000_000),
        committed:  Math.round(num(committedM) * 1_000_000),
        currency:   (currency || "EUR").toUpperCase().slice(0, 3),
        start_date: startDate || null,
        end_date:   endDate   || null,
      };
      await window.melr.updateProject(project.uuid, patch);
      await onSaved();
    } catch (e) { setErr(e.message); setBusy(false); }
  };

  const inp = { padding: "8px 10px", borderRadius: 6, border: "1px solid var(--line)", fontSize: 13, width: "100%", boxSizing: "border-box", background: "var(--bg, white)", color: "var(--text)" };
  const lbl = { display: "block", fontSize: 11, color: "var(--text-faint)", marginBottom: 3, textTransform: "uppercase", letterSpacing: "0.04em" };

  return (
    <Modal title={(lang === "fr" ? "Modifier le projet · " : "Edit project · ") + (project.id || "")}
      onClose={busy ? null : onClose} size="lg"
      footer={<>
        <button className="btn sm" onClick={onClose} disabled={busy}>{lang === "fr" ? "Annuler" : "Cancel"}</button>
        <button className="btn sm primary" onClick={save} disabled={busy}>{busy ? "…" : (lang === "fr" ? "Enregistrer" : "Save")}</button>
      </>}>
      <div style={{ display: "grid", gap: 12 }}>
        {/* Code + secteur */}
        <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 10 }}>
          <div><label style={lbl}>{lang === "fr" ? "Code projet" : "Project code"} *</label>
            <input style={inp} value={code} onChange={(e) => setCode(e.target.value)} maxLength={20} placeholder="P-612" />
          </div>
          <div><label style={lbl}>{lang === "fr" ? "Secteur" : "Sector"}</label>
            <select style={inp} value={sectorId} onChange={(e) => setSectorId(e.target.value)}>
              <option value="">— {lang === "fr" ? "Aucun" : "None"} —</option>
              {(sectors || []).map((s) => (
                // useSectors() expose {id, fr, en, color, ...} (mappe) — PAS name_fr/name_en
                <option key={s.id} value={s.id}>{lang === "fr" ? s.fr : (s.en || s.fr)}</option>
              ))}
            </select>
          </div>
        </div>
        {/* Nom complet FR / EN */}
        <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 10 }}>
          <div><label style={lbl}>{lang === "fr" ? "Nom (français)" : "Name (French)"} *</label>
            <input style={inp} value={nameFr} onChange={(e) => setNameFr(e.target.value)} />
          </div>
          <div><label style={lbl}>{lang === "fr" ? "Nom (anglais)" : "Name (English)"}</label>
            <input style={inp} value={nameEn} onChange={(e) => setNameEn(e.target.value)} />
          </div>
        </div>
        {/* Nom court (acronymes) */}
        <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 10 }}>
          <div><label style={lbl}>{lang === "fr" ? "Nom court (fr)" : "Short name (fr)"}</label>
            <input style={inp} value={shortFr} onChange={(e) => setShortFr(e.target.value)} placeholder="PADS-PI" />
          </div>
          <div><label style={lbl}>{lang === "fr" ? "Nom court (en)" : "Short name (en)"}</label>
            <input style={inp} value={shortEn} onChange={(e) => setShortEn(e.target.value)} />
          </div>
        </div>
        {/* Statut + risque + avancement */}
        <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: 10 }}>
          <div><label style={lbl}>{lang === "fr" ? "Statut" : "Status"}</label>
            <select style={inp} value={status} onChange={(e) => setStatus(e.target.value)}>
              {STATUSES.map((s) => <option key={s.v} value={s.v}>{lang === "fr" ? s.fr : s.en}</option>)}
            </select>
          </div>
          <div><label style={lbl}>{lang === "fr" ? "Risque" : "Risk"}</label>
            <select style={inp} value={risk} onChange={(e) => setRisk(e.target.value)}>
              {RISKS.map((r) => <option key={r.v} value={r.v}>{lang === "fr" ? r.fr : r.en}</option>)}
            </select>
          </div>
          <div><label style={lbl}>{lang === "fr" ? "Avancement (%)" : "Progress (%)"}</label>
            <input style={inp} type="number" min={0} max={100} value={progress} onChange={(e) => setProgress(e.target.value)} />
          </div>
        </div>
        {/* Dates */}
        <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 10 }}>
          <div><label style={lbl}>{lang === "fr" ? "Date de début" : "Start date"}</label>
            <input style={inp} type="date" value={startDate} onChange={(e) => setStartDate(e.target.value)} />
          </div>
          <div><label style={lbl}>{lang === "fr" ? "Date de fin" : "End date"}</label>
            <input style={inp} type="date" value={endDate} onChange={(e) => setEndDate(e.target.value)} />
          </div>
        </div>
        {/* Budget / Decaisse / Engage / Devise */}
        <div>
          <label style={lbl}>{lang === "fr" ? "Finances (en millions)" : "Finances (in millions)"}</label>
          <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr 100px", gap: 10 }}>
            <div><label style={{ ...lbl, marginTop: 0, fontSize: 10 }}>{lang === "fr" ? "Budget" : "Budget"}</label>
              <input style={inp} type="number" step="any" value={budgetM} onChange={(e) => setBudgetM(e.target.value)} />
            </div>
            <div><label style={{ ...lbl, marginTop: 0, fontSize: 10 }}>{lang === "fr" ? "Décaissé" : "Disbursed"}</label>
              <input style={inp} type="number" step="any" value={disbursedM} onChange={(e) => setDisbursedM(e.target.value)} />
            </div>
            <div><label style={{ ...lbl, marginTop: 0, fontSize: 10 }}>{lang === "fr" ? "Engagé" : "Committed"}</label>
              <input style={inp} type="number" step="any" value={committedM} onChange={(e) => setCommittedM(e.target.value)} />
            </div>
            <div><label style={{ ...lbl, marginTop: 0, fontSize: 10 }}>{lang === "fr" ? "Devise" : "Currency"}</label>
              <input style={inp} value={currency} onChange={(e) => setCurrency(e.target.value)} maxLength={3} placeholder="EUR" />
            </div>
          </div>
          <div className="text-faint" style={{ fontSize: 10.5, marginTop: 4 }}>
            {lang === "fr"
              ? "Les montants sont saisis en millions de la devise indiquée et convertis en valeur brute pour le stockage."
              : "Amounts are entered in millions of the currency shown and stored as raw values."}
          </div>
        </div>
        {/* Champs non éditables ici (rappel) */}
        <div style={{ padding: 10, background: "var(--bg-sunken)", borderRadius: 6, fontSize: 11.5, color: "var(--text-faint)" }}>
          {lang === "fr"
            ? "ℹ️ Pour transférer le projet vers une autre organisation/programme ou changer le responsable, utilisez l'écran « Projets »."
            : "ℹ️ To transfer the project to another organization/programme or change its lead, use the 'Projects' screen."}
        </div>
        {err && <div style={{ color: "#b91c1c", fontSize: 12.5 }}>{err}</div>}
      </div>
    </Modal>
  );
}

function PDOverview({ P, INDICATORS, MILESTONES, ACTIVITY, lang, kpisOverride, fmtM }) {
  // Fallback for fixture / standalone use: if no fmtM is passed, format
  // amounts as plain "X.XX M€" (legacy behaviour).
  if (!fmtM) fmtM = (v) => (v != null ? (Number(v).toFixed(2) + " M€") : "—");
  const DEFAULT_KPIS = [
    { l: lang === "fr" ? "Avancement" : "Progress", v: P.progress + "%", s: lang === "fr" ? "vs plan " + P.plan + "%" : "vs plan " + P.plan + "%", tone: "accent" },
    { l: lang === "fr" ? "Décaissé" : "Disbursed", v: fmtM(P.disbursed, P.nativeCurrency), s: P.budget > 0 ? Math.round(P.disbursed / P.budget * 100) + "% " + (lang === "fr" ? "du budget" : "of budget") : "—", tone: "green" },
    { l: lang === "fr" ? "Bénéficiaires" : "Beneficiaries", v: P.beneficiaries, s: lang === "fr" ? "cumul atteint" : "cumulative reached" },
    { l: lang === "fr" ? "Sites actifs" : "Active sites", v: P.sites, s: "3 " + (lang === "fr" ? "pays" : "countries") },
    { l: "VAN", v: "+" + fmtM(P.npv), s: "TRI " + P.irr + "%", tone: "green" },
    { l: "DSCR", v: P.dscr, s: lang === "fr" ? "couverture dette" : "debt coverage" },
  ];
  const KPIS = kpisOverride || DEFAULT_KPIS;

  return (
    <>
      <div className="grid cols-6" style={{ marginBottom: 16 }}>
        {KPIS.map((k, i) => (
          <div key={i} className="kpi">
            <div className="kpi-label">{k.l}</div>
            <div className="kpi-value" style={k.tone ? { color: `var(--${k.tone === "accent" ? "accent" : k.tone})` } : {}}>{k.v}</div>
            <div className="kpi-sub">{k.s}</div>
          </div>
        ))}
      </div>

      <div className="grid cols-3" style={{ gridTemplateColumns: "1.6fr 1fr", gap: 14 }}>
        {/* Indicators summary */}
        <div className="card">
          <div className="card-head">
            <div className="card-title">{lang === "fr" ? "Indicateurs clés" : "Key indicators"}</div>
            <button className="btn xs ghost">{lang === "fr" ? "Tout voir" : "View all"} <Icon.chevronRight /></button>
          </div>
          <div className="card-body flush">
            {INDICATORS.filter((ind) => !ind.textual && ind.trend && ind.trend.length).slice(0, 5).map((ind) => (
              <div key={ind.code} className="pd-ind-row">
                <div style={{ flex: 1, minWidth: 0 }}>
                  <div className="row gap-xs" style={{ marginBottom: 3 }}>
                    <span className="mono text-faint" style={{ fontSize: 10 }}>{ind.code}</span>
                    <span className={"pill xs " + (ind.level === "outcome" ? "violet" : "")} style={{ fontSize: 9 }}>{ind.level === "outcome" ? (lang === "fr" ? "Effet" : "Outcome") : (lang === "fr" ? "Produit" : "Output")}</span>
                  </div>
                  <div className="strong" style={{ fontSize: 12.5, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{ind.name}</div>
                </div>
                <PDSpark data={ind.trend} tone={ind.status} />
                <div style={{ textAlign: "right", minWidth: 110 }}>
                  <div className="mono strong" style={{ fontSize: 13 }}>{ind.cur}{ind.unit}</div>
                  <div className="text-faint" style={{ fontSize: 10.5 }}>{lang === "fr" ? "cible" : "target"} {ind.target}{ind.unit}</div>
                </div>
                <div style={{ width: 100 }}>
                  <div className="bar"><div className="bar-fill" style={{ width: Math.min(100, Math.round((ind.cur - ind.base) / (ind.target - ind.base) * 100)) + "%", background: ind.status === "ok" ? "var(--green)" : ind.status === "warn" ? "var(--amber)" : "var(--red)" }}></div></div>
                </div>
              </div>
            ))}
          </div>
        </div>

        {/* Metadata card */}
        <div className="card">
          <div className="card-head"><div className="card-title">{lang === "fr" ? "Fiche projet" : "Project info"}</div></div>
          <div className="card-body" style={{ display: "flex", flexDirection: "column", gap: 10 }}>
            {[
              { l: lang === "fr" ? "Pays" : "Countries", v: P.countries },
              { l: lang === "fr" ? "Secteur" : "Sector", v: P.sector },
              { l: lang === "fr" ? "Bailleurs" : "Donors", v: P.donor },
              { l: lang === "fr" ? "Période" : "Period", v: P.startDate + " → " + P.endDate },
              { l: lang === "fr" ? "Chef de projet" : "Project lead", v: P.lead, av: true },
              { l: lang === "fr" ? "Responsable S&E" : "M&E lead", v: P.meLead, av: true },
            ].map((row, i) => (
              <div key={i} className="pd-meta-row">
                <div className="text-faint" style={{ fontSize: 11, textTransform: "uppercase", letterSpacing: 0.4 }}>{row.l}</div>
                <div className="row gap-xs" style={{ fontSize: 12.5 }}>
                  {row.av && <span className="avatar xxs" style={{ background: avColorPD(row.v) }}>{initialsPD(row.v)}</span>}
                  <span className="strong">{row.v}</span>
                </div>
              </div>
            ))}
          </div>
        </div>
      </div>

      {/* Timeline + Activity */}
      <div className="grid cols-2" style={{ gap: 14, gridTemplateColumns: "1.6fr 1fr", marginTop: 14 }}>
        <div className="card">
          <div className="card-head"><div className="card-title">{lang === "fr" ? "Jalons" : "Milestones"}</div></div>
          <div className="card-body">
            <div className="pd-milestones">
              {MILESTONES.map((m, i) => (
                <div key={i} className={"pd-ms " + m.state}>
                  <div className="pd-ms-dot">{m.state === "done" ? <Icon.check /> : <span></span>}</div>
                  <div style={{ flex: 1 }}>
                    <div className="strong" style={{ fontSize: 12.5 }}>{m.t}</div>
                    <div className="text-faint mono" style={{ fontSize: 11 }}>{m.d}</div>
                  </div>
                </div>
              ))}
            </div>
          </div>
        </div>

        <div className="card">
          <div className="card-head">
            <div className="card-title">{lang === "fr" ? "Activité récente" : "Recent activity"}</div>
            <button className="btn xs ghost">{lang === "fr" ? "Tout" : "All"} <Icon.chevronRight /></button>
          </div>
          <div className="card-body">
            {ACTIVITY.map((a, i) => {
              const Ic = Icon[a.t] || Icon.info;
              return (
                <div key={i} className="pd-act">
                  <div className={"pd-act-icon tone-" + a.tone}><Ic /></div>
                  <div style={{ flex: 1, fontSize: 12 }}>
                    <div><span className="strong">{a.who}</span> <span className="muted">{a.a}</span></div>
                    <div className="text-faint" style={{ fontSize: 10.5 }}>{a.role} · {a.w}</div>
                  </div>
                </div>
              );
            })}
          </div>
        </div>
      </div>
    </>
  );
}

function PDIndicators({ INDICATORS, lang, projectCode, onChanged, isSuperAdmin, isAdmin, canEditIndicators, hasPerm }) {
  const { useState: useStatePDI } = React;
  // Tri-state filter for the Niveau (level) column. Defaults to "all".
  // Values: 'all' | 'output' | 'outcome' | 'impact' | 'context'.
  const [levelFilter, setLevelFilter] = useStatePDI("all");
  const [addOpen, setAddOpen] = useStatePDI(false);
  const visible = levelFilter === "all"
    ? INDICATORS
    : INDICATORS.filter((i) => i.level === levelFilter);
  // The "Ajouter" button only makes sense on a real (DB-backed) project
  // AND for users who have the indicators.edit permission (or admin /
  // super-admin). Falling back to canEditIndicators which is set by
  // ProjectDetail/App.jsx from screenProps; older invocation sites
  // without the prop are treated as "no permission" — fail closed.
  const mayEdit = !!isSuperAdmin || !!isAdmin || !!canEditIndicators
    || (typeof hasPerm === "function" && (hasPerm("indicators.edit") || hasPerm("users.manage")));
  const canAdd = !!projectCode && !!window.CreateIndicatorModal && mayEdit;
  return (
    <div className="card">
      <div className="card-head"><div className="card-title">{lang === "fr" ? "Cadre logique — Indicateurs" : "Logframe — Indicators"}</div>
        <div className="row gap-sm" style={{ alignItems: "center" }}>
          {/* Inline filter dropdown — replaces the previous placeholder
              <button>Filtrer</button>. Compact + accessible without a
              modal layer. */}
          <Icon.filter />
          <select value={levelFilter} onChange={(e) => setLevelFilter(e.target.value)}
            style={{ padding: "3px 6px", fontSize: 11.5, borderRadius: 4, border: "1px solid var(--line)", background: "var(--bg)", color: "var(--text)" }}>
            <option value="all">{lang === "fr" ? "Tous niveaux" : "All levels"}</option>
            <option value="output">{lang === "fr" ? "Produit" : "Output"}</option>
            <option value="outcome">{lang === "fr" ? "Effet" : "Outcome"}</option>
            <option value="impact">Impact</option>
            <option value="context">Context</option>
          </select>
          {/* Render the Ajouter button only when the user has the
              indicators.edit permission. For non-editors we hide it
              entirely rather than show a disabled stub — they're not
              expected to create indicators at all, and a greyed-out
              button on every page would just be visual noise. */}
          {mayEdit && (
            <button className="btn xs" onClick={() => setAddOpen(true)} disabled={!canAdd}
              title={canAdd ? "" : (lang === "fr" ? "Démo — ouvrez un vrai projet pour ajouter" : "Demo — open a real project to add")}>
              <Icon.plus /> {lang === "fr" ? "Ajouter" : "Add"}
            </button>
          )}
        </div>
      </div>
      {addOpen && window.CreateIndicatorModal && (() => {
        const CIM = window.CreateIndicatorModal;
        return (
          <CIM
            lang={lang}
            defaultProject={projectCode}
            onClose={() => setAddOpen(false)}
            onCreated={async () => { if (onChanged) await onChanged(); setAddOpen(false); }}
          />
        );
      })()}
      <div className="card-body flush">
        <table className="tbl">
          <thead><tr>
            <th style={{ width: 60 }}>Code</th>
            <th style={{ width: 70 }}>{lang === "fr" ? "Niveau" : "Level"}</th>
            <th>{lang === "fr" ? "Libellé" : "Name"}</th>
            <th className="num">{lang === "fr" ? "Base" : "Base"}</th>
            <th className="num">{lang === "fr" ? "Actuel" : "Current"}</th>
            <th className="num">{lang === "fr" ? "Cible" : "Target"}</th>
            <th>{lang === "fr" ? "Tendance" : "Trend"}</th>
            <th>{lang === "fr" ? "Progrès" : "Progress"}</th>
            <th>Statut</th>
          </tr></thead>
          <tbody>
            {visible.length === 0 && (
              <tr><td colSpan={9} style={{ padding: 18, textAlign: "center", color: "var(--text-faint)", fontSize: 12 }}>
                {lang === "fr" ? "Aucun indicateur à ce niveau." : "No indicator at this level."}
              </td></tr>
            )}
            {visible.map((ind) => {
              const pct = Math.min(100, Math.round((ind.cur - ind.base) / (ind.target - ind.base) * 100));
              return (
                <tr key={ind.code}>
                  <td className="mono text-faint">{ind.code}</td>
                  <td><span className={"pill xs " + (ind.level === "outcome" ? "violet" : "")} style={{ fontSize: 9.5 }}>{ind.level === "outcome" ? (lang === "fr" ? "Effet" : "Outcome") : (lang === "fr" ? "Produit" : "Output")}</span></td>
                  <td className="strong">{ind.name}</td>
                  <td className="num mono muted">{ind.base}{ind.unit}</td>
                  <td className="num mono strong">{ind.cur}{ind.unit}</td>
                  <td className="num mono">{ind.target}{ind.unit}</td>
                  <td><PDSpark data={ind.trend} tone={ind.status} /></td>
                  <td><div className="row gap-sm"><div className="bar" style={{ width: 80 }}><div className="bar-fill" style={{ width: pct + "%", background: ind.status === "ok" ? "var(--green)" : "var(--amber)" }}></div></div><span className="mono num-sm">{pct}%</span></div></td>
                  <td>{ind.status === "ok" ? <span className="pill green dot">OK</span> : <span className="pill amber dot">{lang === "fr" ? "Vigilance" : "Watch"}</span>}</td>
                </tr>
              );
            })}
          </tbody>
        </table>
      </div>
    </div>
  );
}

function PDSites({ lang, SITES: SITES_OVERRIDE }) {
  const { useState: useStatePDS } = React;
  const DEFAULT_SITES = [
    { c: "ML", n: "CSCom Tombouctou", t: "Centre", reg: "Tombouctou", b: "8 412", s: "ok", ind: 11 },
    { c: "ML", n: "CSCom Gao Centre", t: "Centre", reg: "Gao", b: "12 207", s: "warn", ind: 11 },
    { c: "ML", n: "CSCom Mopti Sud", t: "Centre", reg: "Mopti", b: "9 845", s: "ok", ind: 11 },
    { c: "BF", n: "CSPS Dori", t: "Centre", reg: "Sahel", b: "6 102", s: "bad", ind: 11 },
    { c: "BF", n: "CSPS Djibo", t: "Centre", reg: "Sahel", b: "7 538", s: "warn", ind: 11 },
    { c: "BF", n: "CSPS Ouahigouya", t: "Centre", reg: "Nord", b: "11 247", s: "ok", ind: 11 },
    { c: "NE", n: "CSI Tillabéri", t: "Centre", reg: "Tillabéri", b: "10 822", s: "ok", ind: 11 },
    { c: "NE", n: "CSI Tahoua", t: "Centre", reg: "Tahoua", b: "13 506", s: "ok", ind: 11 },
    { c: "NE", n: "CSI Maradi-1", t: "Centre", reg: "Maradi", b: "15 213", s: "warn", ind: 11 },
    { c: "ML", n: "CSCom Ségou", t: "Centre", reg: "Ségou", b: "8 117", s: "ok", ind: 11 },
    { c: "ML", n: "CSCom Kayes", t: "Centre", reg: "Kayes", b: "9 388", s: "ok", ind: 11 },
    { c: "BF", n: "CSPS Kaya", t: "Centre", reg: "Centre-Nord", b: "10 042", s: "ok", ind: 11 },
  ];
  const SITES = SITES_OVERRIDE || DEFAULT_SITES;
  const isPort = !!SITES_OVERRIDE;
  const countryName = (c) => ({ ML: "Mali", BF: "Burkina Faso", NE: "Niger", SN: "Sénégal" }[c] || c);
  // 3-way view toggle: 'map' (regional groups) / 'cards' (grid of tiles) /
  // 'table' (default). Stored locally — no persistence needed.
  const [siteView, setSiteView] = useStatePDS("table");
  const stateLabel = (s) => s === "ok" ? (lang === "fr" ? "OK" : "OK")
                          : s === "warn" ? (lang === "fr" ? "Vigilance" : "Watch")
                          : (lang === "fr" ? "Risque" : "Risk");
  const statePillClass = (s) => s === "ok" ? "pill green dot"
                              : s === "warn" ? "pill amber dot"
                              : "pill red dot";
  return (
    <div className="card">
      <div className="card-head"><div className="card-title">{isPort ? (lang === "fr" ? "Postes à quai & terminaux" : "Berths & terminals") : (lang === "fr" ? "Sites couverts" : "Covered sites")} <span className="muted">· {SITES.length}</span></div>
        <div className="seg sm">
          <button className={"seg-btn" + (siteView === "map" ? " active" : "")}
                  onClick={() => setSiteView("map")}
                  title={lang === "fr" ? "Vue carte (regroupement par région)" : "Map view (grouped by region)"}>
            <Icon.map />
          </button>
          <button className={"seg-btn" + (siteView === "cards" ? " active" : "")}
                  onClick={() => setSiteView("cards")}
                  title={lang === "fr" ? "Vue vignettes" : "Card view"}>
            <Icon.layout />
          </button>
          <button className={"seg-btn" + (siteView === "table" ? " active" : "")}
                  onClick={() => setSiteView("table")}
                  title={lang === "fr" ? "Vue tableau" : "Table view"}>
            <Icon.spreadsheet />
          </button>
        </div>
      </div>
      <div className={"card-body" + (siteView === "table" ? " flush" : "")} style={siteView !== "table" ? { padding: 14 } : null}>
        {siteView === "table" && (
          <>
            <table className="tbl">
              <thead><tr>
                <th>{isPort ? (lang === "fr" ? "Poste / Terminal" : "Berth / Terminal") : (lang === "fr" ? "Site" : "Site")}</th>
                <th>{isPort ? (lang === "fr" ? "Type" : "Type") : (lang === "fr" ? "Pays" : "Country")}</th>
                <th>{isPort ? (lang === "fr" ? "Zone" : "Zone") : (lang === "fr" ? "Région" : "Region")}</th>
                <th className="num">{isPort ? (lang === "fr" ? "Volume / trafic" : "Volume / traffic") : (lang === "fr" ? "Bénéficiaires" : "Beneficiaries")}</th>
                <th className="num">{lang === "fr" ? "Indicateurs" : "Indicators"}</th>
                <th>{lang === "fr" ? "État" : "State"}</th>
              </tr></thead>
              <tbody>
                {SITES.map((s, i) => (
                  <tr key={i}>
                    <td><span className="row gap-xs">{!isPort && <span className="mono" style={{ fontSize: 11, background: "var(--bg-sunken)", padding: "1px 5px", borderRadius: 3, color: "var(--text-faint)" }}>{s.c}</span>}<span className="strong">{s.n}</span></span></td>
                    <td className="muted">{isPort ? s.t : countryName(s.c)}</td>
                    <td className="muted">{s.reg}</td>
                    <td className="num mono">{s.b}</td>
                    <td className="num mono">{s.ind}</td>
                    <td><span className={statePillClass(s.s)}>{stateLabel(s.s)}</span></td>
                  </tr>
                ))}
              </tbody>
            </table>
            {!isPort && (
              <div className="text-faint" style={{ padding: "10px 12px", fontSize: 11.5, borderTop: "1px solid var(--line-faint)" }}>
                {lang === "fr" ? "30 sites supplémentaires non affichés — utilisez la recherche ou les filtres." : "30 more sites not shown — use search or filters."}
              </div>
            )}
          </>
        )}
        {siteView === "cards" && (
          <div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(220px, 1fr))", gap: 10 }}>
            {SITES.map((s, i) => (
              <div key={i} className="card" style={{ padding: 12, margin: 0 }}>
                <div style={{ display: "flex", alignItems: "flex-start", gap: 8, marginBottom: 8 }}>
                  {!isPort && (
                    <span className="mono" style={{ fontSize: 10, background: "var(--bg-sunken)", padding: "2px 6px", borderRadius: 3, color: "var(--text-faint)" }}>
                      {s.c}
                    </span>
                  )}
                  <div style={{ flex: 1, minWidth: 0 }}>
                    <div className="strong" style={{ fontSize: 13, lineHeight: 1.3 }}>{s.n}</div>
                    <div className="text-faint" style={{ fontSize: 11, marginTop: 2 }}>
                      {isPort ? s.t : countryName(s.c)} · {s.reg}
                    </div>
                  </div>
                </div>
                <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", fontSize: 11 }}>
                  <div>
                    <span className="mono">{s.b}</span>{" "}
                    <span className="text-faint">{isPort ? (lang === "fr" ? "trafic" : "traffic") : (lang === "fr" ? "bénéf." : "benef.")}</span>
                  </div>
                  <span className={statePillClass(s.s)} style={{ fontSize: 9 }}>{stateLabel(s.s)}</span>
                </div>
                <div className="text-faint" style={{ fontSize: 10.5, marginTop: 4 }}>
                  {s.ind} {lang === "fr" ? "indicateurs" : "indicators"}
                </div>
              </div>
            ))}
          </div>
        )}
        {siteView === "map" && (() => {
          // Group sites by region (since real coords aren't on the schema).
          // Each region renders as a "bubble" sized by site count, coloured
          // by the worst-state site in the group. Gives a quick visual scan
          // of where the project's footprint is + where attention is needed.
          const regions = {};
          SITES.forEach((s) => {
            const key = (isPort ? s.t : countryName(s.c)) + " · " + s.reg;
            if (!regions[key]) regions[key] = { country: isPort ? s.t : countryName(s.c), reg: s.reg, sites: [], worst: "ok" };
            regions[key].sites.push(s);
            if (s.s === "bad" || (s.s === "warn" && regions[key].worst === "ok")) regions[key].worst = s.s;
          });
          const groups = Object.values(regions);
          return (
            <div>
              <div className="text-faint" style={{ fontSize: 11.5, marginBottom: 10 }}>
                {lang === "fr"
                  ? "Vue carte simplifiée — regroupement par région (coordonnées GPS non disponibles pour les sites de ce projet)."
                  : "Simplified map view — grouped by region (GPS coordinates not available on this project's sites)."}
              </div>
              <div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(180px, 1fr))", gap: 10 }}>
                {groups.map((g, i) => {
                  const bg = g.worst === "bad" ? "#fee2e2" : g.worst === "warn" ? "#fef3c7" : "#dcfce7";
                  const border = g.worst === "bad" ? "#dc2626" : g.worst === "warn" ? "#d97706" : "#059669";
                  return (
                    <div key={i} style={{
                      padding: 12, borderRadius: 8, background: bg,
                      border: "2px solid " + border, position: "relative",
                    }}>
                      <div style={{ position: "absolute", top: 8, right: 8, fontSize: 18, fontWeight: 700, color: border }}>
                        {g.sites.length}
                      </div>
                      <div style={{ fontSize: 11, color: "var(--text-faint)", marginBottom: 2 }}>{g.country}</div>
                      <div className="strong" style={{ fontSize: 13 }}>{g.reg}</div>
                      <div style={{ fontSize: 10.5, marginTop: 6 }}>
                        {g.sites.filter((s) => s.s === "ok").length}{" "}
                        <span className="text-faint">{lang === "fr" ? "OK" : "OK"}</span>
                        {g.sites.filter((s) => s.s === "warn").length > 0 && (
                          <> · {g.sites.filter((s) => s.s === "warn").length}{" "}
                            <span className="text-faint">{lang === "fr" ? "veille" : "watch"}</span></>
                        )}
                        {g.sites.filter((s) => s.s === "bad").length > 0 && (
                          <> · {g.sites.filter((s) => s.s === "bad").length}{" "}
                            <span className="text-faint">{lang === "fr" ? "risque" : "risk"}</span></>
                        )}
                      </div>
                    </div>
                  );
                })}
              </div>
            </div>
          );
        })()}
      </div>
    </div>
  );
}

// =====================================================================
// Shared export helpers — capture any DOM element as PNG / JPEG / PDF.
// Used by the ToC infographic, the risk matrix card, the budget burn
// chart card, and the Gantt (via screens-planning.jsx).
//
// Goes through window.melr.captureElement which wraps html2canvas-pro
// (drop-in replacement supporting oklch() / lab() / color-mix()).
// =====================================================================
async function exportDomElement({ ref, format, lang, title, filenamePrefix, scale, fullWidth }) {
  const capture = window.melr && window.melr.captureElement;
  if (!capture) {
    alert(lang === "fr" ? "Bibliothèque d'export indisponible." : "Export library unavailable.");
    return;
  }
  if (!ref || !ref.current) return;
  try {
    // windowWidth = scrollWidth uniquement quand l'element deborde
    // horizontalement (Gantt). Sinon ca force un viewport plus
    // etroit qui peut tronquer les enfants en width:100%.
    const opts = {
      backgroundColor: "#ffffff",
      scale: scale || 2,
      useCORS: true,
    };
    if (fullWidth) opts.windowWidth = ref.current.scrollWidth;
    const canvas = await capture(ref.current, opts);
    const date = new Date().toISOString().slice(0, 10);
    const name = (filenamePrefix || "export") + "-" + date;
    if (format === "png" || format === "jpeg") {
      const mime = format === "jpeg" ? "image/jpeg" : "image/png";
      const ext  = format === "jpeg" ? "jpg" : "png";
      const dataUrl = canvas.toDataURL(mime, format === "jpeg" ? 0.92 : 1);
      const a = document.createElement("a");
      a.href = dataUrl;
      a.download = name + "." + ext;
      document.body.appendChild(a); a.click(); document.body.removeChild(a);
    } else if (format === "pdf") {
      if (!window.jspdf) {
        alert(lang === "fr" ? "Bibliothèque PDF indisponible." : "PDF library unavailable.");
        return;
      }
      const { jsPDF } = window.jspdf;
      // A4 paysage 842 x 595 pt. Adapt le scale a l'image.
      const pdf = new jsPDF({ orientation: "landscape", unit: "pt", format: "a4" });
      const pageW = 842, pageH = 595;
      const imgW = canvas.width, imgH = canvas.height;
      const ratio = Math.min((pageW - 40) / imgW, (pageH - 80) / imgH);
      const w = imgW * ratio, h = imgH * ratio;
      pdf.setFontSize(13); pdf.setFont(undefined, "bold");
      pdf.setTextColor("#4f46e5");
      pdf.text(title || name, 20, 30);
      pdf.setFontSize(9); pdf.setFont(undefined, "normal");
      pdf.setTextColor("#666666");
      pdf.text(date, 20, 46);
      pdf.setTextColor("#000000");
      pdf.addImage(canvas.toDataURL("image/png", 1), "PNG", (pageW - w) / 2, 60, w, h);
      pdf.save(name + ".pdf");
    }
  } catch (e) {
    alert((lang === "fr" ? "Erreur export : " : "Export error: ") + (e.message || e));
  }
}

// Reusable trio of PNG / JPEG / PDF buttons. Pass the ref of the
// wrapper to capture, plus the lang / title / filename context.
function PDExportButtons({ refToExport, lang, title, filenamePrefix, size }) {
  const cls = "btn " + (size || "xs");
  // marginLeft auto = pousse les boutons a droite quand on est dans
  // un card-head ou un toolbar en flex (le titre reste a gauche).
  return (
    <div style={{ display: "flex", gap: 6, marginLeft: "auto" }}>
      <button className={cls} onClick={() => exportDomElement({ ref: refToExport, format: "png", lang, title, filenamePrefix })} title="PNG">
        <Icon.download /> PNG
      </button>
      <button className={cls + " ghost"} onClick={() => exportDomElement({ ref: refToExport, format: "jpeg", lang, title, filenamePrefix })} title="JPEG">
        <Icon.download /> JPEG
      </button>
      <button className={cls + " ghost"} onClick={() => exportDomElement({ ref: refToExport, format: "pdf", lang, title, filenamePrefix })} title="PDF">
        <Icon.download /> PDF
      </button>
    </div>
  );
}

function PDBudget({ P, lang, LINES: LINES_OVERRIDE, fmtM, projectUuid }) {
  const { useState } = React;
  // Ref de la carte « Decaissement vs plan » pour l'export PNG/JPEG/PDF.
  const burnRef = React.useRef(null);
  if (!fmtM) fmtM = (v) => (v != null ? (Number(v).toFixed(2) + " M€") : "—");
  const DEFAULT_LINES = [
    { c: lang === "fr" ? "Équipements médicaux" : "Medical equipment", b: 0.92, d: 0.78, e: 0.04 },
    { c: lang === "fr" ? "Chaîne du froid" : "Cold chain", b: 0.58, d: 0.51, e: 0.01 },
    { c: lang === "fr" ? "Formation agents" : "Staff training", b: 0.42, d: 0.31, e: 0.06 },
    { c: lang === "fr" ? "Médicaments essentiels" : "Essential medicines", b: 0.68, d: 0.34, e: 0.18 },
    { c: lang === "fr" ? "Réhabilitation CSCom" : "CSCom rehabilitation", b: 0.38, d: 0.14, e: 0.20 },
    { c: lang === "fr" ? "Personnel projet" : "Project staff", b: 0.22, d: 0.11, e: 0.00 },
    { c: lang === "fr" ? "Suivi & évaluation" : "M&E", b: 0.10, d: 0.05, e: 0.01 },
    { c: lang === "fr" ? "Frais de gestion" : "Management overhead", b: 0.06, d: 0.03, e: 0.00 },
  ];

  // Live data from budget_lines kicks in when projectUuid is present.
  // For demo / standalone pages we keep the fixture so the burn chart
  // remains representative.
  const live = window.melr && window.melr.useProjectBudget
    ? window.melr.useProjectBudget(projectUuid || null)
    : { data: [], loading: false, refresh: () => {} };
  const useLive = !!projectUuid;
  const [editing, setEditing] = useState(null); // null | 'new' | row object
  const [busy, setBusy] = useState(false);

  // Live amounts are stored as raw <native currency> in budget_lines.
  // The fixture is already expressed in millions. Convert before
  // showing so both code paths can share the same renderer.
  const liveLines = (live.data || []).map((r) => ({
    id: r.id,
    c:  r.category + (r.label ? " — " + r.label : ""),
    b:  (Number(r.budget)    || 0) / 1_000_000,
    d:  (Number(r.disbursed) || 0) / 1_000_000,
    e:  Math.max(0, ((Number(r.committed) || 0) - (Number(r.disbursed) || 0)) / 1_000_000),
    _raw: r,
  }));
  const fixtureLines = (LINES_OVERRIDE || DEFAULT_LINES).map((l, i) => ({ id: "demo-" + i, ...l, _raw: null }));
  const LINES = useLive ? liveLines : fixtureLines;

  // Roll up live lines into totals so the footer matches what's in the
  // table. For demo we keep the project-level numbers (P.budget / P.disbursed
  // / P.committed) since those drive the burn chart on the right.
  const totals = useLive
    ? {
        budget:    LINES.reduce((s, l) => s + (l.b || 0), 0),
        disbursed: LINES.reduce((s, l) => s + (l.d || 0), 0),
        committed: LINES.reduce((s, l) => s + (l.d || 0) + (l.e || 0), 0),
      }
    : { budget: P.budget, disbursed: P.disbursed, committed: P.committed };

  const onDelete = async (row) => {
    if (!row._raw) return;
    if (!confirm(lang === "fr" ? `Supprimer la ligne « ${row.c} » ?` : `Delete line "${row.c}"?`)) return;
    setBusy(true);
    try { await window.melr.budgetLinesCrud.remove(row._raw.id); await live.refresh(); }
    catch (e) { alert(e.message); }
    finally { setBusy(false); }
  };

  return (
    <>
    <div className="grid cols-3" style={{ gridTemplateColumns: "1.5fr 1fr", gap: 14 }}>
      <div className="card">
        <div className="card-head">
          <div className="card-title">
            {lang === "fr" ? "Budget par catégorie" : "Budget by category"}
            <span className="muted"> · {LINES.length}</span>
            <span className="pill" style={{
              marginLeft: 8, fontSize: 10, padding: "2px 8px",
              background: "var(--brand-tint, #eff6ff)", color: "var(--brand, #1d4ed8)",
            }} title={lang === "fr" ? "Devise des montants" : "Amount currency"}>
              {P.nativeCurrency || "EUR"}
            </span>
            {!useLive && (
              <span className="pill" style={{ marginLeft: 8, fontSize: 10, background: "var(--bg-sunken)", color: "var(--text-faint)" }}>
                {lang === "fr" ? "Démo" : "Demo"}
              </span>
            )}
          </div>
          {useLive && (
            <button className="btn xs primary" onClick={() => setEditing("new")} disabled={busy}>
              <Icon.plus /> {lang === "fr" ? "Ajouter" : "Add"}
            </button>
          )}
        </div>
        <div className="card-body flush">
          {useLive && live.loading && (
            <div style={{ padding: 18, textAlign: "center", color: "var(--text-faint)", fontSize: 12.5 }}>
              {lang === "fr" ? "Chargement…" : "Loading…"}
            </div>
          )}
          {useLive && !live.loading && LINES.length === 0 && (
            <div style={{ padding: 22, textAlign: "center", color: "var(--text-faint)", fontSize: 12.5 }}>
              {lang === "fr"
                ? "Aucune ligne budgétaire. Cliquez « Ajouter » pour démarrer."
                : "No budget line. Click 'Add' to start."}
            </div>
          )}
          {LINES.length > 0 && (
            <table className="tbl">
              <thead><tr>
                <th>{lang === "fr" ? "Poste" : "Line"}</th>
                <th className="num">{lang === "fr" ? "Budget" : "Budget"} <span className="text-faint" style={{ fontSize: 10 }}>(M{P.nativeCurrency || "EUR"})</span></th>
                <th className="num">{lang === "fr" ? "Décaissé" : "Disbursed"} <span className="text-faint" style={{ fontSize: 10 }}>(M{P.nativeCurrency || "EUR"})</span></th>
                <th className="num">{lang === "fr" ? "Engagé" : "Committed"} <span className="text-faint" style={{ fontSize: 10 }}>(M{P.nativeCurrency || "EUR"})</span></th>
                <th>{lang === "fr" ? "Exécution" : "Burn"}</th>
                {useLive && <th style={{ width: 70 }}></th>}
              </tr></thead>
              <tbody>
                {LINES.map((l) => {
                  const pct = l.b > 0 ? Math.round((l.d / l.b) * 100) : 0;
                  return (
                    <tr key={l.id}>
                      <td className="strong">{l.c}</td>
                      <td className="num mono">{fmtM(l.b)}</td>
                      <td className="num mono">{fmtM(l.d)}</td>
                      <td className="num mono">{fmtM(l.d + l.e)}</td>
                      <td><div className="row gap-sm"><div className="bar" style={{ width: 100 }}><div className="bar-fill" style={{ width: Math.min(100, pct) + "%", background: pct > 70 ? "var(--green)" : pct > 35 ? "var(--accent)" : "var(--amber)" }}></div></div><span className="mono num-sm" style={{ minWidth: 30 }}>{pct}%</span></div></td>
                      {useLive && (
                        <td className="num">
                          <button className="iconbtn" disabled={!l._raw || busy}
                            title={lang === "fr" ? "Modifier" : "Edit"}
                            onClick={() => setEditing(l._raw)}>
                            <Icon.edit />
                          </button>
                          <button className="iconbtn" disabled={!l._raw || busy}
                            title={lang === "fr" ? "Supprimer" : "Delete"}
                            onClick={() => onDelete(l)}>
                            <Icon.trash />
                          </button>
                        </td>
                      )}
                    </tr>
                  );
                })}
                <tr className="totals">
                  <td className="strong">{lang === "fr" ? "Total" : "Total"}</td>
                  <td className="num mono strong">{fmtM(totals.budget, P.nativeCurrency)}</td>
                  <td className="num mono strong">{fmtM(totals.disbursed, P.nativeCurrency)}</td>
                  <td className="num mono strong">{fmtM(totals.committed, P.nativeCurrency)}</td>
                  <td className="mono">{totals.budget > 0 ? Math.round(totals.disbursed / totals.budget * 100) : 0}%</td>
                  {useLive && <td></td>}
                </tr>
              </tbody>
            </table>
          )}
        </div>
      </div>

      <div className="card">
        <div className="card-head">
          <div className="card-title">{lang === "fr" ? "Décaissement vs plan" : "Disbursement vs plan"}</div>
          <PDExportButtons refToExport={burnRef} lang={lang}
            title={lang === "fr" ? "Décaissement vs plan" : "Disbursement vs plan"}
            filenamePrefix="decaissement" />
        </div>
        <div className="card-body" ref={burnRef}>
          <div className="pd-burn">
            <svg viewBox="0 0 320 180" style={{ width: "100%", height: 180 }}>
              <defs>
                <linearGradient id="burnG" x1="0" x2="0" y1="0" y2="1">
                  <stop offset="0" stopColor="var(--accent)" stopOpacity="0.35" />
                  <stop offset="1" stopColor="var(--accent)" stopOpacity="0" />
                </linearGradient>
              </defs>
              <line x1="0" y1="150" x2="320" y2="150" stroke="var(--line)" />
              <polyline points="0,150 30,138 60,124 90,108 120,90 150,75 180,62 210,52 240,42 270,35 300,30 320,28" fill="none" stroke="var(--text-faint)" strokeDasharray="4 3" strokeWidth="1.5" />
              <polyline points="0,150 30,140 60,128 90,112 120,98 150,84 180,72 210,62" fill="none" stroke="var(--accent)" strokeWidth="2" />
              <path d="M0,150 L30,140 60,128 90,112 120,98 150,84 180,72 210,62 L210,150 L0,150 Z" fill="url(#burnG)" />
              <circle cx="210" cy="62" r="3.5" fill="var(--accent)" />
              <text x="216" y="58" fontSize="10" fill="var(--accent)" fontWeight="600">{fmtM(P.disbursed, P.nativeCurrency)}</text>
            </svg>
            <div className="row gap-md" style={{ fontSize: 11.5, marginTop: 10 }}>
              <span className="row gap-xs"><span style={{ width: 12, height: 2, background: "var(--accent)" }}></span>{lang === "fr" ? "Réalisé" : "Actual"}</span>
              <span className="row gap-xs"><span style={{ width: 12, height: 2, background: "var(--text-faint)", borderTop: "1px dashed" }}></span>{lang === "fr" ? "Plan" : "Plan"}</span>
            </div>
          </div>
        </div>
      </div>
    </div>

    {editing && (
      <PDBudgetLineModal
        lang={lang}
        projectUuid={projectUuid}
        row={editing === "new" ? null : editing}
        currency={P.nativeCurrency || "EUR"}
        onClose={() => setEditing(null)}
        onSaved={async () => { await live.refresh(); setEditing(null); }}
      />
    )}
    </>
  );
}

// =====================================================================
// Budget category catalog — francophone NGO / development-bank
// accounting buckets. Two top-level groups (CAPEX = investment / OPEX
// = recurring) with French-first sub-categories. Stored as a flat
// "CAPEX › Immobilisations corporelles › Équipements" string in
// project_team.role_label-style, so we keep the existing single text
// column and can recover the breadcrumb client-side.
//
// Tweak / extend without breaking existing rows: any value not in the
// catalogue is treated as a free-text custom category.
// =====================================================================
const BUDGET_CATEGORY_CATALOG = [
  {
    group: "CAPEX",
    fr: "CAPEX — Investissements",
    en: "CAPEX — Investments",
    items: [
      { id: "capex_intangible_studies",    fr: "Immo. incorporelles · frais d'études",        en: "Intangibles · study fees" },
      { id: "capex_intangible_notary",     fr: "Immo. incorporelles · frais de notaire",      en: "Intangibles · notary fees" },
      { id: "capex_intangible_licenses",   fr: "Immo. incorporelles · licences / logiciels",  en: "Intangibles · licenses / software" },
      { id: "capex_intangible_other",      fr: "Immo. incorporelles · autres",                en: "Intangibles · other" },
      { id: "capex_construction",          fr: "Immo. corporelles · construction / BTP",      en: "Tangibles · construction / civil works" },
      { id: "capex_rehabilitation",        fr: "Immo. corporelles · réhabilitation",          en: "Tangibles · rehabilitation" },
      { id: "capex_equipment",             fr: "Immo. corporelles · équipements",             en: "Tangibles · equipment" },
      { id: "capex_furniture",             fr: "Immo. corporelles · mobilier / aménagement",  en: "Tangibles · furniture / fit-out" },
      { id: "capex_vehicles",              fr: "Immo. corporelles · matériel roulant",        en: "Tangibles · vehicles / rolling stock" },
      { id: "capex_it",                    fr: "Immo. corporelles · informatique / IT",       en: "Tangibles · IT hardware" },
      { id: "capex_financial",             fr: "Immo. financières · titres / cautions",       en: "Financial · securities / deposits" },
    ],
  },
  {
    group: "OPEX",
    fr: "OPEX — Charges d'exploitation",
    en: "OPEX — Operating expenses",
    items: [
      { id: "opex_staff",                  fr: "Personnel projet · salaires & charges",       en: "Project staff · salaries & charges" },
      { id: "opex_consultants",            fr: "Consultants / expertise externe",             en: "Consultants / external expertise" },
      { id: "opex_training",               fr: "Formation & coaching",                        en: "Training & coaching" },
      { id: "opex_supplies",               fr: "Fournitures & consommables",                  en: "Supplies & consumables" },
      { id: "opex_medical_supplies",       fr: "Médicaments & intrants médicaux",             en: "Medicines & medical inputs" },
      { id: "opex_rent",                   fr: "Loyers & charges immobilières",               en: "Rent & property charges" },
      { id: "opex_utilities",              fr: "Eau, électricité, télécoms",                  en: "Utilities (water/elec/telecom)" },
      { id: "opex_transport",              fr: "Transport, déplacements, missions",           en: "Transport, travel, missions" },
      { id: "opex_per_diem",               fr: "Per diems & frais de séjour",                 en: "Per diems & subsistence" },
      { id: "opex_services",               fr: "Services externes (audit, légal, banque)",    en: "External services (audit, legal, banking)" },
      { id: "opex_maintenance",            fr: "Entretien & maintenance",                     en: "Maintenance & upkeep" },
      { id: "opex_communication",          fr: "Communication & reporting",                   en: "Communication & reporting" },
      { id: "opex_me",                     fr: "Suivi & évaluation (S&E)",                    en: "M&E activities" },
      { id: "opex_overhead",               fr: "Frais de gestion / overhead",                 en: "Management overhead" },
      { id: "opex_contingency",            fr: "Provisions / imprévus",                       en: "Contingency / provisions" },
      { id: "opex_other",                  fr: "Autres charges",                              en: "Other expenses" },
    ],
  },
];

// Resolve a stored category string to a catalogue entry (when it
// matches one of our ids or labels). Returns null for free-text values.
function resolveBudgetCategory(stored) {
  if (!stored) return null;
  const s = String(stored).trim();
  for (const g of BUDGET_CATEGORY_CATALOG) {
    for (const it of g.items) {
      if (it.id === s || it.fr === s || it.en === s) return { group: g, item: it };
    }
  }
  return null;
}

// =====================================================================
// PDBudgetLineModal — create / edit a row in public.budget_lines.
// Amounts are entered in the project's native currency (NOT millions),
// matching how the DB stores them. The PDBudget table displays them in
// millions via fmtM(). Category is a 2-level dropdown sourced from
// BUDGET_CATEGORY_CATALOG with a free-text fallback for legacy /
// custom entries.
// =====================================================================
function PDBudgetLineModal({ lang, projectUuid, row, currency, onClose, onSaved }) {
  const { useState, useMemo } = React;
  const isEdit = !!row;

  // Decompose the stored category: if it matches a catalogue entry,
  // pre-fill both the group + sub-category. Otherwise drop into
  // free-text mode so the user can still edit legacy / one-off rows.
  const initialResolved = useMemo(() => resolveBudgetCategory(row && row.category), [row]);
  const initialMode = initialResolved ? "catalog" : (row && row.category ? "custom" : "catalog");

  const [mode, setMode] = useState(initialMode);
  const [groupCode, setGroupCode] = useState(initialResolved ? initialResolved.group.group : "");
  const [itemId, setItemId]       = useState(initialResolved ? initialResolved.item.id : "");
  const [customCategory, setCustomCategory] = useState(
    !initialResolved && row && row.category ? row.category : ""
  );

  const [label, setLabel]         = useState(row ? (row.label || "") : "");
  const [budget, setBudget]       = useState(row ? (row.budget != null ? String(row.budget) : "") : "");
  const [disbursed, setDisbursed] = useState(row ? (row.disbursed != null ? String(row.disbursed) : "") : "");
  const [committed, setCommitted] = useState(row ? (row.committed != null ? String(row.committed) : "") : "");
  const [position, setPosition]   = useState(row ? (row.position != null ? String(row.position) : "0") : "0");
  const [busy, setBusy] = useState(false);
  const [err, setErr] = useState(null);

  // Build the category string we'll persist. Catalog mode stores the
  // localised label (e.g. "Immo. corporelles · équipements") which is
  // what we want to display in tables and exports. Free-text mode
  // stores whatever the user typed.
  const resolvedCategory = useMemo(() => {
    if (mode === "custom") return customCategory.trim();
    const g = BUDGET_CATEGORY_CATALOG.find((x) => x.group === groupCode);
    if (!g) return "";
    const it = g.items.find((x) => x.id === itemId);
    if (!it) return "";
    return lang === "fr" ? it.fr : it.en;
  }, [mode, groupCode, itemId, customCategory, lang]);

  // Sub-items list for the picked group.
  const groupItems = useMemo(() => {
    const g = BUDGET_CATEGORY_CATALOG.find((x) => x.group === groupCode);
    return g ? g.items : [];
  }, [groupCode]);

  const onSubmit = async () => {
    setErr(null);
    if (!resolvedCategory) { setErr(lang === "fr" ? "Catégorie requise." : "Category required."); return; }
    const b = budget === "" ? 0 : Number(budget);
    const d = disbursed === "" ? 0 : Number(disbursed);
    const c = committed === "" ? 0 : Number(committed);
    if (!isFinite(b) || !isFinite(d) || !isFinite(c)) { setErr(lang === "fr" ? "Montants invalides." : "Invalid amounts."); return; }
    if (d > c && c > 0) { setErr(lang === "fr" ? "Décaissé ne peut dépasser l'engagé." : "Disbursed cannot exceed committed."); return; }
    setBusy(true);
    try {
      const payload = {
        category:  resolvedCategory,
        label:     label.trim() || resolvedCategory,
        budget:    b,
        disbursed: d,
        committed: c || d,
        currency:  (currency || "EUR").slice(0, 3),
        position:  parseInt(position, 10) || 0,
      };
      if (isEdit) await window.melr.budgetLinesCrud.update(row.id, payload);
      else        await window.melr.budgetLinesCrud.create(projectUuid, payload);
      await onSaved();
    } catch (e) { setErr(e.message); }
    finally { setBusy(false); }
  };

  const lbl = { display: "block", fontSize: 10.5, color: "var(--text-faint)", marginBottom: 3, textTransform: "uppercase", letterSpacing: "0.04em", marginTop: 10 };
  const inp = { width: "100%", padding: "6px 8px", borderRadius: 6, border: "1px solid var(--line)", fontSize: 12.5, background: "var(--bg, white)", color: "var(--text)", boxSizing: "border-box", fontFamily: "inherit" };

  const Modal = window.Modal;
  return (
    <Modal
      title={isEdit
        ? (lang === "fr" ? "Modifier la ligne" : "Edit line")
        : (lang === "fr" ? "Nouvelle ligne budgétaire" : "New budget line")}
      onClose={busy ? null : onClose}
      size="md"
      footer={
        <>
          <button className="btn sm" onClick={onClose} disabled={busy}>
            {lang === "fr" ? "Annuler" : "Cancel"}
          </button>
          <button className="btn sm primary" onClick={onSubmit} disabled={busy}>
            {busy ? "…" : (lang === "fr" ? "Enregistrer" : "Save")}
          </button>
        </>
      }
    >
      <label style={{ ...lbl, marginTop: 0 }}>{lang === "fr" ? "Catégorie" : "Category"} *</label>
      <div className="seg" style={{ marginBottom: 6 }}>
        <button type="button" className={"seg-btn" + (mode === "catalog" ? " active" : "")} onClick={() => setMode("catalog")}>
          {lang === "fr" ? "Catalogue" : "Catalog"}
        </button>
        <button type="button" className={"seg-btn" + (mode === "custom" ? " active" : "")} onClick={() => setMode("custom")}>
          {lang === "fr" ? "Personnalisé" : "Custom"}
        </button>
      </div>
      {mode === "catalog" ? (
        <div style={{ display: "grid", gridTemplateColumns: "1fr 1.6fr", gap: 8 }}>
          <select style={inp} value={groupCode}
            onChange={(e) => { setGroupCode(e.target.value); setItemId(""); }}>
            <option value="">— {lang === "fr" ? "Groupe" : "Group"} —</option>
            {BUDGET_CATEGORY_CATALOG.map((g) => (
              <option key={g.group} value={g.group}>
                {lang === "fr" ? g.fr : g.en}
              </option>
            ))}
          </select>
          <select style={inp} value={itemId} onChange={(e) => setItemId(e.target.value)}
            disabled={!groupCode}>
            <option value="">— {lang === "fr" ? "Poste" : "Line item"} —</option>
            {groupItems.map((it) => (
              <option key={it.id} value={it.id}>{lang === "fr" ? it.fr : it.en}</option>
            ))}
          </select>
        </div>
      ) : (
        <input style={inp} value={customCategory}
          onChange={(e) => setCustomCategory(e.target.value)}
          placeholder={lang === "fr" ? "Saisissez une catégorie sur mesure" : "Enter a custom category"} />
      )}

      <label style={lbl}>{lang === "fr" ? "Libellé (optionnel)" : "Label (optional)"}</label>
      <input style={inp} value={label} onChange={(e) => setLabel(e.target.value)}
        placeholder={lang === "fr"
          ? "Détail (ex : « Antibiotiques de 1re ligne »)"
          : "Detail (e.g. 'First-line antibiotics')"} />

      <div style={{
        display: "flex", alignItems: "center", gap: 6,
        background: "var(--bg-sunken)", padding: "6px 10px", borderRadius: 6,
        fontSize: 11.5, color: "var(--text-faint)", marginTop: 10,
      }}>
        <Icon.info />
        <span>
          {lang === "fr"
            ? `Tous les montants sont en ${currency || "EUR"} brut (pas en millions). La conversion en M${(currency || "EUR")} est faite à l'affichage.`
            : `All amounts are in raw ${currency || "EUR"} (not millions). Display values are converted to M${(currency || "EUR")} automatically.`}
        </span>
      </div>

      <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr 80px", gap: 10 }}>
        <div>
          <label style={lbl}>{lang === "fr" ? "Budget" : "Budget"}</label>
          <input type="number" step="0.01" style={inp} value={budget}
            onChange={(e) => setBudget(e.target.value)} placeholder="0" />
        </div>
        <div>
          <label style={lbl}>{lang === "fr" ? "Engagé" : "Committed"}</label>
          <input type="number" step="0.01" style={inp} value={committed}
            onChange={(e) => setCommitted(e.target.value)} placeholder="0" />
        </div>
        <div>
          <label style={lbl}>{lang === "fr" ? "Décaissé" : "Disbursed"}</label>
          <input type="number" step="0.01" style={inp} value={disbursed}
            onChange={(e) => setDisbursed(e.target.value)} placeholder="0" />
        </div>
        <div>
          <label style={lbl}>{lang === "fr" ? "Ordre" : "Order"}</label>
          <input type="number" style={inp} value={position}
            onChange={(e) => setPosition(e.target.value)} />
        </div>
      </div>

      {err && <div style={{ color: "#b91c1c", fontSize: 12, marginTop: 10 }}>{err}</div>}
    </Modal>
  );
}

function PDTeam({ lang, TEAM: TEAM_OVERRIDE, projectUuid, projectOrgId }) {
  const { useState } = React;
  const DEFAULT_TEAM = [
    { n: "Aïssata Diallo", r: lang === "fr" ? "Chef de projet" : "Project lead", l: "Bamako", c: "ML" },
    { n: "Souleymane Touré", r: lang === "fr" ? "Responsable S&E" : "M&E officer", l: "Bamako", c: "ML" },
    { n: "Bintou Tall", r: lang === "fr" ? "Coordinatrice terrain" : "Field coordinator", l: "Tombouctou", c: "ML" },
    { n: "Yacouba Ouédraogo", r: lang === "fr" ? "Chef pays BF" : "Country lead BF", l: "Ouagadougou", c: "BF" },
    { n: "Halima Issoufou", r: lang === "fr" ? "Chef pays NE" : "Country lead NE", l: "Niamey", c: "NE" },
    { n: "Karim Bensaad", r: lang === "fr" ? "Référent qualité" : "Quality officer", l: "Bamako", c: "ML" },
    { n: "Aminata Coulibaly", r: lang === "fr" ? "Formation & coaching" : "Training & coaching", l: "Bamako", c: "ML" },
    { n: "Modou Sarr", r: lang === "fr" ? "Logistique régionale" : "Regional logistics", l: "Dakar", c: "SN" },
    { n: "Khadija Diabaté", r: lang === "fr" ? "Communication & reporting" : "Communication & reporting", l: "Bamako", c: "ML" },
    { n: "Fatima Ould Cheikh", r: lang === "fr" ? "Référente santé maternelle" : "Maternal health lead", l: "Nouakchott", c: "MR" },
  ];
  // When we have a real project, prefer live data from project_team
  // joined to profiles. Otherwise fall back to the demo team passed
  // in by PORT or the hardcoded DEFAULT_TEAM.
  const live = window.melr && window.melr.useProjectTeam
    ? window.melr.useProjectTeam(projectUuid || null)
    : { data: [], loading: false, refresh: () => {} };
  const useLive = !!projectUuid;
  const [picking, setPicking] = useState(false);
  const [busyUser, setBusyUser] = useState(null);

  const liveCards = (live.data || []).map((m) => {
    const name = (m.profile && m.profile.full_name) || (m.profile && m.profile.email) || "—";
    return {
      n: name,
      r: m.role_label || (lang === "fr" ? "Membre" : "Member"),
      l: m.profile && m.profile.email || "",
      c: "",
      _userId: m.user_id,
    };
  });
  const TEAM = useLive ? liveCards : (TEAM_OVERRIDE || DEFAULT_TEAM);

  const onRemove = async (userId, name) => {
    if (!useLive || !userId) return;
    if (!confirm(lang === "fr" ? `Retirer ${name} de l'équipe ?` : `Remove ${name} from the team?`)) return;
    setBusyUser(userId);
    try {
      await window.melr.projectTeamCrud.remove(projectUuid, userId);
      await live.refresh();
    } catch (e) {
      alert((lang === "fr" ? "Échec : " : "Failed: ") + e.message);
    } finally { setBusyUser(null); }
  };

  return (
    <>
      {useLive && (
        <div style={{ display: "flex", alignItems: "center", marginBottom: 10, gap: 8 }}>
          <span className="muted" style={{ fontSize: 12.5 }}>
            {live.loading
              ? (lang === "fr" ? "Chargement…" : "Loading…")
              : (lang === "fr" ? `${TEAM.length} membre${TEAM.length > 1 ? "s" : ""}` : `${TEAM.length} member${TEAM.length === 1 ? "" : "s"}`)}
          </span>
        </div>
      )}
      <div className="grid" style={{ gridTemplateColumns: "repeat(auto-fill, minmax(260px, 1fr))", gap: 12 }}>
        {TEAM.map((p, i) => (
          <div key={p._userId || i} className="card" style={{ padding: 14, display: "flex", alignItems: "center", gap: 12 }}>
            <div className="avatar" style={{ background: avColorPD(p.n), width: 42, height: 42, fontSize: 14 }}>{initialsPD(p.n)}</div>
            <div style={{ flex: 1, minWidth: 0 }}>
              <div className="strong">{p.n}</div>
              <div className="text-faint" style={{ fontSize: 11.5 }}>{p.r}</div>
              {(p.l || p.c) && (
                <div className="text-faint mono" style={{ fontSize: 10.5, marginTop: 2 }}>
                  {[p.l, p.c].filter(Boolean).join(" · ")}
                </div>
              )}
            </div>
            {useLive && p._userId ? (
              <button className="iconbtn" title={lang === "fr" ? "Retirer" : "Remove"}
                disabled={busyUser === p._userId}
                onClick={() => onRemove(p._userId, p.n)}>
                <Icon.trash />
              </button>
            ) : (
              <button className="iconbtn"><Icon.message /></button>
            )}
          </div>
        ))}
        <div
          className="card"
          style={{
            padding: 14, display: "flex", alignItems: "center", justifyContent: "center",
            border: "1px dashed var(--line-strong)", color: "var(--text-faint)",
            cursor: useLive ? "pointer" : "not-allowed",
          }}
          onClick={() => {
            if (!useLive) {
              alert(lang === "fr"
                ? "Cette page est en démo. Ouvrez un vrai projet pour gérer l'équipe."
                : "This page is a demo. Open a real project to manage the team.");
              return;
            }
            setPicking(true);
          }}
          title={useLive ? "" : (lang === "fr" ? "Démo" : "Demo")}
        >
          <Icon.plus /> &nbsp;<span style={{ fontSize: 12 }}>{lang === "fr" ? "Ajouter membre" : "Add member"}</span>
        </div>
      </div>

      {picking && (
        <PDTeamPickerModal
          lang={lang}
          projectUuid={projectUuid}
          projectOrgId={projectOrgId}
          existingUserIds={new Set((live.data || []).map((m) => m.user_id))}
          onClose={() => setPicking(false)}
          onAdded={async () => { await live.refresh(); setPicking(false); }}
        />
      )}
    </>
  );
}

// Catalogue of functional project roles. Different from the org-level
// roles table (admin / super_admin / member …): those gate tenant
// access, these describe what someone DOES on a specific project.
// The user picks 1+ via checkboxes — we persist as a comma-joined
// string in project_team.role_label (single text column).
const PROJECT_ROLE_CATALOG = [
  { id: "project_lead",      fr: "Chef de projet",           en: "Project lead" },
  { id: "me_lead",           fr: "Responsable S&E",          en: "M&E lead" },
  { id: "me_officer",        fr: "Chargé S&E",               en: "M&E officer" },
  { id: "field_coordinator", fr: "Coordinateur terrain",     en: "Field coordinator" },
  { id: "country_lead",      fr: "Chef pays",                en: "Country lead" },
  { id: "quality_officer",   fr: "Référent qualité",         en: "Quality officer" },
  { id: "training",          fr: "Formation & coaching",     en: "Training & coaching" },
  { id: "logistics",         fr: "Logistique",               en: "Logistics" },
  { id: "communication",     fr: "Communication & reporting", en: "Communication & reporting" },
  { id: "finance",           fr: "Finance",                  en: "Finance" },
  { id: "technical_expert",  fr: "Expert technique",         en: "Technical expert" },
  { id: "data_analyst",      fr: "Analyste de données",      en: "Data analyst" },
];

// Member picker: lists every member of the project's organization
// (via organization_memberships, so users with a "home" elsewhere
// who joined this org also appear) and lets the user pick one to
// add to project_team. Role is a multi-select from
// PROJECT_ROLE_CATALOG, persisted as a comma-joined string.
// Filters out anyone already on the team so we don't violate the
// (project_id, user_id) primary key.
function PDTeamPickerModal({ lang, projectUuid, projectOrgId, existingUserIds, onClose, onAdded }) {
  const { useState } = React;
  // useOrgMembershipsList queries organization_memberships joined to
  // profiles for the given org. Catches multi-org members that
  // useOrgMembers (which filters on profiles.organization_id, i.e.
  // the user's "home" org only) would miss — that was the reason
  // super-admins acting in a different org saw an empty list.
  const memListHook = window.melr && window.melr.useOrgMembershipsList;
  const { data: memberships, loading } = memListHook
    ? memListHook(projectOrgId || null)
    : { data: [], loading: false };

  const [q, setQ] = useState("");
  const [selected, setSelected] = useState(null); // profile id
  const [rolesPicked, setRolesPicked] = useState([]); // array of catalog ids
  const [extraRole, setExtraRole] = useState(""); // free text fallback
  const [busy, setBusy] = useState(false);
  const [err, setErr] = useState(null);

  // De-dupe by user_id and keep the joined profile fields.
  const rosterByUser = new Map();
  for (const m of (memberships || [])) {
    if (!m.user_id || rosterByUser.has(m.user_id)) continue;
    rosterByUser.set(m.user_id, {
      id:        m.user_id,
      full_name: m.fullName || (m.profiles && m.profiles.full_name) || null,
      email:     m.email    || (m.profiles && m.profiles.email)    || null,
    });
  }
  const available = Array.from(rosterByUser.values()).filter((p) => !existingUserIds.has(p.id));

  const ql = q.trim().toLowerCase();
  const visible = ql
    ? available.filter((p) =>
        (p.full_name || "").toLowerCase().includes(ql) ||
        (p.email || "").toLowerCase().includes(ql))
    : available;

  const toggleRole = (id) => {
    setRolesPicked((cur) => {
      if (cur.includes(id)) return cur.filter((x) => x !== id);
      // Keep the saved order matching the catalog order rather than
      // click order so badges always render the same.
      const order = PROJECT_ROLE_CATALOG.map((c) => c.id);
      return [...cur, id].sort((a, b) => order.indexOf(a) - order.indexOf(b));
    });
  };

  const onSave = async () => {
    if (!selected) { setErr(lang === "fr" ? "Sélectionnez un membre." : "Pick a member."); return; }
    // Build the role_label string: catalog labels (in catalog order),
    // then any free-text extras the user typed.
    const catLabels = rolesPicked.map((id) => {
      const c = PROJECT_ROLE_CATALOG.find((x) => x.id === id);
      return c ? (lang === "fr" ? c.fr : c.en) : null;
    }).filter(Boolean);
    const extras = (extraRole || "").split(",").map((s) => s.trim()).filter(Boolean);
    const merged = [...catLabels, ...extras].join(", ");

    setBusy(true); setErr(null);
    try {
      await window.melr.projectTeamCrud.add(projectUuid, selected, merged || null);
      await onAdded();
    } catch (e) { setErr(e.message); }
    finally { setBusy(false); }
  };

  const Modal = window.Modal;
  return (
    <Modal
      title={lang === "fr" ? "Ajouter un membre à l'équipe" : "Add a team member"}
      onClose={busy ? null : onClose}
      size="md"
      footer={
        <>
          <button className="btn sm" onClick={onClose} disabled={busy}>
            {lang === "fr" ? "Annuler" : "Cancel"}
          </button>
          <button className="btn sm primary" onClick={onSave} disabled={busy || !selected}>
            {busy ? "…" : (lang === "fr" ? "Ajouter" : "Add")}
          </button>
        </>
      }
    >
      <div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
        {!projectOrgId && (
          <div style={{ background: "var(--bg-sunken)", padding: 8, borderRadius: 6, fontSize: 11.5, color: "var(--text-faint)" }}>
            {lang === "fr"
              ? "Organisation du projet inconnue — la liste peut être incomplète."
              : "Project organization unknown — list may be incomplete."}
          </div>
        )}

        <input
          style={{ padding: "6px 8px", borderRadius: 6, border: "1px solid var(--line)", fontSize: 12.5 }}
          placeholder={lang === "fr" ? "Rechercher par nom ou e-mail…" : "Search by name or email…"}
          value={q} onChange={(e) => setQ(e.target.value)}
          autoFocus
        />

        <div style={{ maxHeight: 240, overflowY: "auto", border: "1px solid var(--line)", borderRadius: 6 }}>
          {loading && <div style={{ padding: 14, color: "var(--text-faint)", fontSize: 12.5, textAlign: "center" }}>{lang === "fr" ? "Chargement…" : "Loading…"}</div>}
          {!loading && visible.length === 0 && (
            <div style={{ padding: 14, color: "var(--text-faint)", fontSize: 12.5, textAlign: "center" }}>
              {available.length === 0
                ? (lang === "fr" ? "Tous les membres de l'organisation sont déjà dans l'équipe." : "All organization members are already on the team.")
                : (lang === "fr" ? "Aucun résultat." : "No results.")}
            </div>
          )}
          {!loading && visible.map((p) => {
            const sel = selected === p.id;
            return (
              <div key={p.id}
                onClick={() => setSelected(p.id)}
                style={{
                  display: "flex", alignItems: "center", gap: 10,
                  padding: "8px 10px", cursor: "pointer",
                  background: sel ? "var(--brand-tint, #eff6ff)" : "transparent",
                  borderBottom: "1px solid var(--line-faint)",
                }}>
                <input type="radio" checked={sel} readOnly />
                <div className="avatar" style={{ background: avColorPD(p.full_name || p.email || "?"), width: 28, height: 28, fontSize: 11 }}>
                  {initialsPD(p.full_name || p.email || "?")}
                </div>
                <div style={{ flex: 1, minWidth: 0 }}>
                  <div className="strong" style={{ fontSize: 12.5 }}>{p.full_name || (lang === "fr" ? "(Sans nom)" : "(No name)")}</div>
                  <div className="text-faint mono" style={{ fontSize: 10.5 }}>{p.email}</div>
                </div>
              </div>
            );
          })}
        </div>

        <div>
          <label style={{ display: "block", fontSize: 10.5, color: "var(--text-faint)", marginBottom: 6, textTransform: "uppercase", letterSpacing: "0.04em" }}>
            {lang === "fr" ? "Rôles dans le projet (optionnel)" : "Roles on this project (optional)"}
            <span style={{ marginLeft: 6, textTransform: "none", letterSpacing: 0 }}>
              ({lang === "fr" ? "cochez plusieurs" : "tick several"})
            </span>
          </label>
          <div style={{
            display: "grid",
            gridTemplateColumns: "repeat(auto-fill, minmax(160px, 1fr))",
            gap: 6,
          }}>
            {PROJECT_ROLE_CATALOG.map((c) => {
              const checked = rolesPicked.includes(c.id);
              return (
                <label key={c.id} style={{
                  display: "flex", alignItems: "center", gap: 6,
                  padding: "5px 8px", border: "1px solid var(--line)", borderRadius: 6,
                  background: checked ? "var(--brand-tint, #eff6ff)" : "transparent",
                  cursor: "pointer", fontSize: 12,
                }}>
                  <input type="checkbox" checked={checked} onChange={() => toggleRole(c.id)} />
                  <span>{lang === "fr" ? c.fr : c.en}</span>
                </label>
              );
            })}
          </div>
          <input
            style={{ width: "100%", padding: "6px 8px", borderRadius: 6, border: "1px solid var(--line)", fontSize: 12.5, boxSizing: "border-box", marginTop: 6 }}
            placeholder={lang === "fr" ? "Autres rôles (séparés par des virgules)…" : "Other roles (comma-separated)…"}
            value={extraRole} onChange={(e) => setExtraRole(e.target.value)}
          />
        </div>

        {err && <div style={{ color: "#b91c1c", fontSize: 12 }}>{err}</div>}
      </div>
    </Modal>
  );
}

function PDFiles({ FILES, lang, projectUuid }) {
  const { useState, useRef } = React;
  // Live documents take over as soon as the project is a real DB row.
  // For fixture-only projects we keep the demo FILES list.
  const live = window.melr && window.melr.useProjectDocuments
    ? window.melr.useProjectDocuments(projectUuid || null)
    : { data: [], loading: false, refresh: () => {} };
  const useLive = !!projectUuid;
  const fileInputRef = useRef(null);
  const [busy, setBusy] = useState(false);
  const [err, setErr] = useState(null);

  const fmtSize = (b) => {
    if (b == null) return "—";
    if (b < 1024) return b + " B";
    if (b < 1024 * 1024) return (b / 1024).toFixed(1) + " KB";
    return (b / 1024 / 1024).toFixed(2) + " MB";
  };
  const fmtDate = (iso) => {
    if (!iso) return "—";
    try { return new Date(iso).toLocaleString(lang === "fr" ? "fr-FR" : "en-US"); }
    catch { return iso; }
  };
  // Map a mime type to the closest icon name in our Icon set.
  const iconForMime = (mime) => {
    if (!mime) return "fileText";
    if (mime.startsWith("image/")) return "image";
    if (mime.includes("pdf")) return "fileText";
    if (mime.includes("spreadsheet") || mime.includes("excel") || mime.includes("csv")) return "table";
    if (mime.includes("word") || mime.includes("document")) return "fileText";
    if (mime.includes("zip") || mime.includes("compressed")) return "archive";
    return "fileText";
  };

  const onPickFile = () => {
    if (!useLive) {
      alert(lang === "fr"
        ? "Le téléversement nécessite un projet connecté à la base. Ce projet est une démo."
        : "Uploads require a database-backed project. This one is a demo.");
      return;
    }
    fileInputRef.current && fileInputRef.current.click();
  };

  const onUpload = async (e) => {
    const file = e.target.files && e.target.files[0];
    e.target.value = "";
    if (!file || !projectUuid) return;
    setBusy(true); setErr(null);
    try {
      await window.melr.uploadProjectDocument(projectUuid, file);
      await live.refresh();
    } catch (ex) {
      setErr(ex.message);
      alert((lang === "fr" ? "Échec : " : "Failed: ") + ex.message);
    } finally { setBusy(false); }
  };

  const onDownload = async (doc) => {
    try {
      const url = await window.melr.getProjectDocumentUrl(doc.storage_path);
      if (url) window.open(url, "_blank", "noopener");
    } catch (ex) {
      alert((lang === "fr" ? "Échec : " : "Failed: ") + ex.message);
    }
  };

  const onDelete = async (doc) => {
    if (!confirm(lang === "fr" ? `Supprimer « ${doc.name} » ?` : `Delete "${doc.name}"?`)) return;
    setBusy(true);
    try {
      await window.melr.removeProjectDocument(doc.id, doc.storage_path);
      await live.refresh();
    } catch (ex) {
      alert((lang === "fr" ? "Échec : " : "Failed: ") + ex.message);
    } finally { setBusy(false); }
  };

  // Rows in display shape — either live docs or fixture entries.
  const liveRows = (live.data || []).map((d) => ({
    id:   d.id,
    name: d.name,
    size: fmtSize(d.size_bytes),
    who:  (d.profile && d.profile.full_name) || "—",
    when: fmtDate(d.uploaded_at),
    icon: iconForMime(d.mime),
    _raw: d,
  }));
  const fixtureRows = (FILES || []).map((f, i) => ({
    id:   "demo-" + i,
    name: f.n,
    size: f.s,
    who:  f.w,
    when: f.d,
    icon: f.t || "fileText",
    _raw: null,
  }));
  const rows = useLive ? liveRows : fixtureRows;

  return (
    <div className="card">
      <div className="card-head">
        <div className="card-title">
          {lang === "fr" ? "Documents" : "Documents"}
          <span className="muted"> · {rows.length}</span>
          {!useLive && (
            <span className="pill" style={{ marginLeft: 8, fontSize: 10, background: "var(--bg-sunken)", color: "var(--text-faint)" }}>
              {lang === "fr" ? "Démo" : "Demo"}
            </span>
          )}
        </div>
        <input ref={fileInputRef} type="file" style={{ display: "none" }} onChange={onUpload} />
        <button className="btn xs primary" onClick={onPickFile} disabled={busy}>
          <Icon.upload /> {busy ? (lang === "fr" ? "Envoi…" : "Uploading…") : (lang === "fr" ? "Téléverser" : "Upload")}
        </button>
      </div>
      {err && <div style={{ padding: "8px 14px", color: "#b91c1c", fontSize: 12 }}>{err}</div>}
      <div className="card-body flush">
        {useLive && live.loading && (
          <div style={{ padding: 18, textAlign: "center", color: "var(--text-faint)", fontSize: 12.5 }}>
            {lang === "fr" ? "Chargement…" : "Loading…"}
          </div>
        )}
        {useLive && !live.loading && rows.length === 0 && (
          <div style={{ padding: 22, textAlign: "center", color: "var(--text-faint)", fontSize: 12.5 }}>
            {lang === "fr"
              ? "Aucun document pour ce projet. Cliquez « Téléverser » pour ajouter une pièce jointe."
              : "No document for this project. Click 'Upload' to attach a file."}
          </div>
        )}
        {rows.length > 0 && (
          <table className="tbl">
            <thead><tr>
              <th>{lang === "fr" ? "Nom" : "Name"}</th>
              <th>{lang === "fr" ? "Taille" : "Size"}</th>
              <th>{lang === "fr" ? "Auteur" : "Owner"}</th>
              <th>{lang === "fr" ? "Modifié" : "Modified"}</th>
              <th></th>
            </tr></thead>
            <tbody>
              {rows.map((f) => {
                const Ic = Icon[f.icon] || Icon.fileText;
                return (
                  <tr key={f.id}>
                    <td><span className="row gap-xs"><Ic className="sm muted" /><span className="strong">{f.name}</span></span></td>
                    <td className="mono muted">{f.size}</td>
                    <td>{f.who}</td>
                    <td className="text-faint">{f.when}</td>
                    <td className="num">
                      {f._raw ? (
                        <>
                          <button className="iconbtn" title={lang === "fr" ? "Télécharger" : "Download"}
                            onClick={() => onDownload(f._raw)}>
                            <Icon.download />
                          </button>
                          <button className="iconbtn" title={lang === "fr" ? "Supprimer" : "Delete"}
                            onClick={() => onDelete(f._raw)} disabled={busy}>
                            <Icon.trash />
                          </button>
                        </>
                      ) : (
                        <button className="iconbtn" disabled
                          title={lang === "fr" ? "Démo — téléversez un vrai document" : "Demo — upload a real document"}>
                          <Icon.download />
                        </button>
                      )}
                    </td>
                  </tr>
                );
              })}
            </tbody>
          </table>
        )}
      </div>
    </div>
  );
}

function PDRisks({ RISKS, lang, projectUuid }) {
  const { useState } = React;
  // Ref de la carte « Matrice probabilite x impact » pour export.
  const matrixRef = React.useRef(null);
  // Live risks override the fixture as soon as we know which DB
  // project we're looking at. Demo / standalone pages keep RISKS.
  const live = window.melr && window.melr.useProjectRisks
    ? window.melr.useProjectRisks(projectUuid || null)
    : { data: [], loading: false, refresh: () => {} };
  const useLive = !!projectUuid;
  const [editing, setEditing] = useState(null); // null | 'new' | row object
  const [busy, setBusy] = useState(false);

  // Map both shapes (fixture {l,c,m,p,i} vs DB {level,title,mitigation,
  // probability,impact}) to one display shape so the table + matrix
  // can be shared between demo and live.
  const rows = useLive
    ? (live.data || []).map((r) => ({
        id: r.id,
        l:  r.level,
        c:  r.title,
        m:  r.mitigation || "",
        p:  r.probability,
        i:  r.impact,
        status: r.status,
        owner:  r.owner && r.owner.full_name,
        _raw: r,
      }))
    : (RISKS || []).map((r, i) => ({ id: "demo-" + i, l: r.l, c: r.c, m: r.m, p: r.p, i: r.i, _raw: null }));

  const onRemove = async (row) => {
    if (!row._raw) return;
    if (!confirm(lang === "fr" ? `Supprimer le risque « ${row.c} » ?` : `Delete risk "${row.c}"?`)) return;
    setBusy(true);
    try { await window.melr.risksCrud.remove(row._raw.id); await live.refresh(); }
    catch (e) { alert(e.message); }
    finally { setBusy(false); }
  };

  const levelLabel = (l) =>
    l === "H" ? (lang === "fr" ? "Élevé" : "High")
    : l === "M" ? (lang === "fr" ? "Moyen" : "Med")
    : (lang === "fr" ? "Faible" : "Low");

  return (
    <>
      <div className="grid cols-2" style={{ gridTemplateColumns: "1.4fr 1fr", gap: 14 }}>
        <div className="card">
          <div className="card-head">
            <div className="card-title">
              {lang === "fr" ? "Registre des risques" : "Risk register"}
              <span className="muted"> · {rows.length}</span>
              {!useLive && (
                <span className="pill" style={{ marginLeft: 8, fontSize: 10, background: "var(--bg-sunken)", color: "var(--text-faint)" }}>
                  {lang === "fr" ? "Démo" : "Demo"}
                </span>
              )}
            </div>
            {useLive && (
              <button className="btn xs primary" onClick={() => setEditing("new")} disabled={busy}>
                <Icon.plus /> {lang === "fr" ? "Ajouter" : "Add"}
              </button>
            )}
          </div>
          <div className="card-body flush">
            {useLive && live.loading && (
              <div style={{ padding: 18, textAlign: "center", color: "var(--text-faint)", fontSize: 12.5 }}>
                {lang === "fr" ? "Chargement…" : "Loading…"}
              </div>
            )}
            {useLive && !live.loading && rows.length === 0 && (
              <div style={{ padding: 22, textAlign: "center", color: "var(--text-faint)", fontSize: 12.5 }}>
                {lang === "fr"
                  ? "Aucun risque enregistré. Cliquez « Ajouter » pour créer le premier."
                  : "No risk recorded. Click 'Add' to create one."}
              </div>
            )}
            {rows.length > 0 && (
              <table className="tbl">
                <thead><tr>
                  <th>{lang === "fr" ? "Niveau" : "Level"}</th>
                  <th>{lang === "fr" ? "Risque" : "Risk"}</th>
                  <th>{lang === "fr" ? "Mitigation" : "Mitigation"}</th>
                  <th className="num">P</th>
                  <th className="num">I</th>
                  {useLive && <th style={{ width: 70 }}></th>}
                </tr></thead>
                <tbody>
                  {rows.map((r) => (
                    <tr key={r.id}>
                      <td><span className={"pill xs " + (r.l === "H" ? "red" : r.l === "M" ? "amber" : "green") + " dot"}>{levelLabel(r.l)}</span></td>
                      <td className="strong">{r.c}</td>
                      <td className="muted">{r.m}</td>
                      <td className="num mono">{r.p}</td>
                      <td className="num mono">{r.i}</td>
                      {useLive && (
                        <td className="num">
                          <button className="iconbtn" disabled={!r._raw || busy}
                            title={lang === "fr" ? "Modifier" : "Edit"}
                            onClick={() => setEditing(r._raw)}>
                            <Icon.edit />
                          </button>
                          <button className="iconbtn" disabled={!r._raw || busy}
                            title={lang === "fr" ? "Supprimer" : "Delete"}
                            onClick={() => onRemove(r)}>
                            <Icon.trash />
                          </button>
                        </td>
                      )}
                    </tr>
                  ))}
                </tbody>
              </table>
            )}
          </div>
        </div>
        <div className="card">
          <div className="card-head">
            <div className="card-title">{lang === "fr" ? "Matrice probabilité × impact" : "Probability × impact"}</div>
            <PDExportButtons refToExport={matrixRef} lang={lang}
              title={lang === "fr" ? "Matrice probabilité × impact" : "Probability × impact"}
              filenamePrefix="matrice-risques" />
          </div>
          <div className="card-body" ref={matrixRef}>
            <div className="pd-risk-grid">
              {[5,4,3,2,1].map((row) => (
                <React.Fragment key={row}>
                  <div className="pd-risk-axis">{row}</div>
                  {[1,2,3,4,5].map((col) => {
                    const tone = row + col >= 8 ? "red" : row + col >= 6 ? "amber" : "green";
                    const here = rows.filter((r) => r.p === col && r.i === row);
                    return (
                      <div key={col} className={"pd-risk-cell " + tone}>
                        {here.map((r, j) => <div key={j} className="pd-risk-bub">{r.l}</div>)}
                      </div>
                    );
                  })}
                </React.Fragment>
              ))}
              <div></div>
              {[1,2,3,4,5].map((c) => <div key={c} className="pd-risk-axis">{c}</div>)}
            </div>
            <div className="row" style={{ justifyContent: "space-between", marginTop: 8, fontSize: 10.5, color: "var(--text-faint)" }}>
              <span>{lang === "fr" ? "Probabilité →" : "Probability →"}</span>
              <span>↑ {lang === "fr" ? "Impact" : "Impact"}</span>
            </div>
          </div>
        </div>
      </div>

      {editing && (
        <PDRiskModal
          lang={lang}
          projectUuid={projectUuid}
          row={editing === "new" ? null : editing}
          onClose={() => setEditing(null)}
          onSaved={async () => { await live.refresh(); setEditing(null); }}
        />
      )}
    </>
  );
}

// =====================================================================
// PDRiskModal — create / edit a row in public.risks. Uses the unified
// <Modal> shell. Level (L/M/H) auto-derives from probability+impact
// (sum < 6 = Low, 6–7 = Med, ≥ 8 = High) but the user can override.
// =====================================================================
function PDRiskModal({ lang, projectUuid, row, onClose, onSaved }) {
  const { useState, useMemo } = React;
  const isEdit = !!row;

  const [title, setTitle] = useState(row ? (row.title || "") : "");
  const [mitigation, setMitigation] = useState(row ? (row.mitigation || "") : "");
  const [probability, setProbability] = useState(row ? (row.probability || 3) : 3);
  const [impact, setImpact] = useState(row ? (row.impact || 3) : 3);
  const [status, setStatus] = useState(row ? (row.status || "open") : "open");
  const [levelOverride, setLevelOverride] = useState(row ? (row.level || null) : null);
  const [busy, setBusy] = useState(false);
  const [err, setErr] = useState(null);

  // Compute the auto-level the same way the matrix colours its cells:
  // sum of P+I bands the heatmap into Low/Med/High.
  const autoLevel = useMemo(() => {
    const s = (Number(probability) || 0) + (Number(impact) || 0);
    if (s >= 8) return "H";
    if (s >= 6) return "M";
    return "L";
  }, [probability, impact]);
  const level = levelOverride || autoLevel;

  const onSubmit = async () => {
    setErr(null);
    if (!title.trim()) { setErr(lang === "fr" ? "Titre requis." : "Title required."); return; }
    const p = Number(probability), i = Number(impact);
    if (!(p >= 1 && p <= 5)) { setErr(lang === "fr" ? "Probabilité 1–5." : "Probability 1-5."); return; }
    if (!(i >= 1 && i <= 5)) { setErr(lang === "fr" ? "Impact 1–5." : "Impact 1-5."); return; }
    setBusy(true);
    try {
      const payload = {
        title:       title.trim(),
        mitigation:  mitigation.trim() || null,
        probability: p,
        impact:      i,
        level:       level,
        status:      status,
      };
      if (isEdit) await window.melr.risksCrud.update(row.id, payload);
      else        await window.melr.risksCrud.create(projectUuid, payload);
      await onSaved();
    } catch (e) { setErr(e.message); }
    finally { setBusy(false); }
  };

  const lbl = { display: "block", fontSize: 10.5, color: "var(--text-faint)", marginBottom: 3, textTransform: "uppercase", letterSpacing: "0.04em", marginTop: 10 };
  const inp = { width: "100%", padding: "6px 8px", borderRadius: 6, border: "1px solid var(--line)", fontSize: 12.5, background: "var(--bg, white)", color: "var(--text)", boxSizing: "border-box", fontFamily: "inherit" };

  const Modal = window.Modal;
  return (
    <Modal
      title={isEdit
        ? (lang === "fr" ? "Modifier le risque" : "Edit risk")
        : (lang === "fr" ? "Nouveau risque" : "New risk")}
      onClose={busy ? null : onClose}
      size="md"
      footer={
        <>
          <button className="btn sm" onClick={onClose} disabled={busy}>
            {lang === "fr" ? "Annuler" : "Cancel"}
          </button>
          <button className="btn sm primary" onClick={onSubmit} disabled={busy}>
            {busy ? "…" : (lang === "fr" ? "Enregistrer" : "Save")}
          </button>
        </>
      }
    >
      <label style={{ ...lbl, marginTop: 0 }}>{lang === "fr" ? "Titre" : "Title"} *</label>
      <input style={inp} value={title} onChange={(e) => setTitle(e.target.value)}
        placeholder={lang === "fr" ? "ex : Rupture stock vaccin Penta-3" : "e.g. Penta-3 vaccine stockout"} />

      <label style={lbl}>{lang === "fr" ? "Mitigation" : "Mitigation"}</label>
      <textarea style={{ ...inp, minHeight: 70, fontFamily: "inherit" }}
        value={mitigation} onChange={(e) => setMitigation(e.target.value)}
        placeholder={lang === "fr" ? "Actions prévues pour réduire le risque…" : "Planned actions to reduce the risk…"} />

      <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: 10 }}>
        <div>
          <label style={lbl}>{lang === "fr" ? "Probabilité" : "Probability"} (1–5)</label>
          <input type="number" min={1} max={5} style={inp} value={probability}
            onChange={(e) => setProbability(e.target.value)} />
        </div>
        <div>
          <label style={lbl}>{lang === "fr" ? "Impact" : "Impact"} (1–5)</label>
          <input type="number" min={1} max={5} style={inp} value={impact}
            onChange={(e) => setImpact(e.target.value)} />
        </div>
        <div>
          <label style={lbl}>{lang === "fr" ? "Statut" : "Status"}</label>
          <select style={inp} value={status} onChange={(e) => setStatus(e.target.value)}>
            <option value="open">{lang === "fr" ? "Ouvert" : "Open"}</option>
            <option value="mitigated">{lang === "fr" ? "Atténué" : "Mitigated"}</option>
            <option value="closed">{lang === "fr" ? "Fermé" : "Closed"}</option>
          </select>
        </div>
      </div>

      <label style={lbl}>{lang === "fr" ? "Niveau" : "Level"}</label>
      <div className="seg">
        {[
          { v: null, label: (lang === "fr" ? "Auto" : "Auto") + " (" + autoLevel + ")" },
          { v: "L",  label: lang === "fr" ? "Faible" : "Low" },
          { v: "M",  label: lang === "fr" ? "Moyen"  : "Med" },
          { v: "H",  label: lang === "fr" ? "Élevé"  : "High" },
        ].map((o) => (
          <button key={String(o.v)} type="button"
            className={"seg-btn" + ((levelOverride === o.v || (!levelOverride && o.v === null)) ? " active" : "")}
            onClick={() => setLevelOverride(o.v)}>
            {o.label}
          </button>
        ))}
      </div>

      {err && <div style={{ color: "#b91c1c", fontSize: 12, marginTop: 10 }}>{err}</div>}
    </Modal>
  );
}

function PDActivity({ ACTIVITY, lang, projectUuid }) {
  const live = window.melr && window.melr.useProjectActivity
    ? window.melr.useProjectActivity(projectUuid || null, 100)
    : { data: [], loading: false, refresh: () => {} };
  const useLive = !!projectUuid;

  // Map action+entity to an icon + tone. Action verbs we expect from
  // the SECURITY DEFINER helpers: 'created', 'updated', 'deleted',
  // 'submitted', 'approved', 'rejected', 'commented', 'uploaded'.
  const ICON_FOR_ACTION = {
    created:   { t: "plus",       tone: "accent" },
    updated:   { t: "edit",       tone: "amber" },
    deleted:   { t: "trash",      tone: "red" },
    submitted: { t: "send",       tone: "accent" },
    approved:  { t: "check",      tone: "green" },
    rejected:  { t: "x",          tone: "red" },
    commented: { t: "message",    tone: "accent" },
    uploaded:  { t: "upload",     tone: "violet" },
    assigned:  { t: "user",       tone: "accent" },
  };
  const ICON_FOR_ENTITY = {
    indicator_value: "trending",
    indicator:       "trending",
    report:          "fileText",
    document:        "fileText",
    validation_item: "shieldCheck",
    plan_action:     "calendar",
    risk:            "alert",
    site:            "globe",
    project:         "folder",
    note:            "message",
  };

  // Relative time formatting — keeps the row compact.
  const fmtRel = (iso) => {
    if (!iso) return "—";
    const d = new Date(iso).getTime();
    if (isNaN(d)) return iso;
    const s = Math.floor((Date.now() - d) / 1000);
    if (s < 60)         return lang === "fr" ? "il y a quelques s" : "just now";
    if (s < 3600)       return (lang === "fr" ? "il y a " : "") + Math.floor(s / 60) + (lang === "fr" ? " min" : " min ago");
    if (s < 86400)      return (lang === "fr" ? "il y a " : "") + Math.floor(s / 3600) + (lang === "fr" ? " h" : " h ago");
    if (s < 86400 * 7)  return (lang === "fr" ? "il y a " : "") + Math.floor(s / 86400) + (lang === "fr" ? " j" : " d ago");
    return new Date(iso).toLocaleDateString(lang === "fr" ? "fr-FR" : "en-US");
  };

  // Render a single activity_logs row to the existing pd-act shape.
  const mapLive = (a) => {
    const aa = ICON_FOR_ACTION[a.action] || { t: "info", tone: "accent" };
    const icon = ICON_FOR_ENTITY[a.entity] || aa.t;
    // Verb phrasing from action + entity (best effort, falls back to
    // the raw verb so unknown events still read).
    const verbFr = {
      created: "a créé", updated: "a modifié", deleted: "a supprimé",
      submitted: "a soumis", approved: "a validé", rejected: "a rejeté",
      commented: "a commenté", uploaded: "a téléversé", assigned: "a assigné",
    }[a.action] || a.action;
    const verbEn = {
      created: "created", updated: "updated", deleted: "deleted",
      submitted: "submitted", approved: "approved", rejected: "rejected",
      commented: "commented on", uploaded: "uploaded", assigned: "assigned",
    }[a.action] || a.action;
    const entLabel = {
      indicator_value: lang === "fr" ? "une valeur d'indicateur" : "an indicator value",
      indicator:       lang === "fr" ? "un indicateur"           : "an indicator",
      report:          lang === "fr" ? "un rapport"              : "a report",
      document:        lang === "fr" ? "un document"             : "a document",
      validation_item: lang === "fr" ? "une demande de validation" : "a validation item",
      plan_action:     lang === "fr" ? "une action du plan"      : "a plan action",
      risk:            lang === "fr" ? "un risque"               : "a risk",
      site:            lang === "fr" ? "un site"                 : "a site",
      project:         lang === "fr" ? "le projet"               : "the project",
      note:            lang === "fr" ? "une note"                : "a note",
    }[a.entity] || a.entity;
    const sentence = (lang === "fr" ? verbFr : verbEn) + " " + entLabel;
    return {
      id:   a.id,
      who:  (a.actor && a.actor.full_name) || (lang === "fr" ? "Quelqu'un" : "Someone"),
      role: "",
      a:    sentence,
      w:    fmtRel(a.occurred_at),
      t:    icon,
      tone: aa.tone,
    };
  };

  const liveRows = (live.data || []).map(mapLive);
  // For demo/standalone we keep the colourful ACTIVITY fixture — but
  // we only render it when there's nothing else to show, otherwise
  // we'd mix real history with fake events.
  const fixtureRows = ACTIVITY || [];
  const rows = useLive ? liveRows : fixtureRows;

  return (
    <div className="card">
      <div className="card-head">
        <div className="card-title">
          {lang === "fr" ? "Journal d'activité" : "Activity log"}
          <span className="muted"> · {rows.length}</span>
          {!useLive && (
            <span className="pill" style={{ marginLeft: 8, fontSize: 10, background: "var(--bg-sunken)", color: "var(--text-faint)" }}>
              {lang === "fr" ? "Démo" : "Demo"}
            </span>
          )}
        </div>
        {useLive && (
          <button className="btn xs ghost" onClick={() => live.refresh()} disabled={live.loading}
            title={lang === "fr" ? "Rafraîchir" : "Refresh"}>
            <Icon.refresh /> {lang === "fr" ? "Rafraîchir" : "Refresh"}
          </button>
        )}
      </div>
      <div className="card-body">
        {useLive && live.loading && (
          <div style={{ padding: 18, textAlign: "center", color: "var(--text-faint)", fontSize: 12.5 }}>
            {lang === "fr" ? "Chargement…" : "Loading…"}
          </div>
        )}
        {useLive && !live.loading && rows.length === 0 && (
          <div style={{ padding: 22, textAlign: "center", color: "var(--text-faint)", fontSize: 12.5 }}>
            {lang === "fr"
              ? "Aucune activité enregistrée pour ce projet pour le moment."
              : "No activity recorded for this project yet."}
          </div>
        )}
        {rows.map((a) => {
          const Ic = Icon[a.t] || Icon.info;
          return (
            <div key={a.id} className="pd-act">
              <div className={"pd-act-icon tone-" + a.tone}><Ic /></div>
              <div style={{ flex: 1, fontSize: 12.5 }}>
                <div><span className="strong">{a.who}</span> <span className="muted">{a.a}</span></div>
                <div className="text-faint" style={{ fontSize: 11 }}>{a.role ? a.role + " · " : ""}{a.w}</div>
              </div>
            </div>
          );
        })}
      </div>
    </div>
  );
}

function PDSpark({ data, tone }) {
  if (!data || !data.length) return <span className="text-faint mono" style={{ fontSize: 10.5 }}>—</span>;
  const w = 90, h = 28, max = Math.max(...data), min = Math.min(...data);
  const pts = data.map((v, i) => `${(i / (data.length - 1)) * w},${h - ((v - min) / (max - min || 1)) * (h - 4) - 2}`).join(" ");
  const color = tone === "warn" ? "var(--amber)" : tone === "bad" ? "var(--red)" : "var(--green)";
  return (
    <svg width={w} height={h} style={{ display: "block" }}>
      <polyline points={pts} fill="none" stroke={color} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
      <circle cx={w} cy={h - ((data[data.length - 1] - min) / (max - min || 1)) * (h - 4) - 2} r="2" fill={color} />
    </svg>
  );
}

function initialsPD(name) { return name.split(/\s+/).slice(0, 2).map((s) => s[0]).join("").toUpperCase(); }
function avColorPD(name) {
  let h = 0; for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) | 0;
  return `oklch(0.78 0.08 ${Math.abs(h) % 360})`;
}

window.ProjectDetail = ProjectDetail;

// ==================== CNUCED INDICATORS — Port management ====================
function PDIndicatorsCNUCED({ INDICATORS, lang }) {
  const groups = [...new Set(INDICATORS.map((i) => i.group))];

  // KPI mini-cards per group: count + average score (0–100)
  const groupStats = groups.map((g) => {
    const items = INDICATORS.filter((i) => i.group === g);
    const numericItems = items.filter((i) => !i.textual && typeof i.cur === "number" && typeof i.base === "number" && typeof i.target === "number");
    const avg = numericItems.length
      ? Math.round(numericItems.reduce((sum, i) => {
          const denom = (i.invert ? (i.base - i.target) : (i.target - i.base)) || 1;
          const num = (i.invert ? (i.base - i.cur) : (i.cur - i.base));
          return sum + Math.max(0, Math.min(1, num / denom));
        }, 0) / numericItems.length * 100)
      : null;
    return { name: g, count: items.length, avg };
  });

  return (
    <div>
      <div className="cnuced-banner">
        <div className="cnuced-banner-icon"><Icon.shieldHalf /></div>
        <div style={{ flex: 1 }}>
          <div className="strong" style={{ fontSize: 13 }}>{lang === "fr" ? "Cadre CNUCED de performance portuaire — Port Performance Scorecard (PPS)" : "UNCTAD Port Performance Scorecard (PPS)"}</div>
          <div className="muted" style={{ fontSize: 11.5 }}>
            {lang === "fr" ? "7 groupes · 94 indicateurs de référence · " + INDICATORS.length + " indicateurs suivis ce trimestre · alignement ODD 3, 5, 7, 8, 9, 11, 12, 13, 14, 16" : "7 groups · 94 reference indicators · " + INDICATORS.length + " tracked this quarter · aligned to SDGs 3, 5, 7, 8, 9, 11, 12, 13, 14, 16"}
          </div>
        </div>
        <div className="row gap-sm">
          <button className="btn xs"><Icon.download /> {lang === "fr" ? "Export CNUCED" : "Export UNCTAD"}</button>
          <button className="btn xs primary"><Icon.send /> {lang === "fr" ? "Soumettre rapport" : "Submit report"}</button>
        </div>
      </div>

      <div className="cnuced-summary">
        {groupStats.map((g) => (
          <div key={g.name} className="cnuced-kpi">
            <div className="cnuced-kpi-name">{g.name}</div>
            <div className="cnuced-kpi-v">
              {g.avg != null ? <span className={g.avg >= 70 ? "tone-green" : g.avg >= 50 ? "tone-amber" : "tone-red"} style={{ fontWeight: 600 }}>{g.avg}</span> : <span className="muted">—</span>}
              <span className="text-faint" style={{ fontSize: 11 }}>{g.avg != null ? "/100" : ""}</span>
            </div>
            <div className="text-faint" style={{ fontSize: 11 }}>{g.count} {lang === "fr" ? "indicateurs" : "indicators"}</div>
          </div>
        ))}
      </div>

      {groups.map((g) => {
        const items = INDICATORS.filter((i) => i.group === g);
        return (
          <div key={g} className="card" style={{ marginTop: 14 }}>
            <div className="card-head">
              <div className="card-title">{g}</div>
              <span className="muted" style={{ fontSize: 11.5 }}>{items.length} {lang === "fr" ? "indicateurs" : "indicators"}</span>
              <button className="btn xs ghost" style={{ marginLeft: "auto" }}><Icon.eye /> {lang === "fr" ? "Détail" : "Detail"}</button>
            </div>
            <div className="card-body flush">
              <table className="tbl cnuced-tbl">
                <thead><tr>
                  <th style={{ width: 70 }}>{lang === "fr" ? "Réf." : "Ref."}</th>
                  <th>{lang === "fr" ? "Indicateur" : "Indicator"}</th>
                  <th className="num" style={{ width: 90 }}>{lang === "fr" ? "Base" : "Base"}</th>
                  <th className="num" style={{ width: 90 }}>{lang === "fr" ? "Actuel" : "Current"}</th>
                  <th className="num" style={{ width: 90 }}>{lang === "fr" ? "Cible" : "Target"}</th>
                  <th style={{ width: 100 }}>{lang === "fr" ? "Tendance" : "Trend"}</th>
                  <th style={{ width: 120 }}>{lang === "fr" ? "Progrès" : "Progress"}</th>
                  <th style={{ width: 70 }}>ODD</th>
                  <th style={{ width: 90 }}>{lang === "fr" ? "Statut" : "Status"}</th>
                </tr></thead>
                <tbody>
                  {items.map((ind) => {
                    if (ind.textual) {
                      return (
                        <tr key={ind.code}>
                          <td className="mono text-faint">{ind.code}</td>
                          <td>
                            <div className="strong">{ind.name}</div>
                            {ind.note && <div className="text-faint" style={{ fontSize: 11 }}>{ind.note}</div>}
                          </td>
                          <td colSpan="5" className="muted" style={{ fontStyle: "italic" }}>{ind.value}</td>
                          <td className="muted mono" style={{ fontSize: 10.5 }}>{ind.odd || "—"}</td>
                          <td><span className="pill green dot">OK</span></td>
                        </tr>
                      );
                    }
                    const denom = (ind.invert ? (ind.base - ind.target) : (ind.target - ind.base)) || 1;
                    const num = (ind.invert ? (ind.base - ind.cur) : (ind.cur - ind.base));
                    const pct = Math.max(0, Math.min(100, Math.round(num / denom * 100)));
                    const fmt = (v) => {
                      if (typeof v !== "number") return v;
                      if (ind.unit === "%") return Math.round(v < 1 ? v * 100 : v) + "%";
                      return v.toLocaleString("fr-FR") + (ind.unit && ind.unit.startsWith("/") ? "" : " ") + (ind.unit || "");
                    };
                    return (
                      <tr key={ind.code}>
                        <td className="mono text-faint">{ind.code}</td>
                        <td className="strong">{ind.name}{ind.note ? <div className="text-faint" style={{ fontSize: 11, fontWeight: 400 }}>{ind.note}</div> : null}</td>
                        <td className="num mono muted">{fmt(ind.base)}</td>
                        <td className="num mono strong">{fmt(ind.cur)}</td>
                        <td className="num mono">{fmt(ind.target)}</td>
                        <td><PDSpark data={ind.trend} tone={ind.status} /></td>
                        <td><div className="row gap-sm"><div className="bar" style={{ width: 70 }}><div className="bar-fill" style={{ width: pct + "%", background: ind.status === "ok" ? "var(--green)" : ind.status === "warn" ? "var(--amber)" : "var(--red)" }}></div></div><span className="mono num-sm">{pct}%</span></div></td>
                        <td className="muted mono" style={{ fontSize: 10.5 }}>{ind.odd || "—"}</td>
                        <td>{ind.status === "ok" ? <span className="pill green dot">OK</span> : ind.status === "warn" ? <span className="pill amber dot">{lang === "fr" ? "Vigilance" : "Watch"}</span> : <span className="pill red dot">{lang === "fr" ? "Critique" : "Crit."}</span>}</td>
                      </tr>
                    );
                  })}
                </tbody>
              </table>
            </div>
          </div>
        );
      })}
    </div>
  );
}

window.PDIndicatorsCNUCED = PDIndicatorsCNUCED;

// ============================================================================
// PROJET · Onglets de cadrage (Phase 2C · 2026-05)
// ============================================================================
// 4 composants qui editent les tables project_objectives, project_toc,
// stakeholders (reutilisee), project_assumptions. Tous suivent le meme
// pattern : liste + bouton "Ajouter" + edition inline avec ✕ pour
// supprimer. Realtime via les hooks dedies (auto-refresh sur changements).
// ============================================================================

// ── Helpers communs ──────────────────────────────────────────────────────
const _pdInp = { padding: "6px 10px", borderRadius: 5, border: "1px solid var(--line)", fontSize: 12.5, width: "100%", boxSizing: "border-box", background: "var(--input-bg, var(--bg, white))", color: "var(--text)" };
const _pdLbl = { fontSize: 10.5, color: "var(--text-faint)", textTransform: "uppercase", letterSpacing: "0.04em", fontWeight: 600, marginBottom: 4 };

function _PdEmptyHint({ lang, msg }) {
  return (
    <div style={{ padding: 14, textAlign: "center", fontSize: 12, color: "var(--text-muted)", background: "var(--bg-sunken)", border: "1px dashed var(--line)", borderRadius: 6 }}>
      {msg || (lang === "fr" ? "Aucun élément." : "No item.")}
    </div>
  );
}

function _PdNoProject({ lang }) {
  return (
    <div className="card" style={{ marginTop: 16 }}>
      <div className="card-body" style={{ padding: 24, textAlign: "center", color: "var(--text-faint)" }}>
        {lang === "fr"
          ? "Aucun projet sélectionné — données indisponibles."
          : "No project selected — data unavailable."}
      </div>
    </div>
  );
}

// ── 1. PDObjectives ──────────────────────────────────────────────────────
// 3 sections (general / specific / result) avec liste + ajout/edition.
function PDObjectives({ lang, projectUuid }) {
  if (!projectUuid) return <_PdNoProject lang={lang} />;
  const { data: objs, refresh } = window.melr.useProjectObjectives(projectUuid);
  const byKind = (k) => (objs || []).filter((o) => o.kind === k);
  const KINDS = [
    { k: "general",  titleFr: "Objectif général",          titleEn: "General objective" },
    { k: "specific", titleFr: "Objectifs spécifiques",     titleEn: "Specific objectives" },
    { k: "result",   titleFr: "Résultats attendus",        titleEn: "Expected results" },
  ];
  return (
    <div style={{ marginTop: 16, display: "grid", gap: 12 }}>
      {KINDS.map((K) => (
        <PDObjectivesSection key={K.k}
          lang={lang}
          projectUuid={projectUuid}
          kind={K.k}
          title={lang === "fr" ? K.titleFr : K.titleEn}
          items={byKind(K.k)}
          refresh={refresh} />
      ))}
    </div>
  );
}

function PDObjectivesSection({ lang, projectUuid, kind, title, items, refresh }) {
  const [adding, setAdding] = React.useState(false);
  const [label, setLabel] = React.useState("");
  const [desc, setDesc] = React.useState("");
  const [busy, setBusy] = React.useState(false);
  const [err, setErr] = React.useState(null);
  // ID de l'item en cours d'edition (modifier) — null = aucun
  const [editingId, setEditingId] = React.useState(null);
  const [editLabel, setEditLabel] = React.useState("");
  const [editDesc, setEditDesc] = React.useState("");

  // Helper : formate l'erreur reseau de maniere utile pour l'utilisateur
  const friendlyErr = (e) => {
    const msg = (e && e.message) || String(e || "");
    if (/failed to fetch|networkerror|load failed/i.test(msg)) {
      return lang === "fr"
        ? "Erreur réseau (Failed to fetch). Vérifiez : (1) votre connexion, (2) que la migration 20260524160000_project_planning_tabs.sql a bien été appliquée dans Supabase, (3) qu'aucune extension navigateur ne bloque la requête."
        : "Network error (Failed to fetch). Check: (1) your connection, (2) that migration 20260524160000_project_planning_tabs.sql was applied in Supabase, (3) no browser extension is blocking the request.";
    }
    if (/permission|denied|42501|forbidden/i.test(msg)) {
      return lang === "fr"
        ? "Permissions insuffisantes : seul un administrateur peut ajouter / modifier ces éléments."
        : "Insufficient permissions: only an admin can add / edit these items.";
    }
    if (/relation.*does not exist|42P01/i.test(msg)) {
      return lang === "fr"
        ? "Table introuvable côté base de données. La migration 20260524160000_project_planning_tabs.sql doit être exécutée dans Supabase."
        : "Table not found in database. Migration 20260524160000_project_planning_tabs.sql must be run in Supabase.";
    }
    return msg;
  };

  const add = async () => {
    if (!label.trim()) return;
    setBusy(true); setErr(null);
    try {
      await window.melr.projectObjectivesCrud.create({
        project_id: projectUuid, kind, label, description: desc,
        sort_order: (items[items.length - 1] || {}).sort_order
          ? (items[items.length - 1].sort_order + 10) : 100,
      });
      setLabel(""); setDesc(""); setAdding(false);
      await refresh();
    } catch (e) { setErr(friendlyErr(e)); }
    finally { setBusy(false); }
  };

  const startEdit = (o) => {
    setEditingId(o.id);
    setEditLabel(o.label || "");
    setEditDesc(o.description || "");
    setErr(null);
  };
  const cancelEdit = () => { setEditingId(null); setEditLabel(""); setEditDesc(""); };

  const saveEdit = async () => {
    if (!editLabel.trim() || !editingId) return;
    setBusy(true); setErr(null);
    try {
      await window.melr.projectObjectivesCrud.update(editingId, {
        label: editLabel,
        description: editDesc,
      });
      cancelEdit();
      await refresh();
    } catch (e) { setErr(friendlyErr(e)); }
    finally { setBusy(false); }
  };

  const remove = async (id, lab) => {
    const msg = lang === "fr"
      ? "Supprimer « " + (lab || "cet objectif") + " » ? Cette action est définitive."
      : "Delete '" + (lab || "this objective") + "'? This is permanent.";
    if (!window.confirm(msg)) return;
    setBusy(true); setErr(null);
    try { await window.melr.projectObjectivesCrud.remove(id); await refresh(); }
    catch (e) { setErr(friendlyErr(e)); }
    finally { setBusy(false); }
  };

  return (
    <div className="card">
      <div className="card-head">
        <div className="card-title">{title}</div>
        <span className="tag-mono" style={{ marginLeft: 6 }}>{items.length}</span>
        <div style={{ flex: 1 }} />
        <button className="btn xs primary" onClick={() => { setAdding((v) => !v); setErr(null); }} disabled={!!editingId}>
          <Icon.plus /> {lang === "fr" ? "Ajouter" : "Add"}
        </button>
      </div>
      <div className="card-body" style={{ display: "grid", gap: 8 }}>
        {items.length === 0 && !adding && <_PdEmptyHint lang={lang} />}

        {items.map((o) => (
          <div key={o.id} style={{
            display: "grid",
            gridTemplateColumns: editingId === o.id ? "1fr" : "1fr auto auto",
            gap: 8, alignItems: editingId === o.id ? "stretch" : "flex-start",
            padding: "8px 10px",
            background: editingId === o.id
              ? "color-mix(in oklch, var(--accent) 6%, transparent)"
              : "var(--bg-sunken)",
            borderRadius: 5,
          }}>
            {editingId === o.id ? (
              // ── Mode édition ────────────────────────────────────────
              <div style={{ display: "grid", gap: 6 }}>
                <input style={_pdInp} autoFocus value={editLabel}
                  onChange={(e) => setEditLabel(e.target.value)}
                  placeholder={lang === "fr" ? "Énoncé" : "Statement"} />
                <textarea style={{ ..._pdInp, fontFamily: "inherit", resize: "vertical" }} rows={2}
                  value={editDesc} onChange={(e) => setEditDesc(e.target.value)}
                  placeholder={lang === "fr" ? "Description (optionnel)" : "Description (optional)"} />
                <div style={{ display: "flex", gap: 6, justifyContent: "flex-end" }}>
                  <button className="btn xs ghost" onClick={cancelEdit} disabled={busy}>
                    {lang === "fr" ? "Annuler" : "Cancel"}
                  </button>
                  <button className="btn xs primary" onClick={saveEdit} disabled={busy || !editLabel.trim()}>
                    {busy ? "…" : (lang === "fr" ? "Enregistrer" : "Save")}
                  </button>
                </div>
              </div>
            ) : (
              <>
                {/* ── Mode lecture ──────────────────────────────────── */}
                <div style={{ minWidth: 0 }}>
                  <div style={{ fontSize: 13, fontWeight: 500, color: "var(--text)" }}>{o.label}</div>
                  {o.description && (
                    <div style={{ fontSize: 12, color: "var(--text-faint)", marginTop: 2, whiteSpace: "pre-wrap" }}>
                      {o.description}
                    </div>
                  )}
                </div>
                <button className="btn xs ghost" onClick={() => startEdit(o)} disabled={busy}
                  title={lang === "fr" ? "Modifier" : "Edit"}>
                  <Icon.edit /> <span style={{ marginLeft: 4 }}>{lang === "fr" ? "Modifier" : "Edit"}</span>
                </button>
                <button className="btn xs ghost" onClick={() => remove(o.id, o.label)} disabled={busy}
                  title={lang === "fr" ? "Supprimer" : "Delete"}
                  style={{ color: "#b91c1c" }}>
                  <Icon.trash /> <span style={{ marginLeft: 4 }}>{lang === "fr" ? "Supprimer" : "Delete"}</span>
                </button>
              </>
            )}
          </div>
        ))}

        {adding && (
          <div style={{ display: "grid", gap: 6, padding: "8px 10px", background: "color-mix(in oklch, var(--accent) 6%, transparent)", borderRadius: 5 }}>
            <input style={_pdInp} autoFocus value={label} onChange={(e) => setLabel(e.target.value)}
              placeholder={lang === "fr" ? "Énoncé de l'objectif" : "Objective statement"} />
            <textarea style={{ ..._pdInp, fontFamily: "inherit", resize: "vertical" }} rows={2}
              value={desc} onChange={(e) => setDesc(e.target.value)}
              placeholder={lang === "fr" ? "Description (optionnel)" : "Description (optional)"} />
            <div style={{ display: "flex", gap: 6, justifyContent: "flex-end" }}>
              <button className="btn xs ghost" onClick={() => { setAdding(false); setLabel(""); setDesc(""); setErr(null); }} disabled={busy}>
                {lang === "fr" ? "Annuler" : "Cancel"}
              </button>
              <button className="btn xs primary" onClick={add} disabled={busy || !label.trim()}>
                {busy ? "…" : (lang === "fr" ? "Enregistrer" : "Save")}
              </button>
            </div>
          </div>
        )}

        {err && (
          <div style={{ padding: "8px 10px", background: "#fee2e2", color: "#991b1b", borderRadius: 5, fontSize: 12 }}>
            ⚠ {err}
          </div>
        )}
      </div>
    </div>
  );
}

// ── 2. PDToc ─────────────────────────────────────────────────────────────
// Theorie du changement : table avec 4 colonnes (Activities, Outputs,
// Outcomes, Impact) en kanban. Pour la Phase 2C on a la liste linéaire
// editable; l'infographie chainee + visuelle vient en Phase 3.
function PDToc({ lang, projectUuid }) {
  if (!projectUuid) return <_PdNoProject lang={lang} />;
  const { data: rows, refresh } = window.melr.useProjectToc(projectUuid);
  const LEVELS = [
    { k: "activity", titleFr: "Activités",  titleEn: "Activities", color: "oklch(0.95 0.04 260)", accent: "oklch(0.55 0.16 260)" },
    { k: "output",   titleFr: "Outputs",    titleEn: "Outputs",    color: "oklch(0.95 0.04 195)", accent: "oklch(0.55 0.13 195)" },
    { k: "outcome",  titleFr: "Outcomes",   titleEn: "Outcomes",   color: "oklch(0.95 0.04 145)", accent: "oklch(0.55 0.16 145)" },
    { k: "impact",   titleFr: "Impact",     titleEn: "Impact",     color: "oklch(0.95 0.05 75)",  accent: "oklch(0.62 0.16 75)"  },
  ];
  const byLevel = (k) => (rows || []).filter((r) => r.level === k);
  // Toggle view : "edit" (kanban 4 colonnes editables) ou "infographic"
  // (sortie visuelle pour partage / capture d'ecran / rapport).
  const [view, setView] = React.useState("edit");
  return (
    <div style={{ marginTop: 16 }}>
      <div className="card" style={{ marginBottom: 12 }}>
        <div className="card-head">
          <div className="card-title" style={{ fontSize: 13.5 }}>
            📊 {lang === "fr" ? "Théorie du changement" : "Theory of change"}
          </div>
          <div style={{ flex: 1 }} />
          {/* Toggle vue */}
          <div style={{ display: "inline-flex", border: "1px solid var(--line)", borderRadius: 6, overflow: "hidden" }}>
            <button className={"btn xs" + (view === "edit" ? " primary" : " ghost")}
              onClick={() => setView("edit")} style={{ borderRadius: 0, border: "none" }}>
              ✏️ {lang === "fr" ? "Édition" : "Edit"}
            </button>
            <button className={"btn xs" + (view === "infographic" ? " primary" : " ghost")}
              onClick={() => setView("infographic")} style={{ borderRadius: 0, border: "none" }}>
              🎨 {lang === "fr" ? "Infographie" : "Infographic"}
            </button>
          </div>
        </div>
        <div className="card-body" style={{ fontSize: 12.5, color: "var(--text-muted)", paddingTop: 4 }}>
          {lang === "fr"
            ? <>Chaîne logique : <strong>Activités → Outputs → Outcomes → Impact</strong>. Chaque maillon peut porter une <strong>hypothèse</strong> pour passer au niveau supérieur.</>
            : <>Logic chain: <strong>Activities → Outputs → Outcomes → Impact</strong>. Each link can carry an <strong>assumption</strong> for moving to the next level.</>}
        </div>
      </div>
      {view === "edit" ? (
        <div style={{ display: "grid", gridTemplateColumns: "repeat(4, 1fr)", gap: 10 }}>
          {LEVELS.map((L) => (
            <PDTocColumn key={L.k}
              lang={lang}
              projectUuid={projectUuid}
              level={L.k}
              title={lang === "fr" ? L.titleFr : L.titleEn}
              color={L.color}
              items={byLevel(L.k)}
              refresh={refresh} />
          ))}
        </div>
      ) : (
        <PDTocInfographic lang={lang} rows={rows || []} levels={LEVELS} />
      )}
    </div>
  );
}

// ── PDTocInfographic ─────────────────────────────────────────────────────
// Vue presentationnelle (read-only) de la theorie du changement. Affiche
// les 4 niveaux empilés verticalement avec une "carte" par niveau qui
// resume les enonces + hypotheses. Le niveau IMPACT est mis en exergue
// (carte plus grande, dorée, badge vision).
//
// Connexions verticales : entre chaque niveau, une fleche descendante
// avec les hypotheses du niveau inferieur (= "Hypothese pour passer au
// niveau superieur") listees en italique. Cela materialise la condition
// pour franchir le maillon suivant.
function PDTocInfographic({ lang, rows, levels }) {
  const byLevel = (k) => rows.filter((r) => r.level === k);
  const infographicRef = React.useRef(null);

  if (rows.length === 0) {
    return (
      <div className="card">
        <div className="card-body" style={{ padding: 32, textAlign: "center", color: "var(--text-faint)" }}>
          {lang === "fr"
            ? "Aucun maillon saisi. Passer en mode « Édition » pour commencer."
            : "No links saved yet. Switch to 'Edit' view to start."}
        </div>
      </div>
    );
  }
  // Ordre BAS -> HAUT pour la lecture montante "ce que je fais"
  // (Activites) -> "ce que je produis" (Outputs) -> "ce que je change"
  // (Outcomes) -> "vers quoi je tends" (Impact, sommet).
  const ORDERED = ["activity", "output", "outcome", "impact"];
  return (
    <div>
      {/* Toolbar export — PNG / JPEG / PDF (helper partage) */}
      <div style={{ display: "flex", justifyContent: "flex-end", marginBottom: 10 }}>
        <PDExportButtons refToExport={infographicRef} lang={lang}
          title={lang === "fr" ? "Théorie du changement" : "Theory of change"}
          filenamePrefix="theorie-changement" />
      </div>
      <div ref={infographicRef} style={{ display: "grid", gap: 18, maxWidth: 900, margin: "0 auto", padding: 16, background: "white" }}>
      {ORDERED.map((lvlKey, idx) => {
        const meta = levels.find((L) => L.k === lvlKey);
        const items = byLevel(lvlKey);
        const isImpact = lvlKey === "impact";
        const isLast = idx === ORDERED.length - 1;
        // Collecte des hypotheses du niveau courant -> niveau superieur
        const assumptionsToNext = items
          .map((i) => i.assumption)
          .filter(Boolean);
        return (
          <div key={lvlKey}>
            <PDTocCard lang={lang} meta={meta} items={items} isImpact={isImpact} />
            {!isLast && (
              <PDTocConnector lang={lang}
                fromColor={meta.accent}
                toColor={(levels.find((L) => L.k === ORDERED[idx + 1]) || {}).accent}
                assumptions={assumptionsToNext} />
            )}
          </div>
        );
      })}
      </div>
    </div>
  );
}

function PDTocCard({ lang, meta, items, isImpact }) {
  if (!meta) return null;
  return (
    <div style={{
      border: "2px solid " + meta.accent,
      background: meta.color,
      borderRadius: isImpact ? 16 : 10,
      padding: isImpact ? "16px 20px" : "12px 16px",
      boxShadow: isImpact
        ? "0 8px 24px -8px " + meta.accent + ", 0 0 0 6px color-mix(in oklch, " + meta.accent + " 12%, transparent)"
        : "0 2px 8px -4px " + meta.accent,
      position: "relative",
    }}>
      {isImpact && (
        <div style={{
          position: "absolute", top: -10, right: 16,
          padding: "3px 10px", borderRadius: 999,
          background: meta.accent, color: "white",
          fontSize: 10, fontWeight: 700,
          letterSpacing: "0.08em", textTransform: "uppercase",
          boxShadow: "0 2px 6px " + meta.accent,
        }}>
          ⭐ {lang === "fr" ? "Vision finale" : "Final vision"}
        </div>
      )}
      <div style={{
        fontSize: isImpact ? 14 : 11,
        fontWeight: 700,
        color: meta.accent,
        textTransform: "uppercase",
        letterSpacing: "0.06em",
        marginBottom: 8,
      }}>
        {lang === "fr" ? meta.titleFr : meta.titleEn}
      </div>
      {items.length === 0 ? (
        <div style={{ fontSize: 12, fontStyle: "italic", color: "var(--text-faint)" }}>
          {lang === "fr" ? "Aucun élément à ce niveau." : "No item at this level."}
        </div>
      ) : (
        <div style={{ display: "grid", gap: 6 }}>
          {items.map((r) => (
            <div key={r.id} style={{
              padding: isImpact ? "8px 12px" : "6px 10px",
              background: "white",
              borderRadius: 6,
              fontSize: isImpact ? 14 : 12.5,
              fontWeight: isImpact ? 500 : 400,
              borderLeft: "3px solid " + meta.accent,
            }}>
              {r.label}
              {r.description && (
                <div className="text-faint" style={{ fontSize: 11, marginTop: 2 }}>
                  {r.description}
                </div>
              )}
            </div>
          ))}
        </div>
      )}
    </div>
  );
}

// ============================================================================
// EXPORT DOCUMENT PROJET (Phase exports · 2026-05)
// ============================================================================
// Genere un document de cadrage du projet avec les 4 sections planifiees :
//   - En-tete : code, nom, secteur, pays, responsable, dates, budget
//   - Objectifs (general / specifiques / resultats)
//   - Theorie du changement (4 niveaux + hypotheses)
//   - Parties prenantes initiales (table)
//   - Hypotheses & conditions (avec statuts)
//
// Format Word : utilise la lib docx (deja en CDN). Layout pro avec titres,
// tableaux et listes. Fichier .docx telechargement direct.
//
// Format PDF : utilise jsPDF (CDN). Layout texte simple — pas de tables
// natives, on utilise jsPDF-autotable ou des positions manuelles.

async function exportProjectDocument(P, projectUuid, lang, format) {
  if (!projectUuid) return;
  const sb = window.melr && window.melr.supabase;
  if (!sb) { alert("Supabase unavailable"); return; }

  try {
    // ── 1. Fetch all data in parallel ────────────────────────────────────
    // Cadrage (4 onglets) + sections supplementaires (indicateurs, sites,
    // risques, plan gantt, budget, equipe).
    const [objs, toc, stk, asm, inds, sites, risks, planActions, budget, team] = await Promise.all([
      sb.from("project_objectives").select("*").eq("project_id", projectUuid).order("sort_order"),
      sb.from("project_toc").select("*").eq("project_id", projectUuid).order("sort_order"),
      sb.from("stakeholders").select("*").eq("project_id", projectUuid),
      sb.from("project_assumptions").select("*").eq("project_id", projectUuid).order("sort_order"),
      sb.from("indicators").select("id, code, level, name_fr, name_en, unit, baseline, target, frequency, value_kind, baseline_text, target_text, source, means_of_verification")
        .eq("project_id", projectUuid).order("code"),
      sb.from("sites").select("id, code, name, kind, region, country_iso2, beneficiaries, status")
        .eq("project_id", projectUuid).order("code"),
      sb.from("risks").select("id, level, title, mitigation, probability, impact, status")
        .eq("project_id", projectUuid),
      sb.from("plan_actions").select("id, wbs, name_fr, name_en, start_date, end_date, progress, status, milestone")
        .eq("project_id", projectUuid).order("position", { ascending: true }),
      sb.from("budget_lines").select("id, category, label, budget, disbursed, committed, currency")
        .eq("project_id", projectUuid).order("position", { ascending: true }),
      sb.from("project_team").select("user_id, role_label, joined_at, profiles(full_name, email)")
        .eq("project_id", projectUuid),
    ]);
    const data = {
      objectives:   objs.data        || [],
      toc:          toc.data         || [],
      stakeholders: stk.data         || [],
      assumptions:  asm.data         || [],
      indicators:   inds.data        || [],
      sites:        sites.data       || [],
      risks:        risks.data       || [],
      planActions:  planActions.data || [],
      budget:       budget.data      || [],
      team:         team.data        || [],
    };

    // ── 2. Capture visual snapshots via html2canvas ──────────────────────
    // Render off-screen HTML for risk matrix, Gantt, ToC infographic;
    // capture each as PNG dataURL. Embedded in both docx and pdf.
    const images = await _captureProjectSnapshots(data, lang);

    if (format === "docx") {
      await _exportDocWord(P, data, images, lang);
    } else if (format === "pdf") {
      await _exportDocPdf(P, data, images, lang);
    }
    // Diagnostic des captures : si certains graphiques n'ont pas pu
    // etre embarques, on dit pourquoi (donnees vides, capture
    // echouee, etc.) plutot que de laisser l'utilisateur deviner.
    const d = images && images._diag;
    if (d) {
      const lines = [];
      if (d.failed.length > 0)
        lines.push((lang === "fr" ? "Graphiques non embarques (echec) : " : "Charts not embedded (failure): ") + d.failed.join(", "));
      if (d.skipped.length > 0)
        lines.push((lang === "fr" ? "Sections sans donnees : " : "Empty sections: ") + d.skipped.join(", "));
      if (lines.length > 0) {
        // Console + alert pour qu'on sache pourquoi tel ou tel
        // graphique manque. Si tout passe (lines.length === 0),
        // on ne derange pas l'utilisateur.
        console.info("[exportProjectDocument]", d);
        alert(lines.join("\n"));
      }
    }
  } catch (e) {
    alert((lang === "fr" ? "Erreur export : " : "Export error: ") + (e.message || e));
  }
}

// ── Helper · Capture visual snapshots (PNG dataURLs) ─────────────────────
// Strategy : on monte un container off-screen (left -10000) avec 3 visuels
// HTML, on capture chacun avec html2canvas, on demonte. Renvoie un objet
// { riskMatrix, gantt, toc } de dataURLs PNG ou null si la donnee est vide.
async function _captureProjectSnapshots(data, lang) {
  // Fallback en cascade pour maximiser le taux de succes :
  //   1. window.melr.captureElement (resolveur OKLCH + defaults)
  //   2. window.html2canvas direct (html2canvas-pro parse oklch
  //      nativement et tout fonctionne)
  const captureFn = (window.melr && window.melr.captureElement)
    || (window.html2canvas
        ? (el, opts) => window.html2canvas(el, opts)
        : null);
  const out = { riskMatrix: null, gantt: null, toc: null, budget: null };
  const diag = { skipped: [], failed: [], captured: [] };
  if (!captureFn) {
    diag.failed.push("captureFn introuvable (html2canvas non charge)");
    out._diag = diag;
    return out;
  }
  const host = document.createElement("div");
  // position:fixed + isole : pas d'inheritance des polices/colors
  // du body (qui utilise des variables OKLCH).
  host.style.cssText = [
    "position:fixed",
    "left:-10000px",
    "top:0",
    "width:820px",
    "background:#ffffff",
    "font-family:Arial,system-ui,sans-serif",
    "color:#111111",
    "line-height:1.4",
    "z-index:-1",
  ].join(";");
  document.body.appendChild(host);
  // Capture chaque visuel dans son propre try/catch pour qu'une
  // capture defaillante n'empeche pas les autres.
  const snap = async (label, builder, key) => {
    try {
      host.innerHTML = builder();
      // Petit delai pour laisser le navigateur layouter avant capture.
      await new Promise((r) => setTimeout(r, 30));
      const canvas = await captureFn(host.firstElementChild, { backgroundColor: "#ffffff", scale: 2, useCORS: true });
      const dataUrl = canvas.toDataURL("image/png", 1);
      if (dataUrl && dataUrl.length > 200) {
        out[key] = dataUrl;
        diag.captured.push(label);
      } else {
        diag.failed.push(label + " (dataURL vide)");
      }
    } catch (e) {
      console.warn("[exportProjectDocument] " + label + " capture failed:", e);
      diag.failed.push(label + " (" + (e.message || e) + ")");
    }
  };
  try {
    if (data.toc.length > 0)         await snap("ToC",       () => _buildTocHtml(data.toc, lang),                "toc");         else diag.skipped.push("ToC (aucun maillon)");
    if (data.risks.length > 0)       await snap("Risques",   () => _buildRiskMatrixHtml(data.risks, lang),       "riskMatrix");  else diag.skipped.push("Risques (registre vide)");
    if (data.planActions.length > 0) await snap("Gantt",     () => _buildGanttHtml(data.planActions, lang),      "gantt");       else diag.skipped.push("Gantt (aucune action planifiee)");
    if (data.budget.length > 0)      await snap("Budget",    () => _buildBudgetHtml(data.budget, lang),          "budget");      else diag.skipped.push("Budget (aucune ligne)");
  } finally {
    document.body.removeChild(host);
  }
  out._diag = diag;
  return out;
}

// ── Helper · HTML for ToC vertical infographic ───────────────────────────
function _buildTocHtml(toc, lang) {
  const ORDER = ["activity", "output", "outcome", "impact"];
  const LEVELS = {
    activity: { titleFr: "Activités", titleEn: "Activities", bg: "#e8edff", border: "#4f46e5" },
    output:   { titleFr: "Outputs",   titleEn: "Outputs",    bg: "#dff7f3", border: "#0d9488" },
    outcome:  { titleFr: "Outcomes",  titleEn: "Outcomes",   bg: "#dcfce7", border: "#16a34a" },
    impact:   { titleFr: "Impact",    titleEn: "Impact",     bg: "#fef3c7", border: "#d97706" },
  };
  const byLevel = (k) => toc.filter((r) => r.level === k);
  const cards = ORDER.map((lvl, idx) => {
    const meta = LEVELS[lvl];
    const items = byLevel(lvl);
    const isImpact = lvl === "impact";
    const assumptionsList = items.map((i) => i.assumption).filter(Boolean);
    return `
      <div style="border:2px solid ${meta.border};background:${meta.bg};border-radius:${isImpact ? 16 : 10}px;padding:${isImpact ? "16px 20px" : "12px 16px"};margin-bottom:8px;position:relative;">
        ${isImpact ? `<div style="position:absolute;top:-10px;right:16px;padding:3px 10px;border-radius:999px;background:${meta.border};color:white;font-size:10px;font-weight:700;letter-spacing:0.08em;text-transform:uppercase;">⭐ ${lang === "fr" ? "Vision finale" : "Final vision"}</div>` : ""}
        <div style="font-size:${isImpact ? 14 : 11}px;font-weight:700;color:${meta.border};text-transform:uppercase;letter-spacing:0.06em;margin-bottom:8px;">${lang === "fr" ? meta.titleFr : meta.titleEn}</div>
        ${items.length === 0
          ? `<div style="font-size:12px;font-style:italic;color:#888;">${lang === "fr" ? "(vide)" : "(empty)"}</div>`
          : items.map((r) => `
            <div style="padding:${isImpact ? "8px 12px" : "6px 10px"};background:white;border-radius:6px;font-size:${isImpact ? 14 : 12}px;font-weight:${isImpact ? 500 : 400};border-left:3px solid ${meta.border};margin-bottom:4px;">${_esc(r.label)}</div>
          `).join("")}
      </div>
      ${idx < ORDER.length - 1 && assumptionsList.length > 0
        ? `<div style="padding:6px 12px;background:#fffbeb;border:1px dashed #ca8a04;border-radius:6px;margin-bottom:8px;font-size:11px;"><strong style="color:#854d0e;">🔗 ${lang === "fr" ? "Hypothèses pour franchir ce maillon" : "Bridging assumptions"}</strong><ul style="margin:4px 0 0 16px;padding:0;">${assumptionsList.map((a) => `<li>${_esc(a)}</li>`).join("")}</ul></div>`
        : (idx < ORDER.length - 1 ? `<div style="text-align:center;font-size:18px;color:#888;margin:-2px 0 6px;">↓</div>` : "")}
    `;
  }).reverse().join(""); // reverse to put Impact at top
  return `<div style="max-width:760px;padding:16px;">${cards}</div>`;
}

// ── Helper · HTML for Risk Matrix (5x5 grid) ─────────────────────────────
function _buildRiskMatrixHtml(risks, lang) {
  // Cell color zones (low-left = green, top-right = red)
  // probability sur axe X (1..5), impact sur axe Y (1..5)
  const zone = (p, i) => {
    const score = p * i;
    if (score <= 4)  return "#dcfce7"; // vert
    if (score <= 9)  return "#fef9c3"; // jaune
    if (score <= 16) return "#fed7aa"; // orange
    return "#fecaca";                  // rouge
  };
  // Grouper les risques par cellule (probability, impact)
  const cell = {};
  risks.forEach((r) => {
    if (!r.probability || !r.impact) return;
    const k = r.probability + "_" + r.impact;
    if (!cell[k]) cell[k] = [];
    cell[k].push(r);
  });
  const rows = [];
  // impact decreasing (5 en haut)
  for (let i = 5; i >= 1; i--) {
    const cells = [];
    cells.push(`<td style="background:#f5f5f5;text-align:center;font-weight:600;width:36px;">${i}</td>`);
    for (let p = 1; p <= 5; p++) {
      const here = cell[p + "_" + i] || [];
      cells.push(`<td style="background:${zone(p, i)};text-align:center;vertical-align:middle;width:80px;height:60px;border:1px solid #e5e7eb;font-size:11px;">${here.length > 0 ? `<strong style="color:#7c2d12;">${here.length}</strong><div style="font-size:9px;color:#666;">${_esc(here.map((r) => r.title).slice(0, 2).join(", "))}${here.length > 2 ? "…" : ""}</div>` : ""}</td>`);
    }
    rows.push("<tr>" + cells.join("") + "</tr>");
  }
  // Axe X label row
  const xLabels = [`<td></td>`];
  for (let p = 1; p <= 5; p++) {
    xLabels.push(`<td style="text-align:center;font-weight:600;background:#f5f5f5;">${p}</td>`);
  }
  rows.push("<tr>" + xLabels.join("") + "</tr>");
  return `
    <div style="padding:16px;width:600px;">
      <div style="font-weight:700;font-size:14px;margin-bottom:8px;text-align:center;">${lang === "fr" ? "Matrice Probabilité × Impact" : "Probability × Impact Matrix"}</div>
      <div style="display:flex;align-items:stretch;gap:8px;">
        <div style="writing-mode:vertical-rl;transform:rotate(180deg);text-align:center;font-size:11px;font-weight:600;color:#374151;display:flex;align-items:center;justify-content:center;">${lang === "fr" ? "IMPACT →" : "IMPACT →"}</div>
        <table style="border-collapse:collapse;flex:1;font-family:system-ui,sans-serif;">${rows.join("")}</table>
      </div>
      <div style="text-align:center;margin-top:4px;font-size:11px;font-weight:600;color:#374151;">${lang === "fr" ? "PROBABILITÉ →" : "PROBABILITY →"}</div>
      <div style="display:flex;gap:10px;justify-content:center;margin-top:8px;font-size:10px;">
        <span style="padding:2px 8px;background:#dcfce7;border-radius:3px;">${lang === "fr" ? "Faible" : "Low"} (1-4)</span>
        <span style="padding:2px 8px;background:#fef9c3;border-radius:3px;">${lang === "fr" ? "Modéré" : "Moderate"} (5-9)</span>
        <span style="padding:2px 8px;background:#fed7aa;border-radius:3px;">${lang === "fr" ? "Élevé" : "High"} (10-16)</span>
        <span style="padding:2px 8px;background:#fecaca;border-radius:3px;">${lang === "fr" ? "Critique" : "Critical"} (17-25)</span>
      </div>
    </div>
  `;
}

// ── Helper · HTML for Gantt chart (horizontal bars) ──────────────────────
function _buildGanttHtml(actions, lang) {
  // Determine timeline bounds
  const dates = actions.map((a) => [a.start_date, a.end_date]).flat().filter(Boolean).sort();
  if (dates.length === 0) return `<div style="padding:16px;color:#888;">${lang === "fr" ? "Aucune action planifiée." : "No planned action."}</div>`;
  const minD = new Date(dates[0]);
  const maxD = new Date(dates[dates.length - 1]);
  const span = Math.max(1, (maxD - minD) / (1000 * 60 * 60 * 24)); // days
  const W = 500; // bar zone width
  // Build rows
  const rows = actions.slice(0, 30).map((a) => { // limit 30 rows
    if (!a.start_date || !a.end_date) {
      return `<tr><td style="padding:4px 6px;font-size:11px;border-bottom:1px solid #eee;">${_esc(a.name_fr || a.wbs || "")}</td><td style="font-size:10px;color:#888;border-bottom:1px solid #eee;">${lang === "fr" ? "(dates manquantes)" : "(missing dates)"}</td></tr>`;
    }
    const s = new Date(a.start_date), e = new Date(a.end_date);
    const offset = ((s - minD) / (1000 * 60 * 60 * 24) / span) * W;
    const width = Math.max(4, ((e - s) / (1000 * 60 * 60 * 24) / span) * W);
    const prog = Math.max(0, Math.min(100, a.progress || 0));
    const color = a.milestone ? "#7c3aed" : (prog >= 100 ? "#16a34a" : prog >= 50 ? "#0ea5e9" : "#facc15");
    return `<tr>
      <td style="padding:4px 8px;font-size:11px;border-bottom:1px solid #eee;width:200px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:200px;">${_esc(a.name_fr || a.wbs || "")}</td>
      <td style="position:relative;padding:4px 4px;border-bottom:1px solid #eee;">
        <div style="position:relative;height:18px;background:#f5f5f5;border-radius:3px;width:${W}px;">
          <div style="position:absolute;left:${offset}px;width:${width}px;height:18px;background:${color};border-radius:3px;">
            <div style="position:absolute;top:0;left:0;height:18px;width:${prog}%;background:rgba(255,255,255,0.4);border-radius:3px;"></div>
            <div style="position:absolute;left:4px;top:0;font-size:9px;line-height:18px;color:white;font-weight:600;white-space:nowrap;">${prog}%</div>
          </div>
        </div>
      </td>
    </tr>`;
  }).join("");
  return `
    <div style="padding:16px;width:780px;">
      <div style="font-weight:700;font-size:14px;margin-bottom:8px;">${lang === "fr" ? "Diagramme de Gantt (synthèse)" : "Gantt chart (summary)"}</div>
      <div style="font-size:10px;color:#666;margin-bottom:6px;">${lang === "fr" ? "Période : " : "Period: "}${minD.toLocaleDateString()} → ${maxD.toLocaleDateString()}</div>
      <table style="border-collapse:collapse;width:100%;"><tbody>${rows}</tbody></table>
      ${actions.length > 30 ? `<div style="margin-top:6px;font-size:10px;font-style:italic;color:#666;">+ ${actions.length - 30} ${lang === "fr" ? "actions supplémentaires non affichées" : "more actions not displayed"}</div>` : ""}
    </div>
  `;
}

// ── Helper · HTML for Budget chart (stacked bars by category) ────────────
function _buildBudgetHtml(lines, lang) {
  // Top 8 lines ordered by budget desc, others aggregated as "Autres"
  const sorted = lines.slice().sort((a, b) => (b.budget || 0) - (a.budget || 0));
  const top = sorted.slice(0, 8);
  const others = sorted.slice(8);
  if (others.length > 0) {
    top.push({
      category: lang === "fr" ? "Autres" : "Others",
      label: "(" + others.length + ")",
      budget:    others.reduce((s, l) => s + (Number(l.budget)    || 0), 0),
      disbursed: others.reduce((s, l) => s + (Number(l.disbursed) || 0), 0),
      committed: others.reduce((s, l) => s + (Number(l.committed) || 0), 0),
      currency: top[0] && top[0].currency,
    });
  }
  const maxB = top.reduce((m, l) => Math.max(m, l.budget || 0), 0) || 1;
  const fmt = (v) => (v == null ? "—" : (Math.round((v / 1_000_000) * 100) / 100).toLocaleString() + " M");
  const cur = (top[0] && top[0].currency) || "EUR";
  const rows = top.map((l) => {
    const wB = ((l.budget    || 0) / maxB) * 100;
    const wD = ((l.disbursed || 0) / maxB) * 100;
    const wC = (((l.committed || 0) - (l.disbursed || 0)) / maxB) * 100;
    const label = (l.category || "") + (l.label ? " — " + l.label : "");
    return `<tr>
      <td style="padding:6px 8px;font-size:11px;border-bottom:1px solid #eee;width:240px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:240px;">${_esc(label)}</td>
      <td style="padding:6px 4px;border-bottom:1px solid #eee;">
        <div style="position:relative;height:18px;width:420px;background:#f5f5f5;border-radius:3px;">
          <div style="position:absolute;left:0;top:0;height:18px;width:${wD}%;background:#16a34a;border-radius:3px 0 0 3px;"></div>
          <div style="position:absolute;left:${wD}%;top:0;height:18px;width:${Math.max(0, wC)}%;background:#facc15;"></div>
          <div style="position:absolute;left:${wD + Math.max(0, wC)}%;top:0;height:18px;width:${Math.max(0, wB - wD - Math.max(0, wC))}%;background:#e5e7eb;border-radius:0 3px 3px 0;"></div>
        </div>
      </td>
      <td style="padding:6px 8px;font-size:11px;border-bottom:1px solid #eee;text-align:right;font-family:monospace;width:80px;">${fmt(l.budget)}</td>
    </tr>`;
  }).join("");
  return `
    <div style="padding:16px;width:780px;">
      <div style="font-weight:700;font-size:14px;margin-bottom:8px;">${lang === "fr" ? "Budget par catégorie (M" : "Budget by category (M"}${cur})</div>
      <table style="border-collapse:collapse;width:100%;"><tbody>${rows}</tbody></table>
      <div style="display:flex;gap:16px;justify-content:center;margin-top:10px;font-size:10px;">
        <span style="display:flex;align-items:center;gap:6px;"><span style="display:inline-block;width:12px;height:10px;background:#16a34a;border-radius:2px;"></span>${lang === "fr" ? "Décaissé" : "Disbursed"}</span>
        <span style="display:flex;align-items:center;gap:6px;"><span style="display:inline-block;width:12px;height:10px;background:#facc15;border-radius:2px;"></span>${lang === "fr" ? "Engagé restant" : "Committed remaining"}</span>
        <span style="display:flex;align-items:center;gap:6px;"><span style="display:inline-block;width:12px;height:10px;background:#e5e7eb;border-radius:2px;"></span>${lang === "fr" ? "Non engagé" : "Uncommitted"}</span>
      </div>
    </div>
  `;
}

function _esc(s) {
  return String(s == null ? "" : s)
    .replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
    .replace(/"/g, "&quot;").replace(/'/g, "&#39;");
}

// Convert PNG dataURL -> Uint8Array (for docx ImageRun)
function _dataUrlToBytes(dataUrl) {
  const base64 = dataUrl.split(",")[1] || "";
  const binary = atob(base64);
  const bytes = new Uint8Array(binary.length);
  for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
  return bytes;
}

// Compute image dimensions for embedding (max width 600px, keep ratio)
function _imageDims(dataUrl, maxWidth) {
  return new Promise((resolve) => {
    const img = new Image();
    img.onload = () => {
      const w = img.naturalWidth, h = img.naturalHeight;
      const scale = w > maxWidth ? maxWidth / w : 1;
      resolve({ width: Math.round(w * scale), height: Math.round(h * scale) });
    };
    img.onerror = () => resolve({ width: 400, height: 300 });
    img.src = dataUrl;
  });
}

// ── Helper · Word (.docx) via docx library ───────────────────────────────
async function _exportDocWord(P, sections, images, lang) {
  if (!window.docx) {
    alert(lang === "fr" ? "Bibliothèque Word indisponible." : "Word library unavailable.");
    return;
  }
  const D = window.docx;
  const STATUS_LABELS = {
    unverified: lang === "fr" ? "Non vérifiée" : "Unverified",
    confirmed:  lang === "fr" ? "Confirmée"    : "Confirmed",
    at_risk:    lang === "fr" ? "À risque"     : "At risk",
    failed:     lang === "fr" ? "Invalidée"    : "Failed",
  };
  const STAKEHOLDER_TYPES = {
    supervisory:    lang === "fr" ? "Tutelle" : "Supervisory",
    implementation: lang === "fr" ? "Mise en œuvre" : "Implementation",
    beneficiary:    lang === "fr" ? "Bénéficiaire" : "Beneficiary",
    donor:          lang === "fr" ? "Donateur" : "Donor",
    supplier:       lang === "fr" ? "Fournisseur" : "Supplier",
    other:          lang === "fr" ? "Autre" : "Other",
  };
  const LEVEL_LABELS = {
    activity: lang === "fr" ? "Activités" : "Activities",
    output:   lang === "fr" ? "Outputs" : "Outputs",
    outcome:  lang === "fr" ? "Outcomes" : "Outcomes",
    impact:   "Impact",
  };
  const KIND_LABELS = {
    general:  lang === "fr" ? "Objectif général" : "General objective",
    specific: lang === "fr" ? "Objectifs spécifiques" : "Specific objectives",
    result:   lang === "fr" ? "Résultats attendus" : "Expected results",
  };

  const h1 = (text) => new D.Paragraph({ text, heading: D.HeadingLevel.HEADING_1, spacing: { before: 240, after: 120 } });
  const h2 = (text) => new D.Paragraph({ text, heading: D.HeadingLevel.HEADING_2, spacing: { before: 200, after: 100 } });
  const h3 = (text) => new D.Paragraph({ text, heading: D.HeadingLevel.HEADING_3, spacing: { before: 160, after: 80 } });
  const p  = (text, opts) => new D.Paragraph({ children: [new D.TextRun(Object.assign({ text }, opts || {}))], spacing: { after: 80 } });
  const bullet = (text) => new D.Paragraph({ children: [new D.TextRun(text || "")], bullet: { level: 0 } });
  const sep = () => new D.Paragraph({ children: [], spacing: { after: 200 } });

  // Build content
  const children = [];

  // ── COVER / HEADER ──
  children.push(new D.Paragraph({
    children: [new D.TextRun({ text: lang === "fr" ? "DOCUMENT DE CADRAGE" : "FRAMING DOCUMENT", bold: true, size: 28, color: "4f46e5" })],
    alignment: D.AlignmentType.CENTER,
    spacing: { after: 120 },
  }));
  children.push(new D.Paragraph({
    children: [new D.TextRun({ text: (P.name || P.id), bold: true, size: 36 })],
    alignment: D.AlignmentType.CENTER,
    spacing: { after: 80 },
  }));
  children.push(new D.Paragraph({
    children: [new D.TextRun({ text: P.id || "", color: "888888", size: 22 })],
    alignment: D.AlignmentType.CENTER,
    spacing: { after: 400 },
  }));

  // Metadata block
  children.push(h2(lang === "fr" ? "Informations clés" : "Key information"));
  const meta = [
    [lang === "fr" ? "Code" : "Code",         P.id || "—"],
    [lang === "fr" ? "Nom" : "Name",          P.name || "—"],
    [lang === "fr" ? "Secteur" : "Sector",    P.sector || "—"],
    [lang === "fr" ? "Pays" : "Countries",    P.countries || "—"],
    [lang === "fr" ? "Responsable" : "Lead",  P.lead || "—"],
    [lang === "fr" ? "Phase" : "Phase",       P.phase || "—"],
    [lang === "fr" ? "Avancement" : "Progress", (P.progress != null ? Math.round(P.progress) + " %" : "—")],
  ];
  meta.forEach(([k, v]) => {
    children.push(new D.Paragraph({
      children: [
        new D.TextRun({ text: k + " : ", bold: true }),
        new D.TextRun({ text: String(v) }),
      ],
      spacing: { after: 60 },
    }));
  });
  children.push(sep());

  // ── OBJECTIFS ──
  children.push(h1(lang === "fr" ? "1. Objectifs du projet" : "1. Project objectives"));
  ["general", "specific", "result"].forEach((kind) => {
    const items = sections.objectives.filter((o) => o.kind === kind);
    if (items.length === 0) return;
    children.push(h2(KIND_LABELS[kind]));
    items.forEach((o) => {
      children.push(bullet(o.label));
      if (o.description) children.push(p("  " + o.description, { italic: true, color: "666666", size: 20 }));
    });
  });
  if (sections.objectives.length === 0) {
    children.push(p(lang === "fr" ? "(Aucun objectif saisi)" : "(No objectives entered)", { italic: true, color: "888888" }));
  }
  children.push(sep());

  // ── THEORIE DU CHANGEMENT ──
  children.push(h1(lang === "fr" ? "2. Théorie du changement" : "2. Theory of change"));
  children.push(p(lang === "fr"
    ? "Chaîne logique : Activités → Outputs → Outcomes → Impact."
    : "Logic chain: Activities → Outputs → Outcomes → Impact.",
    { italic: true, color: "555555" }));
  ["activity", "output", "outcome", "impact"].forEach((lvl) => {
    const items = sections.toc.filter((r) => r.level === lvl);
    if (items.length === 0) return;
    children.push(h2(LEVEL_LABELS[lvl]));
    items.forEach((r) => {
      children.push(bullet(r.label));
      if (r.description) children.push(p("  " + r.description, { italic: true, color: "666666", size: 20 }));
      if (r.assumption)  children.push(p("  → " + (lang === "fr" ? "Hypothèse : " : "Assumption: ") + r.assumption, { color: "996600", size: 20 }));
    });
  });
  if (sections.toc.length === 0) {
    children.push(p(lang === "fr" ? "(Aucun maillon saisi)" : "(No links entered)", { italic: true, color: "888888" }));
  }
  children.push(sep());

  // ── PARTIES PRENANTES ──
  children.push(h1(lang === "fr" ? "3. Parties prenantes" : "3. Stakeholders"));
  if (sections.stakeholders.length === 0) {
    children.push(p(lang === "fr" ? "(Aucune partie prenante saisie)" : "(No stakeholders entered)", { italic: true, color: "888888" }));
  } else {
    // Build a table
    const tableRows = [
      new D.TableRow({ children: [
        new D.TableCell({ children: [p((lang === "fr" ? "Nom" : "Name"), { bold: true })] }),
        new D.TableCell({ children: [p((lang === "fr" ? "Type" : "Type"), { bold: true })] }),
        new D.TableCell({ children: [p((lang === "fr" ? "Rôle" : "Role"), { bold: true })] }),
        new D.TableCell({ children: [p((lang === "fr" ? "Pouvoir" : "Power"), { bold: true })] }),
        new D.TableCell({ children: [p((lang === "fr" ? "Intérêt" : "Interest"), { bold: true })] }),
      ]}),
    ];
    sections.stakeholders.forEach((s) => {
      tableRows.push(new D.TableRow({ children: [
        new D.TableCell({ children: [p(s.name || "")] }),
        new D.TableCell({ children: [p(STAKEHOLDER_TYPES[s.type] || s.type || "")] }),
        new D.TableCell({ children: [p(s.role_label || "—")] }),
        new D.TableCell({ children: [p(s.power ? s.power + "/5" : "—")] }),
        new D.TableCell({ children: [p(s.interest ? s.interest + "/5" : "—")] }),
      ]}));
    });
    children.push(new D.Table({ rows: tableRows, width: { size: 100, type: D.WidthType.PERCENTAGE } }));
  }
  children.push(sep());

  // ── HYPOTHESES ──
  children.push(h1(lang === "fr" ? "4. Hypothèses & conditions" : "4. Assumptions & conditions"));
  if (sections.assumptions.length === 0) {
    children.push(p(lang === "fr" ? "(Aucune hypothèse saisie)" : "(No assumptions entered)", { italic: true, color: "888888" }));
  } else {
    sections.assumptions.forEach((a, i) => {
      children.push(h3((i + 1) + ". " + a.statement));
      children.push(new D.Paragraph({
        children: [
          new D.TextRun({ text: (lang === "fr" ? "Statut : " : "Status: "), bold: true }),
          new D.TextRun({ text: STATUS_LABELS[a.status] || a.status, color:
            a.status === "confirmed" ? "1d8a3a"
            : a.status === "at_risk" ? "b87200"
            : a.status === "failed"  ? "b91c1c"
            : "666666" }),
        ],
        spacing: { after: 60 },
      }));
      if (a.mitigation) {
        children.push(new D.Paragraph({
          children: [
            new D.TextRun({ text: (lang === "fr" ? "Mitigation : " : "Mitigation: "), bold: true }),
            new D.TextRun({ text: a.mitigation }),
          ],
          spacing: { after: 60 },
        }));
      }
      if (a.last_reviewed) {
        children.push(p((lang === "fr" ? "Dernière revue : " : "Last reviewed: ") + new Date(a.last_reviewed).toLocaleDateString(),
          { italic: true, color: "888888", size: 18 }));
      }
    });
  }
  children.push(sep());

  // ── 4-bis. THEORIE DU CHANGEMENT · infographie embedded ──
  // Inseree juste apres la description textuelle du ToC, comme illustration.
  // L'image est generee par html2canvas (snapshot off-screen).
  if (images.toc) {
    children.push(h2(lang === "fr" ? "Visualisation graphique" : "Graphic visualisation"));
    const dims = await _imageDims(images.toc, 600);
    children.push(new D.Paragraph({
      children: [new D.ImageRun({ data: _dataUrlToBytes(images.toc), transformation: dims })],
      alignment: D.AlignmentType.CENTER,
      spacing: { after: 200 },
    }));
  }

  // ── 5. INDICATEURS ──
  children.push(h1(lang === "fr" ? "5. Indicateurs retenus" : "5. Selected indicators"));
  if (sections.indicators.length === 0) {
    children.push(p(lang === "fr" ? "(Aucun indicateur)" : "(No indicator)", { italic: true, color: "888888" }));
  } else {
    const indRows = [
      new D.TableRow({ tableHeader: true, children: [
        new D.TableCell({ children: [p(lang === "fr" ? "Code" : "Code", { bold: true })] }),
        new D.TableCell({ children: [p(lang === "fr" ? "Nom" : "Name", { bold: true })] }),
        new D.TableCell({ children: [p(lang === "fr" ? "Niveau" : "Level", { bold: true })] }),
        new D.TableCell({ children: [p(lang === "fr" ? "Unité" : "Unit", { bold: true })] }),
        new D.TableCell({ children: [p(lang === "fr" ? "Baseline" : "Baseline", { bold: true })] }),
        new D.TableCell({ children: [p(lang === "fr" ? "Cible" : "Target", { bold: true })] }),
        new D.TableCell({ children: [p(lang === "fr" ? "Fréquence" : "Frequency", { bold: true })] }),
      ]}),
    ];
    sections.indicators.forEach((i) => {
      const isPefa = i.value_kind === "score_pefa";
      indRows.push(new D.TableRow({ children: [
        new D.TableCell({ children: [p(i.code || "")] }),
        new D.TableCell({ children: [p(lang === "fr" ? (i.name_fr || "") : (i.name_en || i.name_fr || ""))] }),
        new D.TableCell({ children: [p(i.level || "")] }),
        new D.TableCell({ children: [p(isPefa ? "PEFA" : (i.unit || "—"))] }),
        new D.TableCell({ children: [p(isPefa ? (i.baseline_text || "—") : (i.baseline != null ? String(i.baseline) : "—"))] }),
        new D.TableCell({ children: [p(isPefa ? (i.target_text || "—") : (i.target != null ? String(i.target) : "—"))] }),
        new D.TableCell({ children: [p(i.frequency || "—")] }),
      ]}));
    });
    children.push(new D.Table({ rows: indRows, width: { size: 100, type: D.WidthType.PERCENTAGE } }));
  }
  children.push(sep());

  // ── 6. SITES ──
  children.push(h1(lang === "fr" ? "6. Sites d'intervention" : "6. Intervention sites"));
  if (sections.sites.length === 0) {
    children.push(p(lang === "fr" ? "(Aucun site)" : "(No site)", { italic: true, color: "888888" }));
  } else {
    const sRows = [
      new D.TableRow({ tableHeader: true, children: [
        new D.TableCell({ children: [p(lang === "fr" ? "Code" : "Code", { bold: true })] }),
        new D.TableCell({ children: [p(lang === "fr" ? "Nom" : "Name", { bold: true })] }),
        new D.TableCell({ children: [p(lang === "fr" ? "Type" : "Type", { bold: true })] }),
        new D.TableCell({ children: [p(lang === "fr" ? "Région" : "Region", { bold: true })] }),
        new D.TableCell({ children: [p(lang === "fr" ? "Pays" : "Country", { bold: true })] }),
        new D.TableCell({ children: [p(lang === "fr" ? "Bénéficiaires" : "Beneficiaries", { bold: true })] }),
        new D.TableCell({ children: [p(lang === "fr" ? "Statut" : "Status", { bold: true })] }),
      ]}),
    ];
    sections.sites.forEach((s) => {
      sRows.push(new D.TableRow({ children: [
        new D.TableCell({ children: [p(s.code || "")] }),
        new D.TableCell({ children: [p(s.name || "")] }),
        new D.TableCell({ children: [p(s.kind || "—")] }),
        new D.TableCell({ children: [p(s.region || "—")] }),
        new D.TableCell({ children: [p(s.country_iso2 || "—")] }),
        new D.TableCell({ children: [p(s.beneficiaries != null ? String(s.beneficiaries) : "—")] }),
        new D.TableCell({ children: [p(s.status || "—")] }),
      ]}));
    });
    children.push(new D.Table({ rows: sRows, width: { size: 100, type: D.WidthType.PERCENTAGE } }));
  }
  children.push(sep());

  // ── 7. RISQUES + MATRICE GRAPHIQUE ──
  children.push(h1(lang === "fr" ? "7. Risques & matrice probabilité × impact" : "7. Risks & probability × impact matrix"));
  if (sections.risks.length === 0) {
    children.push(p(lang === "fr" ? "(Aucun risque identifié)" : "(No risk identified)", { italic: true, color: "888888" }));
  } else {
    // Image matrice
    if (images.riskMatrix) {
      const dims = await _imageDims(images.riskMatrix, 540);
      children.push(new D.Paragraph({
        children: [new D.ImageRun({ data: _dataUrlToBytes(images.riskMatrix), transformation: dims })],
        alignment: D.AlignmentType.CENTER,
        spacing: { after: 160 },
      }));
    }
    // Liste detaillee
    children.push(h2(lang === "fr" ? "Détail des risques" : "Risk details"));
    const rRows = [
      new D.TableRow({ tableHeader: true, children: [
        new D.TableCell({ children: [p(lang === "fr" ? "Titre" : "Title", { bold: true })] }),
        new D.TableCell({ children: [p(lang === "fr" ? "Niveau" : "Level", { bold: true })] }),
        new D.TableCell({ children: [p("P×I", { bold: true })] }),
        new D.TableCell({ children: [p(lang === "fr" ? "Mitigation" : "Mitigation", { bold: true })] }),
        new D.TableCell({ children: [p(lang === "fr" ? "Statut" : "Status", { bold: true })] }),
      ]}),
    ];
    sections.risks.forEach((r) => {
      rRows.push(new D.TableRow({ children: [
        new D.TableCell({ children: [p(r.title || "")] }),
        new D.TableCell({ children: [p(r.level || "—")] }),
        new D.TableCell({ children: [p((r.probability || "—") + " × " + (r.impact || "—"))] }),
        new D.TableCell({ children: [p(r.mitigation || "—")] }),
        new D.TableCell({ children: [p(r.status || "—")] }),
      ]}));
    });
    children.push(new D.Table({ rows: rRows, width: { size: 100, type: D.WidthType.PERCENTAGE } }));
  }
  children.push(sep());

  // ── 8. GANTT ──
  children.push(h1(lang === "fr" ? "8. Planning (Gantt)" : "8. Schedule (Gantt)"));
  if (sections.planActions.length === 0) {
    children.push(p(lang === "fr" ? "(Aucune action planifiée)" : "(No planned action)", { italic: true, color: "888888" }));
  } else {
    if (images.gantt) {
      const dims = await _imageDims(images.gantt, 620);
      children.push(new D.Paragraph({
        children: [new D.ImageRun({ data: _dataUrlToBytes(images.gantt), transformation: dims })],
        alignment: D.AlignmentType.CENTER,
        spacing: { after: 160 },
      }));
    }
  }
  children.push(sep());

  // ── 9. BUDGET ──
  children.push(h1(lang === "fr" ? "9. Budget" : "9. Budget"));
  if (sections.budget.length === 0) {
    children.push(p(lang === "fr" ? "(Aucune ligne budgétaire)" : "(No budget line)", { italic: true, color: "888888" }));
  } else {
    // Graphique budget par categorie (si capture)
    if (images.budget) {
      const dims = await _imageDims(images.budget, 540);
      children.push(new D.Paragraph({
        children: [new D.ImageRun({ data: _dataUrlToBytes(images.budget), transformation: dims })],
        alignment: D.AlignmentType.CENTER,
        spacing: { after: 160 },
      }));
    }
    let totalBudget = 0, totalCommitted = 0, totalDisbursed = 0;
    const bRows = [
      new D.TableRow({ tableHeader: true, children: [
        new D.TableCell({ children: [p(lang === "fr" ? "Catégorie" : "Category", { bold: true })] }),
        new D.TableCell({ children: [p(lang === "fr" ? "Libellé" : "Label", { bold: true })] }),
        new D.TableCell({ children: [p(lang === "fr" ? "Budget" : "Budget", { bold: true })] }),
        new D.TableCell({ children: [p(lang === "fr" ? "Engagé" : "Committed", { bold: true })] }),
        new D.TableCell({ children: [p(lang === "fr" ? "Décaissé" : "Disbursed", { bold: true })] }),
        new D.TableCell({ children: [p(lang === "fr" ? "Devise" : "Currency", { bold: true })] }),
      ]}),
    ];
    sections.budget.forEach((b) => {
      totalBudget    += Number(b.budget)    || 0;
      totalCommitted += Number(b.committed) || 0;
      totalDisbursed += Number(b.disbursed) || 0;
      bRows.push(new D.TableRow({ children: [
        new D.TableCell({ children: [p(b.category || "")] }),
        new D.TableCell({ children: [p(b.label || "")] }),
        new D.TableCell({ children: [p((Number(b.budget) || 0).toLocaleString())] }),
        new D.TableCell({ children: [p((Number(b.committed) || 0).toLocaleString())] }),
        new D.TableCell({ children: [p((Number(b.disbursed) || 0).toLocaleString())] }),
        new D.TableCell({ children: [p(b.currency || "—")] }),
      ]}));
    });
    bRows.push(new D.TableRow({ children: [
      new D.TableCell({ columnSpan: 2, children: [p(lang === "fr" ? "TOTAL" : "TOTAL", { bold: true })] }),
      new D.TableCell({ children: [p(totalBudget.toLocaleString(), { bold: true })] }),
      new D.TableCell({ children: [p(totalCommitted.toLocaleString(), { bold: true })] }),
      new D.TableCell({ children: [p(totalDisbursed.toLocaleString(), { bold: true })] }),
      new D.TableCell({ children: [p("", { bold: true })] }),
    ]}));
    children.push(new D.Table({ rows: bRows, width: { size: 100, type: D.WidthType.PERCENTAGE } }));
  }
  children.push(sep());

  // ── 10. ÉQUIPE PROJET ──
  children.push(h1(lang === "fr" ? "10. Équipe projet" : "10. Project team"));
  if (sections.team.length === 0) {
    children.push(p(lang === "fr" ? "(Aucun membre)" : "(No member)", { italic: true, color: "888888" }));
  } else {
    const tRows = [
      new D.TableRow({ tableHeader: true, children: [
        new D.TableCell({ children: [p(lang === "fr" ? "Nom" : "Name", { bold: true })] }),
        new D.TableCell({ children: [p(lang === "fr" ? "Email" : "Email", { bold: true })] }),
        new D.TableCell({ children: [p(lang === "fr" ? "Rôle" : "Role", { bold: true })] }),
        new D.TableCell({ children: [p(lang === "fr" ? "Affecté le" : "Joined", { bold: true })] }),
      ]}),
    ];
    sections.team.forEach((t) => {
      const pr = t.profiles || {};
      tRows.push(new D.TableRow({ children: [
        new D.TableCell({ children: [p(pr.full_name || "—")] }),
        new D.TableCell({ children: [p(pr.email || "—")] }),
        new D.TableCell({ children: [p(t.role_label || "—")] }),
        new D.TableCell({ children: [p(t.joined_at ? new Date(t.joined_at).toLocaleDateString() : "—")] }),
      ]}));
    });
    children.push(new D.Table({ rows: tRows, width: { size: 100, type: D.WidthType.PERCENTAGE } }));
  }

  // Generate document
  const doc = new D.Document({
    creator: "MELR",
    title: "Document de cadrage · " + (P.name || P.id),
    sections: [{ properties: {}, children }],
  });
  const blob = await D.Packer.toBlob(doc);
  const url = URL.createObjectURL(blob);
  const a = document.createElement("a");
  a.href = url;
  a.download = "cadrage-" + (P.id || "projet") + "-" + new Date().toISOString().slice(0, 10) + ".docx";
  document.body.appendChild(a); a.click();
  setTimeout(() => { document.body.removeChild(a); URL.revokeObjectURL(url); }, 100);
}

// ── Helper · PDF via jsPDF ───────────────────────────────────────────────
async function _exportDocPdf(P, sections, images, lang) {
  if (!window.jspdf) {
    alert(lang === "fr" ? "Bibliothèque PDF indisponible." : "PDF library unavailable.");
    return;
  }
  const { jsPDF } = window.jspdf;
  const doc = new jsPDF({ unit: "pt", format: "a4" });

  const STATUS_LABELS = {
    unverified: lang === "fr" ? "Non verifiee" : "Unverified",
    confirmed:  lang === "fr" ? "Confirmee"    : "Confirmed",
    at_risk:    lang === "fr" ? "A risque"     : "At risk",
    failed:     lang === "fr" ? "Invalidee"    : "Failed",
  };
  const STAKEHOLDER_TYPES = {
    supervisory: lang === "fr" ? "Tutelle" : "Supervisory",
    implementation: lang === "fr" ? "Mise en oeuvre" : "Implementation",
    beneficiary: lang === "fr" ? "Beneficiaire" : "Beneficiary",
    donor: lang === "fr" ? "Donateur" : "Donor",
    supplier: lang === "fr" ? "Fournisseur" : "Supplier",
    other: lang === "fr" ? "Autre" : "Other",
  };
  const LEVEL_LABELS = {
    activity: lang === "fr" ? "Activites" : "Activities",
    output: "Outputs",
    outcome: "Outcomes",
    impact: "Impact",
  };
  const KIND_LABELS = {
    general: lang === "fr" ? "Objectif general" : "General objective",
    specific: lang === "fr" ? "Objectifs specifiques" : "Specific objectives",
    result: lang === "fr" ? "Resultats attendus" : "Expected results",
  };

  let y = 50;
  const M = 50;             // marge gauche
  const W = 595 - 2 * M;    // largeur utile (A4 = 595pt large)
  const lineH = 16;

  function ensureSpace(needed) {
    if (y + needed > 800) {
      doc.addPage();
      y = 50;
    }
  }
  function header(text, size, color) {
    ensureSpace(size + 8);
    doc.setFontSize(size);
    doc.setTextColor(color || "#000000");
    doc.setFont(undefined, "bold");
    doc.text(text, M, y);
    y += size + 6;
    doc.setFont(undefined, "normal");
    doc.setTextColor("#000000");
  }
  function body(text, opts) {
    if (text == null) return;
    doc.setFontSize((opts && opts.size) || 10);
    doc.setTextColor((opts && opts.color) || "#000000");
    if (opts && opts.italic) doc.setFont(undefined, "italic");
    const lines = doc.splitTextToSize(String(text), W - (opts && opts.indent ? opts.indent : 0));
    ensureSpace(lines.length * lineH + 4);
    doc.text(lines, M + (opts && opts.indent ? opts.indent : 0), y);
    y += lines.length * lineH;
    if (opts && opts.italic) doc.setFont(undefined, "normal");
    doc.setTextColor("#000000");
  }
  function gap(h) { y += h || 12; }

  // ── COVER ──
  doc.setFontSize(11);
  doc.setTextColor("#4f46e5");
  doc.setFont(undefined, "bold");
  doc.text((lang === "fr" ? "DOCUMENT DE CADRAGE" : "FRAMING DOCUMENT"), 297, y, { align: "center" });
  y += 22;
  doc.setFontSize(20);
  doc.setTextColor("#000000");
  doc.text((P.name || P.id || ""), 297, y, { align: "center" });
  y += 22;
  doc.setFontSize(11);
  doc.setTextColor("#888888");
  doc.setFont(undefined, "normal");
  doc.text(P.id || "", 297, y, { align: "center" });
  doc.setTextColor("#000000");
  y += 36;

  // ── META ──
  header(lang === "fr" ? "Informations cles" : "Key information", 13, "#4f46e5");
  [
    [lang === "fr" ? "Code" : "Code", P.id || "—"],
    [lang === "fr" ? "Nom" : "Name", P.name || "—"],
    [lang === "fr" ? "Secteur" : "Sector", P.sector || "—"],
    [lang === "fr" ? "Pays" : "Countries", P.countries || "—"],
    [lang === "fr" ? "Responsable" : "Lead", P.lead || "—"],
    [lang === "fr" ? "Phase" : "Phase", P.phase || "—"],
    [lang === "fr" ? "Avancement" : "Progress", (P.progress != null ? Math.round(P.progress) + " %" : "—")],
  ].forEach(([k, v]) => {
    doc.setFontSize(10);
    doc.setFont(undefined, "bold");
    doc.text(k + " : ", M, y);
    doc.setFont(undefined, "normal");
    doc.text(String(v), M + 90, y);
    y += lineH;
  });
  gap(16);

  // ── 1. OBJECTIFS ──
  header(lang === "fr" ? "1. Objectifs du projet" : "1. Project objectives", 14, "#4f46e5");
  ["general", "specific", "result"].forEach((kind) => {
    const items = sections.objectives.filter((o) => o.kind === kind);
    if (items.length === 0) return;
    header(KIND_LABELS[kind], 12, "#222222");
    items.forEach((o) => {
      body("• " + o.label, { size: 10 });
      if (o.description) body(o.description, { size: 9, italic: true, color: "#666666", indent: 14 });
    });
    gap(4);
  });
  if (sections.objectives.length === 0) body(lang === "fr" ? "(Aucun objectif saisi)" : "(No objectives)", { italic: true, color: "#888888" });
  gap(12);

  // ── 2. ToC ──
  header(lang === "fr" ? "2. Theorie du changement" : "2. Theory of change", 14, "#4f46e5");
  body(lang === "fr" ? "Chaine logique : Activites -> Outputs -> Outcomes -> Impact." : "Logic chain: Activities -> Outputs -> Outcomes -> Impact.",
    { italic: true, color: "#555555" });
  gap(4);
  ["activity", "output", "outcome", "impact"].forEach((lvl) => {
    const items = sections.toc.filter((r) => r.level === lvl);
    if (items.length === 0) return;
    header(LEVEL_LABELS[lvl], 12, "#222222");
    items.forEach((r) => {
      body("• " + r.label, { size: 10 });
      if (r.description) body(r.description, { size: 9, italic: true, color: "#666666", indent: 14 });
      if (r.assumption)  body("-> " + (lang === "fr" ? "Hypothese : " : "Assumption: ") + r.assumption, { size: 9, color: "#996600", indent: 14 });
    });
    gap(4);
  });
  if (sections.toc.length === 0) body(lang === "fr" ? "(Aucun maillon saisi)" : "(No links)", { italic: true, color: "#888888" });
  gap(12);

  // ── 3. STAKEHOLDERS ──
  header(lang === "fr" ? "3. Parties prenantes" : "3. Stakeholders", 14, "#4f46e5");
  if (sections.stakeholders.length === 0) {
    body(lang === "fr" ? "(Aucune partie prenante saisie)" : "(No stakeholders)", { italic: true, color: "#888888" });
  } else {
    sections.stakeholders.forEach((s) => {
      ensureSpace(lineH * 3);
      doc.setFontSize(10);
      doc.setFont(undefined, "bold");
      doc.text("• " + (s.name || ""), M, y);
      doc.setFont(undefined, "normal");
      doc.text((STAKEHOLDER_TYPES[s.type] || s.type || "") + (s.role_label ? " · " + s.role_label : ""), M + 240, y);
      y += lineH;
      doc.setFontSize(9);
      doc.setTextColor("#666666");
      doc.text((lang === "fr" ? "Pouvoir " : "Power ") + (s.power || "—") + "/5  ·  " +
        (lang === "fr" ? "Interet " : "Interest ") + (s.interest || "—") + "/5", M + 14, y);
      doc.setTextColor("#000000");
      y += lineH;
      if (s.notes) body(s.notes, { size: 9, italic: true, color: "#666666", indent: 14 });
    });
  }
  gap(12);

  // ── 4. HYPOTHESES ──
  header(lang === "fr" ? "4. Hypotheses & conditions" : "4. Assumptions & conditions", 14, "#4f46e5");
  if (sections.assumptions.length === 0) {
    body(lang === "fr" ? "(Aucune hypothese saisie)" : "(No assumptions)", { italic: true, color: "#888888" });
  } else {
    sections.assumptions.forEach((a, i) => {
      header((i + 1) + ". " + a.statement, 11, "#222222");
      doc.setFontSize(9);
      doc.setFont(undefined, "bold");
      doc.text((lang === "fr" ? "Statut : " : "Status: "), M + 14, y);
      doc.setFont(undefined, "normal");
      const sc = a.status === "confirmed" ? "#1d8a3a"
              : a.status === "at_risk" ? "#b87200"
              : a.status === "failed"  ? "#b91c1c" : "#666666";
      doc.setTextColor(sc);
      doc.text(STATUS_LABELS[a.status] || a.status || "", M + 60, y);
      doc.setTextColor("#000000");
      y += lineH;
      if (a.mitigation) body((lang === "fr" ? "Mitigation : " : "Mitigation: ") + a.mitigation, { size: 9, indent: 14 });
      if (a.last_reviewed) body((lang === "fr" ? "Revue : " : "Reviewed: ") + new Date(a.last_reviewed).toLocaleDateString(), { size: 8, italic: true, color: "#888888", indent: 14 });
      gap(6);
    });
  }
  gap(12);

  // ── 4-bis. ToC infographie (image) ──
  if (images.toc) {
    const dims = await _imageDims(images.toc, 500);
    ensureSpace(dims.height + 20);
    doc.addImage(images.toc, "PNG", M, y, dims.width, dims.height);
    y += dims.height + 12;
  }

  // ── 5. INDICATEURS ──
  doc.addPage(); y = 50;
  header(lang === "fr" ? "5. Indicateurs retenus" : "5. Selected indicators", 14, "#4f46e5");
  if (sections.indicators.length === 0) {
    body(lang === "fr" ? "(Aucun indicateur)" : "(No indicator)", { italic: true, color: "#888888" });
  } else {
    doc.setFontSize(8);
    sections.indicators.forEach((i) => {
      const isPefa = i.value_kind === "score_pefa";
      ensureSpace(lineH * 2);
      doc.setFont(undefined, "bold");
      doc.text((i.code || "") + " · " + (lang === "fr" ? (i.name_fr || "") : (i.name_en || i.name_fr || "")), M, y);
      y += 12;
      doc.setFont(undefined, "normal");
      doc.setTextColor("#666666");
      const info = [
        (lang === "fr" ? "Niveau : " : "Level: ") + (i.level || "—"),
        (lang === "fr" ? "Unite : " : "Unit: ") + (isPefa ? "PEFA" : (i.unit || "—")),
        (lang === "fr" ? "Baseline : " : "Baseline: ") + (isPefa ? (i.baseline_text || "—") : (i.baseline != null ? i.baseline : "—")),
        (lang === "fr" ? "Cible : " : "Target: ") + (isPefa ? (i.target_text || "—") : (i.target != null ? i.target : "—")),
        (lang === "fr" ? "Frequence : " : "Frequency: ") + (i.frequency || "—"),
      ].join("  ·  ");
      doc.text(info, M + 8, y);
      doc.setTextColor("#000000");
      y += lineH;
    });
  }
  gap(12);

  // ── 6. SITES ──
  ensureSpace(60);
  header(lang === "fr" ? "6. Sites d'intervention" : "6. Intervention sites", 14, "#4f46e5");
  if (sections.sites.length === 0) {
    body(lang === "fr" ? "(Aucun site)" : "(No site)", { italic: true, color: "#888888" });
  } else {
    sections.sites.forEach((s) => {
      ensureSpace(lineH * 2);
      doc.setFontSize(10);
      doc.setFont(undefined, "bold");
      doc.text("• " + (s.code ? s.code + " · " : "") + (s.name || ""), M, y);
      y += lineH;
      doc.setFontSize(9);
      doc.setFont(undefined, "normal");
      doc.setTextColor("#666666");
      const info = [
        s.kind, s.region, s.country_iso2,
        s.beneficiaries != null ? (s.beneficiaries + " " + (lang === "fr" ? "bénéficiaires" : "beneficiaries")) : null,
        s.status,
      ].filter(Boolean).join("  ·  ");
      if (info) { doc.text(info, M + 14, y); y += lineH; }
      doc.setTextColor("#000000");
    });
  }
  gap(12);

  // ── 7. RISQUES + matrice graphique ──
  doc.addPage(); y = 50;
  header(lang === "fr" ? "7. Risques & matrice probabilite x impact" : "7. Risks & probability x impact matrix", 14, "#4f46e5");
  if (sections.risks.length === 0) {
    body(lang === "fr" ? "(Aucun risque)" : "(No risk)", { italic: true, color: "#888888" });
  } else {
    if (images.riskMatrix) {
      const dims = await _imageDims(images.riskMatrix, 480);
      ensureSpace(dims.height + 20);
      doc.addImage(images.riskMatrix, "PNG", M + (W - dims.width) / 2, y, dims.width, dims.height);
      y += dims.height + 12;
    }
    header(lang === "fr" ? "Detail des risques" : "Risk details", 12, "#222222");
    sections.risks.forEach((r) => {
      ensureSpace(lineH * 3);
      doc.setFontSize(10);
      doc.setFont(undefined, "bold");
      doc.text("• " + (r.title || ""), M, y);
      doc.setFont(undefined, "normal");
      doc.text("P" + (r.probability || "—") + " x I" + (r.impact || "—"), M + 380, y);
      y += lineH;
      if (r.mitigation) body((lang === "fr" ? "Mitigation : " : "Mitigation: ") + r.mitigation, { size: 9, indent: 14, color: "#666666" });
    });
  }
  gap(12);

  // ── 8. GANTT ──
  doc.addPage(); y = 50;
  header(lang === "fr" ? "8. Planning (Gantt)" : "8. Schedule (Gantt)", 14, "#4f46e5");
  if (sections.planActions.length === 0) {
    body(lang === "fr" ? "(Aucune action)" : "(No action)", { italic: true, color: "#888888" });
  } else if (images.gantt) {
    const dims = await _imageDims(images.gantt, 540);
    ensureSpace(dims.height + 20);
    doc.addImage(images.gantt, "PNG", M, y, dims.width, dims.height);
    y += dims.height + 12;
  }
  gap(12);

  // ── 9. BUDGET ──
  ensureSpace(60);
  header(lang === "fr" ? "9. Budget" : "9. Budget", 14, "#4f46e5");
  if (sections.budget.length === 0) {
    body(lang === "fr" ? "(Aucune ligne)" : "(No line)", { italic: true, color: "#888888" });
  } else {
    // Graphique budget par categorie (si capture)
    if (images.budget) {
      const dims = await _imageDims(images.budget, 520);
      ensureSpace(dims.height + 20);
      doc.addImage(images.budget, "PNG", M + (W - dims.width) / 2, y, dims.width, dims.height);
      y += dims.height + 12;
    }
    let tB = 0, tC = 0, tD = 0;
    doc.setFontSize(8);
    // Header
    ensureSpace(lineH);
    doc.setFont(undefined, "bold");
    doc.text(lang === "fr" ? "Categorie" : "Category", M, y);
    doc.text(lang === "fr" ? "Libelle" : "Label", M + 100, y);
    doc.text(lang === "fr" ? "Budget" : "Budget", M + 280, y);
    doc.text(lang === "fr" ? "Engage" : "Committed", M + 360, y);
    doc.text(lang === "fr" ? "Decaisse" : "Disbursed", M + 440, y);
    y += lineH;
    doc.setFont(undefined, "normal");
    sections.budget.forEach((b) => {
      tB += Number(b.budget) || 0;
      tC += Number(b.committed) || 0;
      tD += Number(b.disbursed) || 0;
      ensureSpace(lineH);
      doc.text(String(b.category || "").slice(0, 18), M, y);
      doc.text(String(b.label || "").slice(0, 30), M + 100, y);
      doc.text(String((Number(b.budget) || 0).toLocaleString()), M + 280, y);
      doc.text(String((Number(b.committed) || 0).toLocaleString()), M + 360, y);
      doc.text(String((Number(b.disbursed) || 0).toLocaleString()), M + 440, y);
      y += lineH;
    });
    ensureSpace(lineH);
    doc.setFont(undefined, "bold");
    doc.text("TOTAL", M + 100, y);
    doc.text(tB.toLocaleString(), M + 280, y);
    doc.text(tC.toLocaleString(), M + 360, y);
    doc.text(tD.toLocaleString(), M + 440, y);
    doc.setFont(undefined, "normal");
    y += lineH;
  }
  gap(12);

  // ── 10. EQUIPE ──
  ensureSpace(60);
  header(lang === "fr" ? "10. Equipe projet" : "10. Project team", 14, "#4f46e5");
  if (sections.team.length === 0) {
    body(lang === "fr" ? "(Aucun membre)" : "(No member)", { italic: true, color: "#888888" });
  } else {
    sections.team.forEach((t) => {
      const pr = t.profiles || {};
      ensureSpace(lineH * 2);
      doc.setFontSize(10);
      doc.setFont(undefined, "bold");
      doc.text("• " + (pr.full_name || "—"), M, y);
      doc.setFont(undefined, "normal");
      doc.setFontSize(9);
      doc.setTextColor("#666666");
      doc.text((pr.email || "") + (t.role_label ? "  ·  " + t.role_label : ""), M + 14, y + 12);
      doc.setTextColor("#000000");
      y += lineH * 2;
    });
  }

  // Save
  doc.save("cadrage-" + (P.id || "projet") + "-" + new Date().toISOString().slice(0, 10) + ".pdf");
}

function PDTocConnector({ lang, fromColor, toColor, assumptions }) {
  return (
    <div style={{ position: "relative", padding: "8px 0 8px", display: "flex", justifyContent: "center" }}>
      {/* Fleche verticale degradee */}
      <svg width="40" height={assumptions.length > 0 ? 60 : 40} viewBox={"0 0 40 " + (assumptions.length > 0 ? 60 : 40)}
        style={{ flex: "none" }}>
        <defs>
          <linearGradient id={"toc-grad-" + fromColor + toColor} x1="0%" y1="0%" x2="0%" y2="100%">
            <stop offset="0%" stopColor={fromColor || "#888"} />
            <stop offset="100%" stopColor={toColor || "#888"} />
          </linearGradient>
        </defs>
        <line x1="20" y1="0" x2="20"
          y2={assumptions.length > 0 ? 50 : 30}
          stroke={"url(#toc-grad-" + fromColor + toColor + ")"} strokeWidth="3" />
        <polygon
          points={assumptions.length > 0 ? "12,50 28,50 20,60" : "12,30 28,30 20,40"}
          fill={toColor || "#888"} />
      </svg>
      {assumptions.length > 0 && (
        <div style={{
          marginLeft: 14,
          flex: 1, maxWidth: 600,
          padding: "8px 12px",
          background: "oklch(0.98 0.02 75)",
          border: "1px dashed oklch(0.75 0.10 75)",
          borderRadius: 8,
        }}>
          <div style={{
            fontSize: 10.5, fontWeight: 700, color: "oklch(0.50 0.10 75)",
            textTransform: "uppercase", letterSpacing: "0.05em",
            marginBottom: 4,
          }}>
            🔗 {lang === "fr" ? "Hypothèses pour franchir ce maillon" : "Assumptions to bridge this link"}
          </div>
          <ul style={{ margin: 0, paddingLeft: 18, fontSize: 11.5, color: "var(--text)" }}>
            {assumptions.map((a, i) => (
              <li key={i} style={{ marginBottom: 2 }}>{a}</li>
            ))}
          </ul>
        </div>
      )}
    </div>
  );
}

function PDTocColumn({ lang, projectUuid, level, title, color, items, refresh }) {
  const [adding, setAdding] = React.useState(false);
  const [label, setLabel] = React.useState("");
  const [assumption, setAssumption] = React.useState("");
  const add = async () => {
    if (!label.trim()) return;
    try {
      await window.melr.projectTocCrud.create({
        project_id: projectUuid, level, label, assumption,
        sort_order: ((items[items.length - 1] || {}).sort_order || 90) + 10,
      });
      setLabel(""); setAssumption(""); setAdding(false);
      await refresh();
    } catch (e) { alert(e.message); }
  };
  const remove = async (id) => {
    if (!window.confirm(lang === "fr" ? "Supprimer ce maillon ?" : "Delete this link?")) return;
    try { await window.melr.projectTocCrud.remove(id); await refresh(); }
    catch (e) { alert(e.message); }
  };
  return (
    <div className="card" style={{ background: color }}>
      <div className="card-head" style={{ padding: "10px 12px" }}>
        <div className="card-title" style={{ fontSize: 12.5 }}>{title}</div>
        <span className="tag-mono" style={{ marginLeft: 6 }}>{items.length}</span>
        <div style={{ flex: 1 }} />
        <button className="btn xs" onClick={() => setAdding((v) => !v)} title={lang === "fr" ? "Ajouter" : "Add"}>+</button>
      </div>
      <div className="card-body" style={{ display: "grid", gap: 6, padding: "0 10px 10px" }}>
        {items.length === 0 && !adding && <_PdEmptyHint lang={lang} />}
        {items.map((r) => (
          <div key={r.id} style={{ background: "white", border: "1px solid var(--line)", padding: 8, borderRadius: 5 }}>
            <div style={{ display: "flex", gap: 6, alignItems: "flex-start" }}>
              <div style={{ flex: 1, fontSize: 12, fontWeight: 500 }}>{r.label}</div>
              <button className="btn xs ghost" onClick={() => remove(r.id)} title="✕" style={{ padding: "0 4px" }}><Icon.x /></button>
            </div>
            {r.assumption && (
              <div style={{ fontSize: 10.5, color: "var(--text-faint)", marginTop: 4, fontStyle: "italic" }}>
                🔗 {lang === "fr" ? "Hypothèse : " : "Assumption: "}{r.assumption}
              </div>
            )}
          </div>
        ))}
        {adding && (
          <div style={{ background: "white", padding: 6, borderRadius: 5, border: "1px solid var(--line)" }}>
            <input style={_pdInp} value={label} onChange={(e) => setLabel(e.target.value)}
              placeholder={lang === "fr" ? "Énoncé" : "Statement"} />
            <textarea style={{ ..._pdInp, fontFamily: "inherit", resize: "vertical", marginTop: 4 }} rows={2}
              value={assumption} onChange={(e) => setAssumption(e.target.value)}
              placeholder={lang === "fr" ? "Hypothèse (optionnel)" : "Assumption (optional)"} />
            <div style={{ display: "flex", gap: 4, justifyContent: "flex-end", marginTop: 4 }}>
              <button className="btn xs ghost" onClick={() => { setAdding(false); setLabel(""); setAssumption(""); }}>{lang === "fr" ? "Annuler" : "Cancel"}</button>
              <button className="btn xs primary" onClick={add} disabled={!label.trim()}>{lang === "fr" ? "OK" : "OK"}</button>
            </div>
          </div>
        )}
      </div>
    </div>
  );
}

// ── 3. PDStakeholdersInit · reutilise la table existante stakeholders ────
function PDStakeholdersInit({ lang, projectUuid }) {
  if (!projectUuid) return <_PdNoProject lang={lang} />;
  const { data: stakeholders, refresh } = window.melr.useStakeholders ? window.melr.useStakeholders(projectUuid) : { data: [], refresh: () => {} };
  const TYPES = [
    { v: "supervisory",    fr: "Tutelle",            en: "Supervisory" },
    { v: "implementation", fr: "Mise en œuvre",      en: "Implementation" },
    { v: "beneficiary",    fr: "Bénéficiaire",       en: "Beneficiary" },
    { v: "donor",          fr: "Donateur",           en: "Donor" },
    { v: "supplier",       fr: "Fournisseur",        en: "Supplier" },
    { v: "other",          fr: "Autre",              en: "Other" },
  ];
  const labelType = (v) => {
    const t = TYPES.find((x) => x.v === v);
    return t ? (lang === "fr" ? t.fr : t.en) : v;
  };
  const [adding, setAdding] = React.useState(false);
  const [name, setName] = React.useState("");
  const [type, setType] = React.useState("implementation");
  const [power, setPower] = React.useState(3);
  const [interest, setInterest] = React.useState(3);
  const [roleLabel, setRoleLabel] = React.useState("");
  const [notes, setNotes] = React.useState("");
  const add = async () => {
    if (!name.trim()) return;
    try {
      await window.melr.stakeholdersCrud.create({
        project_id: projectUuid, name, type,
        power: Number(power), interest: Number(interest),
        role_label: roleLabel || null, notes: notes || null,
      });
      setName(""); setType("implementation"); setPower(3); setInterest(3); setRoleLabel(""); setNotes("");
      setAdding(false);
      await refresh();
    } catch (e) { alert(e.message); }
  };
  const remove = async (id) => {
    if (!window.confirm(lang === "fr" ? "Supprimer ce stakeholder ?" : "Delete this stakeholder?")) return;
    try { await window.melr.stakeholdersCrud.remove(id); await refresh(); }
    catch (e) { alert(e.message); }
  };
  return (
    <div style={{ marginTop: 16 }}>
      <div className="card">
        <div className="card-head">
          <div className="card-title">{lang === "fr" ? "Parties prenantes initiales" : "Initial stakeholders"}</div>
          <span className="tag-mono" style={{ marginLeft: 6 }}>{(stakeholders || []).length}</span>
          <div style={{ flex: 1 }} />
          <button className="btn xs primary" onClick={() => setAdding((v) => !v)}>
            <Icon.plus /> {lang === "fr" ? "Ajouter" : "Add"}
          </button>
        </div>
        <div className="card-body flush" style={{ overflowX: "auto" }}>
          {(stakeholders || []).length === 0 && !adding ? (
            <div style={{ padding: 16 }}><_PdEmptyHint lang={lang}
              msg={lang === "fr" ? "Aucune partie prenante. Ajouter les bénéficiaires, partenaires, autorités, donateurs…" : "No stakeholder. Add beneficiaries, partners, authorities, donors…"} /></div>
          ) : (
            <table className="tbl">
              <thead>
                <tr>
                  <th>{lang === "fr" ? "Nom" : "Name"}</th>
                  <th>{lang === "fr" ? "Type" : "Type"}</th>
                  <th>{lang === "fr" ? "Rôle" : "Role"}</th>
                  <th style={{ textAlign: "center" }}>{lang === "fr" ? "Pouvoir" : "Power"}</th>
                  <th style={{ textAlign: "center" }}>{lang === "fr" ? "Intérêt" : "Interest"}</th>
                  <th>{lang === "fr" ? "Notes" : "Notes"}</th>
                  <th style={{ width: 40 }}></th>
                </tr>
              </thead>
              <tbody>
                {(stakeholders || []).map((s) => (
                  <tr key={s.id}>
                    <td style={{ fontWeight: 500 }}>{s.name}</td>
                    <td><span className="pill" style={{ fontSize: 10.5 }}>{labelType(s.type)}</span></td>
                    <td className="text-faint">{s.role_label || "—"}</td>
                    <td style={{ textAlign: "center", fontFamily: "monospace" }}>{s.power || "—"}/5</td>
                    <td style={{ textAlign: "center", fontFamily: "monospace" }}>{s.interest || "—"}/5</td>
                    <td className="text-faint" style={{ fontSize: 11, maxWidth: 220, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{s.notes || "—"}</td>
                    <td><button className="btn xs ghost" onClick={() => remove(s.id)}><Icon.x /></button></td>
                  </tr>
                ))}
              </tbody>
            </table>
          )}
          {adding && (
            <div style={{ padding: 12, background: "color-mix(in oklch, var(--accent) 6%, transparent)", borderTop: "1px solid var(--line)", display: "grid", gridTemplateColumns: "1fr 1fr 1fr 70px 70px", gap: 8, alignItems: "end" }}>
              <label style={{ display: "grid", gap: 4 }}>
                <span style={_pdLbl}>{lang === "fr" ? "Nom *" : "Name *"}</span>
                <input style={_pdInp} value={name} onChange={(e) => setName(e.target.value)} />
              </label>
              <label style={{ display: "grid", gap: 4 }}>
                <span style={_pdLbl}>{lang === "fr" ? "Type" : "Type"}</span>
                <select style={_pdInp} value={type} onChange={(e) => setType(e.target.value)}>
                  {TYPES.map((t) => <option key={t.v} value={t.v}>{lang === "fr" ? t.fr : t.en}</option>)}
                </select>
              </label>
              <label style={{ display: "grid", gap: 4 }}>
                <span style={_pdLbl}>{lang === "fr" ? "Rôle" : "Role"}</span>
                <input style={_pdInp} value={roleLabel} onChange={(e) => setRoleLabel(e.target.value)}
                  placeholder={lang === "fr" ? "Ex. Maître d'œuvre" : "E.g. Project lead"} />
              </label>
              <label style={{ display: "grid", gap: 4 }}>
                <span style={_pdLbl}>{lang === "fr" ? "Pouvoir" : "Power"}</span>
                <input type="number" min="1" max="5" style={_pdInp} value={power} onChange={(e) => setPower(e.target.value)} />
              </label>
              <label style={{ display: "grid", gap: 4 }}>
                <span style={_pdLbl}>{lang === "fr" ? "Intérêt" : "Interest"}</span>
                <input type="number" min="1" max="5" style={_pdInp} value={interest} onChange={(e) => setInterest(e.target.value)} />
              </label>
              <label style={{ display: "grid", gap: 4, gridColumn: "1 / -1" }}>
                <span style={_pdLbl}>Notes</span>
                <textarea rows={2} style={{ ..._pdInp, fontFamily: "inherit", resize: "vertical" }} value={notes} onChange={(e) => setNotes(e.target.value)} />
              </label>
              <div style={{ gridColumn: "1 / -1", display: "flex", justifyContent: "flex-end", gap: 6 }}>
                <button className="btn xs ghost" onClick={() => setAdding(false)}>{lang === "fr" ? "Annuler" : "Cancel"}</button>
                <button className="btn xs primary" onClick={add} disabled={!name.trim()}>{lang === "fr" ? "Enregistrer" : "Save"}</button>
              </div>
            </div>
          )}
        </div>
      </div>
    </div>
  );
}

// ── 4. PDAssumptions ─────────────────────────────────────────────────────
function PDAssumptions({ lang, projectUuid }) {
  if (!projectUuid) return <_PdNoProject lang={lang} />;
  const { data: assumptions, refresh } = window.melr.useProjectAssumptions(projectUuid);
  const STATUSES = [
    { v: "unverified", fr: "Non vérifiée", en: "Unverified", color: "oklch(0.78 0.05 250)" },
    { v: "confirmed",  fr: "Confirmée",    en: "Confirmed",  color: "oklch(0.55 0.16 145)" },
    { v: "at_risk",    fr: "À risque",     en: "At risk",    color: "oklch(0.65 0.16 70)"  },
    { v: "failed",     fr: "Invalidée",    en: "Failed",     color: "oklch(0.55 0.16 12)"  },
  ];
  const statusLabel = (v) => {
    const s = STATUSES.find((x) => x.v === v);
    return s ? (lang === "fr" ? s.fr : s.en) : v;
  };
  const statusColor = (v) => {
    const s = STATUSES.find((x) => x.v === v);
    return s ? s.color : "var(--text-faint)";
  };
  const [adding, setAdding] = React.useState(false);
  const [statement, setStatement] = React.useState("");
  const [mitigation, setMitigation] = React.useState("");
  const add = async () => {
    if (!statement.trim()) return;
    try {
      await window.melr.projectAssumptionsCrud.create({
        project_id: projectUuid, statement, mitigation, status: "unverified",
        sort_order: ((assumptions[assumptions.length - 1] || {}).sort_order || 90) + 10,
      });
      setStatement(""); setMitigation(""); setAdding(false);
      await refresh();
    } catch (e) { alert(e.message); }
  };
  const updateStatus = async (id, newStatus) => {
    try { await window.melr.projectAssumptionsCrud.update(id, { status: newStatus }); }
    catch (e) { alert(e.message); }
  };
  const remove = async (id) => {
    if (!window.confirm(lang === "fr" ? "Supprimer cette hypothèse ?" : "Delete this assumption?")) return;
    try { await window.melr.projectAssumptionsCrud.remove(id); await refresh(); }
    catch (e) { alert(e.message); }
  };
  return (
    <div style={{ marginTop: 16 }}>
      <div className="card">
        <div className="card-head">
          <div className="card-title">{lang === "fr" ? "Hypothèses & conditions" : "Assumptions & conditions"}</div>
          <span className="tag-mono" style={{ marginLeft: 6 }}>{(assumptions || []).length}</span>
          <div style={{ flex: 1 }} />
          <button className="btn xs primary" onClick={() => setAdding((v) => !v)}>
            <Icon.plus /> {lang === "fr" ? "Ajouter" : "Add"}
          </button>
        </div>
        <div className="card-body" style={{ display: "grid", gap: 8 }}>
          {(assumptions || []).length === 0 && !adding && <_PdEmptyHint lang={lang}
            msg={lang === "fr" ? "Aucune hypothèse. Lister les conditions externes qui doivent rester vraies pour la réussite du projet." : "No assumption. List the external conditions that must remain true for project success."} />}
          {(assumptions || []).map((a) => (
            <div key={a.id} style={{
              padding: "10px 12px", background: "var(--bg-sunken)", borderRadius: 6,
              borderLeft: "4px solid " + statusColor(a.status),
            }}>
              <div style={{ display: "flex", gap: 8, alignItems: "flex-start" }}>
                <div style={{ flex: 1 }}>
                  <div style={{ fontSize: 13, fontWeight: 500 }}>{a.statement}</div>
                  {a.mitigation && (
                    <div style={{ fontSize: 11.5, color: "var(--text-muted)", marginTop: 4 }}>
                      🛡 {lang === "fr" ? "Mitigation : " : "Mitigation: "}{a.mitigation}
                    </div>
                  )}
                  {a.last_reviewed && (
                    <div className="text-faint" style={{ fontSize: 10, marginTop: 3 }}>
                      {lang === "fr" ? "Revue le " : "Reviewed "}{new Date(a.last_reviewed).toLocaleDateString()}
                    </div>
                  )}
                </div>
                <select value={a.status} onChange={(e) => updateStatus(a.id, e.target.value)}
                  style={{ ..._pdInp, width: 130, color: "white", background: statusColor(a.status), border: "none" }}>
                  {STATUSES.map((s) => <option key={s.v} value={s.v} style={{ color: "var(--text)", background: "var(--bg)" }}>{lang === "fr" ? s.fr : s.en}</option>)}
                </select>
                <button className="btn xs ghost" onClick={() => remove(a.id)}><Icon.x /></button>
              </div>
            </div>
          ))}
          {adding && (
            <div style={{ padding: 10, background: "color-mix(in oklch, var(--accent) 6%, transparent)", borderRadius: 6, display: "grid", gap: 6 }}>
              <label style={{ display: "grid", gap: 4 }}>
                <span style={_pdLbl}>{lang === "fr" ? "Énoncé de l'hypothèse *" : "Assumption statement *"}</span>
                <textarea rows={2} style={{ ..._pdInp, fontFamily: "inherit", resize: "vertical" }}
                  value={statement} onChange={(e) => setStatement(e.target.value)}
                  placeholder={lang === "fr" ? "Ex. : La sécurité dans la zone d'intervention reste stable." : "E.g. Security in the intervention area stays stable."} />
              </label>
              <label style={{ display: "grid", gap: 4 }}>
                <span style={_pdLbl}>{lang === "fr" ? "Mitigation (optionnel)" : "Mitigation (optional)"}</span>
                <textarea rows={2} style={{ ..._pdInp, fontFamily: "inherit", resize: "vertical" }}
                  value={mitigation} onChange={(e) => setMitigation(e.target.value)}
                  placeholder={lang === "fr" ? "Ex. : Travail en partenariat avec ONG locale." : "E.g. Work in partnership with local NGO."} />
              </label>
              <div style={{ display: "flex", gap: 6, justifyContent: "flex-end" }}>
                <button className="btn xs ghost" onClick={() => setAdding(false)}>{lang === "fr" ? "Annuler" : "Cancel"}</button>
                <button className="btn xs primary" onClick={add} disabled={!statement.trim()}>{lang === "fr" ? "Enregistrer" : "Save"}</button>
              </div>
            </div>
          )}
        </div>
      </div>
    </div>
  );
}

