/* ===================== Designer Tables (manager cockpit) ===================== */
(function () {
const { useState, useEffect, useMemo, useRef } = React;

/* ---- config ---- */
const TKEY = "nove8_tables_v1";
const KKEY = "nove8_kpi_v1";
const POS = {
  "Motion Designer":    { dot: "#6366f1", short: "Motion" },
  "Graphic Designer":   { dot: "#10b981", short: "Graphic" },
  "Innovation Manager": { dot: "#f59e0b", short: "Innovation" },
};
const TYPE_OPTS = [
  { v: "motion",  label: "motion",  color: "#6c79f6" },
  { v: "graphic", label: "graphic", color: "#16b277" },
];
const DEFAULT_TASK_TYPES = [
  { id: "cs",       label: "creatives S", color: "#f0625a" },
  { id: "cm",       label: "creatives M", color: "#6c79f6" },
  { id: "smm",      label: "SMM",         color: "#16b277" },
  { id: "product",  label: "Product",     color: "#f59e0b" },
  { id: "hrpr",     label: "HR/PR",       color: "#ec4899" },
  { id: "meetings", label: "Meetings",    color: "#0ea5e9" },
];
const TTKEY = "nove8_tasktypes_v1";
const FNKEY = "nove8_funnels_v1";
const TLKEY = "nove8_tools_v1";
const TYKEY = "nove8_types_v1";
function loadTypesList() {
  try { const r = localStorage.getItem(TYKEY); if (r) { const a = JSON.parse(r); if (Array.isArray(a) && a.length) return a; } } catch (e) {}
  return TYPE_OPTS.map((o) => ({ id: o.v, label: o.label, color: o.color }));
}
const TYPE_PALETTE = ["#14b8a6", "#8b5cf6", "#ef4444", "#0ea5e9", "#f59e0b", "#ec4899", "#65a30d", "#6366f1", "#d946ef", "#0891b2"];
function loadTaskTypes() { try { const r = localStorage.getItem(TTKEY); if (r) { const a = JSON.parse(r); if (Array.isArray(a) && a.length) return a; } } catch (e) {} return DEFAULT_TASK_TYPES; }
function loadFunnels() { try { const r = localStorage.getItem(FNKEY); if (r) { const a = JSON.parse(r); if (Array.isArray(a)) return a; } } catch (e) {} return []; }
/* Tool names for Innovation Managers (their tables track tools, not creatives) */
function loadTools() { try { const r = localStorage.getItem(TLKEY); if (r) { const a = JSON.parse(r); if (Array.isArray(a)) return a; } } catch (e) {} return []; }
const isInnovation = (d) => !!d && d.position === "Innovation Manager";
/* Hours logged in a week (Innovation Managers) */
function weekHours(week) {
  if (!week || !week.rows) return 0;
  let h = 0;
  week.rows.forEach((r) => { const n = parseFloat(r && r.hours); if (!isNaN(n)) h += n; });
  return Math.round(h * 10) / 10;
}
const ttId = () => "tt_" + Date.now().toString(36) + Math.floor(Math.random() * 1000).toString(36);
const MONTHS = ["January","February","March","April","May","June","July","August","September","October","November","December"];
const YEARS = [2025, 2026];
const KPI_MIN = 30, KPI_GROWTH = 36; // weekly SP thresholds (defaults)
// Bonus thresholds derive from the designer's weekly MIN KPI:
//   month minimum = weekCount * min;  tier threshold = month minimum + offset.
const BONUS_PCTS = [10, 15, 20, 25, 30];
const DEFAULT_BONUS_OFFSETS = [20, 30, 40, 50, 60];
const DEFAULT_KPI = { min: KPI_MIN, growth: KPI_GROWTH, fullDays: 5, threshold: 3, bonusOffsets: DEFAULT_BONUS_OFFSETS, bonusEnabled: true };
function bonusTableFor(weekCount, kpi) {
  const monthMin = weekCount * (kpi.min || 0);
  const offs = (kpi.bonusOffsets && kpi.bonusOffsets.length === 5) ? kpi.bonusOffsets : DEFAULT_BONUS_OFFSETS;
  return { min: monthMin, tiers: BONUS_PCTS.map((p, i) => [monthMin + offs[i], p]) };
}
function loadKpis() { try { const r = localStorage.getItem(KKEY); if (r) return JSON.parse(r); } catch (e) {} return {}; }
function getKpi(kpis, dId) { const k = { ...DEFAULT_KPI, ...(kpis && kpis[dId] ? kpis[dId] : {}) }; if (!k.bonusOffsets || k.bonusOffsets.length !== 5) k.bonusOffsets = DEFAULT_BONUS_OFFSETS; return k; }

/* ---- month-versioned KPI ----
   A KPI edit takes effect from the month it was made in; months before keep
   the values that were in force back then (past stats are never recomputed).
   Storage: kpis[dId] = { versions: [{from: "YYYY-MM", ...kpi fields}] };
   a legacy plain object (no versions) applies to all time. */
const ymKey = (y, m) => y + "-" + String(m + 1).padStart(2, "0");
function normKpi(v) {
  const k = { ...DEFAULT_KPI, ...(v || {}) };
  if (!k.bonusOffsets || k.bonusOffsets.length !== 5) k.bonusOffsets = DEFAULT_BONUS_OFFSETS;
  delete k.from;
  return k;
}
function kpiFor(kpis, dId, year, month) {
  const raw = kpis && kpis[dId];
  if (!raw) return normKpi({});
  const oKey = ymKey(year, month);
  let k;
  if (raw.overrides && raw.overrides[oKey]) {
    // month-specific override (an edit made on a past month) wins over versions
    k = normKpi(raw.overrides[oKey]);
  } else if (!Array.isArray(raw.versions)) {
    k = normKpi(raw); // legacy — one value for all time
  } else {
    const sorted = raw.versions.slice().sort((a, b) => String(a.from).localeCompare(String(b.from)));
    let pick = null;
    for (const v of sorted) { if (String(v.from) <= oKey) pick = v; }
    // months before the first recorded change keep the defaults that were in
    // force back then — a new KPI must never repaint history
    k = normKpi(pick || {});
  }
  // the monthly bonus is designer-wide: on in every month or off in every
  // month — the root flag overrides whatever a version/override carries
  if (typeof raw.bonusEnabled === "boolean") k.bonusEnabled = raw.bonusEnabled;
  return k;
}

/* Effective weekly SP, normalised by worked days:
   - worked >= threshold days: project the daily average across a full week (avg * fullDays)
   - worked  < threshold days: actual SP + remaining days valued at the MIN daily rate (min / fullDays)
   - worked == 0: no result yet (null) */
function effectiveWeekSP(sp, wd, kpi) {
  const full = kpi.fullDays || 5, thr = kpi.threshold || 3;
  const d = Math.max(0, Math.min(full, parseInt(wd, 10) || 0));
  if (d === 0) return null;
  if (d >= thr) return Math.round((sp / d) * full * 10) / 10;
  return Math.round((sp + (full - d) * (kpi.min / full)) * 10) / 10;
}
function tierFromSP(eff, kpi) {
  if (eff == null) return null;
  // compare on the rounded value so the colour always matches the whole-number
  // display: 35.8 shows as 36 → with growth KPI 36 the week is green
  const e = Math.round(eff);
  if (e >= kpi.growth) return "g";
  if (e >= kpi.min) return "y";
  return "r";
}
/* An explicitly-entered 0 in Working days = sick leave / vacation ("off" week).
   An empty field (dash) is simply not filled in and stays neutral. */
function isOffWeek(week) {
  return !!week && (week.wd === 0 || week.wd === "0");
}

/* tier for a week — only once the week has ended (past weeks), else null.
   "off" (violet) shows immediately: vacations are usually known in advance. */
function weekTier(week, weekMeta, kpi, now) {
  if (!week) return null;
  if (isOffWeek(week)) return "off";
  if (weekMeta && weekMeta.end && weekMeta.end >= (now || Date.now())) return null;
  const a = weekAgg(week);
  const hasData = a.sp > 0 || (week.rows && week.rows.some((r) => r && r.app)) || (week.wd && +week.wd > 0);
  if (!hasData) return null;
  return tierFromSP(effectiveWeekSP(a.sp, week.wd, kpi), kpi);
}

const pad = (n) => String(n).padStart(2, "0");
const initials = (n) => n.split(" ").filter(Boolean).slice(0, 2).map((w) => w[0].toUpperCase()).join("");
function textOn(hex) {
  const c = hex.replace("#", ""); if (c.length < 6) return "#fff";
  const r = parseInt(c.substr(0,2),16), g = parseInt(c.substr(2,2),16), b = parseInt(c.substr(4,2),16);
  return (0.299*r + 0.587*g + 0.114*b) > 165 ? "#14171c" : "#fff";
}

/* weeks (Mon–Sun) clipped to the month */
/* work weeks (Mon–Fri) assigned to a month by majority of weekdays — a week
   belongs to the month containing its Wednesday. Weekends ignored. 4–5 per month. */
function weeksOfMonth(year, m) {
  const out = [];
  let mon = new Date(year, m, 1);
  mon.setDate(mon.getDate() - ((mon.getDay() + 6) % 7)); // Monday of the week containing the 1st
  for (let g = 0; g < 8; g++) {
    const wed = new Date(mon); wed.setDate(mon.getDate() + 2);
    const cmp = (wed.getFullYear() - year) * 12 + (wed.getMonth() - m);
    if (cmp > 0) break;
    if (cmp === 0) {
      const fri = new Date(mon); fri.setDate(mon.getDate() + 4);
      const same = mon.getMonth() === fri.getMonth();
      const label = same
        ? `${pad(mon.getDate())}–${pad(fri.getDate())}`
        : `${pad(mon.getDate())}.${pad(mon.getMonth() + 1)}–${pad(fri.getDate())}.${pad(fri.getMonth() + 1)}`;
      const endTs = new Date(fri); endTs.setHours(23, 59, 59, 999);
      out.push({ idx: out.length, label, end: endTs.getTime() });
    }
    mon = new Date(mon); mon.setDate(mon.getDate() + 7);
  }
  return out;
}

/* ---- storage ---- */
function loadTables() { try { const r = localStorage.getItem(TKEY); if (r) return JSON.parse(r); } catch (e) {} return null; }

/* One-time migration: old default zeros in Working days become "not filled in"
   (dash). From now on an explicitly typed 0 means sick/vacation. */
(function migrateWdZeros() {
  try {
    if (localStorage.getItem("nove8_wd0_migrated_v1")) return;
    const raw = localStorage.getItem(TKEY);
    if (raw) {
      const t = JSON.parse(raw);
      let touched = false;
      Object.values(t || {}).forEach((entry) => {
        Object.values((entry && entry.weeks) || {}).forEach((wk) => {
          if (wk && (wk.wd === 0 || wk.wd === "0")) { delete wk.wd; touched = true; }
        });
      });
      if (touched) localStorage.setItem(TKEY, JSON.stringify(t));
    }
    localStorage.setItem("nove8_wd0_migrated_v1", "1");
  } catch (e) {}
})();
const mkey = (dId, year, m) => `${dId}|${year}-${m}`;

function seedTables(designers) {
  // a modest example on the first designer's current month (June 2026) so the matrix
  // demonstrates the green/yellow/red weekly dynamics — like the screenshots.
  const t = {};
  const d0 = designers[0];
  if (d0) {
    const k = mkey(d0.id, 2026, 5);
    const app = d0.productId || "";
    const row = (task, sp, link) => ({ app, type: "motion", task, link: link || "", sp: String(sp) });
    t[k] = { weeks: {
      0: { wd: 5, rows: [ row("cm", 14, "5542"), row("cm", 12), row("cm", 10), row("smm", 6) ] }, // sp 42 → green
      1: { wd: 5, rows: [ row("cm", 16), row("cm", 16) ] },                                        // sp 32 → yellow
      2: { wd: 4, rows: [ row("cs", 10), row("cs", 10) ] },                                        // sp 20 → red
    } };
  }
  return t;
}

/* ---- aggregation ---- */
function weekAgg(week) {
  if (!week || !week.rows) return { task: 0, sp: 0 };
  let task = 0, sp = 0;
  week.rows.forEach((r) => {
    // extra-SP rows count even without an app (granted SP need no product)
    if (r && (r.app || r.extra)) {
      // meetings and extra-SP rows earn SP but are not tasks (not calculations)
      if (!r.meet && !r.extra) task += 1;
      const n = parseFloat(r.sp); if (!isNaN(n)) sp += n;
    }
  });
  return { task, sp: Math.round(sp * 10) / 10 };
}
function monthAgg(tables, dId, year, m, weeks) {
  const entry = tables[mkey(dId, year, m)];
  let task = 0, sp = 0, hours = 0;
  weeks.forEach((w) => {
    const wk = entry && entry.weeks ? entry.weeks[w.idx] : null;
    const a = weekAgg(wk);
    task += a.task; sp += a.sp;
    hours += weekHours(wk); // Innovation Managers track hours instead
  });
  return { task, sp: Math.round(sp * 10) / 10, hours: Math.round(hours * 10) / 10 };
}
const bonusTier = (sp, kpi) => sp >= kpi.growth ? 10 : sp >= kpi.min ? 5 : 0;
const statusColor = (sp, kpi) => sp >= kpi.growth ? "#10b981" : sp >= kpi.min ? "#f59e0b" : "#e0444b";

/* Sum of theoretical (worked-days-normalised) weekly results across the month. */
function monthEffective(tables, dId, year, m, weeks, kpi) {
  const entry = tables[mkey(dId, year, m)];
  let eff = 0;
  weeks.forEach((w) => {
    const wk = entry && entry.weeks ? entry.weeks[w.idx] : null;
    // Off week (explicit 0 working days — sick/vacation): credited with the
    // designer's minimal weekly KPI, so the monthly bonus isn't dragged down.
    if (isOffWeek(wk)) { eff += (kpi.min || 0); return; }
    const a = weekAgg(wk);
    const e = effectiveWeekSP(a.sp, wk && wk.wd, kpi);
    if (e != null) eff += e;
  });
  return Math.round(eff * 10) / 10;
}
/* Monthly bonus %, from the effective month total vs the week-count bonus table. */
function bonusForMonth(effSum, weekCount, kpi) {
  const tbl = bonusTableFor(weekCount, kpi);
  let pct = 0, nextThr = null, nextPct = null;
  for (const [thr, p] of tbl.tiers) {
    if (effSum >= thr) pct = p;
    else if (nextThr == null) { nextThr = thr; nextPct = p; }
  }
  return { pct, min: tbl.min, metMin: effSum >= tbl.min, nextThr, nextPct, max: tbl.tiers[tbl.tiers.length - 1] };
}

/* per task-type weekly rollup (for the matrix) */
function weekTypeAgg(week, typeId) {
  if (!week || !week.rows) return { task: 0, sp: 0 };
  let task = 0, sp = 0;
  week.rows.forEach((r) => { if (r && r.task === typeId) { if (!r.extra) task += 1; const n = parseFloat(r.sp); if (!isNaN(n)) sp += n; } });
  return { task, sp: Math.round(sp * 10) / 10 };
}
/* rows with no / unknown task type (e.g. historical calculations saved before
   the calc card context existed) — aggregated into the matrix "Other" column */
function weekUntypedAgg(week, knownIds) {
  if (!week || !week.rows) return { task: 0, sp: 0 };
  let task = 0, sp = 0;
  week.rows.forEach((r) => {
    if (r && (r.app || r.extra) && (!r.task || !knownIds.has(r.task))) {
      if (!r.extra && !r.meet) task += 1;
      const n = parseFloat(r.sp); if (!isNaN(n)) sp += n;
    }
  });
  return { task, sp: Math.round(sp * 10) / 10 };
}
/* week colour: green = growth met, yellow = threshold met, red = below, null = no data */
const TIER_BG = { g: "#a7e8b0", y: "#fbe48a", r: "#f6a09a", off: "#d9c9f7" };

/* ---- chip select ---- */
function ChipSelect({ value, options, onChange, placeholder, disabled }) {
  const opt = options.find((o) => o.v === value);
  const filled = !!opt;
  const style = filled ? { background: opt.color, color: textOn(opt.color) } : {};
  if (disabled) {
    return (
      <div className={"csel" + (filled ? " filled" : "")}>
        <div className="csel-ro" style={style}>{filled ? opt.label : placeholder}</div>
      </div>
    );
  }
  return (
    <div className={"csel" + (filled ? " filled" : "")}>
      <select value={value || ""} onChange={(e) => onChange(e.target.value)} style={style}>
        <option value="" style={{ color: "#98a0ac", background: "#fff" }}>{placeholder}</option>
        {options.map((o) => <option key={o.v} value={o.v} style={{ color: "#14171c", background: "#fff" }}>{o.label}</option>)}
      </select>
    </div>
  );
}

/* ---- drag grip ---- */
function GripIcon() {
  return <svg width="10" height="14" viewBox="0 0 10 14" fill="currentColor" aria-hidden="true"><circle cx="2.5" cy="3" r="1.25"/><circle cx="7.5" cy="3" r="1.25"/><circle cx="2.5" cy="7" r="1.25"/><circle cx="7.5" cy="7" r="1.25"/><circle cx="2.5" cy="11" r="1.25"/><circle cx="7.5" cy="11" r="1.25"/></svg>;
}

/* ---- week block ---- */
function WeekBlock({ week, data, products, taskTypes, funnels, kpi, readOnly, onCell, onWD, onDeleteRow, onEditLink, dragInfo, setDragInfo, onMoveRow, onAddMeet, onAddExtra, onAddTask, typeOpts, canMerge, mergeSel, onToggleMerge, onStartMerge, onCancelMerge, onDoMerge, onShowMerge, onShowCalc }) {
  const merging = !!mergeSel; // { ids: Set } while picking rows to merge
  const rows = (data && data.rows) ? data.rows.slice() : [];
  while (rows.length < 5) rows.push({});
  if (!readOnly && rows[rows.length - 1] && rows[rows.length - 1].app) rows.push({});
  const agg = weekAgg(data);
  const appOpts = products.map((p) => ({ v: p.id, label: p.name, color: p.color }));
  const taskOpts = taskTypes.map((t) => ({ v: t.id, label: t.label, color: t.color }));
  const funnelOpts = (funnels || []).map((f) => ({ v: f.id, label: f.label || f.name, color: f.color }));
  const labelOf = (opts, v) => { const o = opts.find((x) => x.v === v); return o ? o.label : ""; };
  const isUrl = (s) => /^https?:\/\//i.test(String(s || ""));
  const linkHref = (r) => (r.linkUrl && isUrl(r.linkUrl)) ? r.linkUrl : (isUrl(r.link) ? r.link : null);
  const tier = weekTier(data, week, kpi);
  const passed = week.end < Date.now();
  const off = isOffWeek(data);
  // off week: show the credited weekly average (the designer's min KPI)
  const eff = off ? (kpi.min || 0) : (passed ? effectiveWeekSP(agg.sp, data && data.wd, kpi) : null);
  const TIER_FILL = { g: { bg: "#b7ecc0", fg: "#0a6e44" }, y: { bg: "#fbe48a", fg: "#8a5e12" }, r: { bg: "#f6a09a", fg: "#8f2820" }, off: { bg: "#dcd0f9", fg: "#5b3fa8" } };
  const fill = tier ? TIER_FILL[tier] : null;
  const fillStyle = fill ? { boxShadow: "inset 0 0 0 200px " + fill.bg } : undefined;
  const fgStyle = fill ? { color: fill.fg } : undefined;
  const dateStyle = fill ? { background: fill.bg } : undefined;

  const isDropTarget = dragInfo && dragInfo.wIdx !== week.idx;
  const onDragOver = (e) => { if (isDropTarget) { e.preventDefault(); e.dataTransfer.dropEffect = "move"; } };
  const onDrop = (e) => { if (isDropTarget) { e.preventDefault(); onMoveRow(dragInfo.wIdx, dragInfo.rowIdx, week.idx); setDragInfo(null); } };

  return (
    <div className={"wk" + (readOnly ? " wk-ro" : "") + (tier ? " wk-tier-" + tier : "") + (isDropTarget ? " wk-drop" : "")} onDragOver={onDragOver} onDrop={onDrop}>
      <div className="wk-date">
        <div className="lab">Date Week</div>
        <div className="rng">{week.label}</div>
      </div>
      <div className="wk-mid">
        <div className="wk-grid">
          <div className="wk-griph"></div>
          <div className="colh">App Name</div>
          <div className="colh">Type</div>
          <div className="colh">Task type</div>
          <div className="colh">Name/Link</div>
          <div className="colh">SP</div>
          <div className="colh">Funnel</div>
          <div className="colh-x"></div>
          <div className="colh-x"></div>
        </div>
        {rows.map((r, i) => {
          const hasContent = !!(r.app || r.type || r.task || r.link || r.sp || r.funnel);
          const href = linkHref(r);
          const dragging = dragInfo && dragInfo.wIdx === week.idx && dragInfo.rowIdx === i;
          // extra-SP rows are granted by a manager/admin — designers see them
          // read-only (no drag, no delete)
          const lockedExtra = !!r.extra && readOnly;
          const mergedCount = (r.mergedCount || 0);
          const pickable = merging && !!r.evId;
          const picked = pickable && mergeSel.ids.has(r.evId);
          return (
          <div className={"wk-grid" + (dragging ? " wk-row-dragging" : "") + (picked ? " wk-row-picked" : "")} key={i}>
            {merging
              ? (pickable
                  ? <input type="checkbox" className="wk-pick" checked={picked} title="Pick this task to merge"
                      onChange={() => onToggleMerge(r.evId)} />
                  : <span className="wk-grip wk-grip-empty"></span>)
              : hasContent && !lockedExtra
              ? <span className="wk-grip" draggable title="Drag to move this task to another week"
                  onDragStart={(e) => { setDragInfo({ wIdx: week.idx, rowIdx: i }); e.dataTransfer.effectAllowed = "move"; try { e.dataTransfer.setData("text/plain", week.idx + ":" + i); } catch (_) {} }}
                  onDragEnd={() => setDragInfo(null)}><GripIcon /></span>
              : <span className="wk-grip wk-grip-empty"></span>}
            <ChipSelect value={r.app} options={appOpts} placeholder={r.extra ? "—" : "-select-"} disabled={readOnly} onChange={(v) => onCell(week.idx, i, "app", v)} />
            <ChipSelect value={r.type} options={(typeOpts && typeOpts.length) ? typeOpts : TYPE_OPTS} placeholder="-select-" disabled={readOnly} onChange={(v) => onCell(week.idx, i, "type", v)} />
            <ChipSelect value={r.task} options={taskOpts} placeholder="-select-"
              disabled={readOnly && !r.evId} onChange={(v) => onCell(week.idx, i, "task", v)} />
            <div className="cinput linkcell" onContextMenu={(e) => { e.preventDefault(); onEditLink(week.idx, i, r); }}
              title={r.evId ? "Right-click to change the date of this calculation" : "Right-click to edit number / link"}>
              {href
                ? <a className="linkcell-a" href={href} target="_blank" rel="noopener noreferrer" title={href}>{r.link || href}</a>
                : <span className={"linkcell-t" + (r.link ? "" : " empty")}>{r.link || "—"}</span>}
              {r.extra && <span className="wk-extra-tag" title="Extra SP — earns story points, not counted as a task">extra</span>}
              {mergedCount > 1 && (
                canMerge
                  ? <button type="button" className="wk-merged-tag wk-merged-btn"
                      title={"Merged from " + mergedCount + " calculations — click to see them"}
                      onClick={(e) => { e.stopPropagation(); onShowMerge(r); }}>×{mergedCount}</button>
                  : <span className="wk-merged-tag" title={"Merged from " + mergedCount + " calculations"}>×{mergedCount}</span>
              )}
            </div>
            <input className="cinput" value={r.sp || ""} placeholder="" inputMode="decimal" readOnly={readOnly} tabIndex={readOnly ? -1 : undefined} onChange={(e) => onCell(week.idx, i, "sp", e.target.value)} />
            <ChipSelect value={r.funnel} options={funnelOpts} placeholder={funnelOpts.length ? "-select-" : "—"} disabled={readOnly} onChange={(v) => onCell(week.idx, i, "funnel", v)} />
            {r.evId
              ? <button className="row-info" title="Show what this calculation was built from"
                  onClick={() => onShowCalc(r)}>i</button>
              : <span className="row-del-sp"></span>}
            {hasContent && !lockedExtra
              ? <button className="row-del" title="Delete task" onClick={() => onDeleteRow(week.idx, i)}>×</button>
              : <span className="row-del-sp"></span>}
          </div>
          );
        })}
        {(onAddMeet || onAddExtra || onAddTask || canMerge) && (
          <div className="wk-add-row">
            {!merging && onAddMeet && (
              <button type="button" className="wk-add-meet" onClick={onAddMeet}
                title="Add a meeting — earns SP but does not count as a task">+ Meeting</button>
            )}
            {!merging && onAddTask && (
              <button type="button" className="wk-add-meet" onClick={onAddTask}
                title="Add a completed task — saved as a real calculation, counted in Statistics">+ Task</button>
            )}
            {!merging && onAddExtra && (
              <button type="button" className="wk-add-meet wk-add-extra" onClick={onAddExtra}
                title="Grant extra story points — earns SP but does not count as a task">+ Extra SP</button>
            )}
            {!merging && canMerge && (
              <button type="button" className="wk-add-meet wk-add-merge" onClick={onStartMerge}
                title="Merge calculations — their SP adds up and Statistics counts them as one task">⛓ Merge tasks</button>
            )}
            {merging && (
              <React.Fragment>
                <span className="wk-merge-hint">
                  {mergeSel.ids.size < 2
                    ? "Pick 2 or more tasks to merge — their SP adds up, Statistics counts one task"
                    : "The first picked task keeps the name; SP of " + mergeSel.ids.size + " tasks adds up"}
                </span>
                <button type="button" className="wk-add-meet wk-add-merge" disabled={mergeSel.ids.size < 2 || mergeSel.busy}
                  onClick={onDoMerge}>{mergeSel.busy ? "Merging…" : "Merge " + mergeSel.ids.size}</button>
                <button type="button" className="wk-add-meet" onClick={onCancelMerge} disabled={mergeSel.busy}>Cancel</button>
              </React.Fragment>
            )}
          </div>
        )}
      </div>
      <div className="wk-right">
        <div className="wk-wd">
          <div className="lab">Working days</div>
          <input type="number" min="0" max="31" value={data && data.wd != null ? data.wd : ""} placeholder="—" title="Empty = not filled in · 0 = sick/vacation (week counts as min KPI)" onChange={(e) => onWD(week.idx, e.target.value)} />
        </div>
        <div className="wk-results">
          <div className="wk-res">
            <div className="wk-res-in" style={fillStyle}>
              <div className="k" style={fgStyle}>Task result</div>
              <div className="v" style={fgStyle}>{agg.task}</div>
            </div>
          </div>
          <div className="wk-res">
            <div className="wk-res-in" style={fillStyle}>
              <div className="k" style={fgStyle}>SP result</div>
              <div className="v" style={fgStyle} title={agg.sp !== Math.round(agg.sp) ? "exact: " + agg.sp : undefined}>{Math.round(agg.sp)}</div>
              {eff != null && (
                off
                  ? <div className="wk-norm" style={fgStyle} title="Off week (sick/vacation) — credited with the minimal weekly KPI">≈{eff} / wk · min KPI</div>
                  : <div className="wk-norm" style={fgStyle} title="Normalised by worked days">≈{eff} / wk</div>
              )}
            </div>
          </div>
        </div>
        {!passed && !isOffWeek(data) && (agg.sp > 0 || (data && data.wd)) && <div className="wk-pending">Result colours after the week ends</div>}
      </div>
    </div>
  );
}

/* ---- week block, Innovation Manager flavour: tools / automation work is
   tracked as Tool name + short description + hours, with no SP or KPI ---- */
function InnoWeekBlock({ week, data, tools, readOnly, onCell, onWD, onDeleteRow, dragInfo, setDragInfo, onMoveRow }) {
  const rows = (data && data.rows) ? data.rows.slice() : [];
  const filled = (r) => !!(r && (r.tool || r.desc || r.hours));
  while (rows.length < 4) rows.push({});
  // These entries are always the owner's own notes (no calculations behind
  // them), so they stay editable even in the designer's read-only My Table.
  if (filled(rows[rows.length - 1])) rows.push({});
  const hours = weekHours(data);
  const toolOpts = (tools || []).map((t) => ({ v: t.id, label: t.label || t.name, color: t.color }));
  const off = isOffWeek(data);
  const offFill = off ? { boxShadow: "inset 0 0 0 200px #dcd0f9" } : undefined;
  const offFg = off ? { color: "#5b3fa8" } : undefined;

  const isDropTarget = dragInfo && dragInfo.wIdx !== week.idx;
  const onDragOver = (e) => { if (isDropTarget) { e.preventDefault(); e.dataTransfer.dropEffect = "move"; } };
  const onDrop = (e) => { if (isDropTarget) { e.preventDefault(); onMoveRow(dragInfo.wIdx, dragInfo.rowIdx, week.idx); setDragInfo(null); } };

  return (
    <div className={"wk wk-inno" + (off ? " wk-tier-off" : "") + (isDropTarget ? " wk-drop" : "")}
      onDragOver={onDragOver} onDrop={onDrop}>
      <div className="wk-date">
        <div className="lab">Date Week</div>
        <div className="rng">{week.label}</div>
      </div>
      <div className="wk-mid">
        <div className="wk-grid wk-grid-inno">
          <div className="wk-griph"></div>
          <div className="colh">Tool name</div>
          <div className="colh">Description</div>
          <div className="colh">Hours</div>
          <div className="colh-x"></div>
        </div>
        {rows.map((r, i) => {
          const dragging = dragInfo && dragInfo.wIdx === week.idx && dragInfo.rowIdx === i;
          return (
            <div className={"wk-grid wk-grid-inno" + (dragging ? " wk-row-dragging" : "")} key={i}>
              {filled(r)
                ? <span className="wk-grip" draggable title="Drag to move this entry to another week"
                    onDragStart={(e) => { setDragInfo({ wIdx: week.idx, rowIdx: i }); e.dataTransfer.effectAllowed = "move"; try { e.dataTransfer.setData("text/plain", week.idx + ":" + i); } catch (_) {} }}
                    onDragEnd={() => setDragInfo(null)}><GripIcon /></span>
                : <span className="wk-grip wk-grip-empty"></span>}
              <ChipSelect value={r.tool} options={toolOpts} placeholder={toolOpts.length ? "-select-" : "—"}
                onChange={(v) => onCell(week.idx, i, "tool", v)} />
              <input className="cinput cinput-desc" value={r.desc || ""} placeholder="what was done…"
                onChange={(e) => onCell(week.idx, i, "desc", e.target.value)} />
              <input className="cinput" value={r.hours || ""} placeholder="" inputMode="decimal"
                onChange={(e) => onCell(week.idx, i, "hours", e.target.value)} />
              {filled(r)
                ? <button className="row-del" title="Delete entry" onClick={() => onDeleteRow(week.idx, i)}>×</button>
                : <span className="row-del-sp"></span>}
            </div>
          );
        })}
      </div>
      <div className="wk-right">
        <div className="wk-wd">
          <div className="lab">Working days</div>
          <input type="number" min="0" max="31" value={data && data.wd != null ? data.wd : ""} placeholder="—"
            title="Empty = not filled in · 0 = sick/vacation" onChange={(e) => onWD(week.idx, e.target.value)} />
        </div>
        <div className="wk-results">
          <div className="wk-res">
            <div className="wk-res-in" style={offFill}>
              <div className="k" style={offFg}>Hours</div>
              <div className="v" style={offFg}>{hours || 0}</div>
            </div>
          </div>
        </div>
      </div>
    </div>
  );
}

/* ---- matrix: one month ---- */
function MonthMatrix({ monthIdx, year, current, entry, taskTypes, kpi, showOther }) {
  const weeksData = (entry && entry.weeks) ? entry.weeks : {};
  const weeks = weeksOfMonth(year, monthIdx);
  const knownIds = new Set(taskTypes.map((t) => t.id));
  const sums = { wd: 0, types: taskTypes.map(() => ({ task: 0, sp: 0 })), other: { task: 0, sp: 0 } };
  weeks.forEach((wk) => {
    const w = weeksData[wk.idx];
    if (w) {
      if (w.wd) sums.wd += (+w.wd || 0);
      taskTypes.forEach((t, ti) => { const a = weekTypeAgg(w, t.id); sums.types[ti].task += a.task; sums.types[ti].sp += a.sp; });
      const o = weekUntypedAgg(w, knownIds); sums.other.task += o.task; sums.other.sp += o.sp;
    }
  });
  const r1 = (n) => Math.round(n * 10) / 10;
  return (
    <div className={"mx-card" + (current ? " mx-current" : "")} {...(current ? { "data-current": "1" } : {})}>
      <div className="mx-month">
        <div className="mx-month-h">{current ? "current month" : "month"}</div>
        <div className="mx-month-name">{MONTHS[monthIdx]}</div>
        {/* the weekly KPI that applied in this month (min / growth) */}
        <div className="mx-month-kpi" title="Weekly KPI in this month — minimum / growth">
          kpi {kpi.min}/{kpi.growth}
        </div>
      </div>
      <div className="mx-table-wrap">
        <table className="mx-table">
          <thead>
            <tr>
              <th rowSpan={2} className="mx-th-weeks">weeks</th>
              <th rowSpan={2} className="mx-th-wd">working days</th>
              {taskTypes.map((t) => <th key={t.id} colSpan={2} className="mx-th-type">{t.label}</th>)}
              {showOther && <th colSpan={2} className="mx-th-type" title="Calculations without a task type (older history)">Other</th>}
            </tr>
            <tr>
              {taskTypes.map((t) => (
                <React.Fragment key={t.id}><th className="mx-sub">task</th><th className="mx-sub">sp</th></React.Fragment>
              ))}
              {showOther && <React.Fragment><th className="mx-sub">task</th><th className="mx-sub">sp</th></React.Fragment>}
            </tr>
          </thead>
          <tbody>
            {weeks.map((wk, i) => {
              const w = weeksData[wk.idx];
              const tier = weekTier(w, wk, kpi);
              return (
                <tr key={i}>
                  <td><span className="mx-week" style={tier ? { background: TIER_BG[tier], borderColor: "transparent" } : {}}>W{i + 1} · {wk.label}</span></td>
                  <td><span className="mx-cell">{isOffWeek(w) ? "0" : (w && w.wd ? w.wd : "")}</span></td>
                  {taskTypes.map((t) => {
                    const a = weekTypeAgg(w, t.id);
                    return <React.Fragment key={t.id}>
                      <td><span className="mx-cell">{a.task || ""}</span></td>
                      <td><span className="mx-cell" title={a.sp !== Math.round(a.sp) ? "exact: " + a.sp : undefined}>{Math.round(a.sp) || ""}</span></td>
                    </React.Fragment>;
                  })}
                  {showOther && (() => {
                    const o = weekUntypedAgg(w, knownIds);
                    return <React.Fragment>
                      <td><span className="mx-cell">{o.task || ""}</span></td>
                      <td><span className="mx-cell" title={o.sp !== Math.round(o.sp) ? "exact: " + o.sp : undefined}>{Math.round(o.sp) || ""}</span></td>
                    </React.Fragment>;
                  })()}
                </tr>
              );
            })}
            <tr className="mx-sum">
              <td><span className="mx-sumlabel">SUM</span></td>
              <td><span className="mx-cell sum">{sums.wd || ""}</span></td>
              {taskTypes.map((t, ti) => (
                <React.Fragment key={t.id}>
                  <td><span className="mx-cell sum">{sums.types[ti].task || ""}</span></td>
                  <td><span className="mx-cell sum" title={r1(sums.types[ti].sp) !== Math.round(sums.types[ti].sp) ? "exact: " + r1(sums.types[ti].sp) : undefined}>{Math.round(sums.types[ti].sp) || ""}</span></td>
                </React.Fragment>
              ))}
              {showOther && (
                <React.Fragment>
                  <td><span className="mx-cell sum">{sums.other.task || ""}</span></td>
                  <td><span className="mx-cell sum" title={r1(sums.other.sp) !== Math.round(sums.other.sp) ? "exact: " + r1(sums.other.sp) : undefined}>{Math.round(sums.other.sp) || ""}</span></td>
                </React.Fragment>
              )}
            </tr>
          </tbody>
        </table>
      </div>
    </div>
  );
}

/* ---- matrix: full year ---- */
function MatrixView({ designer, tables, year, taskTypes, kpi, kpiOf }) {
  const wrapRef = useRef(null);
  const now = new Date();
  const curMonth = year === now.getFullYear() ? now.getMonth() : -1;
  // Show the "Other" column only when the year actually has rows without a
  // known task type (historical calculations predate the calc-card context).
  const showOther = useMemo(() => {
    const knownIds = new Set(taskTypes.map((t) => t.id));
    for (let m = 0; m < 12; m++) {
      const entry = tables[mkey(designer.id, year, m)];
      const wks = (entry && entry.weeks) || {};
      for (const wIdx of Object.keys(wks)) {
        const a = weekUntypedAgg(wks[wIdx], knownIds);
        if (a.task > 0 || a.sp > 0) return true;
      }
    }
    return false;
  }, [tables, designer && designer.id, year, taskTypes]);
  useEffect(() => {
    const wrap = wrapRef.current; if (!wrap) return;
    const card = wrap.querySelector("[data-current]");
    const scroller = wrap.closest(".dt-detail");
    if (card && scroller) {
      const cr = card.getBoundingClientRect(), sr = scroller.getBoundingClientRect();
      scroller.scrollTop += (cr.top - sr.top) - 14;
    }
  }, [designer && designer.id, year]);
  return (
    <div className="mx-wrap" ref={wrapRef}>
      <div className="mx-legend">
        <span className="mx-leg-title">Week result vs weekly KPI ({kpi.min} / {kpi.growth} SP)</span>
        <span><span className="mx-dot" style={{ background: TIER_BG.g }}></span>growth met</span>
        <span><span className="mx-dot" style={{ background: TIER_BG.y }}></span>threshold met</span>
        <span><span className="mx-dot" style={{ background: TIER_BG.r }}></span>below threshold</span>
        <span><span className="mx-dot" style={{ background: TIER_BG.off }}></span>off (0 wd) — counts as min KPI</span>
      </div>
      {MONTHS.map((mn, m) => (
        <MonthMatrix key={m} monthIdx={m} year={year} current={m === curMonth} entry={tables[mkey(designer.id, year, m)]} taskTypes={taskTypes} kpi={kpiOf ? kpiOf(m) : kpi} showOther={showOther} />
      ))}
    </div>
  );
}

/* ---- task-type manager (single source for both views) ---- */
function TaskTypeManager({ taskTypes, onAdd, onRemove }) {
  const [open, setOpen] = useState(false);
  const [val, setVal] = useState("");
  const add = () => { if (val.trim()) { onAdd(val.trim()); setVal(""); } };
  return (
    <div className="ttm">
      <button className="ttm-btn" onClick={() => setOpen((o) => !o)}>Task types · {taskTypes.length}</button>
      {open && (
        <div className="ttm-panel">
          <div className="ttm-h">Task types — applied to both views</div>
          <div className="ttm-chips">
            {taskTypes.map((t) => (
              <span className="ttm-chip" key={t.id}>
                <span className="pdot" style={{ background: t.color }}></span>{t.label}
                <button onClick={() => onRemove(t.id)} title="Remove">×</button>
              </span>
            ))}
          </div>
          <div className="ttm-add">
            <input value={val} placeholder="New type…" onChange={(e) => setVal(e.target.value)}
              onKeyDown={(e) => { if (e.key === "Enter") add(); if (e.key === "Escape") setOpen(false); }} />
            <button className="btn primary" disabled={!val.trim()} onClick={add}>Add</button>
          </div>
        </div>
      )}
    </div>
  );
}

/* ---- detail (one designer's monthly table) ---- */
function Detail({ designer, products, tables, year, month, viewMode, setView, taskTypes, funnels, tools, typeOpts, onAddType, onRemoveType, kpi, kpiOf, readOnly, onEditKpi, onSetKpi, onCell, onWD, onDeleteRow, onEditLink, onMoveRow, addMeet, addExtra, addTask, onSyncStats, canMerge, merge, onStartMerge, onCancelMerge, onToggleMerge, onDoMerge, onShowMerge, onShowCalc }) {
  if (!designer) return <div className="placeholder"><div className="pl"><b>No designer selected</b>Pick someone from the list</div></div>;
  const weeks = useMemo(() => weeksOfMonth(year, month), [year, month]);
  const [dragInfo, setDragInfo] = useState(null);
  const entry = tables[mkey(designer.id, year, month)];
  const ma = monthAgg(tables, designer.id, year, month, weeks);
  const effMonth = monthEffective(tables, designer.id, year, month, weeks, kpi);
  const bonus = bonusForMonth(effMonth, weeks.length, kpi);
  const weekEff = weeks.map((w) => {
    const wk = entry && entry.weeks ? entry.weeks[w.idx] : null;
    // off week (explicit 0 wd) is credited with the minimal weekly KPI — show
    // that credit as a term in the bonus equation too
    if (isOffWeek(wk)) return kpi.min || 0;
    const a = weekAgg(wk);
    return effectiveWeekSP(a.sp, wk && wk.wd, kpi);
  });
  const effParts = weekEff.filter((e) => e != null && e > 0);
  const product = products.find((p) => p.id === designer.productId);
  const pinfo = POS[designer.position] || {};
  // Innovation Managers build tools, not creatives: their table tracks tool /
  // description / hours, with no SP, KPI, bonus or matrix.
  const inno = isInnovation(designer);
  const monthHours = inno
    ? Math.round(weeks.reduce((s, w) => s + weekHours(entry && entry.weeks ? entry.weeks[w.idx] : null), 0) * 10) / 10
    : 0;

  return (
    <div className="dt-detail">
      <div className="detail-head">
        <div className="avatar" style={{ background: "#eef0ff", color: "#4f46e5" }}>{initials(designer.name)}</div>
        <div>
          <div className="dh-name">{designer.name}</div>
          <div className="dh-sub">
            <span style={{ display: "inline-flex", alignItems: "center", gap: 5 }}>
              <span style={{ width: 7, height: 7, borderRadius: "50%", background: pinfo.dot }}></span>{designer.position}
            </span>
            {product && <span className="dh-chip" style={{ background: product.color }}>{product.name}</span>}
            <span>{designer.email}</span>
          </div>
        </div>
        <div style={{ flex: 1 }}></div>
        <div className="right-controls">
          {!inno && !readOnly && onSyncStats && <button className="kpi-btn" onClick={onSyncStats}
            title="Compare this designer's manually entered table rows with Statistics and publish the missing ones">Table → Stats</button>}
          {!inno && !readOnly && <button className="kpi-btn" onClick={onEditKpi} title="Edit KPI & week-result logic">KPI / week</button>}
          {!inno && !readOnly && <TaskTypeManager taskTypes={taskTypes} onAdd={onAddType} onRemove={onRemoveType} />}
          {inno && <div className="inno-month-hours" title="Hours logged this month">{monthHours}h this month</div>}
          {!inno && (
            <div className="vtoggle">
              <button className={viewMode === "weekly" ? "on" : ""} onClick={() => setView("weekly")}>Weekly</button>
              <button className={viewMode === "matrix" ? "on" : ""} onClick={() => setView("matrix")}>Matrix</button>
            </div>
          )}
          <div className="detail-period">{(!inno && viewMode === "matrix") ? year : MONTHS[month] + " " + year}</div>
        </div>
      </div>

      {inno ? (
        <React.Fragment>
          {weeks.map((w) => (
            <InnoWeekBlock key={w.idx} week={w} tools={tools} readOnly={readOnly}
              data={entry && entry.weeks ? entry.weeks[w.idx] : null}
              dragInfo={dragInfo} setDragInfo={setDragInfo} onMoveRow={onMoveRow}
              onCell={onCell} onWD={onWD} onDeleteRow={onDeleteRow} />
          ))}
        </React.Fragment>
      ) : viewMode === "matrix" ? (
        <MatrixView designer={designer} tables={tables} year={year} taskTypes={taskTypes} kpi={kpi} kpiOf={kpiOf} />
      ) : (
      <React.Fragment>
      <div className="kpi-row">
        <div className="kpi-card">
          <div className="t">Month Result</div>
          <div className="kpi-twin">
            <div><div className="lab">task</div><div className="num">{ma.task}</div></div>
            <div><div className="lab">sp</div><div className="num">{ma.sp}</div></div>
          </div>
        </div>
        <div className={"kpi-card" + (readOnly ? "" : " kpi-card-clickable")} onClick={readOnly ? undefined : onEditKpi} title={readOnly ? "" : "Edit KPI"}>
          <div className="t">KPI / Week</div>
          <div className="kpi-twin">
            <div><div className="lab">min</div><div className="num">{kpi.min}</div></div>
            <div><div className="lab">growth</div><div className="num">{kpi.growth}</div></div>
          </div>
        </div>
        <div className="kpi-card kpi-card-clickable bonus-card" onClick={onEditKpi} title={readOnly ? "View bonus scale" : "Edit bonus system"}>
          <div className="t">Month Bonus <span className="kpi-wkn">{weeks.length}w</span>
            {!readOnly && <label className="bonus-switch" onClick={(e) => e.stopPropagation()} title={kpi.bonusEnabled ? "Bonus on — click to disable" : "Bonus off — click to enable"}>
              <input type="checkbox" checked={!!kpi.bonusEnabled} onChange={() => onSetKpi({ bonusEnabled: !kpi.bonusEnabled })} />
              <span className="bonus-switch-track"></span>
            </label>}
          </div>
          {kpi.bonusEnabled ? (
            <React.Fragment>
              <div className="bonus-card-row">
                <div className={"kpi-bonus t" + bonus.pct}>{bonus.pct}%</div>
                <div className="bonus-card-kpi">
                  <div><span className="bl">min</span><b>{bonus.min}</b></div>
                  <div><span className="bl">growth</span><b>{weeks.length * kpi.growth}</b></div>
                </div>
              </div>
              <div className="bonus-card-next">
                {bonus.nextThr != null
                  ? <React.Fragment><b>+{Math.max(0, Math.round((bonus.nextThr - effMonth) * 10) / 10)}</b> SP to {bonus.nextPct}%</React.Fragment>
                  : <React.Fragment>🎉 max bonus</React.Fragment>}
              </div>
              <div className="bonus-card-calc">{effParts.length ? effParts.join(" + ") : "0"} = <b>{effMonth}</b> SP</div>
            </React.Fragment>
          ) : (
            <div className="bonus-off">No bonus for this designer</div>
          )}
        </div>
      </div>

      {weeks.map((w) => (
        <WeekBlock key={w.idx} week={w} products={products} taskTypes={taskTypes} funnels={funnels} typeOpts={typeOpts} kpi={kpi} readOnly={readOnly}
          data={entry && entry.weeks ? entry.weeks[w.idx] : null}
          dragInfo={dragInfo} setDragInfo={setDragInfo} onMoveRow={onMoveRow}
          onCell={onCell} onWD={onWD} onDeleteRow={onDeleteRow} onEditLink={onEditLink}
          onAddMeet={addMeet ? () => addMeet(w.idx) : null}
          onAddExtra={addExtra ? () => addExtra(w.idx) : null}
          onAddTask={addTask ? () => addTask(w.idx) : null}
          canMerge={canMerge}
          mergeSel={merge && merge.wIdx === w.idx ? merge : null}
          onStartMerge={() => onStartMerge(w.idx)}
          onCancelMerge={onCancelMerge}
          onToggleMerge={onToggleMerge}
          onDoMerge={onDoMerge}
          onShowMerge={onShowMerge}
          onShowCalc={onShowCalc} />
      ))}
      </React.Fragment>
      )}
    </div>
  );
}

/* ---- rail item ---- */
function RailItem({ designer, product, active, onClick, metrics, kpi }) {
  const pinfo = POS[designer.position] || {};
  return (
    <button className={"rail-item" + (active ? " active" : "")} onClick={onClick}>
      <div className="avatar" style={{ background: "#eef0ff", color: "#4f46e5" }}>{initials(designer.name)}</div>
      <div style={{ minWidth: 0 }}>
        <div className="ri-name">{designer.name}</div>
        <div className="ri-pos">
          <span style={{ width: 6, height: 6, borderRadius: "50%", background: pinfo.dot }}></span>
          {pinfo.short || designer.position}{product ? " · " + product.name : ""}
        </div>
      </div>
      {isInnovation(designer) ? (
        <div className="ri-metrics">
          <div className="ri-metric"><div className="v">{metrics.hours || 0}</div><div className="k">Hours</div></div>
        </div>
      ) : (
        <React.Fragment>
          <div className="ri-metrics">
            <div className="ri-metric"><div className="v">{metrics.sp}</div><div className="k">SP</div></div>
            <div className="ri-metric"><div className="v">{metrics.task}</div><div className="k">Task</div></div>
          </div>
          <span className="ri-status" style={{ background: statusColor(metrics.sp, kpi || DEFAULT_KPI) }} title="vs KPI"></span>
        </React.Fragment>
      )}
    </button>
  );
}

const SearchIcon = () => <svg width="15" height="15" viewBox="0 0 16 16"><circle cx="7" cy="7" r="4.5" stroke="currentColor" strokeWidth="1.5" fill="none"/><path d="M10.5 10.5L14 14" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"/></svg>;

/* ---- KPI editor modal ---- */
function KpiModal({ kpi, designerName, scopeNote, onSave, onClose }) {
  const [min, setMin] = useState(kpi.min);
  const [growth, setGrowth] = useState(kpi.growth);
  const [fullDays, setFullDays] = useState(kpi.fullDays);
  const [threshold, setThreshold] = useState(kpi.threshold);
  const [offsets, setOffsets] = useState(() => (kpi.bonusOffsets && kpi.bonusOffsets.length === 5 ? kpi.bonusOffsets.slice() : DEFAULT_BONUS_OFFSETS.slice()));
  const [bonusOn, setBonusOn] = useState(kpi.bonusEnabled !== false);
  const k = { min: +min || 0, growth: +growth || 0, fullDays: +fullDays || 5, threshold: +threshold || 1, bonusOffsets: offsets.map((o) => +o || 0), bonusEnabled: bonusOn };
  const setOffset = (i, v) => setOffsets((a) => { const n = a.slice(); n[i] = +v || 0; return n; });

  // live preview rows
  const ex = [{ sp: 26, wd: 3 }, { sp: 15, wd: 2 }, { sp: 40, wd: 5 }];
  const tierLabel = { g: "green", y: "yellow", r: "red" };

  return (
    <div className="kpi-modal-backdrop" onClick={onClose}>
      <div className="kpi-modal" onClick={(e) => e.stopPropagation()}>
        <div className="kpi-modal-head">
          <div>
            <h3>KPI / week</h3>
            <p>{designerName} · weekly story-point targets &amp; how the week result is computed</p>
            {scopeNote && <p style={{ marginTop: 4, fontWeight: 700, color: "#5b4bcf" }}>{scopeNote}</p>}
          </div>
          <button className="kpi-x" onClick={onClose}>×</button>
        </div>

        <div className="kpi-modal-body">
          <div className="kpi-fields">
            <div className="kpi-field">
              <label>Min KPI <span>SP / week — must be reached</span></label>
              <input type="number" min="0" value={min} onChange={(e) => setMin(e.target.value)} />
            </div>
            <div className="kpi-field">
              <label>Growth KPI <span>SP / week — target to aim for</span></label>
              <input type="number" min="0" value={growth} onChange={(e) => setGrowth(e.target.value)} />
            </div>
          </div>

          <div className="kpi-logic">
            <div className="kpi-logic-h">Week-result logic</div>
            <p className="kpi-logic-desc">
              The result is normalised by the days a designer actually worked, then compared to the KPIs above.
            </p>
            <div className="kpi-fields">
              <div className="kpi-field">
                <label>Full week <span>working days</span></label>
                <input type="number" min="1" max="7" value={fullDays} onChange={(e) => setFullDays(e.target.value)} />
              </div>
              <div className="kpi-field">
                <label>Averaging threshold <span>min. worked days</span></label>
                <input type="number" min="1" max="7" value={threshold} onChange={(e) => setThreshold(e.target.value)} />
              </div>
            </div>
            <ul className="kpi-rules">
              <li><b>≥ {k.threshold} days worked:</b> project the daily average over the full week → <code>SP / days × {k.fullDays}</code></li>
              <li><b>&lt; {k.threshold} days worked:</b> actual SP + remaining days at the min rate → <code>SP + (rest × {k.min}/{k.fullDays})</code></li>
              <li>Colours appear only after the week has ended.</li>
            </ul>
          </div>

          <div className="kpi-logic">
            <div className="kpi-bonus-toggle">
              <div>
                <div className="kpi-logic-h" style={{ marginBottom: 2 }}>Monthly bonus system</div>
                <span className="kpi-toggle-sub">{bonusOn ? "Enabled for this designer" : "Disabled — this designer has no bonus"}</span>
              </div>
              <label className="bonus-switch lg" title="Enable / disable bonus">
                <input type="checkbox" checked={bonusOn} onChange={() => setBonusOn((v) => !v)} />
                <span className="bonus-switch-track"></span>
              </label>
            </div>
            {bonusOn && <React.Fragment>
            <p className="kpi-logic-desc">Bonus uses the <b>sum of theoretical weekly results</b> over the month. The base derives from <b>Min KPI</b> ({k.min} SP): month minimum = weeks × {k.min}. Each tier adds an offset on top.</p>
            <div className="kpi-bonus-edit">
              <div className="kpi-bonus-col">
                <div className="kpi-bonus-coltitle">Tier offsets (+SP over month min)</div>
                {BONUS_PCTS.map((p, i) => (
                  <div className="kpi-bonus-line" key={i}><span>{p}%</span><input type="number" value={offsets[i]} onChange={(e) => setOffset(i, e.target.value)} /></div>
                ))}
              </div>
              <div className="kpi-bonus-col">
                <div className="kpi-bonus-coltitle">Resulting thresholds</div>
                {[4, 5].map((wc) => {
                  const tbl = bonusTableFor(wc, k);
                  return (
                    <div className="kpi-derived" key={wc}>
                      <div className="kpi-derived-h">{wc}-week · min {tbl.min}</div>
                      <div className="kpi-derived-row">{tbl.tiers.map(([thr, p]) => <span key={p}>{p}%<b>{thr}</b></span>)}</div>
                    </div>
                  );
                })}
              </div>
            </div>
            </React.Fragment>}
          </div>

          <div className="kpi-preview">
            <div className="kpi-logic-h">Preview</div>
            {ex.map((e, i) => {
              const eff = effectiveWeekSP(e.sp, e.wd, k);
              const t = tierFromSP(eff, k);
              return (
                <div className="kpi-prev-row" key={i}>
                  <span>{e.sp} SP · {e.wd}d worked</span>
                  <span className="kpi-prev-arrow">→</span>
                  <span>≈{eff} SP/wk</span>
                  <span className={"kpi-prev-tier tier-" + t}>{tierLabel[t]}</span>
                </div>
              );
            })}
          </div>
        </div>

        <div className="kpi-modal-foot">
          <button className="btn ghost" onClick={onClose}>Cancel</button>
          <button className="btn primary" onClick={() => onSave(k)}>Save KPI</button>
        </div>
      </div>
    </div>
  );
}

/* ---- read-only bonus scale viewer (designer side) ---- */
function BonusInfoModal({ kpi, weekCount, designerName, onClose }) {
  const tbl = bonusTableFor(weekCount, kpi);
  return (
    <div className="kpi-modal-backdrop" onClick={onClose}>
      <div className="kpi-modal" style={{ width: 420 }} onClick={(e) => e.stopPropagation()}>
        <div className="kpi-modal-head">
          <div>
            <h3>Bonus scale</h3>
            <p>{designerName} · how monthly story points map to your bonus</p>
          </div>
          <button className="kpi-x" onClick={onClose}>×</button>
        </div>
        <div className="kpi-modal-body">
          {kpi.bonusEnabled === false ? (
            <div className="bonus-off" style={{ minHeight: 80 }}>No bonus is set for you</div>
          ) : (
            <div className="kpi-logic" style={{ background: "#fff" }}>
              <div className="binfo-row binfo-min"><span>Minimum ({weekCount}-week month)</span><b>{tbl.min} SP</b></div>
              {tbl.tiers.map(([thr, p]) => (
                <div className="binfo-row" key={p}><span>{p}% bonus</span><b>{thr} SP</b></div>
              ))}
              <div className="bonus-d-note" style={{ marginTop: 10 }}>Based on the sum of your theoretical weekly results over the month.</div>
            </div>
          )}
        </div>
        <div className="kpi-modal-foot">
          <button className="btn primary" onClick={onClose}>Got it</button>
        </div>
      </div>
    </div>
  );
}

/* ---- name/link editor (right-click) ---- */
/* ---- name+SP row dialogs: meetings (designer's own table) and extra-SP
   grants (manager/admin) — both earn SP without counting as a task ---- */
function SpRowModal({ title, nameLabel, namePlaceholder, spLabel, products, onSave, onClose }) {
  const { useState } = React;
  const [name, setName] = useState("");
  const [sp, setSp] = useState("");
  const [app, setApp] = useState("");
  const valid = name.trim() && parseFloat(sp) > 0;
  const submit = () => { if (valid) onSave(name.trim(), parseFloat(sp), app); };
  return (
    <div className="kpi-modal-backdrop" onClick={onClose}>
      <div className="kpi-modal" style={{ width: 420 }} onClick={(e) => e.stopPropagation()}>
        <div className="kpi-modal-head">
          <div>
            <h3>{title}</h3>
            <p>Earns story points · not counted as a task</p>
          </div>
          <button className="kpi-x" onClick={onClose}>×</button>
        </div>
        <div className="kpi-modal-body">
          <div className="kpi-field">
            <label>{nameLabel} <span>shown in the table</span></label>
            <input value={name} placeholder={namePlaceholder} autoFocus
              onChange={(e) => setName(e.target.value)}
              onKeyDown={(e) => { if (e.key === "Enter") submit(); if (e.key === "Escape") onClose(); }} />
          </div>
          <div className="kpi-field">
            <label>Story points <span>{spLabel}</span></label>
            <input type="number" min="0" step="0.1" value={sp} placeholder="e.g. 1"
              onChange={(e) => setSp(e.target.value)}
              onKeyDown={(e) => { if (e.key === "Enter") submit(); if (e.key === "Escape") onClose(); }} />
          </div>
          {products && (
            <div className="kpi-field">
              <label>App name <span>optional</span></label>
              <select value={app} onChange={(e) => setApp(e.target.value)}>
                <option value="">— no app —</option>
                {products.map((p) => <option key={p.id} value={p.id}>{p.name}</option>)}
              </select>
            </div>
          )}
        </div>
        <div className="kpi-modal-foot">
          <button className="btn ghost" onClick={onClose}>Cancel</button>
          <button className="btn primary" disabled={!valid} onClick={submit}>Add</button>
        </div>
      </div>
    </div>
  );
}
function MeetModal({ onSave, onClose }) {
  return <SpRowModal title="Add meeting" nameLabel="Meeting name" namePlaceholder="e.g. Weekly sync"
    spLabel="SP for this meeting" onSave={onSave} onClose={onClose} />;
}
function ExtraModal({ products, onSave, onClose }) {
  return <SpRowModal title="Add extra SP" nameLabel="Name" namePlaceholder="e.g. Style guide research"
    spLabel="SP granted for this work" products={products} onSave={onSave} onClose={onClose} />;
}

/* ---- "Add task" (manager/admin): a real completed task, saved to the server
   as a calculation (task_evaluations) — counts as a task and shows up in
   Statistics, the matrix and the designer's table like any other calc ---- */
function TaskModal({ designer, products, typeOpts, taskTypes, funnels, onSave, onClose, saving }) {
  const { useState } = React;
  const [name, setName] = useState("");
  const [url, setUrl] = useState("");
  const [sp, setSp] = useState("");
  const [app, setApp] = useState((designer && designer.productId) || "");
  const [type, setType] = useState("");
  const [task, setTask] = useState("");
  const [funnel, setFunnel] = useState("");
  const valid = name.trim() && parseFloat(sp) > 0 && !saving;
  const submit = () => { if (valid) onSave({ name: name.trim(), url: url.trim(), sp: parseFloat(sp), app, type, task, funnel }); };
  const onKeys = (e) => { if (e.key === "Enter") submit(); if (e.key === "Escape") onClose(); };
  return (
    <div className="kpi-modal-backdrop" onClick={onClose}>
      <div className="kpi-modal" style={{ width: 460 }} onClick={(e) => e.stopPropagation()}>
        <div className="kpi-modal-head">
          <div>
            <h3>Add task</h3>
            <p>Counts as a completed task · included in Statistics</p>
          </div>
          <button className="kpi-x" onClick={onClose}>×</button>
        </div>
        <div className="kpi-modal-body">
          <div className="kpi-field">
            <label>Task name / number <span>shown in the table</span></label>
            <input value={name} placeholder="e.g. WF-1024" autoFocus
              onChange={(e) => setName(e.target.value)} onKeyDown={onKeys} />
          </div>
          <div className="kpi-field">
            <label>Link (URL) <span>optional</span></label>
            <input value={url} placeholder="https://…"
              onChange={(e) => setUrl(e.target.value)} onKeyDown={onKeys} />
          </div>
          <div className="kpi-field">
            <label>Story points <span>SP for this task</span></label>
            <input type="number" min="0" step="0.1" value={sp} placeholder="e.g. 8"
              onChange={(e) => setSp(e.target.value)} onKeyDown={onKeys} />
          </div>
          <div className="kpi-field">
            <label>App name <span>optional</span></label>
            <select value={app} onChange={(e) => setApp(e.target.value)}>
              <option value="">— no app —</option>
              {products.map((p) => <option key={p.id} value={p.id}>{p.name}</option>)}
            </select>
          </div>
          <div className="kpi-field">
            <label>Type <span>optional</span></label>
            <select value={type} onChange={(e) => setType(e.target.value)}>
              <option value="">—</option>
              {(typeOpts || []).map((t) => <option key={t.id} value={t.id}>{t.label}</option>)}
            </select>
          </div>
          <div className="kpi-field">
            <label>Task type <span>optional</span></label>
            <select value={task} onChange={(e) => setTask(e.target.value)}>
              <option value="">—</option>
              {(taskTypes || []).map((t) => <option key={t.id} value={t.id}>{t.label}</option>)}
            </select>
          </div>
          <div className="kpi-field">
            <label>Funnel <span>optional</span></label>
            <select value={funnel} onChange={(e) => setFunnel(e.target.value)}>
              <option value="">—</option>
              {(funnels || []).map((f) => <option key={f.id} value={f.id}>{f.label || f.name}</option>)}
            </select>
          </div>
        </div>
        <div className="kpi-modal-foot">
          <button className="btn ghost" onClick={onClose}>Cancel</button>
          <button className="btn primary" disabled={!valid} onClick={submit}>{saving ? "Saving…" : "Add"}</button>
        </div>
      </div>
    </div>
  );
}

/* ---- "Table → Stats" (manager/admin): one-off migration of a designer's
   manually entered table rows into Statistics. Lists every manual row (no
   evId, not a meeting / extra-SP grant) across all months, and on confirm
   publishes each as a real calculation event, then removes the manual row
   from the table so the overlayed calculation replaces it (no double count). */
function MigrateModal({ items, designerName, products, taskTypes, running, doneMsg, onRun, onClose }) {
  const { useState } = React;
  const [checked, setChecked] = useState(() => new Set(items.map((_, i) => i)));
  const toggle = (i) => setChecked((s) => { const n = new Set(s); n.has(i) ? n.delete(i) : n.add(i); return n; });
  const labelOf = (list, id, key) => { const o = (list || []).find((x) => x.id === id); return o ? (o[key] || o.label || o.name) : ""; };
  const sel = items.filter((_, i) => checked.has(i));
  return (
    <div className="kpi-modal-backdrop" onClick={running ? undefined : onClose}>
      <div className="kpi-modal" style={{ width: 560 }} onClick={(e) => e.stopPropagation()}>
        <div className="kpi-modal-head">
          <div>
            <h3>Table → Statistics · {designerName}</h3>
            <p>Manually entered rows that are NOT in Statistics yet</p>
          </div>
          <button className="kpi-x" onClick={onClose}>×</button>
        </div>
        <div className="kpi-modal-body">
          {doneMsg ? (
            <div style={{ padding: "8px 2px", fontWeight: 700 }}>{doneMsg}</div>
          ) : items.length === 0 ? (
            <div style={{ padding: "8px 2px", fontWeight: 700 }}>
              Nothing to migrate — every task row in this designer's table is already backed by a calculation in Statistics. 🎉
            </div>
          ) : (
            <React.Fragment>
              <div style={{ maxHeight: 320, overflowY: "auto", display: "flex", flexDirection: "column", gap: 6 }}>
                {items.map((it, i) => (
                  <label key={i} style={{ display: "flex", alignItems: "center", gap: 8, fontSize: 13, padding: "6px 8px", border: "1px solid var(--line)", borderRadius: 8, cursor: "pointer" }}>
                    <input type="checkbox" checked={checked.has(i)} onChange={() => toggle(i)} disabled={running} />
                    <span style={{ fontWeight: 700, whiteSpace: "nowrap" }}>{MONTHS[it.m]} {it.y}</span>
                    <span style={{ color: "var(--muted)", whiteSpace: "nowrap" }}>wk {it.weekLabel}</span>
                    <span style={{ flex: 1, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }} title={it.row.link || ""}>{it.row.link || "—"}</span>
                    <span style={{ color: "var(--muted)", whiteSpace: "nowrap" }}>{labelOf(products, it.row.app, "name") || "no app"}{it.row.task ? " · " + labelOf(taskTypes, it.row.task, "label") : ""}</span>
                    <b style={{ whiteSpace: "nowrap" }}>{it.sp} SP</b>
                  </label>
                ))}
              </div>
              <p style={{ margin: "10px 2px 0", fontSize: 12, color: "var(--muted)" }}>
                Each selected row is saved to Statistics as a real calculation (dated Wednesday of its week)
                and the manual row is replaced by it in the table — totals stay the same, nothing is counted twice.
              </p>
            </React.Fragment>
          )}
        </div>
        <div className="kpi-modal-foot">
          <button className="btn ghost" onClick={onClose} disabled={running}>{doneMsg ? "Close" : "Cancel"}</button>
          {!doneMsg && items.length > 0 && (
            <button className="btn primary" disabled={running || sel.length === 0} onClick={() => onRun(sel)}>
              {running ? "Publishing…" : `Add ${sel.length} to Statistics`}
            </button>
          )}
        </div>
      </div>
    </div>
  );
}

function LinkEditModal({ initLink, initUrl, onSave, onClose }) {
  const { useState } = React;
  const [num, setNum] = useState(initLink || "");
  const [url, setUrl] = useState(initUrl || "");
  return (
    <div className="kpi-modal-backdrop" onClick={onClose}>
      <div className="kpi-modal" style={{ width: 420 }} onClick={(e) => e.stopPropagation()}>
        <div className="kpi-modal-head">
          <div>
            <h3>Name / Link</h3>
            <p>Replace the task number or its link</p>
          </div>
          <button className="kpi-x" onClick={onClose}>×</button>
        </div>
        <div className="kpi-modal-body">
          <div className="kpi-field">
            <label>Number / name <span>shown in the table cell</span></label>
            <input value={num} placeholder="e.g. WF-1024" autoFocus
              onChange={(e) => setNum(e.target.value)}
              onKeyDown={(e) => { if (e.key === "Enter") onSave(num.trim(), url.trim()); if (e.key === "Escape") onClose(); }} />
          </div>
          <div className="kpi-field">
            <label>Link (URL) <span>opens in a new tab when clicked</span></label>
            <input value={url} placeholder="https://…"
              onChange={(e) => setUrl(e.target.value)}
              onKeyDown={(e) => { if (e.key === "Enter") onSave(num.trim(), url.trim()); if (e.key === "Escape") onClose(); }} />
          </div>
        </div>
        <div className="kpi-modal-foot">
          <button className="btn ghost" onClick={onClose}>Cancel</button>
          <button className="btn primary" onClick={() => onSave(num.trim(), url.trim())}>Save</button>
        </div>
      </div>
    </div>
  );
}

/* ---- calculation details: the items a calculation was built from, so a
   designer can see exactly what they ticked for a task ---- */
function CalcDetailsModal({ evId, label, products, taskTypes, typesList, funnels, onClose }) {
  const { useState, useEffect } = React;
  const [ev, setEv] = useState(null);
  const [state, setState] = useState('loading'); // loading | ok | missing | error
  const r2 = (n) => Math.round(n * 100) / 100;
  const isUrl = (v) => /^https?:\/\//i.test(String(v || ""));

  useEffect(() => {
    let alive = true;
    fetch("/api/task-evaluations?ids=" + encodeURIComponent(evId), { cache: "no-store" })
      .then((r) => (r.ok ? r.json() : null))
      .then((j) => {
        if (!alive) return;
        const found = (j && Array.isArray(j.events)) ? j.events.find((e) => String(e.id) === String(evId)) : null;
        if (found) { setEv(found); setState('ok'); } else setState('missing');
      })
      .catch(() => { if (alive) setState('error'); });
    return () => { alive = false; };
  }, [evId]);

  const meta = (ev && Array.isArray(ev.items) && ev.items.find((it) => it && it.__meta === true)) || null;
  const mergeRec = (ev && Array.isArray(ev.items) && ev.items.find((it) => it && it.__merge === true)) || null;
  const items = (ev && Array.isArray(ev.items))
    ? ev.items.filter((it) => it && !it.__meta && !it.__merge && typeof it.name === "string")
    : [];
  const labelOf = (list, id, keys) => {
    const o = (list || []).find((x) => x.id === id);
    if (!o) return "";
    for (const k of keys) if (o[k]) return o[k];
    return "";
  };
  const attrs = meta ? [
    ["Product", meta.product || labelOf(products, meta.productId, ["name"])],
    ["Task type", meta.taskType || labelOf(taskTypes, meta.taskTypeId, ["label"])],
    ["Type", meta.type || labelOf(typesList, meta.typeId, ["label"])],
    ["Funnel", meta.funnel || labelOf(funnels, meta.funnelId, ["label", "name"])],
  ].filter(([, v]) => v) : [];
  const href = ev && isUrl(ev.taskLink) ? ev.taskLink : null;
  const dateStr = ev ? new Date(Number(ev.ts) || 0).toLocaleDateString("en-GB", { day: "2-digit", month: "short", year: "numeric" }) : "";

  return (
    <div className="kpi-modal-backdrop" onClick={onClose}>
      <div className="kpi-modal" style={{ width: 500 }} onClick={(e) => e.stopPropagation()}>
        <div className="kpi-modal-head">
          <div>
            <h3>Calculation</h3>
            <p>{label || (ev && ev.taskLink) || "—"}{ev ? " · " + dateStr : ""}</p>
          </div>
          <button className="kpi-x" onClick={onClose}>×</button>
        </div>
        <div className="kpi-modal-body">
          {state === 'loading' && <div className="calcd-empty">Loading…</div>}
          {state === 'missing' && <div className="calcd-empty">This calculation is no longer in the database.</div>}
          {state === 'error' && <div className="calcd-empty">Could not load the calculation — try again.</div>}
          {state === 'ok' && ev && (
            <React.Fragment>
              <div className="calcd-top">
                <div className="calcd-sp"><b>{r2(Number(ev.result) || 0)}</b> SP</div>
                {href && <a className="calcd-link" href={href} target="_blank" rel="noopener noreferrer">open task ↗</a>}
              </div>
              {attrs.length > 0 && (
                <div className="calcd-attrs">
                  {attrs.map(([k, v]) => (
                    <span className="calcd-attr" key={k}><span className="calcd-attr-k">{k}</span>{v}</span>
                  ))}
                </div>
              )}
              {items.length > 0 ? (
                <div className="calcd-items">
                  <div className="calcd-item calcd-item-head">
                    <span>Item</span><span>level</span><span>SP</span>
                  </div>
                  {items.map((it, i) => (
                    <div className="calcd-item" key={i}>
                      <span className="calcd-item-name">{it.name}{it.isMain ? " · main" : ""}{it.isMultiplier ? " · ×" : ""}</span>
                      <span className="calcd-item-lvl">{it.level}</span>
                      <span className="calcd-item-fin">{r2(Number(it.fin) || 0)}</span>
                    </div>
                  ))}
                </div>
              ) : (
                <div className="calcd-empty">No item breakdown was saved for this calculation (older history).</div>
              )}
              {meta && (
                <div className="calcd-params">
                  versions: {meta.numberOfVersions || 0} · iterations lvl {meta.iterationComplexity || 0}
                </div>
              )}
              {mergeRec && Array.isArray(mergeRec.from) && mergeRec.from.length > 0 && (
                <div className="calcd-params">
                  Merged from {mergeRec.from.length + 1} calculations — open the ×{mergeRec.from.length + 1} badge to see them.
                </div>
              )}
            </React.Fragment>
          )}
        </div>
        <div className="kpi-modal-foot">
          <button className="btn primary" onClick={onClose}>Close</button>
        </div>
      </div>
    </div>
  );
}

/* ---- merged-task details: what was folded into this calculation, each part
   expandable to the items it was calculated from, with the option to split
   it back apart ---- */
function MergeInfoModal({ info, onUnmerge, onClose }) {
  const { useState, useEffect } = React;
  const parts = info.parts || [];
  const [details, setDetails] = useState(null); // id -> event (incl. soft-deleted)
  const [open, setOpen] = useState(() => new Set());
  const r2 = (n) => Math.round(n * 100) / 100;
  const isUrl = (v) => /^https?:\/\//i.test(String(v || ""));

  // the merged-away calculations are still in the table (soft-deleted), so their
  // links and item breakdown can be read back on demand
  useEffect(() => {
    const ids = [info.evId, ...parts.map((p) => p.id)].filter(Boolean);
    if (!ids.length) { setDetails({}); return; }
    let alive = true;
    fetch("/api/task-evaluations?deleted=1&ids=" + encodeURIComponent(ids.join(",")), { cache: "no-store" })
      .then((r) => (r.ok ? r.json() : null))
      .then((j) => {
        if (!alive) return;
        const map = {};
        for (const ev of (j && j.events) || []) map[String(ev.id)] = ev;
        setDetails(map);
      })
      .catch(() => { if (alive) setDetails({}); });
    return () => { alive = false; };
  }, [info.evId]);

  const evOf = (id) => (details && details[String(id)]) || null;
  const metaOf = (ev) => (ev && Array.isArray(ev.items) && ev.items.find((it) => it && it.__meta === true)) || null;
  // items the calculation was built from (the carrier also holds the merged-in
  // ones — those are listed under their own row instead)
  const itemsOf = (ev, isCarrier) => {
    if (!ev || !Array.isArray(ev.items)) return null;
    let list = ev.items.filter((it) => it && !it.__meta && !it.__merge && typeof it.name === "string");
    if (isCarrier) {
      const taken = new Map();
      for (const p of parts) {
        const src = evOf(p.id);
        for (const it of (src && Array.isArray(src.items) ? src.items : [])) {
          if (!it || it.__meta || it.__merge || typeof it.name !== "string") continue;
          const k = JSON.stringify(it);
          taken.set(k, (taken.get(k) || 0) + 1);
        }
      }
      list = list.filter((it) => {
        const k = JSON.stringify(it);
        const n = taken.get(k) || 0;
        if (n > 0) { taken.set(k, n - 1); return false; }
        return true;
      });
    }
    return list;
  };

  const toggle = (id) => setOpen((o) => { const n = new Set(o); n.has(id) ? n.delete(id) : n.add(id); return n; });

  const renderRow = (id, label, sp, isCarrier) => {
    const ev = evOf(id);
    const href = ev && isUrl(ev.taskLink) ? ev.taskLink : null;
    const items = itemsOf(ev, isCarrier);
    const meta = metaOf(ev);
    const expanded = open.has(id);
    return (
      <React.Fragment key={id || label}>
        <div className={"merge-row" + (isCarrier ? " merge-row-main" : "")}>
          <button type="button" className="merge-exp" title={expanded ? "Hide the items" : "Show what was calculated"}
            onClick={() => toggle(id)} disabled={!details}>{expanded ? "▾" : "▸"}</button>
          {href
            ? <a className="merge-name merge-link" href={href} target="_blank" rel="noopener noreferrer" title={href}>{label || href}</a>
            : <span className="merge-name" title={label}>{label || "(no number)"}</span>}
          <span className={"merge-tag" + (isCarrier ? "" : " merge-tag-in")}>{isCarrier ? "keeps the name" : "merged in"}</span>
          <b className="merge-sp">{r2(sp || 0)} SP</b>
        </div>
        {expanded && (
          <div className="merge-items">
            {!details && <div className="merge-items-empty">Loading…</div>}
            {details && !ev && <div className="merge-items-empty">This calculation is no longer in the database.</div>}
            {details && ev && (
              <React.Fragment>
                {items && items.length > 0 ? (
                  <React.Fragment>
                    {items.map((it, i) => (
                      <div className="merge-item" key={i}>
                        <span className="mi-name">{it.name}{it.isMain ? " · main" : ""}</span>
                        <span className="mi-lvl">lvl {it.level}</span>
                        <span className="mi-fin">{r2(Number(it.fin) || 0)}</span>
                      </div>
                    ))}
                    {meta && <div className="merge-item merge-item-meta">
                      <span className="mi-name">versions: {meta.numberOfVersions || 0} · iterations lvl {meta.iterationComplexity || 0}</span>
                      <span className="mi-lvl"></span>
                      <span className="mi-fin"></span>
                    </div>}
                  </React.Fragment>
                ) : (
                  <div className="merge-items-empty">No item breakdown was saved for this calculation.</div>
                )}
              </React.Fragment>
            )}
          </div>
        )}
      </React.Fragment>
    );
  };

  const ownSp = Math.round(((info.totalSp || 0) - parts.reduce((s, p) => s + (p.sp || 0), 0)) * 1000) / 1000;

  return (
    <div className="kpi-modal-backdrop" onClick={info.busy ? undefined : onClose}>
      <div className="kpi-modal" style={{ width: 520 }} onClick={(e) => e.stopPropagation()}>
        <div className="kpi-modal-head">
          <div>
            <h3>Merged task · ×{parts.length + 1}</h3>
            <p>Counted as one task in Statistics, with the SP added up</p>
          </div>
          <button className="kpi-x" onClick={onClose} disabled={info.busy}>×</button>
        </div>
        <div className="kpi-modal-body">
          <div className="merge-list">
            {renderRow(info.evId, info.label, ownSp, true)}
            {parts.map((p) => renderRow(p.id, p.label, p.sp, false))}
            <div className="merge-row merge-row-total">
              <span></span>
              <span className="merge-name">Total</span>
              <span></span>
              <b className="merge-sp">{r2(info.totalSp || 0)} SP</b>
            </div>
          </div>
          <p className="merge-note">
            Click ▸ to see the items a calculation was built from · links open the task in a new tab.
            Unmerging restores the merged-in calculations as separate tasks and gives their SP back.
          </p>
        </div>
        <div className="kpi-modal-foot">
          <button className="btn ghost" onClick={onClose} disabled={info.busy}>Close</button>
          <button className="btn primary" onClick={onUnmerge} disabled={info.busy}>
            {info.busy ? "Unmerging…" : "Unmerge"}
          </button>
        </div>
      </div>
    </div>
  );
}

/* ---- date editor for a calculation row: re-dates the calculation itself, so
   the task moves to the matching week and Statistics follows ---- */
function DateEditModal({ initTs, taskLabel, saving, onSave, onClose }) {
  const { useState } = React;
  const iso = (ts) => {
    const d = new Date(Number(ts) || Date.now());
    return d.getFullYear() + "-" + String(d.getMonth() + 1).padStart(2, "0") + "-" + String(d.getDate()).padStart(2, "0");
  };
  const [val, setVal] = useState(() => iso(initTs));
  const valid = /^\d{4}-\d{2}-\d{2}$/.test(val) && !saving;
  const submit = () => { if (valid) onSave(val); };
  return (
    <div className="kpi-modal-backdrop" onClick={onClose}>
      <div className="kpi-modal" style={{ width: 420 }} onClick={(e) => e.stopPropagation()}>
        <div className="kpi-modal-head">
          <div>
            <h3>Task date</h3>
            <p>{taskLabel ? taskLabel + " · " : ""}moves the task to the matching week</p>
          </div>
          <button className="kpi-x" onClick={onClose}>×</button>
        </div>
        <div className="kpi-modal-body">
          <div className="kpi-field">
            <label>Date <span>the day this work is counted on</span></label>
            <input type="date" value={val} autoFocus
              onChange={(e) => setVal(e.target.value)}
              onKeyDown={(e) => { if (e.key === "Enter") submit(); if (e.key === "Escape") onClose(); }} />
          </div>
          <p style={{ margin: "2px 2px 0", fontSize: 12, color: "var(--muted)" }}>
            The date is saved on the calculation itself — the table, the matrix and Statistics all follow it.
          </p>
        </div>
        <div className="kpi-modal-foot">
          <button className="btn ghost" onClick={onClose} disabled={saving}>Cancel</button>
          <button className="btn primary" disabled={!valid} onClick={submit}>{saving ? "Saving…" : "Save"}</button>
        </div>
      </div>
    </div>
  );
}

/* ---- main view ---- */
function DesignerTables({ products, designers, soloId, readOnly }) {
  const solo = !!soloId;
  // Tables content is shared for everyone via the server (no demo seed —
  // real rows come from calculations; wd/manual edits sync below).
  const [tables, setTables] = useState(() => loadTables() || {});
  // Designer edits to calculation rows: { deleted: [evId], moved: { evId: {y,m,w} } }
  const [adjustments, setAdjustments] = useState(() => {
    try { return JSON.parse(localStorage.getItem("nove8_tbl_adj_v1")) || { deleted: [], moved: {} }; }
    catch (e) { return { deleted: [], moved: {} }; }
  });
  const tblDirty = useRef(false);
  const markTablesDirty = () => {
    tblDirty.current = true;
    try { localStorage.setItem("nove8_tbl_edit_ts", String(Date.now())); } catch (e) {}
  };
  const [filter, setFilter] = useState("__all__");
  const [posFilter, setPosFilter] = useState("__all__");
  const [q, setQ] = useState("");
  const [selId, setSelId] = useState(null);
  // open on the current month/year (was hardcoded to June 2026)
  const [year, setYear] = useState(() => new Date().getFullYear());
  const [month, setMonth] = useState(() => new Date().getMonth());
  const [viewMode, setViewMode] = useState("weekly");
  const [taskTypes, setTaskTypes] = useState(loadTaskTypes);
  const [funnels, setFunnels] = useState(loadFunnels);
  const [typesList, setTypesList] = useState(loadTypesList);
  const [tools, setTools] = useState(loadTools);
  const [kpis, setKpis] = useState(loadKpis);
  const [kpiModal, setKpiModal] = useState(false);
  const [bonusInfo, setBonusInfo] = useState(false);
  const [linkEdit, setLinkEdit] = useState(null);
  const [dateEdit, setDateEdit] = useState(null); // { evId, ts, label } — calc row date
  const [calcDetails, setCalcDetails] = useState(null); // { evId, label } — what a calculation was built from
  const [merge, setMerge] = useState(null); // { wIdx, ids: Set, busy } while picking rows to merge
  const [mergeInfo, setMergeInfo] = useState(null); // { evId, label, totalSp, parts, busy } — merged-task details
  const [dateSaving, setDateSaving] = useState(false);
  const [meetModal, setMeetModal] = useState(null); // { wIdx } — "Add meeting" dialog
  const [extraModal, setExtraModal] = useState(null); // { wIdx } — "Add extra SP" dialog (manager/admin)
  const [taskModal, setTaskModal] = useState(null); // { wIdx } — "Add task" dialog (manager/admin)
  const [taskSaving, setTaskSaving] = useState(false);
  const [migrate, setMigrate] = useState(null); // { items } — "Table → Stats" dialog
  const [migrateRunning, setMigrateRunning] = useState(false);
  const [migrateDone, setMigrateDone] = useState("");

  // Persist locally (cache) and publish user edits to the shared store —
  // everyone: designers set their working days / move / delete their tasks,
  // managers and admins edit any table. Server-applied updates don't echo.
  useEffect(() => {
    try { localStorage.setItem(TKEY, JSON.stringify(tables)); } catch (e) {}
    try { localStorage.setItem("nove8_tbl_adj_v1", JSON.stringify(adjustments)); } catch (e) {}
    if (!tblDirty.current) return;
    tblDirty.current = false;
    fetch("/api/tables", {
      method: "PUT",
      headers: { "content-type": "application/json" },
      body: JSON.stringify({ tables, adjustments }),
    }).catch(() => {});
  }, [tables, adjustments]);
  // Load the shared tables on mount (server wins) and keep them fresh.
  useEffect(() => {
    let alive = true;
    const pull = () => {
      fetch("/api/tables", { cache: "no-store" })
        .then((r) => (r.ok ? r.json() : null))
        .then((j) => {
          if (!alive || !j || !j.state) return;
          try {
            const ts = Number(localStorage.getItem("nove8_tbl_edit_ts") || 0);
            if (Date.now() - ts < 45000) return; // don't stomp an in-flight edit
          } catch (e) {}
          const st = j.state;
          if (st.tables && typeof st.tables === "object") {
            setTables((cur) => (JSON.stringify(cur) === JSON.stringify(st.tables) ? cur : st.tables));
          }
          if (st.adjustments && typeof st.adjustments === "object") {
            const norm = { deleted: st.adjustments.deleted || [], moved: st.adjustments.moved || {} };
            setAdjustments((cur) => (JSON.stringify(cur) === JSON.stringify(norm) ? cur : norm));
          }
        })
        .catch(() => {});
    };
    pull();
    const id = setInterval(pull, 30000);
    return () => { alive = false; clearInterval(id); };
  }, []);
  useEffect(() => {
    const reload = (e) => {
      if (!e || !e.key || e.key === TKEY) { const t = loadTables(); if (t) setTables(t); }
      if (!e || !e.key || e.key === FNKEY) setFunnels(loadFunnels());
      if (!e || !e.key || e.key === TTKEY) setTaskTypes(loadTaskTypes());
      if (!e || !e.key || e.key === TYKEY) setTypesList(loadTypesList());
      if (!e || !e.key || e.key === TLKEY) setTools(loadTools());
    };
    const onPush = () => { const t = loadTables(); if (t) setTables(t); };
    const onCatalog = () => { setFunnels(loadFunnels()); setTaskTypes(loadTaskTypes()); setTypesList(loadTypesList()); setTools(loadTools()); };
    window.addEventListener("storage", reload);
    window.addEventListener("nove8-tables-push", onPush);
    window.addEventListener("nove8-catalog-changed", onCatalog);
    window.addEventListener("focus", reload);
    return () => { window.removeEventListener("storage", reload); window.removeEventListener("nove8-tables-push", onPush); window.removeEventListener("nove8-catalog-changed", onCatalog); window.removeEventListener("focus", reload); };
  }, []);
  useEffect(() => { try { localStorage.setItem(TTKEY, JSON.stringify(taskTypes)); } catch (e) {} }, [taskTypes]);
  // KPI lives on the server (shared_state key 'kpi'). Local edits by an
  // admin/manager publish the whole map; everyone else reads it. A dirty flag
  // distinguishes user edits from server-applied updates (no echo).
  const kpiDirty = useRef(false);
  const kpiLoaded = useRef(false);
  useEffect(() => {
    try { localStorage.setItem(KKEY, JSON.stringify(kpis)); } catch (e) {}
    if (!kpiDirty.current) return;
    kpiDirty.current = false;
    try { localStorage.setItem("nove8_kpi_edit_ts", String(Date.now())); } catch (e) {}
    let role = "";
    try { role = localStorage.getItem("nove8_shell_role") || ""; } catch (e) {}
    if (role !== "admin" && role !== "manager") return;
    fetch("/api/kpi", {
      method: "PUT",
      headers: { "content-type": "application/json" },
      body: JSON.stringify(kpis),
    }).catch(() => {});
  }, [kpis]);
  useEffect(() => {
    let alive = true;
    const pull = (initial) => {
      fetch("/api/kpi", { cache: "no-store" })
        .then((r) => (r.ok ? r.json() : null))
        .then((j) => {
          if (!alive) return;
          if (initial) kpiLoaded.current = true;
          if (!j || !j.state || typeof j.state !== "object") return;
          try {
            const ts = Number(localStorage.getItem("nove8_kpi_edit_ts") || 0);
            if (Date.now() - ts < 45000) return; // don't stomp an in-flight edit
          } catch (e) {}
          setKpis((cur) => (JSON.stringify(cur) === JSON.stringify(j.state) ? cur : j.state));
        })
        .catch(() => { if (initial) kpiLoaded.current = true; });
    };
    pull(true);
    const id = setInterval(() => pull(false), 30000);
    return () => { alive = false; clearInterval(id); };
  }, []);

  // Apply a KPI change depending on the VIEWED month:
  // - current (or future) month → becomes the base from that month onward;
  // - a past month → a month-only override; every other month stays as it was.
  const applyKpiChange = (dId, change) => {
    kpiDirty.current = true;
    setKpis((m) => {
      const now = new Date();
      const nowKey = ymKey(now.getFullYear(), now.getMonth());
      const viewKey = ymKey(year, month);
      const cur = m[dId];
      // No stored KPI = the designer lived on the defaults — record them as the
      // baseline so history keeps them after this change.
      let versions = !cur
        ? [{ from: "0000-00", ...DEFAULT_KPI }]
        : (Array.isArray(cur.versions) ? cur.versions.slice() : [{ from: "0000-00", ...cur }]);
      const overrides = (cur && cur.overrides) ? { ...cur.overrides } : {};
      // The monthly bonus flag is designer-wide (all months at once) — it goes
      // to the root, never into a version or a month override.
      let rootBonus = (cur && typeof cur.bonusEnabled === "boolean") ? cur.bonusEnabled : undefined;
      const rest = { ...change };
      if (Object.prototype.hasOwnProperty.call(rest, "bonusEnabled")) {
        rootBonus = !!rest.bonusEnabled;
        delete rest.bonusEnabled;
      }
      const entry = { versions, overrides };
      if (rootBonus !== undefined) entry.bonusEnabled = rootBonus;
      if (Object.keys(rest).length === 0) {
        // bonus-only toggle — nothing month-scoped changes
        return { ...m, [dId]: entry };
      }
      const effView = kpiFor(m, dId, year, month);
      if (viewKey < nowKey) {
        entry.overrides = { ...overrides, [viewKey]: { ...effView, ...rest } };
        return { ...m, [dId]: entry };
      }
      const next = { ...effView, ...rest, from: viewKey };
      entry.versions = versions.filter((v) => String(v.from) !== viewKey);
      entry.versions.push(next);
      entry.versions.sort((a, b) => String(a.from).localeCompare(String(b.from)));
      return { ...m, [dId]: entry };
    });
  };

  // Task-type edits from this panel go to the shared catalog too (the Catalog
  // tab already publishes; this panel used to be local-only).
  const publishTaskTypes = (next) => {
    try { localStorage.setItem("nove8_catalog_edit_ts", String(Date.now())); } catch (e) {}
    let role = "";
    try { role = localStorage.getItem("nove8_shell_role") || ""; } catch (e) {}
    if (role !== "admin" && role !== "manager") return;
    const read = (k) => { try { const r = localStorage.getItem(k); if (r) { const a = JSON.parse(r); if (Array.isArray(a)) return a; } } catch (e) {} return []; };
    fetch("/api/catalog", {
      method: "PUT",
      headers: { "content-type": "application/json" },
      body: JSON.stringify({ taskTypes: next, types: read("nove8_types_v1"), funnels: read(FNKEY) }),
    }).catch(() => {});
  };
  const addType = (label) => {
    const next = [...taskTypes, { id: ttId(), label, color: TYPE_PALETTE[taskTypes.length % TYPE_PALETTE.length] }];
    setTaskTypes(next);
    publishTaskTypes(next);
  };
  const removeType = (id) => {
    const next = taskTypes.filter((t) => t.id !== id);
    setTaskTypes(next);
    publishTaskTypes(next);
  };

  const list = useMemo(() => {
    return designers
      .filter((d) => filter === "__all__" ? true : filter === "__none__" ? d.productId === null : d.productId === filter)
      .filter((d) => posFilter === "__all__" ? true : d.position === posFilter)
      .filter((d) => d.name.toLowerCase().includes(q.trim().toLowerCase()))
      .slice()
      .sort((a, b) => a.name.localeCompare(b.name));
  }, [designers, filter, posFilter, q]);

  // keep a valid selection
  useEffect(() => {
    if (solo) return;
    if (list.length === 0) { if (selId !== null) setSelId(null); return; }
    if (!list.find((d) => d.id === selId)) setSelId(list[0].id);
  }, [list, selId, solo]);

  const weeks = useMemo(() => weeksOfMonth(year, month), [year, month]);
  const curId = solo ? soloId : selId;
  const designer = designers.find((d) => d.id === curId) || null;
  // KPI effective for the VIEWED month — history keeps its period's values
  const kpi = kpiFor(kpis, curId, year, month);

  // ---- Overlay real calculations from the server (task_evaluations) onto the
  // tables, for EVERY designer: the manager's Designers Tables and the solo
  // "My Table" both show actual history. Matched by each account's linked
  // calculator names (handles). Weeks with real calculations display them
  // (server wins); weeks without stay as locally entered.
  const [serverEvents, setServerEvents] = useState(null);
  const refreshEvents = () =>
    fetch("/api/task-evaluations", { cache: "no-store" })
      .then((r) => (r.ok ? r.json() : null))
      .then((j) => { if (j && Array.isArray(j.events)) { setServerEvents(j.events); return true; } return false; })
      .catch(() => false);
  useEffect(() => { refreshEvents(); }, []);

  // Repair pass: moves/deletes recorded before v1.99 only adjusted the tables
  // — the calculation kept its original date (or stayed alive), so Statistics
  // ignored them. An admin/manager session re-dates any moved calculation
  // whose event date still falls outside its target week, and soft-deletes
  // calculations that were removed from the tables.
  const movesRepairBusy = useRef(false);
  useEffect(() => {
    if (movesRepairBusy.current || !serverEvents || !serverEvents.length) return;
    const moved = (adjustments && adjustments.moved) || {};
    const deleted = (adjustments && adjustments.deleted) || [];
    if (!Object.keys(moved).length && !deleted.length) return;
    let role = "";
    try { role = localStorage.getItem("nove8_shell_role") || ""; } catch (e) {}
    if (role !== "admin" && role !== "manager") return;
    const byId = new Map(serverEvents.map((ev) => [String(ev.id), ev]));
    // deleted from the tables but still alive in Statistics
    const delSet = new Set(deleted.map(String));
    const staleDel = deleted.map(String).filter((id) => byId.has(id));
    // moved in the tables but still dated in the old week
    const staleMove = [];
    for (const id of Object.keys(moved)) {
      if (delSet.has(String(id))) continue; // deletion wins
      const tgt = moved[id];
      const ev = byId.get(String(id));
      if (!ev || !tgt || tgt.y == null) continue;
      const wk = weeksOfMonth(tgt.y, tgt.m).find((w) => w.idx === tgt.w);
      if (!wk) continue;
      const start = wk.end - 4 * 86400000 - 86399999; // Monday 00:00
      const endSun = wk.end + 2 * 86400000;           // include weekend
      const ts = Number(ev.ts) || 0;
      if (ts >= start && ts <= endSun) continue; // already dated in its week
      staleMove.push({ id, ts: wk.end - 2 * 86400000 - 43199999 });
    }
    if (!staleMove.length && !staleDel.length) return;
    movesRepairBusy.current = true;
    (async () => {
      for (const s of staleMove) {
        try {
          await fetch("/api/task-evaluations?id=" + encodeURIComponent(s.id), {
            method: "PATCH",
            headers: { "content-type": "application/json" },
            body: JSON.stringify({ ts: s.ts }),
          });
        } catch (e) { /* retried on next load */ }
      }
      for (const id of staleDel) {
        try {
          await fetch("/api/task-evaluations?id=" + encodeURIComponent(id), { method: "DELETE" });
        } catch (e) { /* retried on next load */ }
      }
      await refreshEvents();
      movesRepairBusy.current = false;
    })();
  }, [serverEvents, adjustments]);

  // Year options: the static list extended with any year that actually has
  // calculations on the server, so old history is always reachable.
  const yearOptions = useMemo(() => {
    const ys = new Set(YEARS);
    ys.add(new Date().getFullYear());
    (serverEvents || []).forEach((ev) => {
      const y = new Date(Number(ev.ts) || 0).getFullYear();
      if (y >= 2020 && y <= 2100) ys.add(y);
    });
    return [...ys].sort();
  }, [serverEvents]);

  // Provisional attribution for historical (context-less) calculations:
  // a Motion Designer's untyped rows count as Type=motion, Task type=creative.
  // Uses an existing "creative" task type if the catalog has one, otherwise a
  // synthetic column is appended below. The label match is tolerant: it strips
  // invisible characters and maps Cyrillic look-alike letters (С→c, е→e, …) so
  // a catalog entry typed as "Сreative" doesn't spawn a duplicate column.
  const normLabel = (s) => String(s || "")
    .replace(/[\u200B-\u200D\uFEFF\u00A0]/g, "")
    .trim().toLowerCase()
    .replace(/[а-яёіїє]/g, (ch) => ({ "а": "a", "в": "b", "с": "c", "е": "e", "ё": "e", "н": "h", "і": "i", "к": "k", "м": "m", "о": "o", "р": "p", "т": "t", "х": "x", "у": "y" }[ch] || ch));
  const creativeTT = taskTypes.find((t) => /^creatives?$/.test(normLabel(t.label)));
  const CREATIVE_ID = creativeTT ? creativeTT.id : "__creative";

  const overlayResult = useMemo(() => {
    if (!serverEvents || !serverEvents.length) return { tables, usedCreative: false };
    let usedCreative = false;
    // designer adjustments to calculation rows (shared via /api/tables)
    const adjDeleted = new Set((adjustments && adjustments.deleted) || []);
    const adjMoved = (adjustments && adjustments.moved) || {};
    // index events by the designer name they were saved under
    const byName = new Map();
    for (const ev of serverEvents) {
      const k = String(ev.designer || "").trim().toLowerCase();
      if (!k) continue;
      if (!byName.has(k)) byName.set(k, []);
      byName.get(k).push(ev);
    }
    const next = { ...tables };
    for (const d of designers) {
      if (isInnovation(d)) continue; // tools/hours tables carry no calculations
      const handles = ((d.handles && d.handles.length) ? d.handles : [d.name])
        .map((h) => String(h).trim().toLowerCase());
      const mine = handles.flatMap((h) => byName.get(h) || []);
      if (!mine.length) continue;
      // place each event into its work week (weeks belong to a month by Wednesday;
      // ranges are disjoint, so checking the date's month ± 1 finds the owner)
      const byMonth = new Map(); // "y-m" -> { weekIdx: [events] }
      for (const ev of mine) {
        if (ev.id && adjDeleted.has(ev.id)) continue; // deleted by the designer
        const mv = ev.id ? adjMoved[ev.id] : null;
        let placed = (mv && mv.y != null) ? { y: mv.y, m: mv.m, w: mv.w } : null; // moved to another week
        if (!placed) {
          const ts = Number(ev.ts) || 0;
          const dt = new Date(ts);
          for (const off of [0, -1, 1]) {
            const ref = new Date(dt.getFullYear(), dt.getMonth() + off, 1);
            const y = ref.getFullYear(), m = ref.getMonth();
            for (const wk of weeksOfMonth(y, m)) {
              const start = wk.end - 4 * 86400000 - 86399999; // Monday 00:00
              const endSun = wk.end + 2 * 86400000;           // include weekend
              if (ts >= start && ts <= endSun) { placed = { y, m, w: wk.idx }; break; }
            }
            if (placed) break;
          }
        }
        if (!placed) continue;
        const key = placed.y + "-" + placed.m;
        if (!byMonth.has(key)) byMonth.set(key, {});
        const wks = byMonth.get(key);
        (wks[placed.w] = wks[placed.w] || []).push(ev);
      }
      const appId = d.productId || (products[0] && products[0].id) || "";
      const isMotion = d.position === "Motion Designer";
      for (const [key, wks] of byMonth) {
        const parts = key.split("-");
        const k = mkey(d.id, +parts[0], +parts[1]);
        const entry = { ...(next[k] || {}) };
        const weeksObj = { ...(entry.weeks || {}) };
        Object.keys(wks).forEach((wIdx) => {
          const prev = weeksObj[wIdx] || {};
          // manually entered rows (incl. meetings) survive the overlay —
          // appended after the calculation rows
          const meetRows = (prev.rows || []).filter((r) => r && !r.evId && (r.app || r.type || r.task || r.link || r.sp || r.funnel));
          weeksObj[wIdx] = {
            ...prev,
            rows: wks[wIdx].slice().sort((a, b) => (a.ts || 0) - (b.ts || 0)).map((ev) => {
              // newer calculations carry the calc card's context in a __meta items entry
              const meta = (Array.isArray(ev.items) && ev.items.find((it) => it && it.__meta === true)) || {};
              let task = meta.taskTypeId || "";
              let type = meta.typeId || "";
              // provisional rule: a Motion Designer's untyped history counts as
              // Type=motion, Task type=creative
              if (isMotion && !type) type = "motion";
              if (isMotion && !task) { task = CREATIVE_ID; if (!creativeTT) usedCreative = true; }
              // a merged calculation carries the folded-in ones in a __merge entry
              const mergeRec = (Array.isArray(ev.items) && ev.items.find((it) => it && it.__merge === true)) || null;
              const mergedFrom = (mergeRec && Array.isArray(mergeRec.from)) ? mergeRec.from : [];
              return {
                evId: ev.id || "", // ties the row to its calculation for delete/move
                app: meta.productId || appId,
                type, task,
                funnel: meta.funnelId || "",
                link: meta.taskNumber || ev.taskLink || "",
                linkUrl: ev.taskLink || "",
                sp: String(ev.result != null ? ev.result : ""),
                mergedCount: mergedFrom.length ? mergedFrom.length + 1 : 0,
                mergedFrom: mergedFrom.map((f) => ({ id: f.id, label: f.label || "", sp: Number(f.result) || 0 })),
              };
            }).concat(meetRows),
          };
        });
        entry.weeks = weeksObj; next[k] = entry;
      }
    }
    return { tables: next, usedCreative };
  }, [serverEvents, tables, designers, products, taskTypes, adjustments]);

  const displayTables = overlayResult.tables;
  // If the catalog has no "creative" task type but the provisional rule used
  // one, append a synthetic entry so the weekly chips and matrix column render.
  // Column order otherwise follows the Catalog exactly — rearrange task types
  // there (‹ › arrows) to change the matrix layout for everyone.
  const effTaskTypes = useMemo(() => {
    return (overlayResult.usedCreative && !creativeTT)
      ? [...taskTypes, { id: "__creative", label: "creative", color: "#6c79f6" }]
      : taskTypes.slice();
  }, [overlayResult.usedCreative, creativeTT, taskTypes]);

  const setCell = (wIdx, rowIdx, field, value) => {
    if (!designer) return;
    const disp = displayedRow(wIdx, rowIdx);
    if (disp && disp.evId) {
      // Calculation rows: the classification fields (Type / Task type /
      // Funnel) are editable by an admin/manager — mainly to sort old,
      // context-less history out of the matrix "Other" column. The choice is
      // saved into the calculation itself, so Statistics follows everywhere.
      if (field !== "type" && field !== "task" && field !== "funnel") return;
      if (field === "task" && value === "__creative") return; // synthetic column, not a real catalog type
      let role = "";
      try { role = localStorage.getItem("nove8_shell_role") || ""; } catch (e) {}
      // admins/managers classify anything; a designer may set the Task type
      // on their own calculations (solo view = their own table)
      if (role !== "admin" && role !== "manager" && !(solo && field === "task")) return;
      const meta = {};
      if (field === "task") {
        const t = taskTypes.find((x) => x.id === value);
        meta.taskTypeId = value || "";
        meta.taskType = t ? String(t.label || "") : "";
      } else if (field === "type") {
        const t = typesList.find((x) => x.id === value);
        meta.typeId = value || "";
        meta.type = t ? String(t.label || "") : "";
      } else {
        const f = funnels.find((x) => x.id === value);
        meta.funnelId = value || "";
        meta.funnel = f ? String(f.label || f.name || "") : "";
      }
      fetch("/api/task-evaluations?id=" + encodeURIComponent(disp.evId), {
        method: "PATCH",
        headers: { "content-type": "application/json" },
        body: JSON.stringify({ meta }),
      }).then((r) => { if (r.ok) refreshEvents(); }).catch(() => {});
      return;
    }
    markTablesDirty();
    setTables((t) => {
      const k = mkey(designer.id, year, month);
      const next = { ...t };
      const entry = { ...(next[k] || {}) };
      const wks = { ...(entry.weeks || {}) };
      const wk = { ...(wks[wIdx] || {}) };
      const rows = (wk.rows || []).slice();
      // In weeks that carry calculation rows the display index is shifted, so
      // resolve the stored row by identity; otherwise use the plain index.
      let target = disp ? rows.indexOf(disp) : -1;
      if (target < 0) {
        const dispWk = displayTables[k] && displayTables[k].weeks ? displayTables[k].weeks[wIdx] : null;
        const hasCalc = !!(dispWk && dispWk.rows && dispWk.rows.some((r) => r && r.evId));
        if (hasCalc) { rows.push({}); target = rows.length - 1; }
        else { while (rows.length <= rowIdx) rows.push({}); target = rowIdx; }
      }
      rows[target] = { ...rows[target], [field]: value };
      wk.rows = rows; wks[wIdx] = wk; entry.weeks = wks; next[k] = entry;
      return next;
    });
  };
  const setWD = (wIdx, value) => {
    if (!designer) return;
    markTablesDirty();
    setTables((t) => {
      const k = mkey(designer.id, year, month);
      const next = { ...t };
      const entry = { ...(next[k] || {}) };
      const wks = { ...(entry.weeks || {}) };
      const wk = { ...(wks[wIdx] || {}) };
      wk.wd = value === "" ? "" : Math.max(0, Math.min(31, parseInt(value, 10) || 0));
      wks[wIdx] = wk; entry.weeks = wks; next[k] = entry;
      return next;
    });
  };
  // Resolve the row the user actually sees (overlay rows carry an evId).
  const displayedRow = (wIdx, rowIdx) => {
    const k = mkey(designer.id, year, month);
    const entry = displayTables[k];
    const wk = entry && entry.weeks ? entry.weeks[wIdx] : null;
    return (wk && wk.rows && wk.rows[rowIdx]) || null;
  };

  const deleteRow = (wIdx, rowIdx) => {
    if (!designer) return;
    markTablesDirty();
    const row = displayedRow(wIdx, rowIdx);
    if (row && row.evId) {
      // calculation row — record the deletion as a shared adjustment…
      setAdjustments((a) => ({ ...a, deleted: [...(a.deleted || []), row.evId] }));
      // …and remove the calculation from Statistics too (soft delete on the
      // server — recoverable from the DB if done by mistake)
      fetch("/api/task-evaluations?id=" + encodeURIComponent(row.evId), { method: "DELETE" })
        .then((r) => { if (r.ok || r.status === 404) refreshEvents(); })
        .catch(() => {});
      return;
    }
    setTables((t) => {
      const k = mkey(designer.id, year, month);
      const next = { ...t };
      const entry = { ...(next[k] || {}) };
      const wks = { ...(entry.weeks || {}) };
      const wk = { ...(wks[wIdx] || {}) };
      const rows = (wk.rows || []).slice();
      // resolve by identity (display index shifts in weeks with calc rows);
      // fall back to the plain index for plain weeks
      let target = row ? rows.indexOf(row) : -1;
      if (target < 0 && row && row.mid) target = rows.findIndex((r) => r && r.mid === row.mid);
      if (target < 0) target = rowIdx;
      rows.splice(target, 1);
      wk.rows = rows; wks[wIdx] = wk; entry.weeks = wks; next[k] = entry;
      return next;
    });
  };
  const moveRow = (fromW, fromR, toW) => {
    if (!designer || fromW === toW) return;
    markTablesDirty();
    const row = displayedRow(fromW, fromR);
    if (row && row.evId) {
      // calculation row — record the new week as a shared adjustment
      setAdjustments((a) => ({ ...a, moved: { ...(a.moved || {}), [row.evId]: { y: year, m: month, w: toW } } }));
      // …and re-date the calculation itself (Wednesday noon of the target
      // week), so Statistics — which reads event dates — follows the move
      const wk = weeks.find((w) => w.idx === toW);
      if (wk) {
        fetch("/api/task-evaluations?id=" + encodeURIComponent(row.evId), {
          method: "PATCH",
          headers: { "content-type": "application/json" },
          body: JSON.stringify({ ts: wk.end - 2 * 86400000 - 43199999 }),
        }).then((r) => { if (r.ok) refreshEvents(); }).catch(() => {});
      }
      return;
    }
    setTables((t) => {
      const k = mkey(designer.id, year, month);
      const next = { ...t };
      const entry = { ...(next[k] || {}) };
      const wks = { ...(entry.weeks || {}) };
      const fromWk = { ...(wks[fromW] || {}) };
      const fromRows = (fromWk.rows || []).slice();
      // resolve by identity first (display index shifts in weeks with calc rows)
      let idx = row ? fromRows.indexOf(row) : -1;
      if (idx < 0 && row && row.mid) idx = fromRows.findIndex((r) => r && r.mid === row.mid);
      if (idx < 0) idx = fromR;
      if (idx < 0 || idx >= fromRows.length) return t;
      const moved = fromRows[idx];
      if (!moved || !(moved.app || moved.type || moved.task || moved.link || moved.sp || moved.tool || moved.desc || moved.hours)) return t;
      fromRows.splice(idx, 1);
      fromWk.rows = fromRows; wks[fromW] = fromWk;
      const toWk = { ...(wks[toW] || {}) };
      const toRows = (toWk.rows || []).slice();
      toRows.push(moved);
      toWk.rows = toRows; wks[toW] = toWk;
      entry.weeks = wks; next[k] = entry;
      return next;
    });
  };

  // Meetings: SP-earning rows a designer adds to their own table (not
  // calculations). Stored as manual rows flagged meet:true with a unique mid,
  // so they sync through /api/tables and survive the calculations overlay.
  // Extra SP: same mechanics, flagged extra:true — granted by a manager/admin
  // for valuable work that isn't a countable task.
  const addSpRow = (wIdx, flags, taskId, name, sp, app) => {
    if (!designer) return;
    markTablesDirty();
    const row = {
      ...flags,
      mid: (flags.extra ? "extra_" : "meet_") + Date.now().toString(36) + Math.floor(Math.random() * 1e6).toString(36),
      app: app !== undefined ? app : (designer.productId || (products[0] && products[0].id) || ""),
      type: "",
      task: taskId || "",
      link: name,
      sp: String(sp),
    };
    setTables((t) => {
      const k = mkey(designer.id, year, month);
      const next = { ...t };
      const entry = { ...(next[k] || {}) };
      const wks = { ...(entry.weeks || {}) };
      const wk = { ...(wks[wIdx] || {}) };
      wk.rows = [...(wk.rows || []), row];
      wks[wIdx] = wk; entry.weeks = wks; next[k] = entry;
      return next;
    });
  };
  const addMeeting = (wIdx, name, sp) => {
    const meetingsTT = taskTypes.find((t) => /meet/i.test(String(t.label || "")));
    addSpRow(wIdx, { meet: true }, meetingsTT ? meetingsTT.id : "", name, sp);
  };
  const addExtra = (wIdx, name, sp, app) => {
    const extraTT = taskTypes.find((t) => /extra|bonus/i.test(String(t.label || "")));
    addSpRow(wIdx, { extra: true }, extraTT ? extraTT.id : "", name, sp, app || "");
  };

  // Add a real completed task on behalf of the designer: saved to the server
  // as a calculation event (task_evaluations), stamped mid-week (Wednesday) so
  // it lands in the chosen week. It then flows into Statistics, the matrix and
  // this table through the regular overlay — same as a calculator push.
  const addTask = async (wIdx, f) => {
    if (!designer || taskSaving) return;
    const wk = weeks.find((w) => w.idx === wIdx);
    // week.end is Friday 23:59:59.999 → Wednesday 12:00 of the same week
    const ts = wk ? wk.end - 2 * 86400000 - 43199999 : Date.now();
    const meta = { __meta: true, numberOfVersions: 0, iterationComplexity: 0, taskNumber: f.name };
    if (f.app) {
      meta.productId = f.app;
      const p = products.find((x) => x.id === f.app); if (p) meta.product = p.name;
    }
    if (f.type) {
      meta.typeId = f.type;
      const t = typesList.find((x) => x.id === f.type); if (t) meta.type = t.label;
    }
    if (f.task) {
      meta.taskTypeId = f.task;
      const t = taskTypes.find((x) => x.id === f.task); if (t) meta.taskType = t.label;
    }
    if (f.funnel) {
      meta.funnelId = f.funnel;
      const fn = funnels.find((x) => x.id === f.funnel); if (fn) meta.funnel = fn.label || fn.name;
    }
    const ev = {
      id: "id_" + Date.now().toString(36) + "_" + Math.random().toString(36).slice(2, 8),
      ts,
      designer: (designer.handles && designer.handles.length ? designer.handles[0] : designer.name),
      taskLink: f.url || f.name,
      result: f.sp,
      complexity: 0,
      volume: 0,
      items: [meta],
    };
    setTaskSaving(true);
    try {
      const res = await fetch("/api/task-evaluations", {
        method: "POST",
        headers: { "content-type": "application/json" },
        body: JSON.stringify(ev),
      });
      if (!res.ok) { alert("Could not save the task — try again"); return; }
      await refreshEvents();
      setTaskModal(null);
    } catch (e) {
      alert("No connection — task not saved");
    } finally {
      setTaskSaving(false);
    }
  };

  // ---- Merging calculations -------------------------------------------------
  // Two (or more) calculations that are really one piece of work can be merged:
  // the first picked one becomes the carrier (keeps the name), the others' SP is
  // added to it and their rows are folded in on the server, so Statistics counts
  // ONE task with the combined SP. Reversible via the ×N badge.
  const canMergeHere = (() => {
    if (!designer || isInnovation(designer)) return false;
    if (solo) return true; // a designer merges their own calculations
    let role = "";
    try { role = localStorage.getItem("nove8_shell_role") || ""; } catch (e) {}
    return role === "admin" || role === "manager";
  })();

  const toggleMergePick = (evId) => setMerge((m) => {
    if (!m) return m;
    const ids = new Set(m.ids);
    ids.has(evId) ? ids.delete(evId) : ids.add(evId);
    return { ...m, ids };
  });

  const doMerge = async () => {
    if (!merge || merge.busy || merge.ids.size < 2) return;
    // carrier = the topmost picked row in the week, so the visible order decides
    const k = mkey(designer.id, year, month);
    const wk = displayTables[k] && displayTables[k].weeks ? displayTables[k].weeks[merge.wIdx] : null;
    const order = ((wk && wk.rows) || []).map((r) => r && r.evId).filter(Boolean);
    const picked = order.filter((id) => merge.ids.has(id));
    const ids = picked.length ? picked : Array.from(merge.ids);
    const carrier = ids[0];
    const rest = ids.slice(1);
    setMerge((m) => ({ ...m, busy: true }));
    try {
      const res = await fetch("/api/task-evaluations?action=merge&id=" + encodeURIComponent(carrier), {
        method: "PATCH",
        headers: { "content-type": "application/json" },
        body: JSON.stringify({ ids: rest }),
      });
      if (!res.ok) { alert("Could not merge — try again"); setMerge((m) => ({ ...m, busy: false })); return; }
      await refreshEvents();
      setMerge(null);
    } catch (e) {
      alert("No connection — nothing was merged");
      setMerge((m) => ({ ...m, busy: false }));
    }
  };

  const unmerge = async () => {
    const evId = mergeInfo && mergeInfo.evId;
    if (!evId || (mergeInfo && mergeInfo.busy)) return;
    setMergeInfo((m) => ({ ...m, busy: true }));
    try {
      const res = await fetch("/api/task-evaluations?action=unmerge&id=" + encodeURIComponent(evId), { method: "PATCH" });
      if (!res.ok) { alert("Could not split this task back apart"); setMergeInfo((m) => ({ ...m, busy: false })); return; }
      await refreshEvents();
      setMergeInfo(null);
    } catch (e) {
      alert("No connection — nothing changed");
      setMergeInfo((m) => ({ ...m, busy: false }));
    }
  };

  // Re-date a calculation from the table (designers may re-date their own, an
  // admin/manager anyone's). The date lives on the calculation, so the row
  // moves to the matching week and Statistics follows. Any earlier drag-move
  // adjustment for this calculation is dropped — the date now decides.
  const setCalcDate = async (evId, isoDate) => {
    if (!evId || dateSaving) return;
    let role = "";
    try { role = localStorage.getItem("nove8_shell_role") || ""; } catch (e) {}
    if (role !== "admin" && role !== "manager" && !solo) return;
    const picked = new Date(isoDate + "T12:00:00"); // midday — dodges TZ edges
    if (isNaN(picked.getTime())) return;
    setDateSaving(true);
    try {
      const res = await fetch("/api/task-evaluations?id=" + encodeURIComponent(evId), {
        method: "PATCH",
        headers: { "content-type": "application/json" },
        body: JSON.stringify({ ts: picked.getTime() }),
      });
      if (!res.ok) { alert("Could not change the date — try again"); return; }
      if (adjustments && adjustments.moved && adjustments.moved[evId]) {
        markTablesDirty();
        setAdjustments((a) => {
          const moved = { ...(a.moved || {}) };
          delete moved[evId];
          return { ...a, moved };
        });
      }
      await refreshEvents();
      setDateEdit(null);
    } catch (e) {
      alert("No connection — the date was not changed");
    } finally {
      setDateSaving(false);
    }
  };

  // "Table → Stats": collect the viewed designer's manually entered task rows
  // (no evId, not meetings / extra-SP) across ALL months — these are exactly
  // the rows Statistics doesn't know about.
  const collectManualRows = () => {
    if (!designer || isInnovation(designer)) return []; // tools/hours rows aren't calculations
    const out = [];
    const prefix = designer.id + "|";
    Object.keys(tables).forEach((k) => {
      if (!k.startsWith(prefix)) return;
      const ym = k.slice(prefix.length).split("-");
      const y = +ym[0], m = +ym[1];
      if (!Number.isFinite(y) || !Number.isFinite(m)) return;
      const monthWeeks = weeksOfMonth(y, m);
      const wks = (tables[k] && tables[k].weeks) || {};
      Object.keys(wks).forEach((wIdx) => {
        ((wks[wIdx] && wks[wIdx].rows) || []).forEach((r) => {
          if (!r || r.evId || r.meet || r.extra) return;
          const sp = parseFloat(r.sp);
          if (!(sp > 0)) return;
          const wk = monthWeeks.find((w) => w.idx === +wIdx);
          out.push({ y, m, wIdx: +wIdx, row: r, sp, weekLabel: wk ? wk.label : "?", wkEnd: wk ? wk.end : null });
        });
      });
    });
    out.sort((a, b) => (a.y - b.y) || (a.m - b.m) || (a.wIdx - b.wIdx));
    return out;
  };

  const runMigration = async (items) => {
    if (!designer || migrateRunning) return;
    const dsg = designer; // capture — selection must not change mid-run
    setMigrateRunning(true);
    const posted = [];
    for (const it of items) {
      const r = it.row;
      const ts = it.wkEnd != null ? it.wkEnd - 2 * 86400000 - 43199999 : Date.now(); // Wednesday 12:00
      const meta = { __meta: true, numberOfVersions: 0, iterationComplexity: 0 };
      if (r.link) meta.taskNumber = r.link;
      if (r.app) { meta.productId = r.app; const p = products.find((x) => x.id === r.app); if (p) meta.product = p.name; }
      if (r.type) { meta.typeId = r.type; const t = typesList.find((x) => x.id === r.type); if (t) meta.type = t.label; }
      if (r.task) { meta.taskTypeId = r.task; const t = taskTypes.find((x) => x.id === r.task); if (t) meta.taskType = t.label; }
      if (r.funnel) { meta.funnelId = r.funnel; const f = funnels.find((x) => x.id === r.funnel); if (f) meta.funnel = f.label || f.name; }
      const ev = {
        id: "id_" + Date.now().toString(36) + "_" + Math.random().toString(36).slice(2, 8),
        ts,
        designer: (dsg.handles && dsg.handles.length ? dsg.handles[0] : dsg.name),
        taskLink: r.linkUrl || r.link || "manual task",
        result: it.sp,
        complexity: 0,
        volume: 0,
        items: [meta],
      };
      try {
        const res = await fetch("/api/task-evaluations", {
          method: "POST",
          headers: { "content-type": "application/json" },
          body: JSON.stringify(ev),
        });
        if (res.ok) posted.push(it);
      } catch (e) { /* keep the manual row if the event didn't save */ }
    }
    if (posted.length) {
      const refs = new Set(posted.map((it) => it.row));
      markTablesDirty();
      setTables((t) => {
        const next = { ...t };
        for (const it of posted) {
          const k = mkey(dsg.id, it.y, it.m);
          const entry = next[k];
          if (!entry || !entry.weeks || !entry.weeks[it.wIdx] || !entry.weeks[it.wIdx].rows) continue;
          const wks = { ...entry.weeks };
          wks[it.wIdx] = { ...wks[it.wIdx], rows: wks[it.wIdx].rows.filter((r) => !refs.has(r)) };
          next[k] = { ...entry, weeks: wks };
        }
        return next;
      });
      await refreshEvents();
    }
    setMigrateRunning(false);
    setMigrateDone(
      posted.length === items.length
        ? `Done — ${posted.length} task${posted.length === 1 ? "" : "s"} published to Statistics.`
        : `Published ${posted.length} of ${items.length} — the rest failed to save, reopen to retry.`
    );
  };

  const filters = [{ id: "__all__", name: "All", color: "#98a0ac" }, ...products, { id: "__none__", name: "Unassigned", color: "#c2c8d0" }];

  return (
    <div className="dt">
      <div className="dt-toolbar">
        <h1>{solo ? "My Table" : "Designer Tables"}</h1>
        {!solo && (
          <div className="prodfilter">
            {filters.map((f) => (
              <button key={f.id} className={filter === f.id ? "on" : ""} onClick={() => setFilter(f.id)}>
                {f.id !== "__all__" && <span className="dot" style={{ background: f.color }}></span>}{f.name}
              </button>
            ))}
          </div>
        )}
        <div className="dt-spacer"></div>
        <div className="dt-monthsel">
          <select value={month} onChange={(e) => setMonth(+e.target.value)}>
            {MONTHS.map((mn, i) => <option key={i} value={i}>{mn}</option>)}
          </select>
          <select value={year} onChange={(e) => setYear(+e.target.value)}>
            {yearOptions.map((y) => <option key={y} value={y}>{y}</option>)}
          </select>
        </div>
        {!solo && (
          <div className="dt-search">
            <SearchIcon />
            <input placeholder="Search designer…" value={q} onChange={(e) => setQ(e.target.value)} />
          </div>
        )}
      </div>

      <div className="dt-body">
        {!solo && (
        <div className="dt-rail">
          <div className="rail-head"><span>Designers</span><span>{list.length}</span></div>
          <div className="rail-filters">
            <button className={"posfilter-chip" + (posFilter === "__all__" ? " on" : "")} onClick={() => setPosFilter("__all__")}>All roles</button>
            {Object.keys(POS).map((p) => (
              <button key={p} className={"posfilter-chip" + (posFilter === p ? " on" : "")} onClick={() => setPosFilter(p)}>
                <span className="pdot" style={{ background: POS[p].dot }}></span>{POS[p].short}
              </button>
            ))}
          </div>
          <div className="rail-list">
            {list.map((d) => (
              <RailItem key={d.id} designer={d} product={products.find((p) => p.id === d.productId)}
                active={d.id === selId} onClick={() => setSelId(d.id)}
                metrics={monthAgg(displayTables, d.id, year, month, weeks)} kpi={kpiFor(kpis, d.id, year, month)} />
            ))}
            {list.length === 0 && <div className="rail-empty">No designers match this filter.</div>}
          </div>
        </div>
        )}

        <Detail designer={designer} products={products} tables={displayTables}
          year={year} month={month}
          viewMode={viewMode} setView={setViewMode}
          taskTypes={effTaskTypes} funnels={funnels} tools={tools} typeOpts={typesList.map((t) => ({ v: t.id, label: t.label, color: t.color }))} onAddType={addType} onRemoveType={removeType}
          kpi={kpi} kpiOf={(m) => kpiFor(kpis, curId, year, m)} readOnly={readOnly}
          addMeet={solo && !isInnovation(designer) ? (wIdx) => setMeetModal({ wIdx }) : null}
          addExtra={!solo && !readOnly && !isInnovation(designer) ? (wIdx) => setExtraModal({ wIdx }) : null}
          addTask={!solo && !readOnly && !isInnovation(designer) ? (wIdx) => setTaskModal({ wIdx }) : null}
          canMerge={canMergeHere}
          merge={merge}
          onStartMerge={(wIdx) => setMerge({ wIdx, ids: new Set(), busy: false })}
          onCancelMerge={() => setMerge(null)}
          onToggleMerge={toggleMergePick}
          onDoMerge={doMerge}
          onShowCalc={(row) => setCalcDetails({ evId: row.evId, label: row.link || "" })}
          onShowMerge={(row) => setMergeInfo({
            evId: row.evId,
            label: row.link || "",
            totalSp: parseFloat(row.sp) || 0,
            parts: row.mergedFrom || [],
            busy: false,
          })}
          onSyncStats={!solo && !readOnly && designer && !isInnovation(designer) ? () => { setMigrateDone(""); setMigrate({ items: collectManualRows() }); } : null}
          onEditKpi={() => { if (readOnly) setBonusInfo(true); else if (designer) setKpiModal(true); }}
          onSetKpi={(patch) => !readOnly && designer && applyKpiChange(designer.id, patch)}
          onCell={setCell} onWD={setWD} onDeleteRow={deleteRow} onMoveRow={moveRow}
          onEditLink={(wIdx, rowIdx, row) => {
            // calculation rows carry their date on the server — offer the date
            // editor instead of the (not applicable) name/link editor
            if (row && row.evId) {
              const ev = (serverEvents || []).find((e) => String(e.id) === String(row.evId));
              setDateEdit({ evId: row.evId, ts: ev ? Number(ev.ts) : Date.now(), label: row.link || "" });
              return;
            }
            setLinkEdit({ wIdx, rowIdx, link: row.link || "", linkUrl: row.linkUrl || "" });
          }} />
      </div>
      {bonusInfo && designer && (
        <BonusInfoModal kpi={kpi} weekCount={weeks.length} designerName={designer.name} onClose={() => setBonusInfo(false)} />
      )}
      {meetModal && (
        <MeetModal onClose={() => setMeetModal(null)}
          onSave={(name, sp) => { addMeeting(meetModal.wIdx, name, sp); setMeetModal(null); }} />
      )}
      {extraModal && (
        <ExtraModal products={products} onClose={() => setExtraModal(null)}
          onSave={(name, sp, app) => { addExtra(extraModal.wIdx, name, sp, app); setExtraModal(null); }} />
      )}
      {taskModal && designer && (
        <TaskModal designer={designer} products={products}
          typeOpts={typesList} taskTypes={taskTypes} funnels={funnels} saving={taskSaving}
          onClose={() => !taskSaving && setTaskModal(null)}
          onSave={(f) => addTask(taskModal.wIdx, f)} />
      )}
      {calcDetails && (
        <CalcDetailsModal evId={calcDetails.evId} label={calcDetails.label}
          products={products} taskTypes={taskTypes} typesList={typesList} funnels={funnels}
          onClose={() => setCalcDetails(null)} />
      )}
      {mergeInfo && (
        <MergeInfoModal info={mergeInfo} onUnmerge={unmerge}
          onClose={() => { if (!mergeInfo.busy) setMergeInfo(null); }} />
      )}
      {dateEdit && (
        <DateEditModal initTs={dateEdit.ts} taskLabel={dateEdit.label} saving={dateSaving}
          onClose={() => !dateSaving && setDateEdit(null)}
          onSave={(iso) => setCalcDate(dateEdit.evId, iso)} />
      )}
      {migrate && designer && (
        <MigrateModal items={migrate.items} designerName={designer.name}
          products={products} taskTypes={taskTypes}
          running={migrateRunning} doneMsg={migrateDone}
          onRun={runMigration}
          onClose={() => { if (!migrateRunning) { setMigrate(null); setMigrateDone(""); } }} />
      )}
      {linkEdit && (
        <LinkEditModal initLink={linkEdit.link} initUrl={linkEdit.linkUrl}
          onClose={() => setLinkEdit(null)}
          onSave={(num, url) => {
            setCell(linkEdit.wIdx, linkEdit.rowIdx, "link", num);
            setCell(linkEdit.wIdx, linkEdit.rowIdx, "linkUrl", url);
            setLinkEdit(null);
          }} />
      )}
      {kpiModal && designer && (
        <KpiModal kpi={kpi} designerName={designer.name}
          scopeNote={(() => {
            const now = new Date();
            const nowKey = ymKey(now.getFullYear(), now.getMonth());
            return ymKey(year, month) < nowKey
              ? `Applies to ${MONTHS[month]} ${year} only (editing a past month)`
              : `Applies from ${MONTHS[month]} ${year} onward`;
          })()}
          onClose={() => setKpiModal(false)}
          onSave={(k) => { applyKpiChange(designer.id, k); setKpiModal(false); }} />
      )}
    </div>
  );
}

window.DesignerTables = DesignerTables;
})();
