/* Marque Talent Desk — every figure on a screen comes from one of these
   helpers, so a number rendered twice is one function called twice. Each takes
   the array it summarises; none of them holds data.

   Two chains matter.

   The quote chain, which the match screen and the deal sheet both run:
     rate card × quantity  →  organic fee
                           →  × (1 + usage uplift)  =  quote
     negotiated fee (stored once on the deal)
                           →  × commission rate     =  agency commission
                           →  − commission          =  the creator's net

   The money chain, which is never written twice in the fixture:
     deal.feeCents  →  the invoice's amount  (what the brand owes us)
                    →  the payout's amount   (what we owe the creator, net)

   Suppression: a derived figure is shown as a word, not a zero, when the
   record's state makes it meaningless. Named at each helper that does it. */

const sumBy = (rows, key) => rows.reduce((total, row) => total + row[key], 0);
const sumOf = (rows, get) => rows.reduce((total, row) => total + get(row), 0);

/* ── The roster ──────────────────────────────────────────────────────── */

/* Reach and engagement are computed from the platform rows, never stored, so a
   follower count changed in the fixture moves every screen that mentions it. */
const reachOf = (creator) => sumBy(creator.platforms, "followers");
const engagementOf = (creator) => {
  const total = reachOf(creator);
  return total ? sumOf(creator.platforms, (p) => p.followers * p.engagementRate) / total : null;
};
const leadPlatformOf = (creator) => creator.platforms.slice().sort((a, b) => b.followers - a.followers)[0];

const pitchableCreators = (creators) => creators.filter(CREATOR_FLAGS.isPitchable);
const bookedCreators = (creators) => creators.filter((c) => c.status === "booked");
const comingFreeCreators = (creators, now) => creators.filter((c) => CREATOR_FLAGS.isComingFree(c, now));
const pausedCreators = (creators) => creators.filter((c) => c.status === "paused");

/* A booked creator's brand is the booking's own fact only when no deal on this
   desk owns it; where a deal does, the deal owns the name and the booking reads
   it, so the two can never disagree. */
function bookingBrandOf(creator, deals) {
  if (!creator.booking) return null;
  if (creator.booking.dealId) {
    const deal = dealById(deals, creator.booking.dealId);
    return deal ? deal.brand : null;
  }
  return creator.booking.brand;
}

/* Why a creator cannot be pitched, in words, for the roster and the match
   screen alike. Null when they can. */
function unavailableBecause(creator, deals, now) {
  if (creator.status === "paused") return "Paused at their own request until " + formatAbsolute(creator.pausedUntil).split(",")[0];
  if (creator.status === "booked") return "Booked to " + bookingBrandOf(creator, deals) + " until " + formatAbsolute(creator.bookedUntil).split(",")[0];
  return null;
}

/* ── The quote chain ─────────────────────────────────────────────────── */

const usageOf = (scope) => USAGE_SCOPES.find((u) => u.value === scope) || USAGE_SCOPES[0];
const rateFor = (creator, kind) => (creator.rates[kind] === undefined ? null : creator.rates[kind]);
/* The rate card, read as a range and as a list. Both derive from the card, so
   changing one rate moves the roster band and the quote together. */
const rateCardOf = (creator) => DELIVERABLE_KINDS.filter((k) => rateFor(creator, k.value) !== null).map((k) => ({ kind: k.value, label: k.label, cents: creator.rates[k.value] }));
const rateFloorOf = (creator) => Math.min.apply(null, rateCardOf(creator).map((r) => r.cents));
const rateCeilOf = (creator) => Math.max.apply(null, rateCardOf(creator).map((r) => r.cents));
const briefUnits = (brief) => sumBy(brief.deliverables, "quantity");

/* What this creator would charge for this brief. `complete` is false when they
   have no rate for something the brief asks for — that is work they do not
   take, and the quote is suppressed with the missing kinds named rather than
   totalled as if the line were free. */
function quoteFor(creator, brief) {
  const usage = usageOf(brief.usage);
  const missing = brief.deliverables.filter((line) => rateFor(creator, line.kind) === null).map((line) => line.kind);
  if (missing.length) return { complete: false, missing: missing, organicCents: null, upliftCents: null, totalCents: null, usage: usage };
  const organic = sumOf(brief.deliverables, (line) => rateFor(creator, line.kind) * line.quantity);
  const uplift = Math.round(organic * usage.uplift);
  return { complete: true, missing: [], organicCents: organic, upliftCents: uplift, totalCents: organic + uplift, usage: usage };
}

/* The cheapest complete quote anywhere on the book. This is the floor a brief's
   budget has to clear before it is worth anyone's time, and it is derived from
   the roster rather than typed — sign a cheaper creator and the floor moves.
   Null when nobody on the book can produce the brief at all. */
function rosterFloor(creators, brief) {
  const quotes = creators.map((c) => quoteFor(c, brief)).filter((q) => q.complete);
  if (!quotes.length) return null;
  return Math.min.apply(null, quotes.map((q) => q.totalCents));
}
function cheapestFitCreator(creators, brief) {
  const priced = creators
    .map((c) => ({ creator: c, quote: quoteFor(c, brief) }))
    .filter((row) => row.quote.complete)
    .sort((a, b) => a.quote.totalCents - b.quote.totalCents);
  return priced.length ? priced[0] : null;
}

/* ── Matching a brief to the roster ──────────────────────────────────────
   Three fit axes and two blockers. `fits` is THE act-ability predicate: the
   match screen's heading, the pitch bar's count and the row's own control all
   call this one function, so they cannot disagree. */
function exclusivityConflict(creator, brief, now) {
  return CREATOR_FLAGS.liveExclusivities(creator, now).find(
    (e) => e.category === brief.category && e.brand !== brief.brand
  ) || null;
}

function fitOf(creator, brief, now, deals) {
  const quote = quoteFor(creator, brief);
  const reach = reachOf(creator);
  /* Availability is judged against the brand's window, never against today. */
  const windowStart = BRIEF_FLAGS.windowStart(brief);
  const booked = creator.status === "booked" && creator.bookedUntil > windowStart;
  const paused = creator.status === "paused" && (creator.pausedUntil || 0) > windowStart;
  const conflict = exclusivityConflict(creator, brief, now);
  const axes = {
    audience: reach >= brief.audienceMin,
    category: creator.categories.indexOf(brief.category) > -1,
    rate: quote.complete && quote.totalCents <= brief.budgetCents,
  };
  const canTake = !booked && !paused && !conflict;
  return {
    creator: creator, quote: quote, reach: reach, axes: axes,
    booked: booked, paused: paused, conflict: conflict, canTake: canTake,
    blockedBecause: conflict
      ? "Exclusive to " + conflict.brand + " in " + labelOf(CATEGORIES, conflict.category).toLowerCase() + " until " + formatAbsolute(conflict.until).split(",")[0]
      : booked ? unavailableBecause(creator, deals, now)
      : paused ? unavailableBecause(creator, deals, now)
      : null,
    missedAxes: Object.keys(axes).filter((k) => !axes[k]),
    fits: canTake && axes.audience && axes.category && axes.rate,
  };
}

/* One pass over the roster, split three ways. Blocked comes before off-brief
   because a conflict is a hazard, not a near miss. */
function matchGroups(creators, brief, now, deals) {
  const rows = creators.map((c) => fitOf(c, brief, now, deals));
  return {
    all: rows,
    fits: rows.filter((r) => r.fits).sort((a, b) => b.reach - a.reach),
    blocked: rows.filter((r) => !r.canTake).sort((a, b) => (a.conflict ? -1 : 1)),
    offBrief: rows.filter((r) => r.canTake && !r.fits).sort((a, b) => a.missedAxes.length - b.missedAxes.length),
    conflicts: rows.filter((r) => !!r.conflict),
  };
}

/* ── Briefs ──────────────────────────────────────────────────────────── */
const briefsToTriage = (briefs) => briefs.filter(BRIEF_FLAGS.needsTriage);
const openBriefs = (briefs) => briefs.filter(BRIEF_FLAGS.isOpen);
const briefsWith = (briefs, status) => briefs.filter((b) => b.status === status);
const briefValue = (rows) => sumBy(rows, "budgetCents");

/* ── Deals ───────────────────────────────────────────────────────────── */
const commissionOf = (deal) => Math.round(deal.feeCents * deal.commissionRate);
const creatorNetOf = (deal) => deal.feeCents - commissionOf(deal);
/* The invoice and the payout do not store an amount. They read the deal. */
const invoiceAmountOf = (deal) => deal.feeCents;
const payoutAmountOf = (deal) => creatorNetOf(deal);

const dealsAwaitingSignature = (deals) => deals.filter(CONTRACT_FLAGS.awaitsCountersign);
const signedDeals = (deals) => deals.filter(CONTRACT_FLAGS.isSigned);

/* Where a deal's usage rights run out. RIGHTS_FLAGS owns the arithmetic; this
   is the name the screens have always called it by, kept so the deal sheet and
   the roster note spell the same date. */
const usageEndsAt = (deal) => RIGHTS_FLAGS.endsAt(deal);
const dealsWithRightsExpiring = (deals, now) => deals.filter((d) => CONTRACT_FLAGS.isSigned(d) && RIGHTS_FLAGS.isExpiring(d, now)).sort((a, b) => RIGHTS_FLAGS.endsAt(a) - RIGHTS_FLAGS.endsAt(b));

/* ── Deliverables ────────────────────────────────────────────────────────
   Flattened once, with the deal and the creator attached, so no screen joins
   these by hand. */
function allDeliverables(deals, creators) {
  return deals.reduce((rows, deal) => rows.concat(deal.deliverables.map((item) => ({
    item: item, deal: deal, creator: creatorById(creators, deal.creatorId),
  }))), []);
}
const deliverablesNeedingAttention = (rows, now) => rows.filter((r) => DELIVERABLE_FLAGS.needsAttention(r.item, now));
const deliverablesWithBrand = (rows) => rows.filter((r) => DELIVERABLE_FLAGS.isWithBrand(r.item));
const deliverablesLive = (rows) => rows.filter((r) => DELIVERABLE_FLAGS.isLive(r.item));
const deliverablesAhead = (rows, now) => rows.filter((r) => r.item.status === "scheduled" && !DELIVERABLE_FLAGS.isDueSoon(r.item, now) && !DELIVERABLE_FLAGS.isOverdue(r.item, now));
const byDue = (rows) => rows.slice().sort((a, b) => a.item.dueAt - b.item.dueAt);

/* Who owes the next move on a deliverable the manager cannot act on. */
function deliverableWaitingOn(row) {
  if (row.item.status === "submitted") return row.deal.brand;
  if (row.item.status === "approved") return row.creator.name.split(" ")[0] + ", to post";
  return null;
}

/* ── Performance ─────────────────────────────────────────────────────────
   Measured against `promise`, which is what we told the brand in the pitch. */
const publishedOf = (deal) => deal.deliverables.filter(DELIVERABLE_FLAGS.isLive);
const actualReach = (deal) => sumOf(publishedOf(deal), (d) => d.metrics.reach);
const actualEngagements = (deal) => sumOf(publishedOf(deal), (d) => d.metrics.engagements);
const actualSaves = (deal) => sumOf(publishedOf(deal), (d) => d.metrics.saves);
function actualEngagementRate(deal) {
  const reach = actualReach(deal);
  return reach ? actualEngagements(deal) / reach : null;
}

/* complete → every deliverable live · in_flight → some · not_started → none. */
function campaignState(deal) {
  const live = publishedOf(deal).length;
  if (!live) return "not_started";
  return live === deal.deliverables.length ? "complete" : "in_flight";
}

/* Suppressed rather than zeroed: a campaign is only measured against its
   promise once every piece is live. Half a campaign at 35% of promise is not
   underdelivery, it is Tuesday. Returns null for in_flight and not_started, and
   the screens print a word instead. */
function deliveryIndex(deal) {
  if (campaignState(deal) !== "complete") return null;
  return actualReach(deal) / deal.promise.reach;
}
function engagementIndex(deal) {
  if (campaignState(deal) !== "complete") return null;
  const rate = actualEngagementRate(deal);
  return rate === null ? null : rate / deal.promise.engagementRate;
}
const measurableDeals = (deals) => deals.filter((d) => campaignState(d) !== "not_started");
const completeDeals = (deals) => deals.filter((d) => campaignState(d) === "complete");

/* The agency-wide figure, over completed campaigns only: an in-flight campaign
   has not missed anything yet. */
function promiseKeptRate(deals) {
  const complete = completeDeals(deals);
  const promised = sumOf(complete, (d) => d.promise.reach);
  if (!promised) return null;
  return sumOf(complete, actualReach) / promised;
}

/* ── Money ───────────────────────────────────────────────────────────────
   Invoices and payouts are rows on their deals; these views attach the deal so
   every amount stays derived. */
const invoiceRows = (deals) => deals.map((deal) => ({ invoice: deal.invoice, deal: deal, amount: invoiceAmountOf(deal) }));
const payoutRows = (deals, creators) => deals.map((deal) => ({ payout: deal.payout, deal: deal, creator: creatorById(creators, deal.creatorId), amount: payoutAmountOf(deal) }));

const invoicesOutstanding = (rows) => rows.filter((r) => INVOICE_FLAGS.isOutstanding(r.invoice));
/* Issued and unpaid — the only money a brand actually owes us today. A draft
   invoice is ours, not theirs, and is counted separately wherever it appears. */
const invoicesAwaitingBrand = (rows) => rows.filter((r) => r.invoice.status === "issued");
const invoicesOverdue = (rows, now) => rows.filter((r) => INVOICE_FLAGS.isOverdue(r.invoice, now));
const invoicesToIssue = (rows) => rows.filter((r) => INVOICE_FLAGS.awaitsAgency(r.invoice));
const invoicesPaid = (rows) => rows.filter((r) => INVOICE_FLAGS.isSettled(r.invoice));

const payoutsOwed = (rows) => rows.filter((r) => !PAYOUT_FLAGS.isSettled(r.payout));
const payoutsReleasable = (rows) => rows.filter((r) => PAYOUT_FLAGS.isReleasable(r.payout));
const payoutsBlocked = (rows) => rows.filter((r) => PAYOUT_FLAGS.isBlocked(r.payout));
const moneyTotal = (rows) => sumBy(rows, "amount");

/* Commission the agency has actually banked: only on invoices the brand has
   paid. Commission on an unpaid invoice is a hope, not revenue. */
const commissionBanked = (rows) => sumOf(invoicesPaid(rows), (r) => commissionOf(r.deal));
const commissionPending = (rows) => sumOf(invoicesOutstanding(rows), (r) => commissionOf(r.deal));

/* Payment runs leave on Fridays. One helper, so the toast, the row and the
   dialog all name the same date. */
function nextPayoutRun(now) {
  /* UTC throughout, so the date the toast, the row and the dialog name does not
     depend on where the reader is sitting. */
  const d = new Date(now);
  d.setUTCHours(11, 0, 0, 0);
  const delta = (5 - d.getUTCDay() + 7) % 7;
  return d.getTime() + (delta === 0 && d.getTime() <= now ? 7 : delta) * 864e5;
}

/* ── Brands ──────────────────────────────────────────────────────────────
   Nothing about a brand is stored except its name and where it sits: how much
   they have spent, how long they take to pay and what we owe them are read off
   the briefs, deals and invoices that name them, so a brand page and the money
   ledger cannot disagree. */
const brandById = (brands, id) => brands.find((b) => b.id === id) || null;
const brandOf = (brands, record) => (record ? brandById(brands, record.brandId) : null);
const briefsForBrand = (briefs, brand) => briefs.filter((b) => b.brandId === brand.id).slice().sort((a, b) => b.receivedAt - a.receivedAt);
const dealsForBrand = (deals, brand) => deals.filter((d) => d.brandId === brand.id).slice().sort((a, b) => b.agreedAt - a.agreedAt);
const brandFirstBriefAt = (briefs, brand) => {
  const rows = briefsForBrand(briefs, brand);
  return rows.length ? Math.min.apply(null, rows.map((b) => b.receivedAt)) : null;
};
/* Over the briefs they have had an answer on. A brief still open has not been
   won or lost, so it is outside the rate rather than counted as a loss. */
function brandWinRate(briefs, brand) {
  const decided = briefsForBrand(briefs, brand).filter((b) => !BRIEF_FLAGS.isOpen(b));
  if (!decided.length) return null;
  return { rate: decided.filter((b) => b.status === "won").length / decided.length, decided: decided };
}
/* How long this brand actually takes to pay, over the invoices they have
   settled. Null until one is: a brand with nothing banked has no habit yet. */
function brandDaysToPay(deals, brand) {
  const paid = invoicesPaid(invoiceRows(dealsForBrand(deals, brand)));
  if (!paid.length) return null;
  return { days: Math.round(sumOf(paid, (r) => (r.invoice.paidAt - r.invoice.issuedAt) / 864e5) / paid.length), rows: paid };
}
/* Exclusivity we owe this brand: a creator's clause naming them, still running.
   Read from the roster, so it is the same list the match screen blocks on. */
const exclusivitiesTo = (creators, brand, now) => creators.reduce((rows, creator) => rows.concat(
  CREATOR_FLAGS.liveExclusivities(creator, now).filter((e) => e.brandId === brand.id).map((e) => ({ creator: creator, exclusivity: e }))
), []);
/* A campaign this brand booked outside the desk: no deal, no invoice, no
   commission — the booking is the only record of it. */
const outsideBookings = (creators, brand) => creators.filter((c) => c.booking && c.booking.brandId === brand.id);

/* ── One deliverable ─────────────────────────────────────────────────────── */
const deliverableRowById = (deals, creators, id) => allDeliverables(deals, creators).find((r) => r.item.id === id) || null;
const siblingDeliverables = (row, creators) => byDue(allDeliverables([row.deal], creators));
/* The record's history, read off the timestamps it already carries. Never a
   second narrative authored beside them. */
function deliverableHistory(row) {
  const { item, deal, creator } = row;
  const first = creator.name.split(" ")[0];
  return [
    { at: deal.agreedAt, what: "Booked on " + deal.id, who: deal.brand },
    item.submittedAt ? { at: item.submittedAt, what: "Submitted to " + deal.brand, who: first } : null,
    item.revisionAt ? { at: item.revisionAt, what: "Revisions requested", who: item.revisionBy, note: item.revisionNote } : null,
    item.chasedAt ? { at: item.chasedAt, what: "Chased " + first, who: MARQUE_USER.name } : null,
    item.approvedAt ? { at: item.approvedAt, what: "Approved", who: deal.brand } : null,
    item.publishedAt ? { at: item.publishedAt, what: "Published", who: first } : null,
  ].filter(Boolean).sort((a, b) => a.at - b.at);
}
/* This post's share of what the campaign has delivered so far. Suppressed
   rather than zeroed: an unpublished post has no share of anything. */
function postShareOfCampaign(row) {
  if (!DELIVERABLE_FLAGS.isLive(row.item)) return null;
  const total = actualReach(row.deal);
  return total ? row.item.metrics.reach / total : null;
}

/* ── A creator's statement ───────────────────────────────────────────────
   One helper for the money side of one creator, so the statement, the creator's
   file and the ledger cannot disagree: gross is what brands were billed,
   commission is ours, net is theirs, and paid against owed splits the net by
   what has actually left the account. */
function creatorStatement(deals, creators, creator) {
  const rows = deals.filter((d) => d.creatorId === creator.id).slice().sort((a, b) => b.agreedAt - a.agreedAt);
  const payouts = payoutRows(rows, creators);
  const paid = payouts.filter((r) => PAYOUT_FLAGS.isSettled(r.payout));
  const owed = payoutsOwed(payouts);
  const gross = sumBy(rows, "feeCents");
  return {
    deals: rows, payouts: payouts, paid: paid, owed: owed,
    releasable: payoutsReleasable(payouts), blocked: payoutsBlocked(payouts),
    grossCents: gross,
    commissionCents: sumOf(rows, commissionOf),
    netCents: sumOf(rows, creatorNetOf),
    paidCents: moneyTotal(paid),
    owedCents: moneyTotal(owed),
    /* What our commission has actually worked out at across their book, which
       is not the standard rate the moment one deal was signed below it. */
    effectiveRate: gross ? sumOf(rows, commissionOf) / gross : null,
  };
}
const statementYears = (rows) => rows.map((d) => new Date(d.agreedAt).getUTCFullYear()).filter((y, i, all) => all.indexOf(y) === i).sort((a, b) => b - a);

/* ── The desk's people ─────────────────────────────────────────────────── */
const managerById = (managers, id) => managers.find((m) => m.id === id) || null;
const managerOf = (managers, creator) => (creator ? managerById(managers, creator.managerId) : null);
const creatorsForManager = (creators, manager) => creators.filter((c) => c.managerId === manager.id);
/* One manager's book, every figure read off the creators they own. */
function managerBook(data, manager) {
  const roster = creatorsForManager(data.creators, manager);
  const ids = roster.map((c) => c.id);
  const deals = data.deals.filter((d) => ids.indexOf(d.creatorId) > -1);
  return {
    roster: roster, deals: deals,
    free: pitchableCreators(roster),
    reach: sumOf(roster, reachOf),
    feeCents: sumBy(deals, "feeCents"),
    commissionCents: sumOf(deals, commissionOf),
    attention: deliverablesNeedingAttention(allDeliverables(deals, data.creators), data.now),
  };
}
const unownedCreators = (creators, managers) => creators.filter((c) => !managers.some((m) => m.id === c.managerId));

/* ── App-scope search ────────────────────────────────────────────────────
   The palette footer's count and the results screen run this one function, so
   the number the footer promises is the number the screen shows. Every hit
   carries the line it matched on, so a reader can see why it is there. */
function searchDesk(data, query) {
  const q = String(query || "").trim().toLowerCase();
  if (!q) return { query: "", total: 0, groups: [] };
  const hit = function () { return Array.prototype.slice.call(arguments).some((t) => String(t === null || t === undefined ? "" : t).toLowerCase().indexOf(q) > -1); };
  /* A hit on prose the row does not print would otherwise be an unexplained
     line, so the matched phrase is quoted back with a little of its context.
     `hidden` is checked only when neither the title nor the line carries the
     match, so nothing is quoted twice. */
  const excerpt = (text) => {
    const source = String(text);
    const at = source.toLowerCase().indexOf(q);
    if (at === -1) return null;
    const from = Math.max(0, at - 24), to = Math.min(source.length, at + q.length + 32);
    return (from > 0 ? "…" : "") + source.slice(from, to).trim() + (to < source.length ? "…" : "");
  };
  const why = (row, hidden) => {
    if (hit(row.title, row.line)) return row;
    const found = (hidden || []).map(excerpt).filter(Boolean)[0];
    return found ? Object.assign({}, row, { line: row.line + " · “" + found + "”" }) : row;
  };
  const rows = allDeliverables(data.deals, data.creators);
  const groups = [
    { label: "Creators", items: data.creators.filter((c) => hit(c.name, c.handle, c.based, c.note, c.categories.map((x) => labelOf(CATEGORIES, x)).join(" "))).map((c) => why({
      id: c.id, title: c.name, line: "@" + c.handle + " · " + c.based + " · " + statusValue(CREATOR_STATUS, c.status).label.toLowerCase() + " · " + c.categories.map((x) => labelOf(CATEGORIES, x)).join(", "), route: "/talent/" + c.id }, [c.note])) },
    { label: "Brands", items: data.brands.filter((b) => hit(b.name, b.hq, b.note, labelOf(CATEGORIES, b.sector))).map((b) => why({
      id: b.id, title: b.name, line: labelOf(CATEGORIES, b.sector) + " · " + b.hq + " · " + briefsForBrand(data.briefs, b).length + (briefsForBrand(data.briefs, b).length === 1 ? " brief" : " briefs"), route: "/brands/" + b.id }, [b.note])) },
    { label: "Briefs", items: data.briefs.filter((b) => hit(b.id, b.brand, b.title, b.ask, b.contact.name)).map((b) => why({
      id: b.id, title: b.brand + " · " + b.title, line: b.id + " · " + formatMoney(b.budgetCents) + " · " + statusValue(BRIEF_STATUS, b.status).label, route: "/briefs/" + b.id }, [b.ask, b.contact.name])) },
    { label: "Deals", items: data.deals.filter((d) => hit(d.id, d.brand, d.title)).map((d) => ({
      id: d.id, title: d.brand + " × " + creatorById(data.creators, d.creatorId).name, line: d.id + " · " + formatMoney(d.feeCents) + " · " + statusValue(CONTRACT_STATUS, d.contract.status).label, route: "/deals/" + d.id })) },
    { label: "Deliverables", items: rows.filter((r) => hit(r.item.id, r.item.title, r.item.revisionNote, r.item.url)).map((r) => why({
      id: r.item.id, title: r.item.title, line: r.creator.name + " · " + r.deal.brand + " · " + statusValue(DELIVERABLE_STATUS, r.item.status).label, route: "/deliverables/" + r.item.id }, [r.item.revisionNote, r.item.url])) },
    { label: "Invoices and payouts", items: data.deals.filter((d) => hit(d.invoice.id, d.payout.id, d.payout.reference)).map((d) => ({
      id: d.invoice.id, title: d.invoice.id + " · " + d.payout.id, line: d.brand + " · " + formatMoney(invoiceAmountOf(d)) + " in, " + formatMoney(payoutAmountOf(d)) + " out", route: "/money" })) },
  ].filter((g) => g.items.length);
  return { query: String(query).trim(), total: groups.reduce((n, g) => n + g.items.length, 0), groups: groups };
}

/* ── Lookups ─────────────────────────────────────────────────────────── */
const creatorById = (creators, id) => creators.find((c) => c.id === id) || null;
const briefById = (briefs, id) => briefs.find((b) => b.id === id) || null;
const dealById = (deals, id) => deals.find((d) => d.id === id) || null;
const dealForBrief = (deals, brief) => (brief && brief.dealId ? dealById(deals, brief.dealId) : null);
const briefForDeal = (briefs, deal) => (deal ? briefById(briefs, deal.briefId) : null);

/* ── Nav badges ──────────────────────────────────────────────────────────
   Each counts a single kind of record with a single predicate, and says which.
   Where a screen's own section is deliberately looser than its badge, the
   screen's lede names the difference in words. */
const NAV_BADGES = {
  briefsToTriage: {
    count: (data) => briefsToTriage(data.briefs).length,
    label: "briefs waiting on your triage",
  },
  deliverablesNeedingAttention: {
    /* Revisions requested, overdue, or due inside the week — the same predicate
       the "Needs you" section runs. */
    count: (data) => deliverablesNeedingAttention(allDeliverables(data.deals, data.creators), data.now).length,
    label: "deliverables that need you this week",
  },
  invoicesOverdue: {
    count: (data) => invoicesOverdue(invoiceRows(data.deals), data.now).length,
    label: "invoices past their due date",
  },
};

Object.assign(window, {
  sumBy, sumOf,
  reachOf, engagementOf, leadPlatformOf, pitchableCreators, bookedCreators, comingFreeCreators, pausedCreators,
  bookingBrandOf, unavailableBecause,
  usageOf, rateFor, rateCardOf, rateFloorOf, rateCeilOf, briefUnits, quoteFor, rosterFloor, cheapestFitCreator,
  exclusivityConflict, fitOf, matchGroups,
  briefsToTriage, openBriefs, briefsWith, briefValue,
  commissionOf, creatorNetOf, invoiceAmountOf, payoutAmountOf, dealsAwaitingSignature, signedDeals, usageEndsAt, dealsWithRightsExpiring,
  brandById, brandOf, briefsForBrand, dealsForBrand, brandFirstBriefAt, brandWinRate, brandDaysToPay, exclusivitiesTo, outsideBookings,
  deliverableRowById, siblingDeliverables, deliverableHistory, postShareOfCampaign,
  creatorStatement, statementYears, managerById, managerOf, creatorsForManager, managerBook, unownedCreators, searchDesk,
  allDeliverables, deliverablesNeedingAttention, deliverablesWithBrand, deliverablesLive, deliverablesAhead, byDue, deliverableWaitingOn,
  publishedOf, actualReach, actualEngagements, actualSaves, actualEngagementRate, campaignState, deliveryIndex, engagementIndex,
  measurableDeals, completeDeals, promiseKeptRate,
  invoiceRows, payoutRows, invoicesOutstanding, invoicesAwaitingBrand, invoicesOverdue, invoicesToIssue, invoicesPaid,
  payoutsOwed, payoutsReleasable, payoutsBlocked, moneyTotal, commissionBanked, commissionPending, nextPayoutRun,
  creatorById, briefById, dealById, dealForBrief, briefForDeal, NAV_BADGES,
});
