<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Finance Desk — Meridian</title>
<!-- Standalone build of FinanceDesk.dc.html: the loader in boot.js fetches its
     files at runtime, which a bundler cannot follow, so every product file is
     inlined below in the same order boot.js loads them. Image URLs that live
     only as strings in the product's code are lifted here; at runtime
     window.__resources holds the inlined blob URLs. -->
<meta name="ext-resource-dependency" content="../../assets/templates/logos/15-logo-demo.svg" data-resource-id="brandMark">
<meta name="ext-resource-dependency" content="../../assets/img/avatar-40-01.jpg" data-resource-id="avatar01">
<meta name="ext-resource-dependency" content="../../assets/img/avatar-40-02.jpg" data-resource-id="avatar02">
<meta name="ext-resource-dependency" content="../../assets/img/avatar-40-03.jpg" data-resource-id="avatar03">
<meta name="ext-resource-dependency" content="../../assets/img/avatar-40-04.jpg" data-resource-id="avatar04">
<meta name="ext-resource-dependency" content="../../assets/img/avatar-40-05.jpg" data-resource-id="avatar05">
<script>window.__resources = Object.assign({ brandMark: "../../assets/templates/logos/15-logo-demo.svg", avatar01: "../../assets/img/avatar-40-01.jpg", avatar02: "../../assets/img/avatar-40-02.jpg", avatar03: "../../assets/img/avatar-40-03.jpg", avatar04: "../../assets/img/avatar-40-04.jpg", avatar05: "../../assets/img/avatar-40-05.jpg" }, window.__resources || {});</script>
<link rel="stylesheet" href="../../styles.css">
<link rel="stylesheet" href="./kit.css">
<style>html,body{margin:0;padding:0}body{background:var(--sidebar)}</style>
<template id="__bundler_thumbnail" data-bg-color="#f7f7f6">
<svg viewBox="0 0 1200 800" xmlns="http://www.w3.org/2000/svg"><rect width="1200" height="800" fill="#f7f7f6"/><rect x="120" y="120" width="240" height="560" rx="16" fill="#e6e6e4"/><g fill="#26262b"><rect x="440" y="180" width="180" height="28" rx="6"/><rect x="440" y="300" width="60" height="320" rx="8"/><rect x="540" y="380" width="60" height="240" rx="8"/><rect x="640" y="250" width="60" height="370" rx="8"/><rect x="740" y="420" width="60" height="200" rx="8"/><rect x="840" y="330" width="60" height="290" rx="8"/></g></svg>
</template>
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" crossorigin="anonymous"></script>
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" crossorigin="anonymous"></script>
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" crossorigin="anonymous"></script>
<script src="https://unpkg.com/lucide@0.544.0/dist/umd/lucide.min.js" crossorigin="anonymous"></script>
<script src="../../_ds_bundle.js"></script>
</head>
<body>
<div id="root"></div>
<script type="text/babel" data-presets="react" data-file="lib/contracts.js">
(function(){
/* Contract only — permanent machinery, copied into every product folder so that
   deleting the product deletes it too. No domain value may appear in this file:
   if an unrelated product could not use a value with the same meaning, it
   belongs in enums.js or nav.js beside it. These copies start byte-identical
   between products and stay that way — a fix here is a fix everywhere, never a
   local edit.

   Shapes
     Tone          "neutral" | "info" | "success" | "warning" | "danger"
     StatusValue   { id, label, tone }
     StatusField   { entity, field, means, values: StatusValue[] }
     LabelledValue { value, label }
     Route         { path, label, icon?, group?, parent?, badge?,
                     offSidebar?, menuOnly?, roles? }
*/

const TONES = ["neutral", "info", "success", "warning", "danger"];

/* Classification values render through this — never through a Badge. */
function labelOf(list, value) {
  const hit = (list || []).find((i) => i.value === value);
  return hit ? hit.label : value;
}

/* The one place a status id becomes a label + tone. */
function statusValue(values, id) {
  return (values || []).find((v) => v.id === id) || null;
}

function indexRoutes(nav) {
  const index = {};
  (nav || []).forEach((r) => { index[r.path] = r; });
  return index;
}

function matchRoute(path, index) {
  if (index[path]) return index[path];
  return Object.values(index).find((r) =>
    r.path.indexOf(":") > -1 &&
    new RegExp("^" + r.path.replace(/:[^/]+/g, "[^/]+") + "$").test(path)
  ) || null;
}

/* Breadcrumbs read the index, never the nav tree, and never invent a segment:
   an unresolved path returns [] so the screen — not the route table — is fixed. */
function breadcrumb(path, index) {
  const route = matchRoute(path, index);
  if (!route) return [];
  const trail = [route];
  let parent = route.parent;
  while (parent && index[parent]) { trail.unshift(index[parent]); parent = index[parent].parent; }
  return trail.map((s) => ({ label: s.label, path: s.path, isNamespace: !!s.menuOnly }));
}

Object.assign(window, { TONES, labelOf, statusValue, indexRoutes, matchRoute, breadcrumb });

})();
</script>
<script type="text/babel" data-presets="react" data-file="enums.js">
(function(){
/* Meridian — status vocabulary.
   A value not listed here does not exist. If a screen needs one, it is added
   here first, with a tone grouping, a reason, and at least one fixture.
   Anything computed from other fields is NOT a value — derived flags live at
   the bottom of this file, and take their clock as an argument. */

/* payment.status — where one inbound charge sits in its lifecycle.
   Tone answers "is our money there yet": neutral = nothing owed to us yet or
   given back, info = authorised but not captured, success = settled,
   danger = it failed and someone must act.
   Deliberately absent: `disputed` (a dispute is its own record, and the
   payment stays succeeded while it runs) and `late` (derived from expectedAt). */
const PAYMENT_STATUS = [
  { id: "pending", label: "Pending", tone: "neutral" },
  { id: "authorized", label: "Authorized", tone: "info" },
  { id: "succeeded", label: "Succeeded", tone: "success" },
  { id: "refunded", label: "Refunded", tone: "neutral" },
  { id: "failed", label: "Failed", tone: "danger" },
];
/* ids: "pending" | "authorized" | "succeeded" | "refunded" | "failed" */

/* payout.status — an outbound transfer to a customer's bank.
   The field has five values and the primary screen asks one question — "who is
   this waiting on" — so: warning = waiting on us, info = waiting on the
   network, success = done, danger = it came back.
   Deliberately absent: `blocked` (derived from blockedBy) and `late`
   (derived from expectedAt). */
const PAYOUT_STATUS = [
  { id: "awaiting_approval", label: "Awaiting approval", tone: "warning" },
  { id: "approved", label: "Approved", tone: "info" },
  { id: "in_transit", label: "In transit", tone: "info" },
  { id: "paid", label: "Paid", tone: "success" },
  { id: "rejected", label: "Rejected", tone: "neutral" },
  { id: "returned", label: "Returned", tone: "danger" },
];
/* ids: "awaiting_approval" | "approved" | "in_transit" | "paid" | "rejected" | "returned"
   `rejected` is neutral, not danger: turning a payout down is a deliberate
   ending we chose, while `returned` is the network failing us. */

/* dispute.status — a chargeback's own lifecycle.
   warning = the clock is ours, info = the clock is the network's.
   Deliberately absent: `closing_soon` (derived from evidenceDueAt). */
const DISPUTE_STATUS = [
  { id: "needs_response", label: "Needs response", tone: "warning" },
  { id: "under_review", label: "Under review", tone: "info" },
  { id: "won", label: "Won", tone: "success" },
  { id: "lost", label: "Lost", tone: "danger" },
];
/* ids: "needs_response" | "under_review" | "won" | "lost" */

/* expense.status — one card transaction's review lifecycle.
   warning = waiting on us, info = waiting on the person who spent it,
   neutral = nothing to review yet or a deliberate ending we chose,
   success = signed off.
   Deliberately absent: `missing_receipt` (derived from receiptAt and the
   workspace's receipt policy) and `over_limit` (derived from the card's
   month-to-date spend). */
const EXPENSE_STATUS = [
  { id: "pending", label: "Pending", tone: "neutral" },
  { id: "needs_review", label: "Needs review", tone: "warning" },
  { id: "queried", label: "Queried", tone: "info" },
  { id: "approved", label: "Approved", tone: "success" },
  { id: "denied", label: "Denied", tone: "neutral" },
];
/* ids: "pending" | "needs_review" | "queried" | "approved" | "denied"
   `denied` is neutral for the same reason a rejected payout is: we chose it. */

/* card.status — a corporate card's own lifecycle.
   The question the cards screen asks is "can this card be spent on":
   info = waiting on the holder, success = yes, warning = deliberately stopped,
   neutral = closed for good.
   Deliberately absent: `near_limit` and `expiring` — both derived. */
const CARD_STATUS = [
  { id: "pending_activation", label: "Not activated", tone: "info" },
  { id: "active", label: "Active", tone: "success" },
  { id: "frozen", label: "Frozen", tone: "warning" },
  { id: "cancelled", label: "Cancelled", tone: "neutral" },
];
/* ids: "pending_activation" | "active" | "frozen" | "cancelled" */

/* Classification lists — read through labelOf, never reaching a Badge. */
const PAYMENT_METHODS = [
  { value: "card", label: "Card" },
  { value: "ach", label: "ACH" },
  { value: "wire", label: "Wire" },
  { value: "wallet", label: "Wallet" },
];
const EXPENSE_CATEGORIES = [
  { value: "software", label: "Software" },
  { value: "travel", label: "Travel" },
  { value: "meals", label: "Meals and entertainment" },
  { value: "advertising", label: "Advertising" },
  { value: "equipment", label: "Equipment" },
  { value: "services", label: "Professional services" },
];
const CARD_TYPES = [
  { value: "physical", label: "Physical" },
  { value: "virtual", label: "Virtual" },
];
const TEAM_ROLES = [
  { value: "admin", label: "Admin", note: "Approves up to their own limit, issues cards, edits workspace settings." },
  { value: "approver", label: "Approver", note: "Approves payouts and expenses up to their own limit." },
  { value: "member", label: "Member", note: "Spends on a card and sends receipts in. Approves nothing." },
];

/* Policy — the numbers the derived flags below read, and the user edits on
   /settings/spend-controls. Never inlined into a predicate, never in lib/: one
   object owns them, every predicate takes it as an argument, so saving the form
   really does recount the sidebar badge and every tile that reads it. */
const MERIDIAN_POLICY_DEFAULTS = {
  receiptThresholdCents: 7500,
  receiptCategories: ["meals", "travel"],
  cardWarnShare: 0.8,
  cardDefaultLimitCents: 500000,
};

/* Derived flags. Clock injected, never read from the module. */
const PAYMENT_FLAGS = {
  hasOpenDispute: (payment, disputes) => disputes.some((d) => d.paymentId === payment.id && DISPUTE_FLAGS.isOpen(d)),
  needsAttention: (payment, disputes) => payment.status === "failed" || PAYMENT_FLAGS.hasOpenDispute(payment, disputes),
};

const PAYOUT_FLAGS = {
  awaitsApproval: (payout) => payout.status === "awaiting_approval",
  isBlocked: (payout) => !!payout.blockedBy,
  /* Above the approver's own limit. A permission fact about a person and a
     payout together, so it is derived and the approver is injected — never a
     value on the record and never read from the module. */
  isAboveLimit: (payout, approver) => !approver || payout.amount > approver.approvalLimit,
  /* Waiting on us, nothing in the way, AND inside this person's limit — the
     act-ability predicate the row, the figure, the bulk bar and the sidebar
     badge all call, with the same approver passed to each. */
  isApprovable: (payout, approver) => PAYOUT_FLAGS.awaitsApproval(payout) && !PAYOUT_FLAGS.isBlocked(payout) && !PAYOUT_FLAGS.isAboveLimit(payout, approver),
  isPending: (payout) => ["awaiting_approval", "approved", "in_transit"].indexOf(payout.status) > -1,
  isLate: (payout, now) => !!payout.expectedAt && payout.expectedAt < now && PAYOUT_FLAGS.isPending(payout),
};

/* Expenses. The receipt rule is policy, so it is passed in rather than read. */
const EXPENSE_FLAGS = {
  isSettled: (expense) => expense.status !== "pending",
  awaitsReview: (expense) => expense.status === "needs_review",
  needsReceipt: (expense, policy) => !expense.receiptAt && (expense.amount >= policy.receiptThresholdCents || policy.receiptCategories.indexOf(expense.category) > -1),
  /* Waiting on us AND nothing in the way — the act-ability predicate the row,
     the tile, the bulk bar and the sidebar badge all call. */
  isReviewable: (expense, policy) => EXPENSE_FLAGS.awaitsReview(expense) && !EXPENSE_FLAGS.needsReceipt(expense, policy),
  waitsOnSpender: (expense, policy) => expense.status === "queried" || (EXPENSE_FLAGS.awaitsReview(expense) && EXPENSE_FLAGS.needsReceipt(expense, policy)),
};

/* Cards. Month-to-date spend is computed in metrics.js and injected, like the
   clock: nothing here reaches for data. */
const CARD_FLAGS = {
  isSpendable: (card) => card.status === "active",
  hasLimit: (card) => card.spendLimit > 0,
  isOverLimit: (card, spentCents) => CARD_FLAGS.hasLimit(card) && spentCents >= card.spendLimit,
  isNearLimit: (card, spentCents, policy) => CARD_FLAGS.hasLimit(card) && !CARD_FLAGS.isOverLimit(card, spentCents) && spentCents / card.spendLimit >= policy.cardWarnShare,
  isExpiringSoon: (card, now) => !!card.expiresAt && card.status !== "cancelled" && card.expiresAt - now < 60 * 24 * 3600e3,
};

/* customer.status — whether this company is trading with us.
   Tone answers "can money move to and from them": success = trading normally,
   warning = we paused it ourselves and can resume, danger = risk stopped it,
   neutral = a deliberate ending.
   Deliberately absent: `disputing` (a dispute is its own record and
   CUSTOMER_FLAGS.hasOpenDispute reads it) and `dormant` (derived from the date
   of their last payment). */
const CUSTOMER_STATUS = [
  { id: "live", label: "Live", tone: "success" },
  { id: "on_hold", label: "On hold", tone: "warning" },
  { id: "suspended", label: "Suspended", tone: "danger" },
  { id: "closed", label: "Closed", tone: "neutral" },
];
/* ids: "live" | "on_hold" | "suspended" | "closed" */

const DISPUTE_FLAGS = {
  isOpen: (dispute) => ["needs_response", "under_review"].indexOf(dispute.status) > -1,
  needsEvidence: (dispute) => dispute.status === "needs_response",
  isClosingSoon: (dispute, now) => !!dispute.evidenceDueAt && dispute.evidenceDueAt - now < 48 * 3600e3,
};

/* Transitions, named once and derived from the current status: one function per
   entity, called by the row menu, the detail header and the bulk bar alike.
   kind: "primary" the record's normal path · "undo" instant + consequential,
   raises an undo toast · "confirm" irreversible, opens a confirm dialog. */
function paymentTransitions(payment) {
  switch (payment.status) {
    case "authorized": return [{ id: "capture", label: "Capture payment", kind: "undo", primary: true }];
    case "succeeded": return [{ id: "refund", label: "Refund payment", kind: "confirm", destructive: true }];
    case "failed": return [{ id: "retry", label: "Retry payment", kind: "undo", primary: true }];
    default: return [];
  }
}
/* A held payout offers nothing — the row names the team holding it. Above the
   approver's own limit, Approve exists and cannot be taken: the precondition is
   visible, so it is disabled with the reason rather than omitted. Reject stays
   live, because turning a payout down keeps the money and is not what the limit
   gates. */
function payoutTransitions(payout, approver) {
  if (!PAYOUT_FLAGS.awaitsApproval(payout) || PAYOUT_FLAGS.isBlocked(payout)) return [];
  const above = PAYOUT_FLAGS.isAboveLimit(payout, approver);
  return [
    { id: "approve", label: "Approve payout", kind: "undo", primary: true, disabled: above, disabledReason: above ? "Above your " + formatMoney(approver.approvalLimit) + " approval limit — somebody with a higher one has to release it." : undefined },
    { id: "reject", label: "Reject payout", kind: "confirm", destructive: true },
  ];
}
/* A dispute is answered or conceded; under review the clock belongs to the
   network, so nothing is offered and the screen says who is being waited on. */
function disputeTransitions(dispute) {
  switch (dispute.status) {
    case "needs_response": return [
      { id: "submit_evidence", label: "Submit evidence", kind: "form", primary: true },
      { id: "accept_dispute", label: "Accept dispute", kind: "confirm", destructive: true },
    ];
    default: return [];
  }
}

/* An expense is signed off, sent back, or turned down. Nothing is offered while
   the card authorisation is still settling, or once the money is dealt with; a
   queried expense is waiting on the person who spent it, so the row says so
   instead of carrying a button. Approve exists but cannot be taken until the
   receipt policy is met — that is a visible precondition, so it is disabled
   with the reason rather than omitted. */
function expenseTransitions(expense, policy) {
  if (expense.status !== "needs_review") return [];
  const missing = EXPENSE_FLAGS.needsReceipt(expense, policy);
  return [
    { id: "approve_expense", label: "Approve expense", kind: "undo", primary: true, disabled: missing, disabledReason: missing ? "A receipt is required before this can be approved — " + labelOf(EXPENSE_CATEGORIES, expense.category).toLowerCase() + " spend, " + Math.round(policy.receiptThresholdCents / 100) + " dollar threshold." : undefined },
    { id: "query_expense", label: "Query expense", kind: "form" },
    { id: "deny_expense", label: "Deny expense", kind: "confirm", destructive: true },
  ];
}
/* A card is frozen and unfrozen freely; cancelling one cannot be undone. A card
   the holder has not activated yet is waiting on them, so freezing it is
   meaningless and is omitted rather than disabled. */
function cardTransitions(card) {
  switch (card.status) {
    case "active": return [
      { id: "freeze_card", label: "Freeze card", kind: "undo" },
      { id: "cancel_card", label: "Cancel card", kind: "confirm", destructive: true },
    ];
    case "frozen": return [
      { id: "unfreeze_card", label: "Unfreeze card", kind: "undo", primary: true },
      { id: "cancel_card", label: "Cancel card", kind: "confirm", destructive: true },
    ];
    case "pending_activation": return [{ id: "cancel_card", label: "Cancel card", kind: "confirm", destructive: true }];
    default: return [];
  }
}

/* A customer is paused, resumed, or ended. Putting one on hold is reversible,
   so it is not destructive; closing an account is, so it opens a confirm
   dialog. Nothing is offered on a closed account. */
function customerTransitions(customer) {
  switch (customer.status) {
    case "live": return [
      { id: "hold_customer", label: "Put on hold", kind: "undo" },
      { id: "close_customer", label: "Close account", kind: "confirm", destructive: true },
    ];
    case "on_hold": return [
      { id: "resume_customer", label: "Resume payouts", kind: "undo", primary: true },
      { id: "close_customer", label: "Close account", kind: "confirm", destructive: true },
    ];
    case "suspended": return [
      { id: "resume_customer", label: "Resume payouts", kind: "undo", primary: true },
      { id: "close_customer", label: "Close account", kind: "confirm", destructive: true },
    ];
    default: return [];
  }
}

/* Workspace settings — classification lists, not statuses. */
const PAYOUT_SCHEDULES = [
  { value: "daily", label: "Daily", note: "Every business day, two days after the money settles." },
  { value: "weekly", label: "Weekly, on Fridays", note: "One transfer a week, covering the week to Wednesday." },
  { value: "manual", label: "Manual", note: "Nothing leaves the balance until somebody asks for it." },
];
const NOTIFICATION_KINDS = [
  { value: "payout_approval", label: "Payouts waiting on my approval" },
  { value: "dispute_evidence", label: "Disputes with evidence due" },
  { value: "payment_failed", label: "Failed payments" },
  { value: "balance_low", label: "Available balance below the floor" },
  { value: "expense_review", label: "Card expenses waiting on my review" },
  { value: "card_limit", label: "A card near its monthly limit" },
];

/* What Meridian charges this workspace. Policy, not status: the plan is a
   workspace setting, and these are the rates behind it. /settings/billing
   computes every line from these constants over the payment, payout, dispute,
   card and member arrays — so a fee on that screen can always be checked
   against the rows on the destination it names. */
const BILLING_PLANS = [
  { value: "Launch", label: "Launch", monthlyCents: 0, seatsIncluded: 2, cardsIncluded: 2, note: "No monthly fee; every rate is the standard one." },
  { value: "Scale", label: "Scale", monthlyCents: 39900, seatsIncluded: 5, cardsIncluded: 10, note: "Five seats, ten live cards, approval limits per person and same-day wires." },
  { value: "Enterprise", label: "Enterprise", monthlyCents: 149900, seatsIncluded: 25, cardsIncluded: 50, note: "Negotiated processing, a named treasury contact and audit exports." },
];
const FEE_SCHEDULE = {
  /* Per method, because how the money arrives is what it costs. */
  method: {
    card: { rate: 0.029, fixedCents: 30 },
    wallet: { rate: 0.031, fixedCents: 30 },
    ach: { rate: 0.008, capCents: 500 },
    wire: { rate: 0, fixedCents: 1500 },
  },
  payoutFixedCents: 25,
  disputeFixedCents: 1500,
  extraSeatCents: 900,
  extraCardCents: 300,
};

const CUSTOMER_FLAGS = {
  /* Both derived, so neither is a status value. The clock is injected. */
  hasOpenDispute: (customer, disputes) => disputes.some((d) => d.customer === customer.name && DISPUTE_FLAGS.isOpen(d)),
  isDormant: (customer, payments, now) => {
    const theirs = payments.filter((p) => p.customer === customer.name);
    return customer.status !== "closed" && (!theirs.length || now - Math.max.apply(null, theirs.map((p) => p.createdAt)) > 45 * 24 * 3600e3);
  },
};

/* Notes have NO status field. A note is a sentence somebody wrote, and the only
   question a screen asks about it — is a reply still owed — is computed from the
   thread rather than stored: the person mentioned has not written on that record
   since. Adding `answered` here would be a second place for the same truth. */
const COMMENT_FLAGS = {
  mentions: (comment, userId) => comment.mentions.indexOf(userId) > -1,
  isAwaitingReply: (comment, userId, all) =>
    comment.mentions.indexOf(userId) > -1 &&
    !all.some((c) => c.record.type === comment.record.type && c.record.id === comment.record.id && c.authorId === userId && c.at > comment.at),
};

const STATUS_FIELDS = {
  "payment.status": { entity: "payment", field: "status", means: "Charge lifecycle", values: PAYMENT_STATUS },
  "payout.status": { entity: "payout", field: "status", means: "Transfer lifecycle", values: PAYOUT_STATUS },
  "dispute.status": { entity: "dispute", field: "status", means: "Chargeback lifecycle", values: DISPUTE_STATUS },
  "expense.status": { entity: "expense", field: "status", means: "Card spend review", values: EXPENSE_STATUS },
  "card.status": { entity: "card", field: "status", means: "Card lifecycle", values: CARD_STATUS },
  "customer.status": { entity: "customer", field: "status", means: "Whether money can move to and from them", values: CUSTOMER_STATUS },
};

/* Coverage — values with no fixture yet, so nobody assumes they are unused:
   dispute.status `won` and `lost`. Both are one action away in the kit —
   submitting evidence moves a dispute to `under_review` and accepting one
   moves it to `lost`. Every value of expense.status and card.status has at
   least one fixture, and each is also reachable by an action: approving,
   querying and denying an expense; freezing, unfreezing and cancelling a card;
   and a newly issued card starts at `pending_activation`. */

Object.assign(window, {
  PAYMENT_STATUS, PAYOUT_STATUS, DISPUTE_STATUS, EXPENSE_STATUS, CARD_STATUS, CUSTOMER_STATUS,
  PAYMENT_METHODS, EXPENSE_CATEGORIES, CARD_TYPES, TEAM_ROLES, PAYOUT_SCHEDULES, NOTIFICATION_KINDS,
  BILLING_PLANS, FEE_SCHEDULE,
  MERIDIAN_POLICY_DEFAULTS,
  PAYMENT_FLAGS, PAYOUT_FLAGS, DISPUTE_FLAGS, EXPENSE_FLAGS, CARD_FLAGS, CUSTOMER_FLAGS, COMMENT_FLAGS,
  customerTransitions,
  paymentTransitions, payoutTransitions, disputeTransitions, expenseTransitions, cardTransitions, STATUS_FIELDS,
});

})();
</script>
<script type="text/babel" data-presets="react" data-file="nav.js">
(function(){
/* Meridian — the route table.
   Deliberately NOT routes: the notification panel, the ⌘K palette, the row
   menus, the refund and reject confirm dialogs, the period scope control, the
   payments and payouts tabs, the card issuing dialog, the expense query dialog
   and the invite dialog. They are states of a screen, not destinations — a
   breadcrumb that wants one of them is a bug in the screen, not a missing route.

   Every route in this table has a screen behind it, and every entity the
   product lists now has the action that creates it (AGENTS §5): a customer, a
   payout and a payment each have a registered `/new` form, a card is issued
   through a staged dialog from /cards, and a teammate is invited through one
   from /people. A dispute has no create action — a chargeback arrives from the
   card network, never from this desk — and the disputes screen says so.

   Eleven top-level destinations, dense record work and four per-destination
   counts, so this product runs the SIDEBAR shell with TWO groups: the money
   moving through the platform, and the workspace doing the moving. Settings,
   the profile and the auth screens are registered off the sidebar and reached
   from the account menu. */

const MERIDIAN_NAV = [
  /* Group: Money. */
  { path: "/", label: "Dashboard", icon: "layout-dashboard", group: "Money" },
  { path: "/payments", label: "Payments", icon: "arrow-down-left", group: "Money" },
  { path: "/payouts", label: "Payouts", icon: "arrow-up-right", badge: "payoutsApprovable", group: "Money" },
  { path: "/disputes", label: "Disputes", icon: "shield-alert", badge: "disputesNeedingEvidence", group: "Money" },
  { path: "/balances", label: "Balances", icon: "wallet", group: "Money" },
  { path: "/reports", label: "Reports", icon: "trending-up", group: "Money" },

  /* Group: Workspace — our own spend, the companies on the other side of it,
     the people here, and what they are saying to each other. */
  { path: "/expenses", label: "Expenses", icon: "receipt", badge: "expensesToReview", group: "Workspace" },
  { path: "/cards", label: "Cards", icon: "credit-card", group: "Workspace" },
  { path: "/customers", label: "Customers", icon: "building-2", group: "Workspace" },
  { path: "/people", label: "People", icon: "users", group: "Workspace" },
  { path: "/activity", label: "Activity", icon: "message-square", badge: "notesAwaitingReply", group: "Workspace" },

  /* Registered, not in the nav. */
  { path: "/payments/new", label: "New payment", parent: "/payments", offSidebar: true },
  { path: "/payouts/new", label: "New payout", parent: "/payouts", offSidebar: true },
  { path: "/disputes/:id", label: "Dispute", parent: "/disputes", offSidebar: true },
  { path: "/cards/:id", label: "Card", parent: "/cards", offSidebar: true },
  { path: "/customers/new", label: "New customer", parent: "/customers", offSidebar: true },
  { path: "/customers/:id", label: "Customer", parent: "/customers", offSidebar: true },
  { path: "/people/:id", label: "Person", parent: "/people", offSidebar: true },
  { path: "/settings", label: "Workspace settings", offSidebar: true },
  { path: "/settings/profile", label: "Your profile", parent: "/settings", offSidebar: true },
  { path: "/settings/notifications", label: "Notification preferences", parent: "/settings", offSidebar: true },
  { path: "/settings/spend-controls", label: "Spend controls", parent: "/settings", offSidebar: true },
  { path: "/settings/team", label: "Team and approvals", parent: "/settings", offSidebar: true },
  { path: "/settings/billing", label: "Plan and billing", parent: "/settings", offSidebar: true },

  /* Auth namespace — excluded from the shell nav; no product chrome. Six
     screens, one flow: sign in → the 2FA challenge → the product. Each is
     registered because a person can arrive at any of them cold, from a link. */
  { path: "/signin", label: "Sign in", offSidebar: true },
  { path: "/two-factor", label: "Two-factor challenge", parent: "/signin", offSidebar: true },
  { path: "/verify-code", label: "Sign in with a code", parent: "/signin", offSidebar: true },
  { path: "/forgot-password", label: "Forgot password", parent: "/signin", offSidebar: true },
  { path: "/reset-password", label: "Set a new password", parent: "/signin", offSidebar: true },
  { path: "/signout", label: "Sign out", offSidebar: true },
];

const MERIDIAN_ROUTES = indexRoutes(MERIDIAN_NAV);

/* Projections the shell renders — derived by filtering the tree, never retyped.
   Two groups, so §6.3's "groups earn their labels" gives both one. */
const MERIDIAN_SIDEBAR = ["Money", "Workspace"].map((label) => ({
  label: label,
  items: MERIDIAN_NAV.filter((r) => !r.offSidebar && r.group === label),
}));

const MERIDIAN_ACCOUNT_MENU = [
  MERIDIAN_ROUTES["/settings/profile"],
  MERIDIAN_ROUTES["/settings"],
  MERIDIAN_ROUTES["/settings/spend-controls"],
  MERIDIAN_ROUTES["/settings/team"],
  MERIDIAN_ROUTES["/settings/notifications"],
  MERIDIAN_ROUTES["/settings/billing"],
  MERIDIAN_ROUTES["/signout"],
];

/* Paths that render without product chrome (AGENTS §7.1). */
const MERIDIAN_AUTH_PATHS = ["/signin", "/two-factor", "/verify-code", "/forgot-password", "/reset-password", "/signout"];

/* /cards/:id → /cards/crd_4471. The breadcrumb and every nested row use it, so
   no screen builds a path by concatenation. */
const resolvePath = (pattern, params) => pattern.replace(/:([^/]+)/g, (_, key) => (params && params[key] != null ? params[key] : ":" + key));

/* A note names the record it was written on, and the mentions queue opens it.
   Only record types that HAVE a screen can carry notes, so this never resolves
   to a route that does not exist. */
const RECORD_PATTERNS = { dispute: "/disputes/:id", card: "/cards/:id", customer: "/customers/:id", member: "/people/:id" };
const recordRoute = (record) => resolvePath(RECORD_PATTERNS[record.type], { id: record.id });

/* The preview menu's page list — this table read whole and grouped, never
   retyped, with the parameterised routes resolved against the fixture so the
   link lands on a real record rather than on ":id". A pattern with no fixture
   example is left out rather than offered as a link resolving nowhere. */
const MERIDIAN_PAGES = (data) => {
  const dispute = data.disputes[0], card = data.cards[0], customer = data.customers[0], person = data.members[0];
  return templatePages(MERIDIAN_NAV, MERIDIAN_AUTH_PATHS, {
    "/disputes/:id": dispute ? resolvePath("/disputes/:id", { id: dispute.id }) : null,
    "/cards/:id": card ? resolvePath("/cards/:id", { id: card.id }) : null,
    "/customers/:id": customer ? resolvePath("/customers/:id", { id: customer.id }) : null,
    "/people/:id": person ? resolvePath("/people/:id", { id: person.id }) : null,
  });
};

Object.assign(window, { MERIDIAN_NAV, MERIDIAN_ROUTES, MERIDIAN_SIDEBAR, MERIDIAN_ACCOUNT_MENU, MERIDIAN_AUTH_PATHS, MERIDIAN_PAGES, resolvePath, recordRoute });

})();
</script>
<script type="text/babel" data-presets="react" data-file="fixtures.js">
(function(){
/* Meridian — the one fixture set. Every screen reads these arrays; a record
   that appears twice is this entry read twice, never a second literal written
   to match. Timestamps are offsets from load so the time column and the
   countdowns are always true.

   Money is authored in CENTS everywhere in this file, with no exceptions: the
   formatters and every helper in metrics.js assume it.

   Narrative check: notifications and blocked reasons below name real records —
   dsp_2XB, po_7739, pay_9K1H7, crd_2093 and crd_6642 — and agree with their
   status, their derived flags and their clock. */

const MERIDIAN_NOW = Date.now();
const H = 3600e3;
const ago = (h) => MERIDIAN_NOW - h * H;
const ahead = (h) => MERIDIAN_NOW + h * H;

/* The workspace's people. One record each: `role` is the job title shown in the
   account menu, `teamRole` is the permission value from TEAM_ROLES, and the
   signed-in user is one of these entries rather than a second literal — the
   profile screen and the team screen edit the same record.

   `approvalLimit` is the most money this person can release on their own, and
   it is read: PAYOUT_FLAGS.isApprovable takes the approver as an argument, the
   same way the receipt rule takes the policy. A payout above the signed-in
   user's limit is not approvable by her at any altitude — badge, figure, row or
   bulk bar — and the row names who can. Margit sits above Dana so the state is
   a real escalation rather than a dead end. */
const MEMBERS = [
  { id: "usr_dana", name: "Dana Whitfield", role: "Treasury operations", teamRole: "admin", email: "dana@northwind.example", avatar: window.__resources.avatar02, approvalLimit: 5000000, canIssueCards: true, notify: ["payout_approval", "dispute_evidence", "expense_review"] },
  { id: "usr_tomas", name: "Tomas Weir", role: "Platform engineering", teamRole: "approver", email: "tomas@northwind.example", avatar: window.__resources.avatar01, approvalLimit: 2500000, canIssueCards: false, notify: ["payment_failed"] },
  { id: "usr_priya", name: "Priya Raman", role: "Marketing", teamRole: "member", email: "priya@northwind.example", avatar: window.__resources.avatar03, approvalLimit: 0, canIssueCards: false, notify: [] },
  { id: "usr_ines", name: "Ines Duarte", role: "Sales", teamRole: "member", email: "ines@northwind.example", avatar: window.__resources.avatar04, approvalLimit: 0, canIssueCards: false, notify: [] },
  { id: "usr_margit", name: "Margit Solberg", role: "Finance director", teamRole: "admin", email: "margit@northwind.example", avatar: window.__resources.avatar05, approvalLimit: 50000000, canIssueCards: true, notify: ["payout_approval", "balance_low"] },
];
const MERIDIAN_SIGNED_IN = "usr_dana";
const MERIDIAN_USER = MEMBERS[0];
/* The statement descriptor and the payout schedule are workspace settings, not
   status: they are edited on /settings and read nowhere else. */
const MERIDIAN_WORKSPACE = { name: "Northwind Markets", plan: "Scale", descriptor: "NORTHWIND MKTS", schedule: "weekly" };

const PAYMENTS = [
  { id: "pay_9K4T2", customer: "Harborview Freight", method: "wire", amount: 4820000, status: "succeeded", createdAt: ago(1.5) },
  { id: "pay_9K3Q8", customer: "Lumen Retail Group", method: "card", amount: 128450, status: "succeeded", createdAt: ago(3) },
  { id: "pay_9K2M1", customer: "Delta Provisions", method: "ach", amount: 964000, status: "pending", createdAt: ago(5) },
  { id: "pay_9K1H7", customer: "Kestrel Software", method: "card", amount: 31200, status: "failed", createdAt: ago(9), failureReason: "Card declined" },
  { id: "pay_9J9D4", customer: "Alcove Interiors", method: "wallet", amount: 218075, status: "succeeded", createdAt: ago(14) },
  { id: "pay_9J8B0", customer: "Brightline Labs", method: "ach", amount: 1500000, status: "authorized", createdAt: ago(20) },
  { id: "pay_9J6X3", customer: "Sable & Finch", method: "card", amount: 84520, status: "refunded", createdAt: ago(30) },
  { id: "pay_9J2R6", customer: "Northbeam Logistics", method: "wire", amount: 2750000, status: "succeeded", createdAt: ago(48) },
  { id: "pay_9H8L9", customer: "Onyx Dental", method: "card", amount: 41000, status: "succeeded", createdAt: ago(72) },
  { id: "pay_9H4F2", customer: "Kestrel Software", method: "card", amount: 31200, status: "succeeded", createdAt: ago(96) },
  { id: "pay_9H1C5", customer: "Vantage Fitness", method: "ach", amount: 630000, status: "succeeded", createdAt: ago(120) },
  { id: "pay_9G7Z8", customer: "Copperline Coffee", method: "card", amount: 12840, status: "failed", createdAt: ago(156), failureReason: "Insufficient funds" },
  { id: "pay_9G2V1", customer: "Harborview Freight", method: "wire", amount: 5290000, status: "succeeded", createdAt: ago(216) },
  { id: "pay_9F8S4", customer: "Meadowlark Farms", method: "ach", amount: 375000, status: "succeeded", createdAt: ago(288) },
  { id: "pay_9F1N7", customer: "Lumen Retail Group", method: "card", amount: 294010, status: "succeeded", createdAt: ago(408) },
  { id: "pay_9E6J0", customer: "Ridgeway Legal", method: "card", amount: 110000, status: "refunded", createdAt: ago(528) },
  { id: "pay_9E2G3", customer: "Brightline Labs", method: "wire", amount: 1840000, status: "succeeded", createdAt: ago(624) },
  { id: "pay_9D5P6", customer: "Copperline Coffee", method: "card", amount: 9600, status: "succeeded", createdAt: ago(984) },
  { id: "pay_9C9K9", customer: "Vantage Fitness", method: "ach", amount: 512000, status: "succeeded", createdAt: ago(1392) },
  { id: "pay_9B4E2", customer: "Onyx Dental", method: "card", amount: 98000, status: "succeeded", createdAt: ago(1776) },
];

/* Each dispute's amount is its payment's amount — the payment owns the figure. */
const DISPUTES = [
  { id: "dsp_31A", paymentId: "pay_9F1N7", customer: "Lumen Retail Group", amount: 294010, reason: "Product not received", status: "needs_response", openedAt: ago(40), evidenceDueAt: ahead(96) },
  { id: "dsp_2XB", paymentId: "pay_9H1C5", customer: "Vantage Fitness", amount: 630000, reason: "Unrecognised charge", status: "needs_response", openedAt: ago(72), evidenceDueAt: ahead(22) },
  { id: "dsp_19K", paymentId: "pay_9H8L9", customer: "Onyx Dental", amount: 41000, reason: "Duplicate charge", status: "under_review", openedAt: ago(144), evidenceDueAt: null, evidenceSubmittedAt: ago(96) },
];

const PAYOUTS = [
  { id: "po_7742", destination: "Harborview Freight", account: "•••• 4417", amount: 6148000, status: "awaiting_approval", requestedAt: ago(4), expectedAt: ahead(26) },
  { id: "po_7741", destination: "Lumen Retail Group", account: "•••• 8802", amount: 1290560, status: "awaiting_approval", requestedAt: ago(9), expectedAt: ahead(30) },
  { id: "po_7739", destination: "Vantage Fitness", account: "•••• 2130", amount: 630000, status: "awaiting_approval", requestedAt: ago(50), expectedAt: ahead(18), blockedBy: { team: "Risk", reason: "Compliance review — open dispute dsp_2XB" } },
  { id: "po_7736", destination: "Brightline Labs", account: "•••• 6651", amount: 1840000, status: "approved", requestedAt: ago(30), approvedAt: ago(26), approvedBy: "Dana Whitfield", expectedAt: ahead(8) },
  { id: "po_7733", destination: "Meadowlark Farms", account: "•••• 0094", amount: 375000, status: "in_transit", requestedAt: ago(80), expectedAt: ahead(6) },
  { id: "po_7728", destination: "Onyx Dental", account: "•••• 7725", amount: 98000, status: "paid", requestedAt: ago(96), paidAt: ago(68) },
  { id: "po_7725", destination: "Sable & Finch", account: "•••• 3318", amount: 84520, status: "returned", requestedAt: ago(200), returnedAt: ago(176), returnReason: "Account closed" },
  { id: "po_7719", destination: "Copperline Coffee", account: "•••• 5540", amount: 12840, status: "rejected", requestedAt: ago(268), rejectedAt: ago(240), rejectedBy: "Dana Whitfield", rejectReason: "Duplicate of po_7712" },
];

/* Available is the only stored money figure. Reserved and committed are
   computed from the disputes and payouts above — see metrics.js. */
const BALANCE = { currency: "USD", availableCents: 41280644, bank: "Northwind Operating •••• 1182" };

/* Corporate cards. `spendLimit` is per rolling 30 days — the window the cards
   screen measures spend over, so the meter is never empty at the start of a
   calendar month. A cancelled card keeps its limit for the record. */
const CARDS = [
  { id: "crd_4471", label: "Operations", last4: "4471", holderId: "usr_dana", type: "physical", status: "active", spendLimit: 750000, openedAt: ago(9000), expiresAt: ahead(24 * 300) },
  { id: "crd_2093", label: "Marketing", last4: "2093", holderId: "usr_priya", type: "physical", status: "active", spendLimit: 500000, openedAt: ago(7400), expiresAt: ahead(24 * 40) },
  { id: "crd_8810", label: "Cloud vendors", last4: "8810", holderId: "usr_tomas", type: "virtual", status: "active", spendLimit: 1200000, openedAt: ago(5200), expiresAt: ahead(24 * 500) },
  { id: "crd_6642", label: "Travel — sales", last4: "6642", holderId: "usr_ines", type: "physical", status: "frozen", spendLimit: 400000, openedAt: ago(4300), expiresAt: ahead(24 * 200), frozenAt: ago(30), frozenBy: "Dana Whitfield", frozenReason: "Two receipts outstanding on trip spend" },
  { id: "crd_1157", label: "Contractor pool", last4: "1157", holderId: "usr_priya", type: "virtual", status: "pending_activation", spendLimit: 150000, openedAt: ago(52), expiresAt: ahead(24 * 700) },
  { id: "crd_9930", label: "Operations (replaced)", last4: "9930", holderId: "usr_dana", type: "physical", status: "cancelled", spendLimit: 250000, openedAt: ago(14000), expiresAt: ahead(24 * 100), cancelledAt: ago(600), cancelledBy: "Dana Whitfield", cancelReason: "Replaced by crd_4471" },
];

/* Card spend. `receiptAt` is a fact about the record; whether the receipt is
   REQUIRED is policy, so it lives in MERIDIAN_POLICY_DEFAULTS and the flag that
   reads it is derived. A pending row is an authorisation that has not settled —
   there is nothing to review yet and it is outside every spend total. */
const EXPENSES = [
  { id: "exp_8754", cardId: "crd_4471", spenderId: "usr_dana", merchant: "Kestrel Software", category: "software", amount: 31200, status: "pending", occurredAt: ago(3), receiptAt: null },
  { id: "exp_8758", cardId: "crd_8810", spenderId: "usr_tomas", merchant: "Vercel", category: "software", amount: 24000, status: "pending", occurredAt: ago(6), receiptAt: null },
  { id: "exp_8762", cardId: "crd_2093", spenderId: "usr_priya", merchant: "Craftline Print", category: "advertising", amount: 95000, status: "needs_review", occurredAt: ago(20), receiptAt: ago(19) },
  { id: "exp_8776", cardId: "crd_4471", spenderId: "usr_dana", merchant: "Copperline Coffee", category: "meals", amount: 9200, status: "needs_review", occurredAt: ago(36), receiptAt: ago(35) },
  { id: "exp_8770", cardId: "crd_4471", spenderId: "usr_dana", merchant: "Statewide Notary", category: "services", amount: 15000, status: "queried", occurredAt: ago(64), receiptAt: null, queriedAt: ago(40), queriedBy: "Dana Whitfield", queryNote: "Which closing was this for?" },
  { id: "exp_8774", cardId: "crd_4471", spenderId: "usr_dana", merchant: "Postal Union", category: "services", amount: 12400, status: "approved", occurredAt: ago(90), receiptAt: ago(89), reviewedAt: ago(80), reviewedBy: "Dana Whitfield" },
  { id: "exp_8798", cardId: "crd_2093", spenderId: "usr_priya", merchant: "Meta Ads", category: "advertising", amount: 210000, status: "approved", occurredAt: ago(120), receiptAt: ago(119), reviewedAt: ago(110), reviewedBy: "Dana Whitfield" },
  { id: "exp_8780", cardId: "crd_4471", spenderId: "usr_dana", merchant: "Fernwood Office", category: "equipment", amount: 128000, status: "approved", occurredAt: ago(170), receiptAt: ago(168), reviewedAt: ago(160), reviewedBy: "Tomas Weir" },
  { id: "exp_8801", cardId: "crd_8810", spenderId: "usr_tomas", merchant: "Amazon Web Services", category: "software", amount: 862000, status: "approved", occurredAt: ago(200), receiptAt: ago(198), reviewedAt: ago(150), reviewedBy: "Dana Whitfield" },
  { id: "exp_8784", cardId: "crd_6642", spenderId: "usr_ines", merchant: "Calder & Sons", category: "meals", amount: 21800, status: "needs_review", occurredAt: ago(240), receiptAt: null },
  { id: "exp_8786", cardId: "crd_6642", spenderId: "usr_ines", merchant: "Harbour Grand Hotel", category: "travel", amount: 96500, status: "needs_review", occurredAt: ago(250), receiptAt: null },
  { id: "exp_8788", cardId: "crd_6642", spenderId: "usr_ines", merchant: "Meridian Air", category: "travel", amount: 184000, status: "approved", occurredAt: ago(260), receiptAt: ago(255), reviewedAt: ago(230), reviewedBy: "Dana Whitfield" },
  { id: "exp_8795", cardId: "crd_2093", spenderId: "usr_priya", merchant: "Google Ads", category: "advertising", amount: 165000, status: "approved", occurredAt: ago(300), receiptAt: ago(299), reviewedAt: ago(290), reviewedBy: "Dana Whitfield" },
  { id: "exp_8790", cardId: "crd_8810", spenderId: "usr_tomas", merchant: "Datadog", category: "software", amount: 129000, status: "needs_review", occurredAt: ago(400), receiptAt: ago(399) },
  { id: "exp_8766", cardId: "crd_2093", spenderId: "usr_priya", merchant: "LinkedIn Ads", category: "advertising", amount: 42000, status: "approved", occurredAt: ago(500), receiptAt: ago(498), reviewedAt: ago(480), reviewedBy: "Dana Whitfield" },
  { id: "exp_8750", cardId: "crd_8810", spenderId: "usr_tomas", merchant: "GitHub", category: "software", amount: 45000, status: "approved", occurredAt: ago(760), receiptAt: ago(758), reviewedAt: ago(740), reviewedBy: "Dana Whitfield" },
  { id: "exp_8744", cardId: "crd_8810", spenderId: "usr_tomas", merchant: "Cloudflare", category: "software", amount: 78000, status: "approved", occurredAt: ago(1100), receiptAt: ago(1098), reviewedAt: ago(1080), reviewedBy: "Dana Whitfield" },
  { id: "exp_8740", cardId: "crd_2093", spenderId: "usr_priya", merchant: "Sable & Finch", category: "meals", amount: 18400, status: "denied", occurredAt: ago(1180), receiptAt: ago(1179), deniedAt: ago(1100), deniedBy: "Dana Whitfield", denyReason: "Personal spend — repaid through payroll" },
  { id: "exp_8736", cardId: "crd_9930", spenderId: "usr_dana", merchant: "Fernwood Office", category: "equipment", amount: 64000, status: "approved", occurredAt: ago(1400), receiptAt: ago(1398), reviewedAt: ago(1380), reviewedBy: "Tomas Weir" },
  { id: "exp_8730", cardId: "crd_6642", spenderId: "usr_ines", merchant: "Rail North", category: "travel", amount: 38600, status: "approved", occurredAt: ago(1500), receiptAt: ago(1495), reviewedAt: ago(1470), reviewedBy: "Dana Whitfield" },
  { id: "exp_8724", cardId: "crd_4471", spenderId: "usr_dana", merchant: "Halloway Insurance", category: "services", amount: 21000, status: "approved", occurredAt: ago(1700), receiptAt: ago(1698), reviewedAt: ago(1680), reviewedBy: "Tomas Weir" },
  { id: "exp_8718", cardId: "crd_8810", spenderId: "usr_tomas", merchant: "Sentry", category: "software", amount: 33000, status: "approved", occurredAt: ago(2000), receiptAt: ago(1999), reviewedAt: ago(1980), reviewedBy: "Dana Whitfield" },
];

/* The companies on the other side of the money. A payment, a payout and a
   dispute each carry the customer's NAME, and this is the record that name
   refers to — the name is written once per company here, and every screen that
   needs the company behind a row looks it up by it (see metrics.js). `since` is
   when they were onboarded; `payoutAccount` is the bank account their payouts
   land in, and matches the account printed on those payout rows.

   Every value of customer.status has a fixture, and each one is here for a
   reason the rest of the data agrees with: Vantage Fitness is on hold while
   dsp_2XB runs and po_7739 is held for it, Sable & Finch is suspended because
   po_7725 came back "account closed", and Ridgeway Legal is closed — both its
   payments were refunded and nothing has arrived since. */
const CUSTOMERS = [
  { id: "cus_harborview", name: "Harborview Freight", ownerId: "usr_dana", status: "live", country: "United States", currency: "USD", since: ago(9800), payoutAccount: "•••• 4417", contact: { name: "Ruth Callan", email: "ruth@harborview.example" } },
  { id: "cus_lumen", name: "Lumen Retail Group", ownerId: "usr_ines", status: "live", country: "United States", currency: "USD", since: ago(8600), payoutAccount: "•••• 8802", contact: { name: "Marco Deniz", email: "marco@lumenretail.example" } },
  { id: "cus_vantage", name: "Vantage Fitness", ownerId: "usr_dana", status: "on_hold", country: "United States", currency: "USD", since: ago(6100), payoutAccount: "•••• 2130", contact: { name: "Neve Ashworth", email: "neve@vantagefitness.example" } },
  { id: "cus_brightline", name: "Brightline Labs", ownerId: "usr_tomas", status: "live", country: "Ireland", currency: "USD", since: ago(5400), payoutAccount: "•••• 6651", contact: { name: "Aoife Kerr", email: "aoife@brightlinelabs.example" } },
  { id: "cus_kestrel", name: "Kestrel Software", ownerId: "usr_tomas", status: "live", country: "United Kingdom", currency: "USD", since: ago(4900), payoutAccount: null, contact: { name: "Owen Pryce", email: "owen@kestrelsoftware.example" } },
  { id: "cus_northbeam", name: "Northbeam Logistics", ownerId: "usr_dana", status: "live", country: "Canada", currency: "USD", since: ago(4300), payoutAccount: null, contact: { name: "Simone Vail", email: "simone@northbeam.example" } },
  { id: "cus_delta", name: "Delta Provisions", ownerId: "usr_ines", status: "live", country: "United States", currency: "USD", since: ago(3800), payoutAccount: null, contact: { name: "Hal Brenner", email: "hal@deltaprovisions.example" } },
  { id: "cus_alcove", name: "Alcove Interiors", ownerId: "usr_ines", status: "live", country: "United States", currency: "USD", since: ago(3200), payoutAccount: null, contact: { name: "Perla Nunes", email: "perla@alcove.example" } },
  { id: "cus_onyx", name: "Onyx Dental", ownerId: "usr_ines", status: "live", country: "United States", currency: "USD", since: ago(2600), payoutAccount: "•••• 7725", contact: { name: "Dr Iris Falk", email: "iris@onyxdental.example" } },
  { id: "cus_meadowlark", name: "Meadowlark Farms", ownerId: "usr_ines", status: "live", country: "United States", currency: "USD", since: ago(2200), payoutAccount: "•••• 0094", contact: { name: "Jonah Reyes", email: "jonah@meadowlark.example" } },
  { id: "cus_copperline", name: "Copperline Coffee", ownerId: "usr_ines", status: "live", country: "United States", currency: "USD", since: ago(1900), payoutAccount: "•••• 5540", contact: { name: "Bea Toft", email: "bea@copperline.example" } },
  { id: "cus_sable", name: "Sable & Finch", ownerId: "usr_ines", status: "suspended", country: "United States", currency: "USD", since: ago(1600), payoutAccount: "•••• 3318", contact: { name: "Cleo Sable", email: "cleo@sableandfinch.example" } },
  { id: "cus_ridgeway", name: "Ridgeway Legal", ownerId: "usr_ines", status: "closed", country: "United States", currency: "USD", since: ago(1400), payoutAccount: null, contact: { name: "Tom Ridgeway", email: "tom@ridgewaylegal.example" } },
];

/* Notes — the product's social record, and the only one that is written BY the
   people in MEMBERS rather than by the money. A mention is a member id, never a
   name (`@usr_tomas`), so a person's name exists once in this file; whether the
   mention is still owed a reply is derived, not stored (COMMENT_FLAGS).

   Notes are only on records that have a screen — a dispute and a card — so a
   mention always opens onto its own thread.

   Narrative check: cmt_301/302 sit inside dsp_2XB's 22-hour evidence clock and
   agree with po_7739 being held for that dispute; cmt_304/305 straddle
   crd_6642's freeze at ago(30) and name the two receipts frozenReason gives as
   the cause; cmt_307 is priya on the card ntf_5 says is over its limit. */
const COMMENTS = [
  { id: "cmt_301", record: { type: "dispute", id: "dsp_2XB" }, authorId: "usr_dana", at: ago(26), mentions: ["usr_tomas"], body: "@usr_tomas Vantage say they never recognised this charge. Did anything ever come back signed for it? Evidence is due inside a day and the $6,300.00 is held against the balance until it closes." },
  { id: "cmt_302", record: { type: "dispute", id: "dsp_2XB" }, authorId: "usr_tomas", at: ago(19), mentions: ["usr_dana"], body: "Nothing signed. The shipping log stops at the depot, so it shows we sent it and not that they got it. @usr_dana your call whether we send that or accept it and keep the fee." },
  { id: "cmt_303", record: { type: "dispute", id: "dsp_31A" }, authorId: "usr_ines", at: ago(20), mentions: [], body: "Lumen's ops contact thinks the pallet went to their old warehouse. They are looking for the signed manifest — worth holding a day before we answer, the clock is four days out." },
  { id: "cmt_304", record: { type: "card", id: "crd_6642" }, authorId: "usr_dana", at: ago(31), mentions: ["usr_ines"], body: "@usr_ines the hotel and the Calder & Sons meal are the two receipts holding this card. Send them over and I will unfreeze it the same day." },
  { id: "cmt_305", record: { type: "card", id: "crd_6642" }, authorId: "usr_ines", at: ago(28), mentions: ["usr_dana"], body: "The hotel folio has to be reissued by the venue and they said Monday. @usr_dana can the card stay frozen until then, or do you want the statement line instead?" },
  { id: "cmt_306", record: { type: "card", id: "crd_6642" }, authorId: "usr_tomas", at: ago(24), mentions: [], body: "Both open items are travel on the same trip — nothing else on this card is waiting on anyone." },
  { id: "cmt_307", record: { type: "card", id: "crd_2093" }, authorId: "usr_priya", at: ago(5), mentions: ["usr_dana"], body: "@usr_dana the Craftline print run took this past the 30-day limit. The campaign ends Friday — can the limit go up until then, or should the rest go on the contractor card?" },
  { id: "cmt_308", record: { type: "customer", id: "cus_vantage" }, authorId: "usr_dana", at: ago(48), mentions: ["usr_ines"], body: "@usr_ines Vantage are on hold while the chargeback runs, so their payout is sitting rather than lost. If they call, that is the line — nothing is decided until the network answers." },
  { id: "cmt_309", record: { type: "customer", id: "cus_sable" }, authorId: "usr_ines", at: ago(170), mentions: [], body: "Their bank closed the account on us, which is why the payout came back. Suspended until they send new details in writing — they know." },
  { id: "cmt_310", record: { type: "member", id: "usr_ines" }, authorId: "usr_dana", at: ago(29), mentions: [], body: "Ines is on the road until Thursday, so her accounts sit with me this week. Travel receipts are the only thing outstanding on her card." },
];

/* Each route must be one nav.js registers. There are no per-record screens for
   payments and payouts, so those two land on the list that holds the record;
   a dispute has one, because the disputes screen is list-detail. */
const NOTIFICATIONS = [
  { id: "ntf_5", title: "Card past its 30-day limit", body: "crd_2093 · Marketing · $5,120.00 of $5,000.00", at: ago(3), route: "/cards/crd_2093" },
  { id: "ntf_4", title: "Two receipts outstanding", body: "crd_6642 · Ines Duarte · $1,183.00 waiting on a receipt", at: ago(8), route: "/expenses" },
  { id: "ntf_3", title: "Evidence due in under a day", body: "dsp_2XB · Vantage Fitness · $6,300.00", at: ago(2), route: "/disputes/dsp_2XB" },
  { id: "ntf_2", title: "Payout held for compliance review", body: "po_7739 · Vantage Fitness · $6,300.00", at: ago(12), route: "/payouts" },
  { id: "ntf_1", title: "Payment failed — card declined", body: "pay_9K1H7 · Kestrel Software · $312.00", at: ago(9), route: "/payments" },
  { id: "ntf_6", title: "Tomas Weir is waiting on your call", body: "dsp_2XB · send the shipping log, or accept the dispute", at: ago(19), route: "/disputes/dsp_2XB" },
];

Object.assign(window, { MERIDIAN_NOW, MERIDIAN_USER, MERIDIAN_SIGNED_IN, MEMBERS, MERIDIAN_WORKSPACE, PAYMENTS, DISPUTES, PAYOUTS, CARDS, EXPENSES, BALANCE, NOTIFICATIONS, COMMENTS, CUSTOMERS });

})();
</script>
<script type="text/babel" data-presets="react" data-file="metrics.js">
(function(){
/* Meridian — 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. */

const PERIODS = [
  { value: "7", label: "Last 7 days" },
  { value: "30", label: "Last 30 days" },
  { value: "90", label: "Last 90 days" },
];

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

/* Scope. Applied before any figure computes. */
function paymentsInPeriod(payments, days, now) {
  return payments.filter((p) => p.createdAt >= now - days * 24 * 3600e3);
}
function disputesInPeriod(disputes, days, now) {
  return disputes.filter((d) => d.openedAt >= now - days * 24 * 3600e3);
}
function expensesInPeriod(expenses, days, now) {
  return expenses.filter((e) => e.occurredAt >= now - days * 24 * 3600e3);
}

/* Calendar spellings, declared once for the product (ui-runtime's distance
   rules govern event times; a chart bucket is a day, not an event). */
const formatDay = (ts) => new Date(ts).toLocaleDateString("en-GB", { day: "numeric", month: "short" });
const formatDayFull = (ts) => new Date(ts).toLocaleDateString("en-GB", { weekday: "short", day: "numeric", month: "long" });

/* Day buckets — the spine every chart in the product draws on. One entry per
   day in the window, oldest first, each carrying the rows that fell in it so a
   bar can say what it counts. */
function dayBuckets(now, days) {
  const midnight = new Date(now); midnight.setHours(0, 0, 0, 0);
  const out = [];
  for (let i = days - 1; i >= 0; i--) out.push({ at: midnight.getTime() - i * 864e5 });
  return out;
}
function bucketBy(rows, stamp, now, days) {
  const buckets = dayBuckets(now, days).map((b) => ({ at: b.at, rows: [] }));
  const first = buckets[0].at;
  rows.forEach((row) => {
    const ts = stamp(row);
    if (!ts || ts < first) return;
    const index = Math.floor((ts - first) / 864e5);
    if (buckets[index]) buckets[index].rows.push(row);
  });
  return buckets;
}

/* Payments */
const succeededPayments = (rows) => rows.filter((p) => p.status === "succeeded");
const refundedPayments = (rows) => rows.filter((p) => p.status === "refunded");
const failedPayments = (rows) => rows.filter((p) => p.status === "failed");
/* An attempt is a payment whose outcome is known — pending and authorized have
   no outcome yet, so they are outside this set. */
const settledAttempts = (rows) => rows.filter((p) => ["succeeded", "refunded", "failed"].indexOf(p.status) > -1);
const authorisedAttempts = (rows) => rows.filter((p) => ["succeeded", "refunded"].indexOf(p.status) > -1);

const netVolume = (rows) => sumBy(succeededPayments(rows), "amount") - sumBy(refundedPayments(rows), "amount");
const refundTotal = (rows) => sumBy(refundedPayments(rows), "amount");
function authorisationRate(rows) {
  const attempts = settledAttempts(rows);
  if (!attempts.length) return null;
  return authorisedAttempts(rows).length / attempts.length;
}

/* Method mix over the volume it claims to break down. */
function methodMix(rows, methods) {
  const succeeded = succeededPayments(rows);
  const total = sumBy(succeeded, "amount");
  return methods
    .map((m) => {
      const of = succeeded.filter((p) => p.method === m.value);
      return { method: m.value, label: m.label, count: of.length, amount: sumBy(of, "amount"), share: total ? sumBy(of, "amount") / total : 0 };
    })
    .filter((row) => row.count > 0)
    .sort((a, b) => b.amount - a.amount);
}

/* Top customers, over the array the caller scoped. Share decomposes the same
   net volume the table's own total prints. */
function topCustomers(payments, count) {
  const total = netVolume(payments);
  const byName = {};
  succeededPayments(payments).concat(refundedPayments(payments)).forEach((p) => {
    byName[p.customer] = byName[p.customer] || { customer: p.customer, rows: [], amount: 0 };
    byName[p.customer].rows.push(p);
    byName[p.customer].amount += p.status === "refunded" ? -p.amount : p.amount;
  });
  return Object.keys(byName)
    .map((name) => Object.assign(byName[name], { share: total ? byName[name].amount / total : 0 }))
    .sort((a, b) => b.amount - a.amount)
    .slice(0, count);
}

/* Payouts */
/* The approver is passed in wherever this is called, so the badge, the figure,
   the row and the bulk bar cannot drift apart. */
const approvablePayouts = (payouts, approver) => payouts.filter((p) => PAYOUT_FLAGS.isApprovable(p, approver));
const awaitingApprovalPayouts = (payouts) => payouts.filter(PAYOUT_FLAGS.awaitsApproval);
const pendingPayouts = (payouts) => payouts.filter(PAYOUT_FLAGS.isPending);
const pendingPayoutTotal = (payouts) => sumBy(pendingPayouts(payouts), "amount");
const inTransitTotal = (payouts) => sumBy(payouts.filter((p) => p.status === "in_transit"), "amount");

/* Expenses — the money going out on the workspace's own cards. The receipt rule
   is policy, so every predicate here is handed the policy object rather than
   reaching for one. */
const settledExpenses = (rows) => rows.filter(EXPENSE_FLAGS.isSettled);
const pendingExpenses = (rows) => rows.filter((e) => e.status === "pending");
const expensesAwaitingReview = (rows) => rows.filter(EXPENSE_FLAGS.awaitsReview);
const reviewableExpenses = (rows, policy) => rows.filter((e) => EXPENSE_FLAGS.isReviewable(e, policy));
const expensesMissingReceipt = (rows, policy) => rows.filter((e) => EXPENSE_FLAGS.needsReceipt(e, policy));
const queriedExpenses = (rows) => rows.filter((e) => e.status === "queried");
/* Settled spend only: a pending authorisation has not moved any money yet. */
const cardSpendTotal = (rows) => sumBy(settledExpenses(rows), "amount");

function spendByCategory(rows, categories) {
  const settled = settledExpenses(rows);
  const total = sumBy(settled, "amount");
  return categories
    .map((c) => {
      const of = settled.filter((e) => e.category === c.value);
      return { category: c.value, label: c.label, count: of.length, amount: sumBy(of, "amount"), share: total ? sumBy(of, "amount") / total : 0 };
    })
    .filter((row) => row.count > 0)
    .sort((a, b) => b.amount - a.amount);
}

/* Cards. The limit is per rolling 30 days, so the spend it is measured against
   is too — one window, named once, read by the tile, the meter and the flag. */
const CARD_WINDOW_DAYS = 30;
const expensesOnCard = (expenses, cardId) => expenses.filter((e) => e.cardId === cardId);
function cardUtilisation(card, expenses, now, policy) {
  const window = expensesInPeriod(expensesOnCard(expenses, card.id), CARD_WINDOW_DAYS, now);
  const spent = cardSpendTotal(window);
  return {
    rows: window,
    spent: spent,
    limit: card.spendLimit,
    share: card.spendLimit > 0 ? spent / card.spendLimit : 0,
    over: CARD_FLAGS.isOverLimit(card, spent),
    near: CARD_FLAGS.isNearLimit(card, spent, policy),
  };
}
const liveCards = (cards) => cards.filter((c) => c.status !== "cancelled");
const cardsAtRisk = (cards, expenses, now, policy) => liveCards(cards).filter((c) => {
  const use = cardUtilisation(c, expenses, now, policy);
  return use.over || use.near;
});

/* Money in and out of the balance, one bucket a day. In is settled payments;
   out is payouts that actually landed plus card spend that has settled. The
   caller states that scope in words beside the chart. */
function movementByDay(payments, payouts, expenses, now, days) {
  const inBuckets = bucketBy(succeededPayments(payments), (p) => p.createdAt, now, days);
  const payoutBuckets = bucketBy(payouts.filter((p) => p.status === "paid"), (p) => p.paidAt, now, days);
  const spendBuckets = bucketBy(settledExpenses(expenses), (e) => e.occurredAt, now, days);
  return inBuckets.map((bucket, i) => ({
    at: bucket.at,
    payments: bucket.rows,
    payouts: payoutBuckets[i].rows,
    expenses: spendBuckets[i].rows,
    in: sumBy(bucket.rows, "amount"),
    out: sumBy(payoutBuckets[i].rows, "amount") + sumBy(spendBuckets[i].rows, "amount"),
  }));
}

/* Net volume a day — the same arithmetic netVolume does, bucketed, so the bars
   add back to the headline over the same window. */
function volumeByDay(payments, now, days) {
  return bucketBy(settledAttempts(payments), (p) => p.createdAt, now, days).map((bucket) => ({
    at: bucket.at,
    rows: bucket.rows,
    amount: netVolume(bucket.rows),
    failed: failedPayments(bucket.rows),
  }));
}

/* Disputes */
const openDisputes = (disputes) => disputes.filter(DISPUTE_FLAGS.isOpen);
const disputesNeedingEvidence = (disputes) => disputes.filter(DISPUTE_FLAGS.needsEvidence);
/* The money held back against open disputes — read by the balance card and by
   the disputes card alike. */
const disputeExposure = (disputes) => sumBy(openDisputes(disputes), "amount");

/* Balance. Available is stored; everything else here is computed from the
   payouts and disputes that claim the money. */
function balanceBreakdown(balance, payouts, disputes) {
  const committed = pendingPayoutTotal(payouts);
  const reserved = disputeExposure(disputes);
  return { available: balance.availableCents, committed: committed, reserved: reserved, total: balance.availableCents + committed + reserved };
}

/* The sidebar badge predicates, resolved by name from nav.js. */
const NAV_BADGES = {
  payoutsApprovable: {
    /* "Payouts you can approve now" — held ones and ones above your own
       approval limit are both excluded, and the card that lists them says so in
       words, naming who can release them instead. */
    count: (data) => approvablePayouts(data.payouts, data.approver).length,
    label: "payouts you can approve",
  },
  disputesNeedingEvidence: {
    count: (data) => disputesNeedingEvidence(data.disputes).length,
    label: "disputes needing evidence",
  },
  expensesToReview: {
    /* "Expenses you can approve now" — the ones still waiting on a receipt are
       excluded, and the screen that lists them says so in words. */
    count: (data) => reviewableExpenses(data.expenses, data.policy).length,
    label: "expenses you can approve",
  },
  notesAwaitingReply: {
    /* Notes that mentioned the signed-in user and have had no answer from them
       on that record since — the same predicate the note badge, the thread
       footer and the activity screen read. */
    count: (data) => mentionsAwaiting(data.comments, MERIDIAN_SIGNED_IN).length,
    label: "notes waiting on your reply",
  },
};

/* Notes. Nothing here is a figure about money; the counts and the queue are
   still arrays a reader can name. `peopleOn` is derived from the record's own
   person fields plus its thread — never a stored watcher list. */
const commentsOn = (comments, type, id) => comments.filter((c) => c.record.type === type && c.record.id === id).slice().sort((a, b) => a.at - b.at);
const commentCount = (comments, type, id) => commentsOn(comments, type, id).length;
const peopleOn = (comments, record, members) => {
  const ids = [];
  const add = (id) => { if (id && ids.indexOf(id) === -1 && members.some((m) => m.id === id)) ids.push(id); };
  if (record.type === "card" && record.holderId) add(record.holderId);
  commentsOn(comments, record.type, record.id).forEach((c) => { add(c.authorId); c.mentions.forEach(add); });
  return ids.map((id) => members.find((m) => m.id === id));
};
const mentionsAwaiting = (comments, userId) =>
  comments.filter((c) => COMMENT_FLAGS.isAwaitingReply(c, userId, comments)).slice().sort((a, b) => b.at - a.at);

/* One processing-fee formula in the product: FEE_SCHEDULE over one amount.
   Read by the billing screen's processing line and by the new-payment preview,
   so a fee quoted before the charge and a fee billed after it are one function
   called twice. */
const methodFee = (method, amountCents) => {
  const rate = FEE_SCHEDULE.method[method] || FEE_SCHEDULE.method.card;
  const variable = Math.round(amountCents * rate.rate);
  const capped = rate.capCents ? Math.min(variable, rate.capCents) : variable;
  return capped + (rate.fixedCents || 0);
};

/* Customers. A payment, payout or dispute carries the customer's NAME, so the
   record is found by it and the figures below are sums over the arrays those
   screens render — nothing about a customer's money is stored on the customer. */
const customerByName = (customers, name) => customers.find((c) => c.name === name) || null;
const customerById = (customers, id) => customers.find((c) => c.id === id) || null;
const paymentsFor = (payments, customer) => payments.filter((p) => p.customer === customer.name);
const payoutsFor = (payouts, customer) => payouts.filter((p) => p.destination === customer.name);
const disputesFor = (disputes, customer) => disputes.filter((d) => d.customer === customer.name);
const customerVolume = (payments, customer) => sumBy(succeededPayments(paymentsFor(payments, customer)), "amount");
const customerPaidOut = (payouts, customer) => sumBy(payoutsFor(payouts, customer).filter((p) => p.status === "paid"), "amount");
const customerReserved = (disputes, customer) => sumBy(openDisputes(disputesFor(disputes, customer)), "amount");
const customerLastPaymentAt = (payments, customer) => {
  const theirs = paymentsFor(payments, customer);
  return theirs.length ? Math.max.apply(null, theirs.map((p) => p.createdAt)) : null;
};
/* Ordered by what they are worth to us, because that is what the screen is for. */
const customersByVolume = (customers, payments) => customers.slice().sort((a, b) => customerVolume(payments, b) - customerVolume(payments, a));

/* People. A person's spend, cards and approvals are all arrays somewhere else,
   filtered by their id — except approvals, which the payout records name by
   the person's own name at the moment of approval. */
const cardsHeldBy = (cards, member) => cards.filter((c) => c.holderId === member.id);
const expensesBy = (expenses, member) => expenses.filter((e) => e.spenderId === member.id);
const spendBy = (expenses, member) => cardSpendTotal(settledExpenses(expensesBy(expenses, member)));
const approvalsBy = (payouts, member) => payouts.filter((p) => p.approvedBy === member.name);
const customersOwnedBy = (customers, member) => customers.filter((c) => c.ownerId === member.id);

/* Activity — derived entirely from the records: every event below is a field on
   a payment, payout, dispute, expense or card, plus the notes. Nothing about
   activity is stored, so the feed cannot drift from the desk. */
function activityFeed(data, now) {
  const events = [];
  const memberByName = (name) => data.members.find((m) => m.name === name) || null;
  const push = (e) => { if (e.at) events.push(e); };

  data.comments.forEach((c) => push({
    id: "ev_" + c.id, at: c.at, kind: "note", actorId: c.authorId, comment: c,
    title: "wrote a note", route: recordRoute(c.record), record: c.record,
  }));
  data.payouts.forEach((p) => {
    push({ id: "ev_" + p.id + "_approved", at: p.approvedAt, kind: "money", actorId: p.approvedBy ? (memberByName(p.approvedBy) || {}).id : null, title: "approved a payout", body: formatMoney(p.amount) + " to " + p.destination + " · " + p.id, route: "/payouts" });
    push({ id: "ev_" + p.id + "_rejected", at: p.rejectedAt, kind: "money", actorId: p.rejectedBy ? (memberByName(p.rejectedBy) || {}).id : null, title: "rejected a payout", body: formatMoney(p.amount) + " to " + p.destination + " · " + (p.rejectReason || p.id), route: "/payouts" });
    push({ id: "ev_" + p.id + "_returned", at: p.returnedAt, kind: "money", actorId: null, title: "a payout came back", body: formatMoney(p.amount) + " to " + p.destination + " · " + (p.returnReason || p.id), route: "/payouts" });
    push({ id: "ev_" + p.id + "_paid", at: p.paidAt, kind: "money", actorId: null, title: "a payout landed", body: formatMoney(p.amount) + " to " + p.destination + " · " + p.id, route: "/payouts" });
  });
  data.disputes.forEach((d) => {
    push({ id: "ev_" + d.id + "_opened", at: d.openedAt, kind: "money", actorId: null, title: "a dispute was opened", body: formatMoney(d.amount) + " · " + d.customer + " · " + d.reason, route: recordRoute({ type: "dispute", id: d.id }) });
    push({ id: "ev_" + d.id + "_evidence", at: d.evidenceSubmittedAt, kind: "money", actorId: null, title: "evidence went to the network", body: d.customer + " · " + d.id, route: recordRoute({ type: "dispute", id: d.id }) });
  });
  data.expenses.forEach((e) => {
    push({ id: "ev_" + e.id + "_reviewed", at: e.reviewedAt, kind: "spend", actorId: e.reviewedBy ? (memberByName(e.reviewedBy) || {}).id : null, title: "approved an expense", body: formatMoney(e.amount) + " at " + e.merchant, route: "/expenses" });
    push({ id: "ev_" + e.id + "_queried", at: e.queriedAt, kind: "spend", actorId: e.queriedBy ? (memberByName(e.queriedBy) || {}).id : null, title: "queried an expense", body: formatMoney(e.amount) + " at " + e.merchant + " · " + (e.queryNote || ""), route: "/expenses" });
    push({ id: "ev_" + e.id + "_denied", at: e.deniedAt, kind: "spend", actorId: e.deniedBy ? (memberByName(e.deniedBy) || {}).id : null, title: "denied an expense", body: formatMoney(e.amount) + " at " + e.merchant + " · " + (e.denyReason || ""), route: "/expenses" });
  });
  data.cards.forEach((c) => {
    push({ id: "ev_" + c.id + "_frozen", at: c.frozenAt, kind: "spend", actorId: c.frozenBy ? (memberByName(c.frozenBy) || {}).id : null, title: "froze a card", body: c.label + " · •••• " + c.last4 + " · " + (c.frozenReason || ""), route: recordRoute({ type: "card", id: c.id }) });
    push({ id: "ev_" + c.id + "_cancelled", at: c.cancelledAt, kind: "spend", actorId: c.cancelledBy ? (memberByName(c.cancelledBy) || {}).id : null, title: "cancelled a card", body: c.label + " · •••• " + c.last4 + " · " + (c.cancelReason || ""), route: recordRoute({ type: "card", id: c.id }) });
  });
  return events.filter((e) => e.at <= now).sort((a, b) => b.at - a.at);
}
const ACTIVITY_SCOPES = [
  { value: "all", label: "Everything" },
  { value: "note", label: "Notes" },
  { value: "money", label: "Money in and out" },
  { value: "spend", label: "Card spend" },
];
const activityInScope = (events, scope) => (scope === "all" ? events : events.filter((e) => e.kind === scope));

Object.assign(window, {
  PERIODS, sumBy, paymentsInPeriod, disputesInPeriod, expensesInPeriod,
  formatDay, formatDayFull, dayBuckets, bucketBy,
  succeededPayments, refundedPayments, failedPayments, settledAttempts, authorisedAttempts,
  netVolume, refundTotal, authorisationRate, methodMix, topCustomers,
  approvablePayouts, awaitingApprovalPayouts, pendingPayouts, pendingPayoutTotal, inTransitTotal,
  settledExpenses, pendingExpenses, expensesAwaitingReview, reviewableExpenses, expensesMissingReceipt,
  queriedExpenses, cardSpendTotal, spendByCategory,
  CARD_WINDOW_DAYS, expensesOnCard, cardUtilisation, liveCards, cardsAtRisk,
  movementByDay, volumeByDay,
  openDisputes, disputesNeedingEvidence, disputeExposure, balanceBreakdown, NAV_BADGES,
  commentsOn, commentCount, peopleOn, mentionsAwaiting,
  customerByName, customerById, paymentsFor, payoutsFor, disputesFor,
  customerVolume, customerPaidOut, customerReserved, customerLastPaymentAt, customersByVolume,
  cardsHeldBy, expensesBy, spendBy, approvalsBy, customersOwnedBy,
  activityFeed, activityInScope, ACTIVITY_SCOPES, methodFee,
});

})();
</script>
<script type="text/babel" data-presets="react" data-file="lib/ui-runtime.js">
(function(){
/* Shared render runtime — copied per product, domain-free. Status tone, money,
   time and the floating layer live here so no screen assigns a tone or spells a
   duration its own way.

   Local addition, reported in README: Floating / FloatingTip. These portal to the
   document in viewport coordinates, reposition on scroll and resize, flip when
   there is no room, and clamp to the viewport. The design system's own Menu,
   Popover and Tooltip now do all of that too — these remain because they carry a
   different API the products lean on: one element as the trigger, a render
   callback that receives `close`, and no compound Trigger/Popup pair. Reach for
   the design system's overlays in new work. */

const { Badge } = window.OriginDesignSystem_7c998b;

const TONE_VARIANT = { neutral: "secondary", info: "info", success: "success", warning: "warning", danger: "destructive" };

/* The only place a status becomes a label and a colour. */
function StatusBadge({ values, value, size = "sm" }) {
  const found = statusValue(values, value);
  if (!found) { console.warn("[ui-runtime] value not in enum:", value); return null; }
  return <Badge size={size} variant={TONE_VARIANT[found.tone]}>{found.label}</Badge>;
}

const moneyFormatter = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" });
const formatMoney = (cents) => moneyFormatter.format(cents / 100);
const formatPercent = (ratio) => (ratio === null ? "—" : (ratio * 100).toFixed(1) + "%");
const formatCount = (n) => new Intl.NumberFormat("en-US").format(n);

/* One format rule per column. Distance is absolute: three days ago and three
   days ahead format alike. Direction is never carried by the format. */
function formatWhen(ts, now) {
  if (!ts) return "Never";
  const d = Math.abs(ts - now);
  const date = new Date(ts);
  if (d < 24 * 3600e3) return date.toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit" });
  if (d < 7 * 24 * 3600e3) return date.toLocaleDateString("en-GB", { weekday: "short" }) + " " + date.toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit" });
  return date.toLocaleDateString("en-GB", { day: "numeric", month: "short" });
}
const formatAbsolute = (ts) => (ts ? new Date(ts).toLocaleString("en-GB", { day: "numeric", month: "short", year: "numeric", hour: "2-digit", minute: "2-digit" }) : "");

/* One spelling for every countdown in the product. Direction is in the words. */
function formatCountdown(ts, now) {
  if (!ts) return "No deadline";
  const diff = ts - now;
  const abs = Math.abs(diff);
  const unit = abs >= 36 * 3600e3 ? Math.round(abs / 864e5) : Math.round(abs / 3600e3);
  const noun = abs >= 36 * 3600e3 ? (unit === 1 ? "day" : "days") : (unit === 1 ? "hour" : "hours");
  return unit + " " + noun + (diff >= 0 ? " left" : " over");
}

function Icon({ name, size = 16 }) {
  const host = React.useRef(null);
  React.useEffect(() => {
    if (!host.current || !window.lucide) return;
    host.current.innerHTML = "";
    const el = document.createElement("i");
    el.setAttribute("data-lucide", name);
    host.current.appendChild(el);
    window.lucide.createIcons({ attrs: { width: size, height: size } });
  }, [name, size]);
  return <span className="fd-icon" ref={host} aria-hidden="true" style={{ width: size, height: size }} />;
}

function useFloating(open, anchorRef, panelRef, align, side) {
  const [pos, setPos] = React.useState({ top: -9999, left: -9999 });
  const place = React.useCallback(() => {
    const host = anchorRef.current, panel = panelRef.current;
    if (!host || !panel) return;
    /* Measure the TRIGGER, not the wrapper. The wrapper is inline-flex and static,
       so an absolutely-positioned trigger inside it — a chart bar, a pinned badge —
       does not sit where the wrapper does, and measuring the wrapper puts every
       overlay in the same wrong place. */
    const anchor = host.firstElementChild || host;
    const a = anchor.getBoundingClientRect(), p = panel.getBoundingClientRect();
    const m = 8;
    let top, left;
    if (side === "right") {
      left = a.right + 6;
      top = a.top + (a.height - p.height) / 2;
    } else {
      top = a.bottom + 4;
      if (top + p.height > window.innerHeight - m) {
        const above = a.top - 4 - p.height;
        top = above > m ? above : Math.max(m, window.innerHeight - m - p.height);
      }
      left = align === "end" ? a.right - p.width : align === "center" ? a.left + (a.width - p.width) / 2 : a.left;
    }
    left = Math.min(Math.max(m, left), Math.max(m, window.innerWidth - m - p.width));
    top = Math.min(Math.max(m, top), Math.max(m, window.innerHeight - m - p.height));
    setPos({ top: top, left: left });
  }, [align, side, anchorRef, panelRef]);
  React.useLayoutEffect(() => { if (open) place(); }, [open, place]);
  React.useEffect(() => {
    if (!open) return undefined;
    const onMove = () => place();
    window.addEventListener("scroll", onMove, true);
    window.addEventListener("resize", onMove);
    return () => { window.removeEventListener("scroll", onMove, true); window.removeEventListener("resize", onMove); };
  }, [open, place]);
  return pos;
}

function Floating({ trigger, children, align = "end", width, role = "menu", panelClass = "o-menu", label }) {
  const [open, setOpen] = React.useState(false);
  const anchorRef = React.useRef(null), panelRef = React.useRef(null);
  const pos = useFloating(open, anchorRef, panelRef, align, "bottom");
  React.useEffect(() => {
    if (!open) return undefined;
    const away = (e) => {
      if (anchorRef.current && anchorRef.current.contains(e.target)) return;
      if (panelRef.current && panelRef.current.contains(e.target)) return;
      setOpen(false);
    };
    const onKey = (e) => { if (e.key === "Escape") setOpen(false); };
    document.addEventListener("mousedown", away);
    document.addEventListener("keydown", onKey);
    return () => { document.removeEventListener("mousedown", away); document.removeEventListener("keydown", onKey); };
  }, [open]);
  const close = () => setOpen(false);
  return (
    <span ref={anchorRef} className="fd-floating-anchor">
      {React.cloneElement(trigger, { onClick: () => setOpen(!open), "aria-expanded": open, "aria-haspopup": role === "menu" ? "menu" : "dialog" })}
      {open ? ReactDOM.createPortal(
        <div ref={panelRef} role={role} aria-label={label} className={"o-popup " + panelClass} style={{ position: "fixed", top: pos.top, left: pos.left, zIndex: 60, minWidth: width || "12rem" }}>
          {typeof children === "function" ? children(close) : children}
        </div>, document.body) : null}
    </span>
  );
}

function FloatingTip({ label, side = "right", children }) {
  const [open, setOpen] = React.useState(false);
  const anchorRef = React.useRef(null), panelRef = React.useRef(null);
  const pos = useFloating(open, anchorRef, panelRef, "center", side);
  return (
    <span ref={anchorRef} className="fd-floating-anchor" onMouseEnter={() => setOpen(true)} onMouseLeave={() => setOpen(false)} onFocus={() => setOpen(true)} onBlur={() => setOpen(false)}>
      {children}
      {open ? ReactDOM.createPortal(
        <span ref={panelRef} role="tooltip" className="o-tooltip" style={{ position: "fixed", top: pos.top, left: pos.left, zIndex: 70, whiteSpace: "nowrap" }}>{label}</span>,
        document.body) : null}
    </span>
  );
}

Object.assign(window, { StatusBadge, TONE_VARIANT, formatMoney, formatPercent, formatCount, formatWhen, formatAbsolute, formatCountdown, Icon, Floating, FloatingTip });

})();
</script>
<script type="text/babel" data-presets="react" data-file="lib/command-palette.js">
(function(){
/* App-scope search — copied per product, domain-free. This file owns the
   shortcut, the query, the empty state and the route check; the shell supplies
   the record groups. The field in the toolbar is read-only and opens this same
   overlay, so search is a palette, not a filter.

   Optional `footer`: one persistent row under the list, given the palette's own
   close function. It is not filtered by the query, so it is for a destination
   rather than a record — "see every result", "open the full list". Products
   without one pass nothing and get no row. */

const { Command } = window.OriginDesignSystem_7c998b;

function useCommandPalette() {
  const [open, setOpen] = React.useState(false);
  React.useEffect(() => {
    const onKey = (e) => {
      if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") { e.preventDefault(); setOpen((v) => !v); }
      if (e.key === "Escape") setOpen(false);
    };
    document.addEventListener("keydown", onKey);
    return () => document.removeEventListener("keydown", onKey);
  }, []);
  return { open: open, setOpen: setOpen, bound: true };
}

function CommandPalette({ open, onClose, routes, recordGroups = [], onNavigate, placeholder, label, emptyMessage, footer }) {
  /* Route check: an item that leads nowhere is a bug in the caller, not a
     silent dead end. */
  React.useEffect(() => {
    if (!open) return;
    recordGroups.forEach((g) => g.items.forEach((i) => {
      if (i.route && !matchRoute(i.route, routes)) console.warn("[command-palette] item points at an unregistered route:", i.route);
    }));
  }, [open, recordGroups, routes]);

  if (!open) return null;

  const destinations = Object.values(routes)
    .filter((r) => !r.offSidebar)
    .map((r) => ({ value: r.path, label: r.label, route: r.path, shortcut: r.path === "/" ? null : null }));
  const groups = [{ label: "Go to", items: destinations }].concat(recordGroups);

  return (
    <>
      <div className="o-backdrop" onClick={onClose} />
      <div className="o-viewport">
        <div className="o-dialog fd-command" role="dialog" aria-modal="true" aria-label={label || "Search"}>
          <Command
            groups={groups}
            placeholder={placeholder || "Search…"}
            emptyMessage={emptyMessage || "Nothing matches that. Try a record or a destination."}
            onSelect={(item) => { if (item.route) onNavigate(item.route); onClose(); }}
          />
          {footer ? <div className="fd-command__foot">{typeof footer === "function" ? footer(onClose) : footer}</div> : null}
        </div>
      </div>
    </>
  );
}

Object.assign(window, { useCommandPalette, CommandPalette });

})();
</script>
<script type="text/babel" data-presets="react" data-file="lib/template-menu.js">
(function(){
/* Preview page menu — copied per product, domain-free, byte-identical across
   products. This is the template's own harness chrome, not part of any product
   shell: it exists so a reader who opens a template can reach every screen the
   route table registers, including the ones a running product would only reach
   from a real session — a record, an account screen, an expired reset link.

   Two things it deliberately owns, and why:
   · The page list is a projection of the product's route table (nav.js passes
     it through templatePages), so a route added anywhere shows up here without
     being retyped, and a record route with no fixture example is not offered.
   · The theme control. Auth screens render no shell (AGENTS §7.1), so on those
     screens the product has no theme control at all — and §7.3 wants dark mode
     verified on them too. Outside auth this sits beside the shell's own theme
     button on purpose: one belongs to the product, one to the harness, and the
     harness panel says "Template" at the top so the two do not read as a
     duplicated duty.

   It injects its own stylesheet once rather than adding rules to each product's
   kit.css, which is what keeps the eight copies identical. The one rule it
   reaches outside itself lifts the toast region so toasts stack above the
   button instead of burying it. */

const { Input } = window.OriginDesignSystem_7c998b;

const TEMPLATE_MENU_CSS = `
.tm-root{position:fixed;inset-block-end:var(--space-5);inset-inline-end:var(--space-5);z-index:80;display:flex;flex-direction:column;align-items:flex-end;gap:var(--space-2);pointer-events:none}
.tm-root>*{pointer-events:auto}
.tm-fab{display:inline-flex;width:44px;height:44px;flex:none;align-items:center;justify-content:center;border:0;border-radius:var(--radius-full);background:var(--foreground);color:var(--background);box-shadow:var(--shadow-lg);cursor:pointer;opacity:0;transition:transform var(--duration-fast) ease,opacity var(--duration-normal) ease}
/* The fab is preview chrome, not product UI: it stays out of the way until the
   pointer is in the app, and is always present for the keyboard and while open. */
html:hover .tm-fab,.tm-root:focus-within .tm-fab,.tm-fab[aria-expanded="true"]{opacity:1}
.tm-fab:hover{transform:translateY(-1px)}
.tm-fab:focus-visible{outline:2px solid var(--ring);outline-offset:2px}
.tm-panel{display:flex;width:21rem;max-width:calc(100vw - var(--space-6));max-height:min(70vh,32rem);flex-direction:column;overflow:hidden;border:1px solid var(--border);border-radius:var(--radius-xl);background:var(--popover);color:var(--popover-foreground);box-shadow:var(--shadow-lg)}
.tm-head{display:flex;align-items:center;gap:var(--space-2);border-bottom:1px solid var(--border);padding:var(--space-3)}
.tm-chip{border:1px solid var(--border);border-radius:var(--radius-full);padding:1px var(--space-1-5);font-family:var(--font-mono);font-size:10px;letter-spacing:.04em;text-transform:uppercase;color:var(--muted-foreground)}
.tm-head__title{font-family:var(--font-heading);font-size:var(--text-sm);font-weight:var(--font-weight-semibold)}
.tm-head__count{margin-inline-start:auto;font-size:var(--text-xs);color:var(--muted-foreground)}
.tm-filter{padding:var(--space-2) var(--space-3)}
.tm-body{flex:1;min-height:0;overflow-y:auto;padding:0 var(--space-2) var(--space-2)}
.tm-group+.tm-group{margin-block-start:var(--space-2)}
.tm-group__label{padding:var(--space-1-5) var(--space-2);font-size:var(--text-xs);font-weight:var(--font-weight-medium);color:var(--muted-foreground)}
.tm-item{display:flex;width:100%;align-items:baseline;gap:var(--space-2);border:0;border-radius:var(--radius-lg);background:transparent;padding:var(--space-1-5) var(--space-2);text-align:left;font:inherit;color:inherit;cursor:pointer}
.tm-item:hover{background:var(--accent)}
.tm-item:focus-visible{outline:2px solid var(--ring);outline-offset:-2px}
.tm-item.is-active{background:var(--accent);color:var(--accent-foreground);font-weight:var(--font-weight-medium)}
.tm-item__label{flex:1;min-width:0;font-size:var(--text-sm);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.tm-item__path{flex:none;max-width:9rem;font-family:var(--font-mono);font-size:10px;color:var(--muted-foreground);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.tm-empty{padding:var(--space-4) var(--space-2);font-size:var(--text-sm);color:var(--muted-foreground)}
.tm-foot{display:flex;align-items:center;gap:var(--space-2);border-top:1px solid var(--border);padding:var(--space-2-5) var(--space-3)}
.tm-foot__label{font-size:var(--text-xs);color:var(--muted-foreground)}
.tm-seg{display:flex;margin-inline-start:auto;gap:2px;border:1px solid var(--border);border-radius:var(--radius-md);padding:2px}
.tm-seg__btn{border:0;border-radius:calc(var(--radius-md) - 3px);background:transparent;padding:2px var(--space-2);font:inherit;font-size:var(--text-xs);color:var(--muted-foreground);cursor:pointer}
.tm-seg__btn.is-on{background:var(--accent);color:var(--accent-foreground)}
.o-toast-region{inset-block-end:calc(var(--space-5) + 44px + var(--space-2))}
@media (max-width:600px){.tm-root{inset-inline:var(--space-3);inset-block-end:var(--space-3)}.tm-panel{width:100%;max-width:none;max-height:64vh}.o-toast-region{inset-block-end:calc(var(--space-3) + 44px + var(--space-2))}}
`;

/* The route table read whole and grouped. `examples` maps a parameterised
   pattern to one concrete path from the fixture; a pattern with no example is
   left out rather than offered as a link that resolves nowhere. */
function templatePages(nav, authPaths, examples) {
  const destinations = [], records = [], settings = [], auth = [];
  /* An off-nav route may name its own group ("Create", "Reports"); a route in
     the nav may not, because there `group` is the sidebar's own sectioning. */
  const named = {}, order = [];
  const put = (label, item) => { if (!named[label]) { named[label] = []; order.push(label); } named[label].push(item); };
  nav.forEach((route) => {
    const parameterised = route.path.indexOf(":") > -1;
    const to = parameterised ? (examples || {})[route.path] : route.path;
    if (!to) return;
    const item = { to: to, label: route.label };
    if (authPaths.indexOf(route.path) > -1) auth.push(item);
    else if (!route.offSidebar) destinations.push(item);
    else if (route.group) put(route.group, item);
    else if (parameterised) records.push(item);
    else settings.push(item);
  });
  return [{ label: "Destinations", items: destinations }]
    .concat(order.map((label) => ({ label: label, items: named[label] })))
    .concat([
      { label: "Records", items: records },
      { label: "Account and settings", items: settings },
      { label: "Auth", items: auth },
    ])
    .filter((group) => group.items.length > 0);
}

function TemplateMenu({ product, path, onNavigate, groups, dark, onToggleTheme }) {
  const [open, setOpen] = React.useState(false);
  const [query, setQuery] = React.useState("");
  const rootRef = React.useRef(null);

  React.useEffect(() => {
    if (document.getElementById("tm-styles")) return;
    const style = document.createElement("style");
    style.id = "tm-styles";
    style.textContent = TEMPLATE_MENU_CSS;
    document.head.appendChild(style);
  }, []);

  React.useEffect(() => {
    if (!open) return undefined;
    const away = (e) => { if (rootRef.current && !rootRef.current.contains(e.target)) setOpen(false); };
    const onKey = (e) => { if (e.key === "Escape") setOpen(false); };
    document.addEventListener("mousedown", away);
    document.addEventListener("keydown", onKey);
    return () => { document.removeEventListener("mousedown", away); document.removeEventListener("keydown", onKey); };
  }, [open]);

  const total = groups.reduce((n, group) => n + group.items.length, 0);
  const q = query.trim().toLowerCase();
  const shown = groups
    .map((group) => ({ label: group.label, items: group.items.filter((item) => !q || (item.label + " " + item.to).toLowerCase().indexOf(q) > -1) }))
    .filter((group) => group.items.length > 0);
  const go = (to) => { onNavigate(to); setOpen(false); setQuery(""); };

  return (
    <div className="tm-root" ref={rootRef}>
      {open ? (
        <div className="tm-panel" role="dialog" aria-label={product + " — template pages"}>
          <div className="tm-head">
            <span className="tm-chip">Template</span>
            <span className="tm-head__title">{product}</span>
            <span className="tm-head__count">{total} pages</span>
          </div>
          <div className="tm-filter">
            <Input size="sm" value={query} onChange={(e) => setQuery(e.target.value)}
              aria-label="Filter pages" placeholder="Filter pages…" startAddon={<Icon name="search" size={14} />} />
          </div>
          <div className="tm-body">
            {shown.length ? shown.map((group) => (
              <div className="tm-group" key={group.label}>
                <div className="tm-group__label">{group.label}</div>
                {group.items.map((item) => {
                  const active = item.to === path;
                  return (
                    <button type="button" key={item.to} className={"tm-item" + (active ? " is-active" : "")}
                      aria-current={active ? "page" : undefined} onClick={() => go(item.to)}>
                      <span className="tm-item__label">{item.label}</span>
                      <span className="tm-item__path">{item.to}</span>
                    </button>
                  );
                })}
              </div>
            )) : <p className="tm-empty">No page matches “{query.trim()}”.</p>}
          </div>
          {onToggleTheme ? (
            <div className="tm-foot">
              <span className="tm-foot__label">Theme</span>
              <div className="tm-seg" role="group" aria-label="Theme">
                <button type="button" className={"tm-seg__btn" + (dark ? "" : " is-on")} aria-pressed={!dark} onClick={() => { if (dark) onToggleTheme(); }}>Light</button>
                <button type="button" className={"tm-seg__btn" + (dark ? " is-on" : "")} aria-pressed={!!dark} onClick={() => { if (!dark) onToggleTheme(); }}>Dark</button>
              </div>
            </div>
          ) : null}
        </div>
      ) : null}
      <button type="button" className="tm-fab" aria-expanded={open}
        aria-label={open ? "Close the template page menu" : "Template pages — " + total + " screens in " + product}
        onClick={() => setOpen(!open)}>
        <Icon name={open ? "x" : "layout-grid"} size={18} />
      </button>
    </div>
  );
}

Object.assign(window, { TemplateMenu, templatePages });

})();
</script>
<script type="text/babel" data-presets="react" data-file="MeridianShell.jsx">
(function(){
/* Meridian shell — SIDEBAR shell, one navigation owner (the sidebar owns every
   top-level destination; the toolbar is utility only).
   Density: comfortable — 32px nav rows, 16px page gutters, 44px table rows.
   Separation: hairline rules and the tinted --sidebar plane; cards are the only
   raised surface, one elevation step, no nesting.
   Active state: --sidebar-accent wash + foreground token + medium weight.
   App-scope duties: search → toolbar palette · account → sidebar footer ·
   utility → toolbar (notifications, theme) · page context → page header.
   Eleven destinations in TWO labelled groups — Money is the platform's money
   moving, Workspace is this company's own spend, the accounts on the other side
   of it, the people here and what they are saying to each other.
   Primary content per destination:
     Dashboard   — figure row over a volume chart and four actionable cards
     Payments    — a fixed-window volume chart over the table; /payments/new is
                   a staged form with a live fee preview
     Payouts     — figure row over the approval queue, then the table;
                   /payouts/new is a staged form over the balance chain
     Disputes    — list-detail; /disputes/:id is the selection, with its thread
     Balances    — the balance broken into its three parts, over the movement
                   chart and the two tables that claim it
     Reports     — chart-led: money in and out by day, then the mixes and the
                   customer table behind it
     Expenses    — scoped figures and a spend chart over the review table
     Cards       — a canvas of card tiles, each with its own limit meter;
                   /cards/:id is one card's chart, facts, ledger and thread
     Customers   — table ordered by volume; /customers/:id is one account's
                   figures, its payments and payouts, its disputes and thread
     People      — a canvas of person tiles with faces; /people/:id is one
                   person's spend, cards, approvals, accounts and thread
     Activity    — a feed, derived from the records, with the person who did it
   Cards and People are the destinations that would most plausibly have been
   tables and are not: Cards answers "how much of this limit is left", which a
   meter shows and a row does not, and People answers "who is this and what are
   they carrying", which wants a face at a readable size.
   Settings (workspace, spend controls, team, notifications, billing), the
   profile and the auth screens are registered off the sidebar and reached from
   the account menu; auth renders with no shell at all. */

const {
  Sidebar, SidebarHeader, SidebarContent, SidebarFooter, SidebarGroup, SidebarGroupLabel,
  SidebarMenu, SidebarMenuItem, SidebarMenuButton, SidebarMenuBadge, SidebarInset, SidebarSeparator,
  Button, Input, Kbd, Avatar, Badge: ShellBadge, Separator,
  MenuItem, MenuSeparator, MenuGroupLabel,
  Breadcrumb, BreadcrumbList, BreadcrumbItem, BreadcrumbLink, BreadcrumbPage, BreadcrumbSeparator,
  Toast, ToastRegion,
} = window.OriginDesignSystem_7c998b;

function useShellMode() {
  const read = () => (window.innerWidth >= 1280 ? "expanded" : window.innerWidth >= 1024 ? "rail" : "drawer");
  const [mode, setMode] = React.useState(read);
  React.useEffect(() => {
    const onResize = () => setMode(read());
    window.addEventListener("resize", onResize);
    return () => window.removeEventListener("resize", onResize);
  }, []);
  return mode;
}

function BrandBlock({ rail, workspace }) {
  return (
    <div className="fd-brand">
      <img className="fd-brand__logo" src={window.__resources.brandMark} alt="" width="28" height="28" />
      {rail ? null : (
        <span className="fd-brand__text">
          <span className="fd-brand__name">Meridian</span>
          <span className="fd-brand__workspace">{workspace.name}</span>
        </span>
      )}
    </div>
  );
}

function NavRow({ route, active, rail, badge, onNavigate }) {
  const button = (
    <SidebarMenuButton isActive={active} aria-current={active ? "page" : undefined} aria-label={rail ? route.label : undefined} onClick={() => onNavigate(route.path)}>
      <Icon name={route.icon} />
      {rail ? null : <span>{route.label}</span>}
    </SidebarMenuButton>
  );
  const tip = badge && badge.count > 0 ? route.label + " — " + badge.count + " " + badge.label : route.label;
  return (
    <SidebarMenuItem>
      {rail ? <FloatingTip label={tip}>{button}</FloatingTip> : button}
      {badge && badge.count > 0 ? (
        rail ? <span className="fd-rail-dot" aria-hidden="true" /> : <SidebarMenuBadge aria-label={badge.count + " " + badge.label}>{badge.count}</SidebarMenuBadge>
      ) : null}
    </SidebarMenuItem>
  );
}

function AccountMenu({ rail, onNavigate, profile }) {
  const trigger = (
    <button type="button" className={"fd-account" + (rail ? " fd-account--rail" : "")} aria-label={"Account — " + profile.name}>
      <Avatar size={rail ? 28 : 32} src={profile.avatar} alt="" fallback={profile.name.split(" ").map((w) => w[0]).slice(0, 2).join("")} />
      {rail ? null : (
        <span className="fd-account__text">
          <span className="fd-account__name">{profile.name}</span>
          <span className="fd-account__role">{profile.role}</span>
        </span>
      )}
      {rail ? null : <Icon name="chevrons-up-down" />}
    </button>
  );
  return (
    <Floating trigger={trigger} align="start" width="15rem" label="Account">
      {(close) => (
        <>
          <MenuGroupLabel>{profile.name} · {profile.role}</MenuGroupLabel>
          {MERIDIAN_ACCOUNT_MENU.map((route) => (
            <MenuItem key={route.path} onClick={() => { onNavigate(route.path); close(); }}>{route.label}</MenuItem>
          ))}
        </>
      )}
    </Floating>
  );
}

function NotificationsButton({ items, unread, onRead, onNavigate }) {
  const trigger = (
    <span className="fd-utility">
      <Button variant="ghost" size="icon-sm" aria-label={unread ? unread + " unread notifications" : "Notifications"}>
        <Icon name="bell" />
      </Button>
      {unread ? <span className="fd-badge-dot" aria-hidden="true">{unread}</span> : null}
    </span>
  );
  return (
    <FloatingTip label="Notifications" side="bottom">
      <Floating trigger={trigger} align="end" width="22rem" role="dialog" panelClass="o-popover fd-notify" label="Notifications">
        {(close) => (
          <>
            <div className="fd-notify__head">
              <span className="o-popover__title">Notifications</span>
              <Button variant="ghost" size="xs" onClick={() => { onRead(); }}>Mark all read</Button>
            </div>
            <div className="fd-notify__list">
              {items.map((n) => (
                <button type="button" key={n.id} className="fd-notify__item" onClick={() => { onNavigate(n.route); close(); }}>
                  <span className="fd-notify__title">{n.title}</span>
                  <span className="fd-notify__body">{n.body}</span>
                  <span className="fd-notify__time" title={formatAbsolute(n.at)}>{formatWhen(n.at, MERIDIAN_NOW)}</span>
                </button>
              ))}
            </div>
          </>
        )}
      </Floating>
    </FloatingTip>
  );
}

function Toolbar({ mode, dark, onToggleTheme, onOpenPalette, onOpenDrawer, notifications, unread, onRead, onNavigate }) {
  return (
    <div className="fd-toolbar">
      {mode === "drawer" ? (
        <Button variant="ghost" size="icon-sm" aria-label="Open navigation" onClick={onOpenDrawer}><Icon name="menu" /></Button>
      ) : null}
      <div className="fd-search" onClick={onOpenPalette}>
        <Input
          readOnly
          size="sm"
          aria-label="Search Meridian"
          placeholder="Search payments, payouts, customers, people…"
          startAddon={<Icon name="search" />}
          endAddon={<Kbd>⌘K</Kbd>}
          onFocus={onOpenPalette}
        />
      </div>
      <div className="fd-toolbar__utility">
        <NotificationsButton items={notifications} unread={unread} onRead={onRead} onNavigate={onNavigate} />
        <FloatingTip label={dark ? "Switch to light" : "Switch to dark"} side="bottom">
          <Button variant="ghost" size="icon-sm" aria-label={dark ? "Switch to light theme" : "Switch to dark theme"} onClick={onToggleTheme}>
            <Icon name={dark ? "sun" : "moon"} />
          </Button>
        </FloatingTip>
      </div>
    </div>
  );
}

/* PageHeader is rendered by every screen and none of them pass the router, so the
   shell registers it once per render and the crumbs read it from here. */
let breadcrumbNavigate = null;

function PageHeader({ path, title, actions, params }) {
  const trail = breadcrumb(path, MERIDIAN_ROUTES);
  return (
    <header className="fd-page-header">
      <div className="fd-page-header__text">
        <Breadcrumb>
          <BreadcrumbList>
            {trail.map((seg, i) => (
              <React.Fragment key={seg.path}>
                {i > 0 ? <BreadcrumbSeparator /> : null}
                <BreadcrumbItem>
                  {i === trail.length - 1
                    ? <BreadcrumbPage>{seg.label}</BreadcrumbPage>
                    : seg.isNamespace ? <span>{seg.label}</span>
                      : <BreadcrumbLink href={"#" + resolvePath(seg.path, params || {})} onSelect={() => breadcrumbNavigate && breadcrumbNavigate(resolvePath(seg.path, params || {}))}>{seg.label}</BreadcrumbLink>}
                </BreadcrumbItem>
              </React.Fragment>
            ))}
          </BreadcrumbList>
        </Breadcrumb>
        <h1 className="fd-page-title">{title}</h1>
      </div>
      {actions ? <div className="fd-page-header__actions">{actions}</div> : null}
    </header>
  );
}

function MeridianShell({ path, onNavigate, dark, onToggleTheme, data, profile, workspace, notifications, unread, onRead, palette, toasts, onDismissToast, children }) {
  breadcrumbNavigate = onNavigate;
  const mode = useShellMode();
  const [drawer, setDrawer] = React.useState(false);
  React.useEffect(() => { if (mode !== "drawer") setDrawer(false); }, [mode]);
  const rail = mode === "rail";
  const navOpen = mode !== "drawer" || drawer;

  return (
    <div className="fd-shell" data-mode={mode}>
      {mode === "drawer" && drawer ? <div className="fd-drawer-scrim" onClick={() => setDrawer(false)} /> : null}
      {navOpen ? (
        <Sidebar className="fd-nav" aria-label="Meridian">
          <SidebarHeader>
            <div className="fd-nav__top">
              <BrandBlock rail={rail} workspace={workspace} />
              {mode === "drawer" ? <Button variant="ghost" size="icon-sm" aria-label="Close navigation" onClick={() => setDrawer(false)}><Icon name="x" /></Button> : null}
            </div>
          </SidebarHeader>
          <SidebarContent>
            {MERIDIAN_SIDEBAR.map((group, index) => (
              <SidebarGroup key={group.label || index}>
                {rail || !group.label ? null : <SidebarGroupLabel>{group.label}</SidebarGroupLabel>}
                <SidebarMenu>
                  {group.items.map((route) => {
                    const spec = route.badge ? NAV_BADGES[route.badge] : null;
                    return (
                      <NavRow
                        key={route.path}
                        route={route}
                        rail={rail}
                        active={route.path === path || (route.path !== "/" && path.indexOf(route.path + "/") === 0)}
                        badge={spec ? { count: spec.count(data), label: spec.label } : null}
                        onNavigate={(p) => { onNavigate(p); setDrawer(false); }}
                      />
                    );
                  })}
                </SidebarMenu>
              </SidebarGroup>
            ))}
          </SidebarContent>
          <SidebarFooter>
            <SidebarSeparator />
            <AccountMenu rail={rail} onNavigate={onNavigate} profile={profile} />
          </SidebarFooter>
        </Sidebar>
      ) : null}

      <SidebarInset className="fd-inset">
        <Toolbar
          mode={mode}
          dark={dark}
          onToggleTheme={onToggleTheme}
          onOpenPalette={() => palette.setOpen(true)}
          onOpenDrawer={() => setDrawer(true)}
          notifications={notifications}
          unread={unread}
          onRead={onRead}
          onNavigate={onNavigate}
        />
        <div className="fd-content">{children}</div>
      </SidebarInset>

      <ToastRegion>
        {toasts.map((t) => (
          <Toast key={t.id} variant={t.variant} title={t.title} description={t.description} onClose={() => onDismissToast(t.id)}
            action={t.onUndo ? <Button variant="outline" size="xs" onClick={() => { t.onUndo(); onDismissToast(t.id); }}>Undo</Button> : null} />
        ))}
      </ToastRegion>
    </div>
  );
}

Object.assign(window, { MeridianShell, PageHeader, useShellMode, NavRow, AccountMenu });

})();
</script>
<script type="text/babel" data-presets="react" data-file="MeridianParts.jsx">
(function(){
/* Meridian — shared parts for the list and record screens. Nothing here holds
   data or assigns a status tone; StatusBadge does that and metrics.js does the
   arithmetic. Reported in the README as local additions. */
(() => {
  const {
    Button, Input, Select, Checkbox, Textarea, Field, FieldLabel, FieldDescription, FieldError,
    Dialog, DialogPopup, DialogHeader, DialogTitle, DialogDescription, DialogPanel, DialogFooter,
    RadioGroup, MenuItem, MenuGroupLabel,
  } = window.OriginDesignSystem_7c998b;

  /* Buttons for a transition set. The set comes from enums.js; this draws it,
     and disables with the stated reason where the set says so. */
  function Transitions({ transitions, onRun, size = "sm" }) {
    if (!transitions.length) return null;
    return (
      <span className="fd-actions__row">
        {transitions.map((t) => {
          const button = (
            <Button key={t.id} size={size} disabled={!!t.disabled}
              variant={t.destructive ? "destructive-outline" : t.primary ? "default" : "outline"}
              onClick={() => onRun(t)}>{t.label}</Button>
          );
          return t.disabled && t.disabledReason
            ? <FloatingTip key={t.id} label={t.disabledReason} side="bottom">{button}</FloatingTip>
            : button;
        })}
      </span>
    );
  }

  /* The filter bar every list screen in this product opens with: text filter
     leftmost, then 2–4 dropdowns, then the reset. */
  function FilterBar({ query, onQuery, placeholder, selects, onClear, dirty }) {
    return (
      <div className="fd-filters">
        <div className="fd-filters__text">
          <Input size="sm" value={query} placeholder={placeholder} aria-label={placeholder} startAddon={<Icon name="search" />} onChange={(e) => onQuery(e.target.value)} />
        </div>
        {selects.map((s) => (
          <span className="fd-filters__select" key={s.id}>
            <Select size="sm" items={s.items} value={s.value} aria-label={s.label} onValueChange={s.onChange} />
          </span>
        ))}
        {dirty ? <Button variant="ghost" size="sm" onClick={onClear}>Clear filters</Button> : null}
      </div>
    );
  }

  function BulkBar({ count, note, children, onClear }) {
    if (!count) return null;
    return (
      <div className="fd-bulk">
        <span className="fd-bulk__count">{count} selected</span>
        {note ? <span className="fd-footnote">{note}</span> : null}
        <span className="fd-bulk__actions">
          {children}
          <Button variant="ghost" size="sm" onClick={onClear}>Clear selection</Button>
        </span>
      </div>
    );
  }

  /* A staged dialog — its own commit model, so an instant screen may open one. */
  function FormDialog({ request, onCancel, onSubmit }) {
    const [values, setValues] = React.useState({});
    const [touched, setTouched] = React.useState(false);
    const [submitted, setSubmitted] = React.useState(false);
    React.useEffect(() => { setValues(request ? request.initial || {} : {}); setTouched(false); setSubmitted(false); }, [request]);
    if (!request) return null;
    const errors = request.validate ? request.validate(values) : {};
    const valid = Object.keys(errors).length === 0;
    const set = (id, value) => { setTouched(true); setValues((v) => Object.assign({}, v, { [id]: value })); };
    return (
      <Dialog open onOpenChange={(open) => { if (!open) onCancel(); }}>
        <DialogPopup>
          <DialogHeader>
            <DialogTitle>{request.title}</DialogTitle>
            <DialogDescription>{request.description}</DialogDescription>
          </DialogHeader>
          <DialogPanel>
            <div className="fd-stack">
              {request.fields.filter((f) => !f.when || f.when(values)).map((field) => (
                <Field key={field.id}>
                  <FieldLabel htmlFor={"fd-f-" + field.id}>{field.label}</FieldLabel>
                  {field.kind === "radio" ? (
                    <RadioGroup name={"fd-f-" + field.id} items={field.options} value={values[field.id] || ""} onValueChange={(v) => set(field.id, v)} />
                  ) : field.kind === "checks" ? (
                    <div className="fd-checks">
                      {field.options.map((option) => (
                        <label className="fd-check" key={option.value}>
                          <Checkbox checked={(values[field.id] || []).indexOf(option.value) > -1}
                            onCheckedChange={() => set(field.id, (values[field.id] || []).indexOf(option.value) > -1
                              ? (values[field.id] || []).filter((v) => v !== option.value)
                              : (values[field.id] || []).concat([option.value]))} />
                          <span className="fd-check__text"><span>{option.label}</span></span>
                        </label>
                      ))}
                    </div>
                  ) : field.kind === "text" ? (
                    <Textarea id={"fd-f-" + field.id} rows={3} placeholder={field.placeholder} value={values[field.id] || ""} invalid={submitted && !!errors[field.id]} onChange={(e) => set(field.id, e.target.value)} />
                  ) : (
                    <Input id={"fd-f-" + field.id} placeholder={field.placeholder} value={values[field.id] || ""} invalid={submitted && !!errors[field.id]} onChange={(e) => set(field.id, e.target.value)} />
                  )}
                  {submitted && errors[field.id] ? <FieldError>{errors[field.id]}</FieldError> : field.hint ? <FieldDescription>{field.hint}</FieldDescription> : null}
                </Field>
              ))}
            </div>
          </DialogPanel>
          <DialogFooter>
            {touched ? <Button variant="outline" onClick={onCancel}>Cancel</Button> : null}
            <Button disabled={!touched} onClick={() => { setSubmitted(true); if (valid) onSubmit(values); }}>{request.submitLabel}</Button>
          </DialogFooter>
        </DialogPopup>
      </Dialog>
    );
  }

  function ExportMenu({ scope, onExport }) {
    return (
      <Floating align="end" width="12rem" label="Export"
        trigger={<Button variant="outline" size="sm"><Icon name="download" />Export<Icon name="chevron-down" /></Button>}>
        {(close) => (
          <>
            <MenuGroupLabel>{scope}</MenuGroupLabel>
            {["CSV", "XLSX"].map((format) => (
              <MenuItem key={format} onClick={() => { close(); onExport(format); }}>{format}</MenuItem>
            ))}
          </>
        )}
      </Floating>
    );
  }

  /* The staged form bar every settings screen ends with. */
  function FormBar({ dirty, note, onCancel, onSave, label }) {
    return (
      <div className="fd-formbar">
        <span className="fd-formbar__note">{note}</span>
        {dirty ? <Button variant="outline" onClick={onCancel}>Cancel</Button> : null}
        <Button disabled={!dirty} onClick={onSave}>{label}</Button>
      </div>
    );
  }

  /* A table row carries the transition its status ASKS for — the primary one —
     as a button. Everything else goes in the row's menu: a list is not where you
     put a button that cannot be undone, and a second button competing with the
     primary one is one entry point too many. */
  function RowActions({ transitions, onRun, label }) {
    const primary = transitions.filter((t) => t.primary);
    const rest = transitions.filter((t) => !t.primary);
    if (!primary.length && !rest.length) return <span className="fd-muted">—</span>;
    return (
      <span className="fd-actions__row">
        {primary.length ? <Transitions transitions={primary} onRun={onRun} /> : null}
        {rest.length ? (
          <Floating align="end" width="13rem" label={label}
            trigger={<Button variant="ghost" size="icon-sm" aria-label={label}><Icon name="ellipsis" /></Button>}>
            {(close) => rest.map((t) => (
              <MenuItem key={t.id} variant={t.destructive ? "destructive" : "default"} onClick={() => { close(); onRun(t); }}>{t.label}</MenuItem>
            ))}
          </Floating>
        ) : null}
      </span>
    );
  }

  Object.assign(window, { Transitions, RowActions, FilterBar, BulkBar, FormDialog, ExportMenu, FormBar });
})();

})();
</script>
<script type="text/babel" data-presets="react" data-file="Social.jsx">
(function(){
/* Meridian — the social layer: faces, notes and the mentions queue.

   A note is NOT a status field: it has no lifecycle, so enums.js carries no
   `comment.status`. Whether a mention is still owed is DERIVED — the person
   mentioned has not written on that record since (COMMENT_FLAGS.isAwaitingReply
   in enums.js), read by the queue card, the note itself and the count above the
   thread alike.

   Notes live only on records that HAVE a screen — a dispute and a card — so a
   mention in the queue always opens onto the thread it came from rather than a
   list where the conversation is invisible.

   A name is never written here: a mention token in a comment body is a member
   id (`@usr_tomas`), resolved through the one member fixture at render. */
(() => {
  const {
    Card, CardHeader, CardTitle, CardDescription, CardPanel, CardFooter, CardAction,
    Button, Textarea, Badge, Avatar,
  } = window.OriginDesignSystem_7c998b;

  const MENTION = /@usr_[a-z0-9_]+/g;
  const initialsOf = (name) => name.split(" ").map((w) => w[0]).slice(0, 2).join("");
  const personOf = (members, id) => members.find((m) => m.id === id) || { id: id, name: "somebody who has left", avatar: null, role: "" };
  /* What a record is CALLED comes from the record itself, never retyped. One
     branch per record type in RECORD_PATTERNS (nav.js), so a note can only be
     written on a record that has both a name and a route. */
  const RECORD_TITLES = {
    dispute: (data, id) => (data.disputes.find((r) => r.id === id) || {}).customer,
    card: (data, id) => (data.cards.find((r) => r.id === id) || {}).label,
    customer: (data, id) => (data.customers.find((r) => r.id === id) || {}).name,
    member: (data, id) => (data.members.find((r) => r.id === id) || {}).name,
  };
  const recordTitle = (data, record) => {
    const read = RECORD_TITLES[record.type];
    return (read && read(data, record.id)) || record.id;
  };

  function Face({ person, size = 24 }) {
    return (
      <FloatingTip label={person.name + (person.role ? " · " + person.role : "")} side="top">
        <span className="fd-face"><Avatar size={size} src={person.avatar} alt="" fallback={initialsOf(person.name)} /></span>
      </FloatingTip>
    );
  }

  /* Faces of the people on a record, not of the whole workspace: the ids come
     from peopleOn(), which reads the record's own person fields and its notes. */
  function FaceStack({ people, max = 4, size = 24 }) {
    if (!people.length) return null;
    const shown = people.slice(0, max);
    const rest = people.length - shown.length;
    return (
      <span className="fd-faces">
        {shown.map((p) => <Face key={p.id} person={p} size={size} />)}
        {rest > 0 ? <FloatingTip label={people.slice(max).map((p) => p.name).join(", ")} side="top"><span className="fd-faces__more">+{rest}</span></FloatingTip> : null}
      </span>
    );
  }

  function MentionText({ body, members }) {
    const parts = body.split(/(@usr_[a-z0-9_]+)/g);
    return (
      <span>
        {parts.map((part, i) => (part.indexOf("@usr_") === 0
          ? <span className="fd-mention" key={i}>@{personOf(members, part.slice(1)).name}</span>
          : <React.Fragment key={i}>{part}</React.Fragment>))}
      </span>
    );
  }

  function Note({ comment, comments, members, signedInId, now }) {
    const author = personOf(members, comment.authorId);
    const owed = comment.mentions.filter((id) => COMMENT_FLAGS.isAwaitingReply(comment, id, comments));
    return (
      <div className="fd-note">
        <Face person={author} size={28} />
        <div className="fd-note__body">
          <span className="fd-note__head">
            <span className="fd-note__who">{author.name}{comment.authorId === signedInId ? " (you)" : ""}</span>
            <span className="fd-note__when" title={formatAbsolute(comment.at)}>{formatWhen(comment.at, now)}</span>
            {owed.length ? <Badge size="sm" variant="warning">Waiting on {owed.map((id) => (id === signedInId ? "you" : personOf(members, id).name.split(" ")[0])).join(" and ")}</Badge> : null}
          </span>
          <p className="fd-note__text"><MentionText body={comment.body} members={members} /></p>
        </div>
      </div>
    );
  }

  /* Instant, and consequential: posting applies immediately and App.jsx raises
     an undo toast. Mentions are not a separate control — they are read out of
     the text, and the line under the box names who the draft will notify. */
  function Composer({ members, signedInId, onPost, placeholder }) {
    const [draft, setDraft] = React.useState("");
    const notifying = (draft.match(MENTION) || []).map((t) => t.slice(1)).filter((id) => members.some((m) => m.id === id));
    const others = members.filter((m) => m.id !== signedInId);
    const ready = draft.trim().length > 3;
    return (
      <div className="fd-composer">
        <Textarea rows={2} value={draft} placeholder={placeholder} aria-label={placeholder} onChange={(e) => setDraft(e.target.value)} />
        <div className="fd-composer__row">
          {others.map((m) => (
            <Button key={m.id} variant="outline" size="sm" onClick={() => setDraft((d) => (d ? d.replace(/\s*$/, " ") : "") + "@" + m.id + " ")}>
              <Face person={m} size={16} />{m.name.split(" ")[0]}
            </Button>
          ))}
          <span className="fd-composer__spacer" />
          <span className="fd-footnote">
            {notifying.length
              ? notifying.map((id) => personOf(members, id).name).join(" and ") + (notifying.length > 1 ? " get" : " gets") + " this in their queue"
              : "Nobody is notified unless you mention them"}
          </span>
          <Button size="sm" disabled={!ready} onClick={() => { onPost(draft.trim()); setDraft(""); }}>Post note</Button>
        </div>
      </div>
    );
  }

  /* The thread on a record. Oldest first, because a case is read forwards. */
  function ThreadCard({ record, data, signedInId, now, onPost, description }) {
    const thread = commentsOn(data.comments, record.type, record.id);
    const people = peopleOn(data.comments, record, data.members);
    const owed = thread.filter((c) => c.mentions.some((id) => COMMENT_FLAGS.isAwaitingReply(c, id, data.comments)));
    return (
      <Card>
        <CardHeader>
          <CardTitle>Notes</CardTitle>
          <CardDescription>{description}</CardDescription>
          <CardAction><FaceStack people={people} /></CardAction>
        </CardHeader>
        <CardPanel>
          <div className="fd-thread">
            {thread.length
              ? thread.map((c) => <Note key={c.id} comment={c} comments={data.comments} members={data.members} signedInId={signedInId} now={now} />)
              : <span className="fd-footnote">Nothing written on this one yet.</span>}
            <Composer members={data.members} signedInId={signedInId} placeholder="Write a note. Mention somebody to put it in their queue." onPost={(body) => onPost(record, body)} />
          </div>
        </CardPanel>
        <CardFooter className="fd-card-footer">
          <span className="fd-footnote">
            {thread.length
              ? thread.length + (thread.length === 1 ? " note · " : " notes · ")
              : ""}
            {people.length} {people.length === 1 ? "person" : "people"} on this record
            {owed.length ? " · " + owed.length + " waiting on a reply" : ""}
          </span>
        </CardFooter>
      </Card>
    );
  }

  /* The dashboard's social surface: every note that mentioned you and that you
     have not answered on that record since. One predicate, the same one the
     note badge and the thread footer read. */
  function ReplyQueueCard({ data, signedInId, now, onNavigate }) {
    const awaiting = mentionsAwaiting(data.comments, signedInId);
    return (
      <Card>
        <CardHeader>
          <CardTitle>Waiting on your reply</CardTitle>
          <CardDescription>
            {awaiting.length
              ? awaiting.length + " " + (awaiting.length === 1 ? "note mentions" : "notes mention") + " you and you have not written back on that record since."
              : "Nobody is waiting on a reply from you."}
          </CardDescription>
        </CardHeader>
        <CardPanel>
          {awaiting.length ? (
            <div className="fd-replies">
              {awaiting.map((c) => {
                const author = personOf(data.members, c.authorId);
                return (
                  <div className="fd-reply" key={c.id}>
                    <Face person={author} size={28} />
                    <div className="fd-note__body">
                      <span className="fd-note__head">
                        <span className="fd-note__who">{author.name}</span>
                        <span className="fd-note__when" title={formatAbsolute(c.at)}>{formatWhen(c.at, now)}</span>
                        <span className="fd-note__on">on {recordTitle(data, c.record)}</span>
                      </span>
                      <p className="fd-note__text"><MentionText body={c.body} members={data.members} /></p>
                    </div>
                    <Button variant="outline" size="sm" onClick={() => onNavigate(recordRoute(c.record))}>Open<Icon name="arrow-right" /></Button>
                  </div>
                );
              })}
            </div>
          ) : (
            <span className="fd-footnote">Notes you are mentioned in land here until you answer on the record.</span>
          )}
        </CardPanel>
      </Card>
    );
  }

  Object.assign(window, { Face, FaceStack, MentionText, ThreadCard, ReplyQueueCard, recordTitle });
})();

})();
</script>
<script type="text/babel" data-presets="react" data-file="Charts.jsx">
(function(){
/* Meridian — the product's two chart primitives, reported in the README as
   local additions. Neither holds data or does arithmetic: metrics.js buckets
   the rows and these draw them.

   Every bar is a figure (AGENTS §2), so every bar carries the tooltip that says
   which day it is, what it sums and how many rows it summed. Bars are CSS
   heights against the tallest bucket in the window; nothing here animates. */
(() => {
  const { Meter } = window.OriginDesignSystem_7c998b;

  /* One column a day. `series` names the keys drawn inside each column, in
     order, so a single-series chart is one bar and the movement chart is two.
     A series' count names EVERY array its figure sums (`rows`: [{key, noun}]),
     so the qualifier decomposes its own figure's set even where one bar adds
     two kinds of record together. */
  function DayChart({ buckets, series, format, label, note, tall }) {
    const peak = Math.max.apply(null, buckets.map((b) => Math.max.apply(null, series.map((s) => Math.abs(b[s.key] || 0)))).concat([1]));
    const axisEvery = Math.max(1, Math.round(buckets.length / 6));
    return (
      <div className="fd-chartbox">
        <div className={"fd-chart" + (tall ? " fd-chart--tall" : "")} role="img" aria-label={label + " — " + buckets.length + " days, tallest bar " + format(peak)}>
          {buckets.map((bucket) => {
            const empty = series.every((s) => !bucket[s.key]);
            const countOf = (s) => (s.rows || [])
              .map((part) => ({ n: (bucket[part.key] || []).length, noun: part.noun }))
              .filter((part) => part.n > 0)
              .map((part) => part.n + " " + (part.n === 1 ? part.noun : part.noun + "s"));
            const tip = formatDayFull(bucket.at) + " — " + (empty
              ? "nothing"
              : series.filter((s) => bucket[s.key]).map((s) => {
                const counted = countOf(s);
                return s.label + " " + format(bucket[s.key]) + (counted.length ? " across " + counted.join(" and ") : "");
              }).join(" · "));
            return (
              <FloatingTip key={bucket.at} side="bottom" label={tip}>
                <span className="fd-chart__col">
                  {empty ? <span className="fd-chart__bar fd-chart__bar--empty" /> : series.map((s) => (
                    <span
                      key={s.key}
                      className={"fd-chart__bar" + (s.tone ? " fd-chart__bar--" + s.tone : "")}
                      style={{ height: Math.max(2, (Math.abs(bucket[s.key] || 0) / peak) * 100) + "%" }}
                    />
                  ))}
                </span>
              </FloatingTip>
            );
          })}
        </div>
        <div className="fd-chart__axis">
          {buckets.map((bucket, i) => (
            <span key={bucket.at}>{i === 0 || i === buckets.length - 1 || i % axisEvery === 0 ? formatDay(bucket.at) : ""}</span>
          ))}
        </div>
        <div className="fd-chart__foot">
          {series.length > 1 ? (
            <span className="fd-legend">
              {series.map((s) => (
                <span className="fd-legend__item" key={s.key}>
                  <span className={"fd-legend__swatch" + (s.tone ? " fd-chart__bar--" + s.tone : "")} aria-hidden="true" />
                  {s.label}
                </span>
              ))}
            </span>
          ) : null}
          {note ? <span className="fd-footnote">{note}</span> : null}
        </div>
      </div>
    );
  }

  /* A proportion list: label, count, figure, bar. The rows come from methodMix
     or spendByCategory, both of which decompose the total printed above them. */
  function MixRows({ rows, total, unitLabel }) {
    return (
      <div className="fd-stack">
        {rows.map((row) => (
          <div className="fd-mix" key={row.label}>
            <div className="fd-mix__head">
              <span>{row.label}<span className="fd-mix__count">{row.count}</span></span>
              <span className="fd-amount">{formatMoney(row.amount)}</span>
            </div>
            <Meter value={row.amount} max={total} aria-label={row.label + " share of " + unitLabel} />
          </div>
        ))}
      </div>
    );
  }

  Object.assign(window, { DayChart, MixRows });
})();

})();
</script>
<script type="text/babel" data-presets="react" data-file="DashboardCards.jsx">
(function(){
/* Dashboard cards. Commit model: INSTANT (declared in DashboardScreen.jsx) —
   no Save button anywhere; each control confirms itself, consequential ones
   with an undo toast and irreversible ones with a confirm dialog. */

const {
  Card, CardHeader, CardTitle, CardDescription, CardAction, CardPanel, CardFooter,
  Table, TableHeader, TableBody, TableRow, TableHead, TableCell,
  Button: CardsButton, Badge: CardsBadge, Separator: CardsSeparator, Meter,
  Tabs, TabsList, TabsTab, MenuItem: RowMenuItem, MenuSeparator: RowMenuSeparator,
  Empty, EmptyHeader, EmptyMedia, EmptyTitle, EmptyDescription, EmptyContent,
} = window.OriginDesignSystem_7c998b;

function RowMenu({ label, children }) {
  return (
    <Floating
      align="end"
      width="13rem"
      label={label}
      trigger={<CardsButton variant="ghost" size="icon-sm" aria-label={label}><Icon name="more-horizontal" /></CardsButton>}
    >
      {children}
    </Floating>
  );
}

/* ── Payouts ─────────────────────────────────────────────────────────── */

function PayoutQueueCard({ payouts, approver, members, now, onApprove, onReject, onNavigate }) {
  const queue = awaitingApprovalPayouts(payouts);
  const approvable = approvablePayouts(payouts, approver);
  const blocked = queue.filter(PAYOUT_FLAGS.isBlocked);
  const overLimit = queue.filter((p) => !PAYOUT_FLAGS.isBlocked(p) && PAYOUT_FLAGS.isAboveLimit(p, approver));
  const total = sumBy(queue, "amount");

  return (
    <Card className="fd-card fd-card--table">
      <CardHeader>
        <CardTitle>Payouts awaiting approval</CardTitle>
        <CardDescription>
          {formatMoney(total)} across {queue.length} payouts — {approvable.length} you can approve now
          {blocked.length ? ", " + blocked.length + " waiting on compliance review" : ""}
          {overLimit.length ? ", " + overLimit.length + " above your " + formatMoney(approver.approvalLimit) + " limit" : ""}.
        </CardDescription>
      </CardHeader>
      <div className="fd-card-table">
        <Table>
          <TableHeader>
            <TableRow>
              <TableHead>Payout</TableHead>
              <TableHead className="fd-num">Amount</TableHead>
              <TableHead className="fd-drop-md">Expected</TableHead>
              <TableHead className="fd-actions-head">Approval</TableHead>
            </TableRow>
          </TableHeader>
          <TableBody>
            {queue.map((payout) => {
              const isBlocked = PAYOUT_FLAGS.isBlocked(payout);
              const above = !isBlocked && PAYOUT_FLAGS.isAboveLimit(payout, approver);
              const higher = above ? members.filter((m) => m.approvalLimit >= payout.amount) : [];
              const transitions = payoutTransitions(payout, approver);
              const approve = transitions.find((t) => t.id === "approve");
              const reject = transitions.find((t) => t.id === "reject");
              return (
                <TableRow key={payout.id} className={isBlocked || above ? "fd-row--recessed" : ""}>
                  <TableCell>
                    <span className="fd-record">
                      <span className="fd-record__name">{payout.destination}</span>
                      <span className="fd-record__meta"><code>{payout.id}</code> · {payout.account}</span>
                    </span>
                  </TableCell>
                  <TableCell className="fd-num fd-amount">{formatMoney(payout.amount)}</TableCell>
                  <TableCell className="fd-drop-md">
                    <span title={formatAbsolute(payout.expectedAt)}>{formatWhen(payout.expectedAt, now)}</span>
                    <span className="fd-sub">{formatCountdown(payout.expectedAt, now)}</span>
                  </TableCell>
                  <TableCell className="fd-actions">
                    {isBlocked ? (
                      <span className="fd-waiting">Waiting on {payout.blockedBy.team} — {payout.blockedBy.reason}</span>
                    ) : above ? (
                      <span className="fd-waiting">Above your limit — {higher.length ? higher.map((m) => m.name).join(" or ") : "nobody here"} can release it</span>
                    ) : (
                      <span className="fd-actions__row">
                        <CardsButton size="xs" onClick={() => onApprove(payout)}>{approve.label.replace(" payout", "")}</CardsButton>
                        <CardsButton size="xs" variant="destructive-outline" onClick={() => onReject(payout)}>{reject.label.replace(" payout", "")}</CardsButton>
                      </span>
                    )}
                  </TableCell>
                </TableRow>
              );
            })}
          </TableBody>
        </Table>
      </div>
      <CardFooter className="fd-card-footer">
        <span className="fd-footnote">{formatMoney(pendingPayoutTotal(payouts))} committed to payouts in total, including approved and in transit.</span>
        <CardsButton variant="ghost" size="sm" onClick={() => onNavigate("/payouts")}>All payouts<Icon name="arrow-right" /></CardsButton>
      </CardFooter>
    </Card>
  );
}

/* ── Payments ────────────────────────────────────────────────────────── */

const PAYMENT_TABS = [
  { value: "all", label: "All", predicate: () => true },
  { value: "succeeded", label: "Succeeded", predicate: (p) => p.status === "succeeded" },
  { value: "attention", label: "Needs attention", predicate: (p, disputes) => PAYMENT_FLAGS.needsAttention(p, disputes) },
];
const PAYMENTS_SHOWN = 6;

function PaymentsCard({ payments, disputes, periodLabel, now, onCapture, onRetry, onRefund, onNavigate }) {
  const [tab, setTab] = React.useState("all");
  const spec = PAYMENT_TABS.find((t) => t.value === tab);
  const rows = payments.filter((p) => spec.predicate(p, disputes)).sort((a, b) => b.createdAt - a.createdAt);
  const shown = rows.slice(0, PAYMENTS_SHOWN);

  return (
    <Card className="fd-card fd-card--table">
      <CardHeader>
        <CardTitle>Payments</CardTitle>
        <CardDescription>{periodLabel}, newest first.</CardDescription>
      </CardHeader>
      <div className="fd-card-tabs">
        <Tabs value={tab} onValueChange={setTab}>
          <TabsList variant="underline" size="sm">
            {PAYMENT_TABS.map((t) => (
              <TabsTab key={t.value} value={t.value} size="sm">
                {t.label}<span className="fd-tab-count">{payments.filter((p) => t.predicate(p, disputes)).length}</span>
              </TabsTab>
            ))}
          </TabsList>
        </Tabs>
      </div>
      {shown.length === 0 ? (
        <CardPanel>
          <Empty>
            <EmptyHeader>
              <EmptyMedia variant="icon"><Icon name="check" size={18} /></EmptyMedia>
              <EmptyTitle>Nothing needs attention</EmptyTitle>
              <EmptyDescription>No payment in the {periodLabel.toLowerCase()} failed or picked up a dispute.</EmptyDescription>
            </EmptyHeader>
            <EmptyContent>
              <CardsButton variant="outline" size="sm" onClick={() => setTab("all")}>Show all payments</CardsButton>
            </EmptyContent>
          </Empty>
        </CardPanel>
      ) : (
        <div className="fd-card-table">
          <Table>
            <TableHeader>
              <TableRow>
                <TableHead>Payment</TableHead>
                <TableHead className="fd-drop-lg">Method</TableHead>
                <TableHead className="fd-num">Amount</TableHead>
                <TableHead>Status</TableHead>
                <TableHead className="fd-drop-md">Created</TableHead>
                <TableHead className="fd-actions-head"><span className="o-sr">Actions</span></TableHead>
              </TableRow>
            </TableHeader>
            <TableBody>
              {shown.map((payment) => {
                const disputed = PAYMENT_FLAGS.hasOpenDispute(payment, disputes);
                const transitions = paymentTransitions(payment);
                return (
                  <TableRow key={payment.id}>
                    <TableCell>
                      <span className="fd-record">
                        <span className="fd-record__name">{payment.customer}</span>
                        <span className="fd-record__meta"><code>{payment.id}</code>{payment.failureReason ? " · " + payment.failureReason : ""}</span>
                      </span>
                    </TableCell>
                    <TableCell className="fd-drop-lg fd-muted">{labelOf(PAYMENT_METHODS, payment.method)}</TableCell>
                    <TableCell className="fd-num fd-amount">{formatMoney(payment.amount)}</TableCell>
                    <TableCell>
                      <span className="fd-status">
                        <StatusBadge values={PAYMENT_STATUS} value={payment.status} />
                        {disputed ? <CardsBadge size="sm" variant="warning">Disputed</CardsBadge> : null}
                      </span>
                    </TableCell>
                    <TableCell className="fd-drop-md fd-muted" title={formatAbsolute(payment.createdAt)}>{formatWhen(payment.createdAt, now)}</TableCell>
                    <TableCell className="fd-actions">
                      <RowMenu label={"Actions for " + payment.id}>
                        {(close) => (
                          <>
                            <RowMenuItem onClick={() => { onNavigate("/payments"); close(); }}>Open in payments</RowMenuItem>
                            {payment.status === "succeeded" || payment.status === "refunded" ? (
                              <RowMenuItem onClick={close}>Download receipt</RowMenuItem>
                            ) : null}
                            {transitions.length ? <RowMenuSeparator /> : null}
                            {transitions.map((t) => (
                              <RowMenuItem
                                key={t.id}
                                variant={t.destructive ? "destructive" : "default"}
                                onClick={() => {
                                  close();
                                  if (t.id === "capture") onCapture(payment);
                                  if (t.id === "retry") onRetry(payment);
                                  if (t.id === "refund") onRefund(payment);
                                }}
                              >{t.label}</RowMenuItem>
                            ))}
                          </>
                        )}
                      </RowMenu>
                    </TableCell>
                  </TableRow>
                );
              })}
            </TableBody>
          </Table>
        </div>
      )}
      <CardFooter className="fd-card-footer">
        <span className="fd-footnote">
          {shown.length ? "Showing 1–" + shown.length + " of " + rows.length : "No rows"} · {periodLabel.toLowerCase()}
        </span>
        <CardsButton variant="ghost" size="sm" onClick={() => onNavigate("/payments")}>All payments<Icon name="arrow-right" /></CardsButton>
      </CardFooter>
    </Card>
  );
}

/* ── Balance ─────────────────────────────────────────────────────────── */

function BalanceCard({ balance, payouts, disputes, onNavigate }) {
  const b = balanceBreakdown(balance, payouts, disputes);
  return (
    <Card className="fd-card">
      <CardHeader>
        <CardTitle>Balance</CardTitle>
        <CardDescription>{balance.bank}</CardDescription>
      </CardHeader>
      <CardPanel className="fd-stack">
        <div>
          <div className="fd-figure">{formatMoney(b.available)}</div>
          <div className="fd-figure__label">Available now</div>
        </div>
        <Meter value={b.available} max={b.total} aria-label="Available share of total balance" />
        <dl className="fd-defs">
          <div><dt>Committed to payouts</dt><dd>{formatMoney(b.committed)}</dd></div>
          <div><dt>Held against open disputes</dt><dd>{formatMoney(b.reserved)}</dd></div>
          <div><dt>In transit to bank</dt><dd>{formatMoney(inTransitTotal(payouts))}</dd></div>
          <div className="fd-defs__total"><dt>Total balance</dt><dd>{formatMoney(b.total)}</dd></div>
        </dl>
      </CardPanel>
      <CardFooter className="fd-card-footer">
        <CardsButton variant="ghost" size="sm" onClick={() => onNavigate("/balances")}>Balance history<Icon name="arrow-right" /></CardsButton>
      </CardFooter>
    </Card>
  );
}

/* ── Disputes ────────────────────────────────────────────────────────── */

function DisputesCard({ disputes, now, onNavigate }) {
  const open = openDisputes(disputes);
  const needing = disputesNeedingEvidence(disputes);
  return (
    <Card className="fd-card">
      <CardHeader>
        <CardTitle>Disputes needing evidence</CardTitle>
        <CardDescription>{needing.length} of {open.length} open disputes are waiting on you, {formatMoney(disputeExposure(disputes))} held in total.</CardDescription>
      </CardHeader>
      <CardPanel className="fd-list">
        {needing.map((dispute) => {
          const soon = DISPUTE_FLAGS.isClosingSoon(dispute, now);
          return (
            <div className="fd-list__row" key={dispute.id}>
              <div className="fd-list__main">
                <span className="fd-record__name">{dispute.customer}</span>
                <span className="fd-record__meta"><code>{dispute.id}</code> · {dispute.reason}</span>
              </div>
              <div className="fd-list__side">
                <span className="fd-amount">{formatMoney(dispute.amount)}</span>
                <span className={soon ? "fd-clock fd-clock--danger" : "fd-clock fd-clock--warning"} title={formatAbsolute(dispute.evidenceDueAt)}>
                  {formatCountdown(dispute.evidenceDueAt, now)}
                </span>
              </div>
              <CardsButton size="xs" variant="outline" onClick={() => onNavigate("/disputes/" + dispute.id)}>Submit evidence</CardsButton>
            </div>
          );
        })}
      </CardPanel>
      <CardFooter className="fd-card-footer">
        <CardsButton variant="ghost" size="sm" onClick={() => onNavigate("/disputes")}>All {disputes.length} disputes<Icon name="arrow-right" /></CardsButton>
      </CardFooter>
    </Card>
  );
}

/* ── Volume by day ───────────────────────────────────────────────────── */

/* The same arithmetic the net-volume tile does, bucketed by day, so the bars
   add back to the figure above them over the same scoped array. */
function VolumeCard({ payments, periodLabel, now, days }) {
  const buckets = volumeByDay(payments, now, days);
  const busiest = buckets.slice().sort((a, b) => b.amount - a.amount)[0];
  const failed = failedPayments(payments);
  return (
    <Card className="fd-card">
      <CardHeader>
        <CardTitle>Net volume by day</CardTitle>
        <CardDescription>{formatMoney(netVolume(payments))} across {settledAttempts(payments).length} payments with an outcome, {periodLabel.toLowerCase()}.</CardDescription>
      </CardHeader>
      <CardPanel>
        <DayChart
          buckets={buckets}
          series={[{ key: "amount", label: "Net volume", rows: [{ key: "rows", noun: "payment" }] }]}
          format={formatMoney}
          label="Net volume a day"
          note={busiest && busiest.amount ? "Busiest day " + formatDay(busiest.at) + ", " + formatMoney(busiest.amount) + " across " + busiest.rows.length + " payments." : "Nothing settled in this period."}
        />
      </CardPanel>
      <CardFooter className="fd-card-footer">
        <span className="fd-footnote">Refunds are subtracted on the day the payment was taken. {failed.length} failed payments, {formatMoney(sumBy(failed, "amount"))}, are outside every bar.</span>
      </CardFooter>
    </Card>
  );
}

/* ── Card spend waiting on review ─────────────────────────────────────── */

function ExpenseReviewCard({ expenses, cards, members, policy, now, onApprove, onRun, onNavigate }) {
  const waiting = expensesAwaitingReview(expenses);
  const approvable = reviewableExpenses(expenses, policy);
  const nameOf = (id) => (members.find((m) => m.id === id) || { name: "somebody who has left" }).name;
  const cardOf = (id) => cards.find((c) => c.id === id);
  return (
    <Card className="fd-card">
      <CardHeader>
        <CardTitle>Card spend waiting on you</CardTitle>
        <CardDescription>
          {approvable.length} of {waiting.length} expenses under review can be approved now, {formatMoney(sumBy(approvable, "amount"))} in total
          {waiting.length - approvable.length ? "; " + (waiting.length - approvable.length) + " are still owed a receipt" : ""}.
        </CardDescription>
      </CardHeader>
      <CardPanel className="fd-list">
        {waiting.map((expense) => {
          const owed = EXPENSE_FLAGS.needsReceipt(expense, policy);
          const on = cardOf(expense.cardId);
          return (
            <div className={owed ? "fd-list__row fd-row--recessed" : "fd-list__row"} key={expense.id}>
              <div className="fd-list__main">
                <span className="fd-record__name">{expense.merchant}</span>
                <span className="fd-record__meta"><code>{expense.id}</code> · {on ? on.label : "card closed"} · {nameOf(expense.spenderId)}</span>
              </div>
              <div className="fd-list__side">
                <span className="fd-amount">{formatMoney(expense.amount)}</span>
                <span className="fd-sub" title={formatAbsolute(expense.occurredAt)}>{formatWhen(expense.occurredAt, now)}</span>
              </div>
              {owed
                ? <span className="fd-waiting">Waiting on {nameOf(expense.spenderId)} — no receipt on {labelOf(EXPENSE_CATEGORIES, expense.category).toLowerCase()} spend</span>
                : <span className="fd-actions__row">
                  <CardsButton size="xs" onClick={() => onApprove(expense)}>Approve</CardsButton>
                  <CardsButton size="xs" variant="outline" onClick={() => onRun(expense, { id: "query_expense" })}>Query</CardsButton>
                </span>}
            </div>
          );
        })}
      </CardPanel>
      <CardFooter className="fd-card-footer">
        <span className="fd-footnote">Receipts are required over {formatMoney(policy.receiptThresholdCents)}.</span>
        <CardsButton variant="ghost" size="sm" onClick={() => onNavigate("/expenses")}>All expenses<Icon name="arrow-right" /></CardsButton>
      </CardFooter>
    </Card>
  );
}

/* ── Method mix ──────────────────────────────────────────────────────── */

function MethodMixCard({ payments, periodLabel }) {
  const succeeded = succeededPayments(payments);
  const mix = methodMix(payments, PAYMENT_METHODS);
  const total = sumBy(succeeded, "amount");
  return (
    <Card className="fd-card">
      <CardHeader>
        <CardTitle>How the volume arrived</CardTitle>
        <CardDescription>{formatMoney(total)} succeeded across {succeeded.length} payments, {periodLabel.toLowerCase()}.</CardDescription>
      </CardHeader>
      <CardPanel className="fd-stack">
        <MixRows rows={mix} total={total} unitLabel="succeeded volume" />
      </CardPanel>
    </Card>
  );
}

Object.assign(window, { PayoutQueueCard, PaymentsCard, BalanceCard, DisputesCard, MethodMixCard, VolumeCard, ExpenseReviewCard, RowMenu });

})();
</script>
<script type="text/babel" data-presets="react" data-file="DashboardScreen.jsx">
(function(){
/* Dashboard — the screen the product opens on.
   Commit model: INSTANT. There is no Save button anywhere on this screen; each
   control confirms itself. Harmless controls apply bare (period scope, tab),
   consequential ones apply immediately and raise an undo toast (capture, retry,
   approve), irreversible ones open a confirm dialog (refund, reject).
   Data state: populated. Loading, empty, no-results and error are branches of
   one conditional elsewhere — none of them is on screen beside these rows. */

const {
  Card: TileCard, CardPanel: TileCardPanel,
  Select, Button: ScreenButton, MenuItem: ExportItem, MenuGroupLabel: ExportLabel,
  AlertDialog, AlertDialogPopup, AlertDialogHeader, AlertDialogTitle, AlertDialogDescription, AlertDialogFooter,
  Empty: ScreenEmpty, EmptyHeader: ScreenEmptyHeader, EmptyMedia: ScreenEmptyMedia,
  EmptyTitle: ScreenEmptyTitle, EmptyDescription: ScreenEmptyDescription, EmptyContent: ScreenEmptyContent,
} = window.OriginDesignSystem_7c998b;

/* The figure tile every screen opens with. The icon is inset in the top-right
   corner at the card's own radius less its inset, so the chip is concentric
   with the card rather than merely near its corner. It names the KIND of figure
   and nothing else — it carries no state, so it never competes with the value. */
function MetricTile({ label, value, qualifier, icon }) {
  return (
    <TileCard className={"fd-tile" + (icon ? " fd-tile--icon" : "")}>
      {icon ? <span className="fd-tile__icon" aria-hidden="true"><Icon name={icon} size={16} /></span> : null}
      <TileCardPanel className="fd-tile__body">
        <span className="fd-tile__label">{label}</span>
        <span className="fd-tile__value">{value}</span>
        <span className="fd-tile__qualifier">{qualifier}</span>
      </TileCardPanel>
    </TileCard>
  );
}

function ConfirmDialog({ request, onCancel, onConfirm }) {
  return (
    <AlertDialog open={!!request} onOpenChange={(open) => { if (!open) onCancel(); }}>
      <AlertDialogPopup>
        <AlertDialogHeader>
          <AlertDialogTitle>{request ? request.title : ""}</AlertDialogTitle>
          <AlertDialogDescription>{request ? request.description : ""}</AlertDialogDescription>
        </AlertDialogHeader>
        <AlertDialogFooter>
          <ScreenButton variant="outline" onClick={onCancel}>Cancel</ScreenButton>
          <ScreenButton variant="destructive" onClick={onConfirm}>{request ? request.confirmLabel : ""}</ScreenButton>
        </AlertDialogFooter>
      </AlertDialogPopup>
    </AlertDialog>
  );
}

/* Nothing in this product links here: every rendered entry point resolves to a
   registered route. It exists for a hand-typed or stale path — which has no
   breadcrumb, because the route table is what breadcrumbs are made of. */
function RouteNotBuilt({ path, onNavigate }) {
  const route = matchRoute(path, MERIDIAN_ROUTES);
  return (
    <div className="fd-page">
      <PageHeader path={path} title={route ? route.label : "Not found"} />
      <ScreenEmpty>
        <ScreenEmptyHeader>
          <ScreenEmptyMedia variant="icon"><Icon name="route" size={18} /></ScreenEmptyMedia>
          <ScreenEmptyTitle>{route ? route.label + " is registered, not built" : "There is nothing at " + path}</ScreenEmptyTitle>
          <ScreenEmptyDescription>{route ? "The route and its place in the palette are real; the screen behind it is not part of this template." : "This path is not in Meridian's route table, so it has no breadcrumb and nothing links to it. The eleven destinations in the sidebar are the whole product."}</ScreenEmptyDescription>
        </ScreenEmptyHeader>
        <ScreenEmptyContent>
          <ScreenButton variant="outline" onClick={() => onNavigate("/")}>Back to dashboard</ScreenButton>
        </ScreenEmptyContent>
      </ScreenEmpty>
    </div>
  );
}

function DashboardScreen({ data, pushToast, onNavigate, period, onPeriodChange, commits }) {
  const now = MERIDIAN_NOW;
  /* Every commit is App.jsx's, so this row and the row on the payouts screen
     call one function (AGENTS §8.2). */
  const { approvePayout, rejectPayout, refundPayment, capturePayment, retryPayment, approveExpense, onExpenseTransition } = commits;
  const days = Number(period);
  const periodLabel = PERIODS.find((p) => p.value === period).label;

  /* Every figure below reads one of these two scoped arrays, or the live
     payouts / disputes / balance the cards name for themselves. */
  const periodPayments = paymentsInPeriod(data.payments, days, now);
  const periodDisputes = disputesInPeriod(data.disputes, days, now);
  const periodExpenses = expensesInPeriod(data.expenses, days, now);

  const succeeded = succeededPayments(periodPayments);
  const refunded = refundedPayments(periodPayments);
  const failed = failedPayments(periodPayments);
  const attempts = settledAttempts(periodPayments);
  const authorised = authorisedAttempts(periodPayments);

  const exportMenu = (
    <Floating
      align="end"
      width="12rem"
      label="Export"
      trigger={<ScreenButton variant="outline" size="sm"><Icon name="download" />Export<Icon name="chevron-down" /></ScreenButton>}
    >
      {(close) => (
        <>
          <ExportLabel>{periodLabel}</ExportLabel>
          {["CSV", "XLSX"].map((format) => (
            <ExportItem key={format} onClick={() => { close(); pushToast({ title: "Export started", description: periodLabel + " · " + format + " — we'll email it to " + MERIDIAN_USER.name.split(" ")[0].toLowerCase() + "@northwind.example" }); }}>
              {format}
            </ExportItem>
          ))}
        </>
      )}
    </Floating>
  );

  return (
    <div className="fd-page">
      <PageHeader path="/" title="Dashboard" actions={exportMenu} />

      {/* Scope. Sits above the figures it moves. */}
      <div className="fd-scope">
        <div className="fd-scope__control">
          <label className="fd-scope__label" htmlFor="fd-period">Period</label>
          <Select id="fd-period" size="sm" items={PERIODS} value={period} onValueChange={onPeriodChange} />
        </div>
        <p className="fd-scope__note">Volume figures cover the {periodLabel.toLowerCase()}. Balance, payouts and disputes are current.</p>
      </div>

      <div className="fd-metrics">
        <MetricTile
          icon="trending-up" label="Net volume"
          value={formatMoney(netVolume(periodPayments))}
          qualifier={formatMoney(sumBy(succeeded, "amount")) + " across " + succeeded.length + " succeeded, less " + formatMoney(refundTotal(periodPayments)) + " refunded"}
        />
        <MetricTile
          icon="percent" label="Authorisation rate"
          value={formatPercent(authorisationRate(periodPayments))}
          qualifier={authorised.length + " of " + attempts.length + " attempts with an outcome"}
        />
        <MetricTile
          icon="x-circle" label="Failed payments"
          value={formatCount(failed.length)}
          qualifier={formatMoney(sumBy(failed, "amount")) + " not collected"}
        />
        <MetricTile
          icon="shield-alert" label="Disputes opened"
          value={formatMoney(sumBy(periodDisputes, "amount"))}
          qualifier={periodDisputes.length + " opened, " + disputesNeedingEvidence(periodDisputes).length + " still need evidence"}
        />
        <MetricTile
          icon="receipt" label="Card spend"
          value={formatMoney(cardSpendTotal(periodExpenses))}
          qualifier={settledExpenses(periodExpenses).length + " settled expenses on " + liveCards(data.cards).length + " live cards, " + reviewableExpenses(periodExpenses, data.policy).length + " of them waiting on your review"}
        />
      </div>

      <div className="fd-columns">
        <div className="fd-col">
          <VolumeCard payments={periodPayments} periodLabel={periodLabel} now={now} days={days} />
          <PayoutQueueCard payouts={data.payouts} approver={data.approver} members={data.members} now={now} onApprove={approvePayout} onReject={rejectPayout} onNavigate={onNavigate} />
          <PaymentsCard
            payments={periodPayments}
            disputes={data.disputes}
            periodLabel={periodLabel}
            now={now}
            onCapture={capturePayment}
            onRetry={retryPayment}
            onRefund={refundPayment}
            onNavigate={onNavigate}
          />
        </div>
        <div className="fd-col">
          <BalanceCard balance={data.balance} payouts={data.payouts} disputes={data.disputes} onNavigate={onNavigate} />
          <DisputesCard disputes={data.disputes} now={now} onNavigate={onNavigate} />
          <ReplyQueueCard data={data} signedInId={MERIDIAN_SIGNED_IN} now={now} onNavigate={onNavigate} />
          <ExpenseReviewCard
            expenses={data.expenses}
            cards={data.cards}
            members={data.members}
            policy={data.policy}
            now={now}
            onApprove={approveExpense}
            onRun={onExpenseTransition}
            onNavigate={onNavigate}
          />
          <MethodMixCard payments={periodPayments} periodLabel={periodLabel} />
        </div>
      </div>

    </div>
  );
}

Object.assign(window, { DashboardScreen, RouteNotBuilt, MetricTile, ConfirmDialog });

})();
</script>
<script type="text/babel" data-presets="react" data-file="PaymentsScreen.jsx">
(function(){
/* Payments — the money coming in.
   Commit model: INSTANT. Capture and retry apply and raise an undo toast;
   refund is irreversible and opens a confirm dialog.
   Data state: one branch — populated, or the no-results branch when the filters
   match nothing. The tabs and the filters narrow the list only; there are no
   figures above them to move.
   List architecture, as every list screen in this product has it: filter bar,
   bulk action bar, table, stated count. */
(() => {
  const {
    Card, CardHeader, CardTitle, CardDescription, CardPanel, CardFooter,
    Table, TableHeader, TableBody, TableRow, TableHead, TableCell,
    Tabs, TabsList, TabsTab, Checkbox, Button, Badge,
    Empty, EmptyHeader, EmptyMedia, EmptyTitle, EmptyDescription, EmptyContent,
  } = window.OriginDesignSystem_7c998b;

  function PaymentsScreen({ data, now, onNavigate, onPaymentTransition, pushToast }) {
    const [query, setQuery] = React.useState("");
    const [method, setMethod] = React.useState("all");
    const [status, setStatus] = React.useState("all");
    const [tab, setTab] = React.useState("all");
    const [selected, setSelected] = React.useState([]);
    /* One fixed window for the chart, stated in words: this screen has no scope
       control, so the chart is not moved by the filters below it (§5.3). */
    const CHART_DAYS = 30;
    const chartPayments = paymentsInPeriod(data.payments, CHART_DAYS, now);
    const chartBuckets = volumeByDay(chartPayments, now, CHART_DAYS);

    const attention = data.payments.filter((p) => PAYMENT_FLAGS.needsAttention(p, data.disputes));
    const scoped = tab === "attention" ? attention : data.payments;
    const filtered = scoped.filter((p) => {
      if (query && (p.id + " " + p.customer).toLowerCase().indexOf(query.toLowerCase()) === -1) return false;
      if (method !== "all" && p.method !== method) return false;
      if (status !== "all" && p.status !== status) return false;
      return true;
    });
    const dirty = query !== "" || method !== "all" || status !== "all";
    const clear = () => { setQuery(""); setMethod("all"); setStatus("all"); };
    const toggle = (id) => setSelected((l) => (l.indexOf(id) > -1 ? l.filter((x) => x !== id) : l.concat([id])));
    const allPicked = filtered.length > 0 && filtered.every((p) => selected.indexOf(p.id) > -1);
    const picked = data.payments.filter((p) => selected.indexOf(p.id) > -1);

    return (
      <div className="fd-page">
        <PageHeader path="/payments" title="Payments" actions={<span className="fd-actions__row"><ExportMenu scope={"All " + data.payments.length + " payments"} onExport={(format) => pushToast({ title: "Export started", description: data.payments.length + " payments · " + format })} /><Button size="sm" onClick={() => onNavigate("/payments/new")}><Icon name="plus" />New payment</Button></span>} />

        <Card className="fd-card">
          <CardHeader>
            <CardTitle>Net volume by day</CardTitle>
            <CardDescription>{formatMoney(netVolume(chartPayments))} across the last {CHART_DAYS} days — every payment in the workspace, not the filtered rows below.</CardDescription>
          </CardHeader>
          <CardPanel>
            <DayChart
              buckets={chartBuckets}
              series={[{ key: "amount", label: "Net volume", rows: [{ key: "rows", noun: "payment" }] }]}
              format={formatMoney}
              label="Net volume a day"
              note={settledAttempts(chartPayments).length + " payments with an outcome in the window; " + failedPayments(chartPayments).length + " failed and sit outside every bar."}
            />
          </CardPanel>
        </Card>

        <FilterBar
          query={query} onQuery={setQuery} placeholder="Search by payment or customer" dirty={dirty} onClear={clear}
          selects={[
            { id: "method", label: "Method", value: method, onChange: setMethod, items: [{ value: "all", label: "Every method" }].concat(PAYMENT_METHODS.map((m) => ({ value: m.value, label: m.label }))) },
            { id: "status", label: "Status", value: status, onChange: setStatus, items: [{ value: "all", label: "Every status" }].concat(PAYMENT_STATUS.map((s) => ({ value: s.id, label: s.label }))) },
          ]}
        />

        <BulkBar
          count={picked.length}
          note={formatMoney(sumBy(picked, "amount")) + " across the selection."}
          onClear={() => setSelected([])}
        >
          <Button variant="outline" size="sm" onClick={() => { pushToast({ title: "Export started", description: picked.length + " payments · CSV — we'll email it to " + MERIDIAN_USER.name.split(" ")[0].toLowerCase() + "@northwind.example" }); setSelected([]); }}>Export selected</Button>
        </BulkBar>

        <Card>
          <CardHeader>
            <CardTitle>Every payment</CardTitle>
            <CardDescription>Newest first. The action column carries the one transition the payment's status allows.</CardDescription>
          </CardHeader>
          <div className="fd-card-tabs">
            <Tabs value={tab} onValueChange={(next) => { setTab(next); setSelected([]); }}>
              <TabsList variant="underline">
                <TabsTab value="all">All<span className="fd-tab-count">{data.payments.length}</span></TabsTab>
                <TabsTab value="attention">Needs attention<span className="fd-tab-count">{attention.length}</span></TabsTab>
              </TabsList>
            </Tabs>
          </div>
          {filtered.length === 0 ? (
            <CardPanel>
              <Empty>
                <EmptyHeader>
                  <EmptyMedia variant="icon"><Icon name="search-x" size={18} /></EmptyMedia>
                  <EmptyTitle>No payments match those filters</EmptyTitle>
                  <EmptyDescription>Nothing in {tab === "attention" ? "the failed-or-disputed list" : "this workspace"} matches. Widen the filters and try again.</EmptyDescription>
                </EmptyHeader>
                <EmptyContent><Button onClick={clear}>Clear filters</Button></EmptyContent>
              </Empty>
            </CardPanel>
          ) : (
            <div className="fd-card-table">
              <Table variant="card">
                <TableHeader>
                  <TableRow>
                    <TableHead style={{ width: "2.5rem" }}>
                      <Checkbox checked={allPicked} indeterminate={!allPicked && filtered.some((p) => selected.indexOf(p.id) > -1)}
                        aria-label="Select every payment shown" onCheckedChange={(checked) => setSelected(checked ? filtered.map((p) => p.id) : [])} />
                    </TableHead>
                    <TableHead>Payment</TableHead>
                    <TableHead className="fd-drop-md">Method</TableHead>
                    <TableHead>Status</TableHead>
                    <TableHead className="fd-num">Amount</TableHead>
                    <TableHead className="fd-num fd-drop-lg">Created</TableHead>
                    <TableHead className="fd-actions-head">Action</TableHead>
                  </TableRow>
                </TableHeader>
                <TableBody>
                  {filtered.map((payment) => {
                    const dispute = data.disputes.find((d) => d.paymentId === payment.id && DISPUTE_FLAGS.isOpen(d));
                    return (
                      <TableRow key={payment.id}>
                        <TableCell><Checkbox checked={selected.indexOf(payment.id) > -1} aria-label={"Select " + payment.id} onCheckedChange={() => toggle(payment.id)} /></TableCell>
                        <TableCell>
                          <span className="fd-record">
                            <span className="fd-record__name">{payment.customer}</span>
                            <span className="fd-record__meta"><code>{payment.id}</code></span>
                          </span>
                        </TableCell>
                        <TableCell className="fd-drop-md">{labelOf(PAYMENT_METHODS, payment.method)}</TableCell>
                        <TableCell>
                          <span className="fd-status">
                            <StatusBadge values={PAYMENT_STATUS} value={payment.status} />
                            {dispute ? <button type="button" className="fd-link" onClick={() => onNavigate("/disputes/" + dispute.id)}><Badge size="sm" variant="warning">Disputed</Badge></button> : null}
                          </span>
                          {payment.failureReason ? <span className="fd-sub">{payment.failureReason}</span> : null}
                        </TableCell>
                        <TableCell className="fd-num fd-amount">{formatMoney(payment.amount)}</TableCell>
                        <TableCell className="fd-num fd-muted fd-drop-lg" title={formatAbsolute(payment.createdAt)}>{formatWhen(payment.createdAt, now)}</TableCell>
                        <TableCell className="fd-actions">
                          <RowActions transitions={paymentTransitions(payment)} onRun={(t) => onPaymentTransition(payment, t)} label={"Actions for " + payment.id} />
                        </TableCell>
                      </TableRow>
                    );
                  })}
                </TableBody>
              </Table>
            </div>
          )}
          <CardFooter className="fd-card-footer">
            <span className="fd-footnote">Showing {filtered.length} of {data.payments.length} payments{tab === "attention" ? ", from the " + attention.length + " that failed or are disputed" : ""}.</span>
            <span className="fd-footnote">{formatMoney(netVolume(filtered))} net across the rows shown.</span>
          </CardFooter>
        </Card>
      </div>
    );
  }

  Object.assign(window, { PaymentsScreen });
})();

})();
</script>
<script type="text/babel" data-presets="react" data-file="PayoutsScreen.jsx">
(function(){
/* Payouts — the money going out, approval queue first.
   Commit model: INSTANT. Approve applies and raises an undo toast; reject is
   irreversible and opens a confirm dialog.
   Data state: populated, with a no-results branch for the filters.
   The figures are current rather than period-scoped — a queue has no period —
   and each names the array beneath it. */
(() => {
  const {
    Card, CardHeader, CardTitle, CardDescription, CardPanel, CardFooter,
    Table, TableHeader, TableBody, TableRow, TableHead, TableCell,
    Tabs, TabsList, TabsTab, Checkbox, Button, Badge,
    Empty, EmptyHeader, EmptyMedia, EmptyTitle, EmptyDescription, EmptyContent,
  } = window.OriginDesignSystem_7c998b;

  function PayoutsScreen({ data, now, onNavigate, onPayoutTransition, onBulkApprove, pushToast }) {
    const [query, setQuery] = React.useState("");
    const [status, setStatus] = React.useState("all");
    const [tab, setTab] = React.useState("queue");
    const [selected, setSelected] = React.useState([]);

    const approver = data.approver;
    const approvable = approvablePayouts(data.payouts, approver);
    const awaiting = awaitingApprovalPayouts(data.payouts);
    const blocked = awaiting.filter(PAYOUT_FLAGS.isBlocked);
    /* Held and above-your-limit are different exclusions and the screen says so
       separately: one is another team's decision, the other is yours to escalate. */
    const overLimit = awaiting.filter((p) => !PAYOUT_FLAGS.isBlocked(p) && PAYOUT_FLAGS.isAboveLimit(p, approver));
    const higherApprovers = (payout) => data.members.filter((m) => m.approvalLimit >= payout.amount);
    const inTransit = data.payouts.filter((p) => p.status === "in_transit");
    const scoped = tab === "queue" ? awaiting : data.payouts;
    const filtered = scoped.filter((p) => {
      if (query && (p.id + " " + p.destination + " " + p.account).toLowerCase().indexOf(query.toLowerCase()) === -1) return false;
      if (status !== "all" && p.status !== status) return false;
      return true;
    });
    const dirty = query !== "" || status !== "all";
    const clear = () => { setQuery(""); setStatus("all"); };
    const selectable = filtered.filter((p) => PAYOUT_FLAGS.isApprovable(p, approver));
    const picked = data.payouts.filter((p) => selected.indexOf(p.id) > -1);
    const toggle = (id) => setSelected((l) => (l.indexOf(id) > -1 ? l.filter((x) => x !== id) : l.concat([id])));
    const allPicked = selectable.length > 0 && selectable.every((p) => selected.indexOf(p.id) > -1);

    return (
      <div className="fd-page">
        <PageHeader path="/payouts" title="Payouts" actions={<span className="fd-actions__row"><ExportMenu scope={"All " + data.payouts.length + " payouts"} onExport={(format) => pushToast({ title: "Export started", description: data.payouts.length + " payouts · " + format })} /><Button size="sm" onClick={() => onNavigate("/payouts/new")}><Icon name="plus" />New payout</Button></span>} />

        <div className="fd-metrics">
          <MetricTile icon="check-check" label="Ready to approve" value={formatMoney(sumBy(approvable, "amount"))} qualifier={approvable.length + " of the " + awaiting.length + " waiting on us are unheld and inside your " + formatMoney(approver.approvalLimit) + " limit"} />
          <MetricTile icon="pause" label="Held or above your limit" value={formatMoney(sumBy(blocked.concat(overLimit), "amount"))} qualifier={blocked.length || overLimit.length ? [blocked.length ? blocked.length + " blocked by " + blocked.map((p) => p.blockedBy.team).join(", ") : null, overLimit.length ? overLimit.length + " above your " + formatMoney(approver.approvalLimit) + " limit" : null].filter(Boolean).join(", ") : "Nothing is held"} />
          <MetricTile icon="send" label="In transit" value={formatMoney(inTransitTotal(data.payouts))} qualifier={inTransit.length + " with the network, none of it recallable"} />
          <MetricTile icon="arrow-up-right" label="Committed" value={formatMoney(pendingPayoutTotal(data.payouts))} qualifier={pendingPayouts(data.payouts).length + " payouts that have not landed yet"} />
        </div>

        <FilterBar
          query={query} onQuery={setQuery} placeholder="Search by payout, destination or account" dirty={dirty} onClear={clear}
          selects={[{ id: "status", label: "Status", value: status, onChange: setStatus, items: [{ value: "all", label: "Every status" }].concat(PAYOUT_STATUS.map((s) => ({ value: s.id, label: s.label }))) }]}
        />

        <BulkBar
          count={picked.length}
          note={formatMoney(sumBy(picked, "amount")) + " would leave the balance."}
          onClear={() => setSelected([])}
        >
          <Button size="sm" onClick={() => { onBulkApprove(picked); setSelected([]); }}>Approve {picked.length} payouts</Button>
        </BulkBar>

        <Card>
          <CardHeader>
            <CardTitle>{tab === "queue" ? "Waiting on us" : "Every payout"}</CardTitle>
            <CardDescription>Only payouts with nothing in the way and inside your own approval limit can be selected or approved; the rest keep their row, their badge and their reason.</CardDescription>
          </CardHeader>
          <div className="fd-card-tabs">
            <Tabs value={tab} onValueChange={(next) => { setTab(next); setSelected([]); }}>
              <TabsList variant="underline">
                <TabsTab value="queue">Approval queue<span className="fd-tab-count">{awaiting.length}</span></TabsTab>
                <TabsTab value="all">All<span className="fd-tab-count">{data.payouts.length}</span></TabsTab>
              </TabsList>
            </Tabs>
          </div>
          {filtered.length === 0 ? (
            <CardPanel>
              <Empty>
                <EmptyHeader>
                  <EmptyMedia variant="icon"><Icon name="check-check" size={18} /></EmptyMedia>
                  <EmptyTitle>{dirty ? "No payouts match those filters" : "Nothing is waiting on us"}</EmptyTitle>
                  <EmptyDescription>{dirty ? "Widen the filters and try again." : "Every payout has been approved, sent or closed."}</EmptyDescription>
                </EmptyHeader>
                <EmptyContent>{dirty ? <Button onClick={clear}>Clear filters</Button> : <Button variant="outline" onClick={() => setTab("all")}>Show every payout</Button>}</EmptyContent>
              </Empty>
            </CardPanel>
          ) : (
            <div className="fd-card-table">
              <Table variant="card">
                <TableHeader>
                  <TableRow>
                    <TableHead style={{ width: "2.5rem" }}>
                      <Checkbox checked={allPicked} indeterminate={!allPicked && selectable.some((p) => selected.indexOf(p.id) > -1)}
                        aria-label="Select every approvable payout shown" onCheckedChange={(checked) => setSelected(checked ? selectable.map((p) => p.id) : [])} />
                    </TableHead>
                    <TableHead>Destination</TableHead>
                    <TableHead>Status</TableHead>
                    <TableHead className="fd-num">Amount</TableHead>
                    <TableHead className="fd-num fd-drop-md">Expected</TableHead>
                    <TableHead className="fd-actions-head">Action</TableHead>
                  </TableRow>
                </TableHeader>
                <TableBody>
                  {filtered.map((payout) => {
                    /* Recession is for a payout nobody here can act on: it is
                       with the network or already closed. A held payout is
                       waiting on a person, so it stays at full weight. */
                    const recessed = ["in_transit", "paid", "rejected"].indexOf(payout.status) > -1;
                    const transitions = payoutTransitions(payout, approver);
                    const above = PAYOUT_FLAGS.awaitsApproval(payout) && !PAYOUT_FLAGS.isBlocked(payout) && PAYOUT_FLAGS.isAboveLimit(payout, approver);
                    const higher = above ? higherApprovers(payout) : [];
                    return (
                      <TableRow key={payout.id} className={recessed ? "fd-row--recessed" : undefined}>
                        <TableCell>
                          <Checkbox disabled={!PAYOUT_FLAGS.isApprovable(payout, approver)} checked={selected.indexOf(payout.id) > -1}
                            aria-label={"Select " + payout.id} onCheckedChange={() => toggle(payout.id)} />
                        </TableCell>
                        <TableCell>
                          <span className="fd-record">
                            <span className="fd-record__name">{payout.destination}</span>
                            <span className="fd-record__meta"><code>{payout.id}</code> · {payout.account}</span>
                          </span>
                        </TableCell>
                        <TableCell>
                          <StatusBadge values={PAYOUT_STATUS} value={payout.status} />
                          {payout.blockedBy ? <span className="fd-sub fd-waiting">Held by {payout.blockedBy.team} — {payout.blockedBy.reason}</span> : null}
                          {above ? <span className="fd-sub fd-waiting">Above your {formatMoney(approver.approvalLimit)} limit — {higher.length ? higher.map((m) => m.name).join(" or ") + " can release it" : "nobody here can release it on their own"}</span> : null}
                          {payout.returnReason ? <span className="fd-sub">{payout.returnReason}</span> : null}
                          {payout.rejectReason ? <span className="fd-sub">{payout.rejectReason}</span> : null}
                        </TableCell>
                        <TableCell className="fd-num fd-amount">{formatMoney(payout.amount)}</TableCell>
                        <TableCell className="fd-num fd-muted fd-drop-md" title={payout.expectedAt ? formatAbsolute(payout.expectedAt) : undefined}>
                          {payout.status === "paid" ? "Paid " + formatWhen(payout.paidAt, now) : payout.expectedAt ? formatWhen(payout.expectedAt, now) : "—"}
                        </TableCell>
                        <TableCell className="fd-actions">
                          {transitions.length
                            ? <Transitions transitions={transitions} onRun={(t) => onPayoutTransition(payout, t)} />
                            : payout.blockedBy
                              ? <span className="fd-muted">Waiting on {payout.blockedBy.team}</span>
                              : <span className="fd-muted">—</span>}
                        </TableCell>
                      </TableRow>
                    );
                  })}
                </TableBody>
              </Table>
            </div>
          )}
          <CardFooter className="fd-card-footer">
            <span className="fd-footnote">Showing {filtered.length} of {tab === "queue" ? awaiting.length + " waiting on us" : data.payouts.length + " payouts"}.</span>
            <span className="fd-footnote">{formatMoney(sumBy(filtered, "amount"))} across the rows shown.</span>
          </CardFooter>
        </Card>
      </div>
    );
  }

  Object.assign(window, { PayoutsScreen });
})();

})();
</script>
<script type="text/babel" data-presets="react" data-file="DisputesScreen.jsx">
(function(){
/* Disputes — list-detail: the list on the left, the dispute the route names on
   the right. /disputes/:id is that selection, not a second screen.
   Commit model: INSTANT. Submitting evidence opens a staged dialog from this
   instant screen; accepting a dispute is irreversible and opens a confirm
   dialog. A dispute under review offers nothing, and the panel says who the
   clock belongs to.
   Data state: populated. */
(() => {
  const {
    Card, CardHeader, CardTitle, CardDescription, CardPanel, CardFooter,
    Button, Badge, Separator, Alert, AlertTitle, AlertDescription,
    Empty, EmptyHeader, EmptyMedia, EmptyTitle, EmptyDescription,
  } = window.OriginDesignSystem_7c998b;

  function DisputeRow({ dispute, active, now, onNavigate }) {
    const soon = DISPUTE_FLAGS.isOpen(dispute) && DISPUTE_FLAGS.isClosingSoon(dispute, now);
    return (
      <button type="button" className={"fd-pick" + (active ? " fd-pick--on" : "")} onClick={() => onNavigate("/disputes/" + dispute.id)}>
        <span className="fd-list__main">
          <span className="fd-record__name">{dispute.customer}</span>
          <span className="fd-record__meta"><code>{dispute.id}</code> · {dispute.reason}</span>
        </span>
        <span className="fd-list__side">
          <span className="fd-amount">{formatMoney(dispute.amount)}</span>
          <span className={"fd-clock" + (soon ? " fd-clock--danger" : "")}>
            {dispute.evidenceDueAt ? formatCountdown(dispute.evidenceDueAt, now) : "With the network"}
          </span>
        </span>
        <span className="fd-pick__foot"><StatusBadge values={DISPUTE_STATUS} value={dispute.status} /></span>
      </button>
    );
  }

  function DisputesScreen({ data, now, selectedId, onNavigate, onDisputeTransition, onPostComment }) {
    const disputes = data.disputes;
    const open = openDisputes(disputes);
    const needsEvidence = disputesNeedingEvidence(disputes);
    const dispute = selectedId ? disputes.find((d) => d.id === selectedId) : null;
    const payment = dispute ? data.payments.find((p) => p.id === dispute.paymentId) : null;
    const soon = dispute && DISPUTE_FLAGS.isOpen(dispute) && DISPUTE_FLAGS.isClosingSoon(dispute, now);

    return (
      <div className="fd-page">
        <PageHeader
          path={dispute ? "/disputes/:id" : "/disputes"}
          title={dispute ? dispute.customer : "Disputes"}
          actions={dispute ? <Transitions transitions={disputeTransitions(dispute)} onRun={(t) => onDisputeTransition(dispute, t)} size="default" /> : null}
        />

        <div className="fd-columns">
          <div className="fd-col">
            {dispute ? (
              <>
                {soon ? (
                  <Alert variant="error">
                    <AlertTitle>Evidence due — {formatCountdown(dispute.evidenceDueAt, now)}</AlertTitle>
                    <AlertDescription>After that the card network decides without us and {formatMoney(dispute.amount)} is lost. The money is already held back against the balance.</AlertDescription>
                  </Alert>
                ) : null}
                <Card>
                  <CardHeader>
                    <CardTitle>The claim</CardTitle>
                    <CardDescription>{dispute.reason} · opened {formatWhen(dispute.openedAt, now)}</CardDescription>
                  </CardHeader>
                  <CardPanel>
                    <dl className="fd-defs">
                      <div><dt>Status</dt><dd><StatusBadge values={DISPUTE_STATUS} value={dispute.status} /></dd></div>
                      <div><dt>Customer</dt><dd>{dispute.customer}</dd></div>
                      <div><dt>Payment it came from</dt><dd><code>{dispute.paymentId}</code>{payment ? " · " + labelOf(PAYMENT_METHODS, payment.method) : ""}</dd></div>
                      <div><dt>Opened</dt><dd title={formatAbsolute(dispute.openedAt)}>{formatWhen(dispute.openedAt, now)}</dd></div>
                      <div><dt>Evidence due</dt><dd title={dispute.evidenceDueAt ? formatAbsolute(dispute.evidenceDueAt) : undefined}>{dispute.evidenceDueAt ? formatCountdown(dispute.evidenceDueAt, now) : "Nothing owed by us"}</dd></div>
                      {dispute.evidenceSubmittedAt ? <div><dt>Evidence sent</dt><dd title={formatAbsolute(dispute.evidenceSubmittedAt)}>{formatWhen(dispute.evidenceSubmittedAt, now)}</dd></div> : null}
                      <div className="fd-defs__total"><dt>Disputed</dt><dd>{formatMoney(dispute.amount)}</dd></div>
                    </dl>
                  </CardPanel>
                  <CardFooter className="fd-card-footer">
                    <span className="fd-footnote">
                      {DISPUTE_FLAGS.needsEvidence(dispute)
                        ? "The clock is ours: nothing happens until we answer."
                        : dispute.status === "under_review"
                          ? "The clock is the network's. Evidence went in " + formatWhen(dispute.evidenceSubmittedAt, now) + "; they take up to 75 days."
                          : "Closed."}
                    </span>
                  </CardFooter>
                </Card>
                {payment ? (
                  <Card>
                    <CardHeader>
                      <CardTitle>The payment</CardTitle>
                      <CardDescription>The payment owns the amount; the dispute reads it.</CardDescription>
                    </CardHeader>
                    <CardPanel>
                      <dl className="fd-defs">
                        <div><dt>Payment</dt><dd><code>{payment.id}</code></dd></div>
                        <div><dt>Status</dt><dd><StatusBadge values={PAYMENT_STATUS} value={payment.status} /></dd></div>
                        <div><dt>Method</dt><dd>{labelOf(PAYMENT_METHODS, payment.method)}</dd></div>
                        <div><dt>Taken</dt><dd title={formatAbsolute(payment.createdAt)}>{formatWhen(payment.createdAt, now)}</dd></div>
                        <div className="fd-defs__total"><dt>Amount</dt><dd>{formatMoney(payment.amount)}</dd></div>
                      </dl>
                    </CardPanel>
                    <CardFooter className="fd-card-footer">
                      <Button variant="outline" size="sm" onClick={() => onNavigate("/payments")}>Open in payments</Button>
                    </CardFooter>
                  </Card>
                ) : null}
                <ThreadCard
                  record={{ type: "dispute", id: dispute.id }}
                  data={data}
                  signedInId={MERIDIAN_SIGNED_IN}
                  now={now}
                  onPost={onPostComment}
                  description={DISPUTE_FLAGS.needsEvidence(dispute)
                    ? "What we know before we answer the network. Mention somebody to put it in their queue."
                    : "Kept with the case. Mention somebody to put it in their queue."}
                />
              </>
            ) : (
              <Card>
                <CardPanel>
                  <Empty>
                    <EmptyHeader>
                      <EmptyMedia variant="icon"><Icon name="shield-alert" size={18} /></EmptyMedia>
                      <EmptyTitle>Pick a dispute</EmptyTitle>
                      <EmptyDescription>
                        {needsEvidence.length
                          ? needsEvidence.length + " of the " + open.length + " open disputes are waiting on evidence from us."
                          : "Nothing is waiting on evidence from us."}
                      </EmptyDescription>
                    </EmptyHeader>
                  </Empty>
                </CardPanel>
              </Card>
            )}
          </div>

          <div className="fd-col">
            <Card>
              <CardHeader>
                <CardTitle>All disputes</CardTitle>
                <CardDescription>{formatMoney(disputeExposure(disputes))} held back against the {open.length} still open.</CardDescription>
              </CardHeader>
              <CardPanel>
                <div className="fd-picklist">
                  {disputes.map((d) => (
                    <DisputeRow key={d.id} dispute={d} active={dispute && d.id === dispute.id} now={now} onNavigate={onNavigate} />
                  ))}
                </div>
              </CardPanel>
              <CardFooter className="fd-card-footer">
                <span className="fd-footnote">{disputes.length} disputes · {needsEvidence.length} need evidence.</span>
              </CardFooter>
            </Card>
          </div>
        </div>
      </div>
    );
  }

  Object.assign(window, { DisputesScreen });
})();

})();
</script>
<script type="text/babel" data-presets="react" data-file="BalancesScreen.jsx">
(function(){
/* Balances — where the money is, and what is already claiming it.
   Commit model: INSTANT. The only actions on this screen are navigation.
   Data state: populated.
   Nothing here stores a total: available is the one stored figure, committed is
   the payouts that have not landed and reserved is the open disputes, both
   summed from the arrays printed beneath them. */
(() => {
  const {
    Card, CardHeader, CardTitle, CardDescription, CardPanel, CardFooter,
    Table, TableHeader, TableBody, TableRow, TableHead, TableCell, Button, Badge,
  } = window.OriginDesignSystem_7c998b;

  function Part({ label, amount, total, tone, note }) {
    return (
      <div className="fd-part">
        <div className="fd-spread">
          <span>{label}</span>
          <span className="fd-amount">{formatMoney(amount)}</span>
        </div>
        <span className={"fd-bar" + (tone ? " fd-bar--" + tone : "")} role="img" aria-label={label + ", " + formatMoney(amount)}>
          <span className="fd-bar__fill" style={{ width: (total > 0 ? (amount / total) * 100 : 0).toFixed(1) + "%" }} />
        </span>
        <span className="fd-footnote">{note}</span>
      </div>
    );
  }

  function BalancesScreen({ data, now, onNavigate }) {
    const breakdown = balanceBreakdown(data.balance, data.payouts, data.disputes);
    const committing = pendingPayouts(data.payouts);
    const reserving = openDisputes(data.disputes);
    /* One window for the movement chart, stated in words beside it. */
    const movement = movementByDay(data.payments, data.payouts, data.expenses, now, 30);

    return (
      <div className="fd-page">
        <PageHeader path="/balances" title="Balances" />

        <div className="fd-metrics">
          <MetricTile icon="wallet" label="Available" value={formatMoney(breakdown.available)} qualifier={"Settled and unclaimed, in " + data.balance.bank} />
          <MetricTile icon="arrow-up-right" label="Committed" value={formatMoney(breakdown.committed)} qualifier={committing.length + " payouts approved, in transit or waiting on us"} />
          <MetricTile icon="shield-alert" label="Reserved" value={formatMoney(breakdown.reserved)} qualifier={reserving.length + " open disputes, held until the network decides"} />
          <MetricTile icon="layers" label="On the platform" value={formatMoney(breakdown.total)} qualifier="Available plus everything already claiming it" />
        </div>

        <Card>
          <CardHeader>
            <CardTitle>What the balance is made of</CardTitle>
            <CardDescription>Three parts of {formatMoney(breakdown.total)}. Only the first is yours to move today.</CardDescription>
          </CardHeader>
          <CardPanel>
            <div className="fd-stack">
              <Part label="Available" amount={breakdown.available} total={breakdown.total} note="Pays out on the workspace's schedule, or on demand." />
              <Part label="Committed to payouts" amount={breakdown.committed} total={breakdown.total} tone="soft" note={committing.length + " payouts, the largest " + formatMoney(Math.max.apply(null, committing.map((p) => p.amount).concat([0]))) + "."} />
              <Part label="Reserved against disputes" amount={breakdown.reserved} total={breakdown.total} tone="warn" note={reserving.length + " disputes; the money returns to available if they are won."} />
            </div>
          </CardPanel>
          <CardFooter className="fd-card-footer">
            <span className="fd-footnote">Available is the only stored figure. Committed and reserved are summed from the two tables below.</span>
          </CardFooter>
        </Card>

        <Card className="fd-card">
          <CardHeader>
            <CardTitle>What moved the balance</CardTitle>
            <CardDescription>Last 30 days. In is payments that settled; out is payouts that landed in the bank plus card spend that settled — approvals and authorisations move nothing yet.</CardDescription>
          </CardHeader>
          <CardPanel>
            <DayChart
              buckets={movement}
              series={[{ key: "in", label: "Settled in", rows: [{ key: "payments", noun: "payment" }] }, { key: "out", label: "Paid and spent out", tone: "soft", rows: [{ key: "payouts", noun: "payout" }, { key: "expenses", noun: "expense" }] }]}
              format={formatMoney}
              label="Money in and out a day"
              note={formatMoney(sumBy(movement, "in")) + " in and " + formatMoney(sumBy(movement, "out")) + " out over the window."}
            />
          </CardPanel>
          <CardFooter className="fd-card-footer">
            <span className="fd-footnote">The three parts above are today's position; these bars are how it got there.</span>
            <Button variant="outline" size="sm" onClick={() => onNavigate("/reports")}>Open reports</Button>
          </CardFooter>
        </Card>

        <div className="fd-columns fd-columns--even">
          <div className="fd-col">
            <Card>
              <CardHeader>
                <CardTitle>Committing it</CardTitle>
                <CardDescription>{committing.length} payouts that have left the queue but not the account.</CardDescription>
              </CardHeader>
              <div className="fd-card-table">
                <Table variant="card">
                  <TableHeader>
                    <TableRow>
                      <TableHead>Destination</TableHead>
                      <TableHead>Status</TableHead>
                      <TableHead className="fd-num">Amount</TableHead>
                    </TableRow>
                  </TableHeader>
                  <TableBody>
                    {committing.map((payout) => (
                      <TableRow key={payout.id}>
                        <TableCell>
                          <span className="fd-record">
                            <span className="fd-record__name">{payout.destination}</span>
                            <span className="fd-record__meta"><code>{payout.id}</code></span>
                          </span>
                        </TableCell>
                        <TableCell><StatusBadge values={PAYOUT_STATUS} value={payout.status} /></TableCell>
                        <TableCell className="fd-num fd-amount">{formatMoney(payout.amount)}</TableCell>
                      </TableRow>
                    ))}
                  </TableBody>
                </Table>
              </div>
              <CardFooter className="fd-card-footer">
                <span className="fd-footnote">Sums to {formatMoney(breakdown.committed)}.</span>
                <Button variant="outline" size="sm" onClick={() => onNavigate("/payouts")}>Open payouts</Button>
              </CardFooter>
            </Card>
          </div>

          <div className="fd-col">
            <Card>
              <CardHeader>
                <CardTitle>Reserving it</CardTitle>
                <CardDescription>{reserving.length} open disputes, held until each is decided.</CardDescription>
              </CardHeader>
              <div className="fd-card-table">
                <Table variant="card">
                  <TableHeader>
                    <TableRow>
                      <TableHead>Customer</TableHead>
                      <TableHead>Status</TableHead>
                      <TableHead className="fd-num">Amount</TableHead>
                    </TableRow>
                  </TableHeader>
                  <TableBody>
                    {reserving.map((dispute) => (
                      <TableRow key={dispute.id}>
                        <TableCell>
                          <span className="fd-record">
                            <button type="button" className="fd-link fd-record__name" onClick={() => onNavigate("/disputes/" + dispute.id)}>{dispute.customer}</button>
                            <span className="fd-record__meta"><code>{dispute.id}</code> · {dispute.reason}</span>
                          </span>
                        </TableCell>
                        <TableCell><StatusBadge values={DISPUTE_STATUS} value={dispute.status} /></TableCell>
                        <TableCell className="fd-num fd-amount">{formatMoney(dispute.amount)}</TableCell>
                      </TableRow>
                    ))}
                  </TableBody>
                </Table>
              </div>
              <CardFooter className="fd-card-footer">
                <span className="fd-footnote">Sums to {formatMoney(breakdown.reserved)}.</span>
                <Button variant="outline" size="sm" onClick={() => onNavigate("/disputes")}>Open disputes</Button>
              </CardFooter>
            </Card>
          </div>
        </div>
      </div>
    );
  }

  Object.assign(window, { BalancesScreen });
})();

})();
</script>
<script type="text/babel" data-presets="react" data-file="ExpensesScreen.jsx">
(function(){
/* Expenses — the money going out on the workspace's own cards.
   Commit model: INSTANT. Approving applies immediately and raises an undo
   toast, querying opens a staged dialog (it needs a sentence), denying is
   irreversible and opens a confirm dialog.
   Data state: one branch — populated, or the no-results branch when the filters
   match nothing.
   Scope and filters (§5.3): the period select above the figures moves the
   figures and the chart; the text field, the three dropdowns and the selection
   narrow the list only.
   Recession (§5.4): an expense waiting on the person who spent it — queried, or
   still owed a receipt — is recessed, and the row names who is being waited on. */
(() => {
  const {
    Card, CardHeader, CardTitle, CardDescription, CardPanel, CardFooter,
    Table, TableHeader, TableBody, TableRow, TableHead, TableCell,
    Select, Checkbox, Button, Badge,
    Empty, EmptyHeader, EmptyMedia, EmptyTitle, EmptyDescription, EmptyContent,
  } = window.OriginDesignSystem_7c998b;

  function ExpensesScreen({ data, now, period, onPeriodChange, onExpenseTransition, onBulkApprove, pushToast }) {
    const [query, setQuery] = React.useState("");
    const [card, setCard] = React.useState("all");
    const [status, setStatus] = React.useState("all");
    const [category, setCategory] = React.useState("all");
    const [selected, setSelected] = React.useState([]);
    const policy = data.policy;
    const days = Number(period);
    const periodLabel = PERIODS.find((p) => p.value === period).label;
    const nameOf = (id) => (data.members.find((m) => m.id === id) || { name: "Someone who has left" }).name;
    const cardOf = (id) => data.cards.find((c) => c.id === id);

    /* Scope first: every figure and the chart read this array. */
    const scoped = expensesInPeriod(data.expenses, days, now);
    const settled = settledExpenses(scoped);
    const pending = pendingExpenses(scoped);
    const reviewable = reviewableExpenses(scoped, policy);
    const missing = expensesMissingReceipt(expensesAwaitingReview(scoped), policy);
    const queried = queriedExpenses(scoped);
    const buckets = bucketBy(settled, (e) => e.occurredAt, now, days).map((b) => ({ at: b.at, spend: sumBy(b.rows, "amount"), rows: b.rows }));

    /* Then the list filters, which move nothing above them. */
    const filtered = scoped.filter((e) => {
      if (query && (e.id + " " + e.merchant + " " + nameOf(e.spenderId)).toLowerCase().indexOf(query.toLowerCase()) === -1) return false;
      if (card !== "all" && e.cardId !== card) return false;
      if (status !== "all" && e.status !== status) return false;
      if (category !== "all" && e.category !== category) return false;
      return true;
    }).sort((a, b) => b.occurredAt - a.occurredAt);
    const dirty = query !== "" || card !== "all" || status !== "all" || category !== "all";
    const clear = () => { setQuery(""); setCard("all"); setStatus("all"); setCategory("all"); };
    const toggle = (id) => setSelected((l) => (l.indexOf(id) > -1 ? l.filter((x) => x !== id) : l.concat([id])));
    const allPicked = filtered.length > 0 && filtered.every((e) => selected.indexOf(e.id) > -1);
    const picked = scoped.filter((e) => selected.indexOf(e.id) > -1);
    const pickedApprovable = reviewableExpenses(picked, policy);

    return (
      <div className="fd-page">
        <PageHeader
          path="/expenses"
          title="Expenses"
          actions={<ExportMenu scope={periodLabel + " · " + scoped.length + " expenses"} onExport={(format) => pushToast({ title: "Export started", description: scoped.length + " expenses · " + periodLabel.toLowerCase() + " · " + format })} />}
        />

        <div className="fd-scope">
          <div className="fd-scope__control">
            <label className="fd-scope__label" htmlFor="fd-exp-period">Period</label>
            <Select id="fd-exp-period" size="sm" items={PERIODS} value={period} onValueChange={onPeriodChange} />
          </div>
          <p className="fd-scope__note">Figures and the chart cover spend that occurred in the {periodLabel.toLowerCase()}, across every card in {MERIDIAN_WORKSPACE.name}.</p>
        </div>

        <div className="fd-metrics">
          <MetricTile
            icon="receipt" label="Card spend"
            value={formatMoney(cardSpendTotal(scoped))}
            qualifier={settled.length + " settled expenses" + (pending.length ? ", plus " + formatMoney(sumBy(pending, "amount")) + " authorised and not yet settled" : "")}
          />
          <MetricTile
            icon="check-check" label="You can approve now"
            value={formatCount(reviewable.length)}
            qualifier={formatMoney(sumBy(reviewable, "amount")) + " across them" + (missing.length ? "; " + missing.length + " more are waiting on a receipt" : "")}
          />
          <MetricTile
            icon="paperclip" label="Waiting on a receipt"
            value={formatCount(missing.length)}
            qualifier={missing.length
              ? formatMoney(sumBy(missing, "amount")) + " held up — receipts are required over " + formatMoney(policy.receiptThresholdCents) + " and on " + policy.receiptCategories.map((c) => labelOf(EXPENSE_CATEGORIES, c).toLowerCase()).join(" and ")
              : "Every expense waiting on review has its receipt"}
          />
          <MetricTile
            icon="message-square" label="Queried"
            value={formatCount(queried.length)}
            qualifier={queried.length ? formatMoney(sumBy(queried, "amount")) + " sent back to the people who spent it" : "Nothing has been sent back"}
          />
        </div>

        <Card className="fd-card">
          <CardHeader>
            <CardTitle>Spend by day</CardTitle>
            <CardDescription>{formatMoney(cardSpendTotal(scoped))} of settled card spend, {periodLabel.toLowerCase()}. Authorisations that have not settled are outside every bar.</CardDescription>
          </CardHeader>
          <CardPanel>
            <DayChart
              buckets={buckets}
              series={[{ key: "spend", label: "Card spend", rows: [{ key: "rows", noun: "expense" }] }]}
              format={formatMoney}
              label="Settled card spend a day"
              note={"Tallest day: " + formatMoney(Math.max.apply(null, buckets.map((b) => b.spend).concat([0]))) + "."}
            />
          </CardPanel>
        </Card>

        <FilterBar
          query={query} onQuery={setQuery} placeholder="Search by merchant, expense or person" dirty={dirty} onClear={clear}
          selects={[
            { id: "card", label: "Card", value: card, onChange: setCard, items: [{ value: "all", label: "Every card" }].concat(data.cards.map((c) => ({ value: c.id, label: c.label + " ·••• " + c.last4 }))) },
            { id: "status", label: "Status", value: status, onChange: setStatus, items: [{ value: "all", label: "Every status" }].concat(EXPENSE_STATUS.map((s) => ({ value: s.id, label: s.label }))) },
            { id: "category", label: "Category", value: category, onChange: setCategory, items: [{ value: "all", label: "Every category" }].concat(EXPENSE_CATEGORIES.map((c) => ({ value: c.value, label: c.label }))) },
          ]}
        />

        <BulkBar
          count={picked.length}
          note={formatMoney(sumBy(picked, "amount")) + " selected · " + pickedApprovable.length + " of them can be approved now."}
          onClear={() => setSelected([])}
        >
          <Button size="sm" disabled={!pickedApprovable.length} onClick={() => { onBulkApprove(pickedApprovable); setSelected([]); }}>
            Approve {pickedApprovable.length ? pickedApprovable.length : ""} expenses
          </Button>
        </BulkBar>

        <Card>
          <CardHeader>
            <CardTitle>Every expense in the period</CardTitle>
            <CardDescription>Newest first. A row waiting on the person who spent it is recessed and says so instead of carrying a button.</CardDescription>
          </CardHeader>
          {filtered.length === 0 ? (
            <CardPanel>
              <Empty>
                <EmptyHeader>
                  <EmptyMedia variant="icon"><Icon name="search-x" size={18} /></EmptyMedia>
                  <EmptyTitle>No expenses match those filters</EmptyTitle>
                  <EmptyDescription>Nothing in the {periodLabel.toLowerCase()} matches. Widen the filters, or lengthen the period above.</EmptyDescription>
                </EmptyHeader>
                <EmptyContent><Button onClick={clear}>Clear filters</Button></EmptyContent>
              </Empty>
            </CardPanel>
          ) : (
            <div className="fd-card-table">
              <Table variant="card">
                <TableHeader>
                  <TableRow>
                    <TableHead style={{ width: "2.5rem" }}>
                      <Checkbox checked={allPicked} indeterminate={!allPicked && filtered.some((e) => selected.indexOf(e.id) > -1)}
                        aria-label="Select every expense shown" onCheckedChange={(checked) => setSelected(checked ? filtered.map((e) => e.id) : [])} />
                    </TableHead>
                    <TableHead>Expense</TableHead>
                    <TableHead className="fd-drop-lg">Card</TableHead>
                    <TableHead className="fd-drop-md">Spent by</TableHead>
                    <TableHead>Status</TableHead>
                    <TableHead className="fd-num">Amount</TableHead>
                    <TableHead className="fd-num fd-drop-lg">Occurred</TableHead>
                    <TableHead className="fd-actions-head">Action</TableHead>
                  </TableRow>
                </TableHeader>
                <TableBody>
                  {filtered.map((expense) => {
                    const owed = EXPENSE_FLAGS.needsReceipt(expense, policy);
                    const waiting = EXPENSE_FLAGS.waitsOnSpender(expense, policy);
                    const on = cardOf(expense.cardId);
                    return (
                      <TableRow key={expense.id} className={waiting ? "fd-row--recessed" : ""}>
                        <TableCell><Checkbox checked={selected.indexOf(expense.id) > -1} aria-label={"Select " + expense.id} onCheckedChange={() => toggle(expense.id)} /></TableCell>
                        <TableCell>
                          <span className="fd-record">
                            <span className="fd-record__name">{expense.merchant}</span>
                            <span className="fd-record__meta"><code>{expense.id}</code> · {labelOf(EXPENSE_CATEGORIES, expense.category)}</span>
                          </span>
                        </TableCell>
                        <TableCell className="fd-drop-lg fd-muted">{on ? on.label : "—"}<span className="fd-sub">•••• {on ? on.last4 : "????"}</span></TableCell>
                        <TableCell className="fd-drop-md fd-muted">{nameOf(expense.spenderId)}</TableCell>
                        <TableCell>
                          <span className="fd-status">
                            <StatusBadge values={EXPENSE_STATUS} value={expense.status} />
                            {owed ? <Badge size="sm" variant="warning">No receipt</Badge> : null}
                          </span>
                          {waiting ? (
                            <span className="fd-sub">
                              Waiting on {nameOf(expense.spenderId)} — {expense.status === "queried" ? expense.queryNote : "receipt required for " + labelOf(EXPENSE_CATEGORIES, expense.category).toLowerCase() + " spend"}
                            </span>
                          ) : expense.status === "approved" ? (
                            <span className="fd-sub">Signed off by {expense.reviewedBy}</span>
                          ) : expense.status === "denied" ? (
                            <span className="fd-sub">{expense.denyReason}</span>
                          ) : null}
                        </TableCell>
                        <TableCell className="fd-num fd-amount">{formatMoney(expense.amount)}</TableCell>
                        <TableCell className="fd-num fd-muted fd-drop-lg" title={formatAbsolute(expense.occurredAt)}>{formatWhen(expense.occurredAt, now)}</TableCell>
                        <TableCell className="fd-actions">
                          <RowActions transitions={expenseTransitions(expense, policy)} onRun={(t) => onExpenseTransition(expense, t)} label={"Actions for " + expense.id} />
                        </TableCell>
                      </TableRow>
                    );
                  })}
                </TableBody>
              </Table>
            </div>
          )}
          <CardFooter className="fd-card-footer">
            <span className="fd-footnote">Showing {filtered.length} of {scoped.length} expenses in the {periodLabel.toLowerCase()}.</span>
            <span className="fd-footnote">{formatMoney(cardSpendTotal(filtered))} settled across the rows shown.</span>
          </CardFooter>
        </Card>
      </div>
    );
  }

  Object.assign(window, { ExpensesScreen });
})();

})();
</script>
<script type="text/babel" data-presets="react" data-file="CardsScreen.jsx">
(function(){
/* Cards — the workspace's corporate cards, and one card's own ledger.
   Commit model: INSTANT. Freezing and unfreezing apply immediately with an undo
   toast; cancelling a card is irreversible and opens a confirm dialog; issuing
   one needs four fields, so it opens a staged dialog from this instant screen.
   Data state: populated.
   Primary display: a canvas of card tiles, not a table — the question this
   destination answers is "how much of this card's limit is left", which a meter
   answers and a row does not. The ledger lives on the card's own route.
   Every limit and every spend figure is measured over one rolling window,
   CARD_WINDOW_DAYS, named once in metrics.js and stated in words below. */
(() => {
  const {
    Card, CardHeader, CardTitle, CardDescription, CardPanel, CardFooter, CardAction,
    Table, TableHeader, TableBody, TableRow, TableHead, TableCell,
    Button, Badge, Avatar, Meter, Alert, AlertTitle, AlertDescription,
  } = window.OriginDesignSystem_7c998b;

  function LimitMeter({ use }) {
    return (
      <div className="fd-part">
        <div className="fd-spread">
          <span className="fd-figure">{formatMoney(use.spent)}</span>
          <span className={use.over ? "fd-clock fd-clock--danger" : use.near ? "fd-clock fd-clock--warning" : "fd-footnote"}>
            {use.limit > 0
              ? use.over ? formatMoney(use.spent - use.limit) + " over the limit" : formatMoney(use.limit - use.spent) + " left"
              : "No limit set"}
          </span>
        </div>
        <Meter value={Math.min(use.spent, use.limit)} max={use.limit || 1} aria-label={"Spend against the " + CARD_WINDOW_DAYS + "-day limit"} />
        <span className="fd-footnote">{formatPercent(use.share)} of {formatMoney(use.limit)} over the last {CARD_WINDOW_DAYS} days, across {use.rows.length} expenses.</span>
      </div>
    );
  }

  function CardTile({ card, holder, use, waiting, onOpen, onRun }) {
    const transitions = cardTransitions(card);
    return (
      <Card className={card.status === "cancelled" ? "fd-card fd-row--recessed" : "fd-card"}>
        <CardHeader>
          <CardTitle>{card.label}</CardTitle>
          <CardDescription>
            <span className="fd-pan">•••• {card.last4}</span> · {labelOf(CARD_TYPES, card.type)}
          </CardDescription>
          <CardAction><StatusBadge values={CARD_STATUS} value={card.status} /></CardAction>
        </CardHeader>
        <CardPanel className="fd-stack">
          <span className="fd-holder">
            <Avatar size={24} src={holder.avatar} alt="" fallback={holder.name.slice(0, 1)} />
            {holder.name}
          </span>
          {card.status === "cancelled" ? (
            <span className="fd-footnote">Cancelled {formatWhen(card.cancelledAt, MERIDIAN_NOW)} by {card.cancelledBy} — {card.cancelReason}. Nothing can be spent on it.</span>
          ) : card.status === "pending_activation" ? (
            <span className="fd-waiting">Waiting on {holder.name} to activate it. Nothing has been spent yet.</span>
          ) : (
            <LimitMeter use={use} />
          )}
          <span className="fd-meta">
            <span>Expires {formatDay(card.expiresAt)}</span>
            <span>Opened {formatDay(card.openedAt)}</span>
            {waiting ? <span>{waiting}</span> : null}
          </span>
        </CardPanel>
        <CardFooter className="fd-card-footer">
          <Button variant="ghost" size="sm" onClick={onOpen}>Open card<Icon name="arrow-right" /></Button>
          {transitions.length ? <RowActions transitions={transitions} onRun={onRun} label={"Actions for " + card.label} /> : null}
        </CardFooter>
      </Card>
    );
  }

  function CardsScreen({ data, now, onNavigate, onCardTransition, onIssueCard, issuer }) {
    const policy = data.policy;
    const live = liveCards(data.cards);
    const atRisk = cardsAtRisk(data.cards, data.expenses, now, policy);
    const windowSpend = cardSpendTotal(expensesInPeriod(data.expenses, CARD_WINDOW_DAYS, now));
    const holderOf = (card) => data.members.find((m) => m.id === card.holderId) || { name: "Unassigned", avatar: null };
    const useOf = (card) => cardUtilisation(card, data.expenses, now, policy);
    const ordered = live.slice().sort((a, b) => useOf(b).share - useOf(a).share).concat(data.cards.filter((c) => c.status === "cancelled"));

    const newCard = (
      <Button size="sm" disabled={!issuer.canIssueCards} onClick={onIssueCard}><Icon name="plus" />New card</Button>
    );

    return (
      <div className="fd-page">
        <PageHeader
          path="/cards"
          title="Cards"
          actions={issuer.canIssueCards ? newCard : <FloatingTip side="bottom" label={"Only an admin can issue a card — you are set up as " + labelOf(TEAM_ROLES, issuer.teamRole).toLowerCase() + " in team and approvals."}>{newCard}</FloatingTip>}
        />

        <div className="fd-metrics">
          <MetricTile icon="receipt" label={"Spend, last " + CARD_WINDOW_DAYS + " days"} value={formatMoney(windowSpend)} qualifier={"Settled spend across all " + data.cards.length + " cards, the window every limit is measured over"} />
          <MetricTile icon="credit-card" label="Live cards" value={formatCount(live.length)} qualifier={live.filter(CARD_FLAGS.isSpendable).length + " active, " + live.filter((c) => c.status === "frozen").length + " frozen, " + live.filter((c) => c.status === "pending_activation").length + " not activated"} />
          <MetricTile icon="gauge" label="Near or past their limit" value={formatCount(atRisk.length)} qualifier={atRisk.length ? atRisk.map((c) => c.label).join(", ") + " — over " + formatPercent(policy.cardWarnShare) + " of the limit" : "Every card is under " + formatPercent(policy.cardWarnShare) + " of its limit"} />
          <MetricTile icon="inbox" label="Waiting on review" value={formatCount(reviewableExpenses(data.expenses, policy).length)} qualifier={formatMoney(sumBy(reviewableExpenses(data.expenses, policy), "amount")) + " of card spend you can approve now"} />
        </div>

        {atRisk.length ? (
          <Alert variant="warning">
            <AlertTitle>{atRisk.length === 1 ? atRisk[0].label + " is " + (useOf(atRisk[0]).over ? "past" : "close to") + " its limit" : atRisk.length + " cards are near or past their limit"}</AlertTitle>
            <AlertDescription>
              {atRisk.map((c) => c.label + " — " + formatMoney(useOf(c).spent) + " of " + formatMoney(c.spendLimit)).join(" · ")}. A card at its limit declines the next authorisation; raise the limit on the card, or freeze it.
            </AlertDescription>
          </Alert>
        ) : null}

        <div className="fd-cardgrid">
          {ordered.map((card) => (
            <CardTile
              key={card.id}
              card={card}
              holder={holderOf(card)}
              use={useOf(card)}
              waiting={CARD_FLAGS.isExpiringSoon(card, now) ? "Expires in " + formatCountdown(card.expiresAt, now).replace(" left", "") : null}
              onOpen={() => onNavigate("/cards/" + card.id)}
              onRun={(t) => onCardTransition(card, t)}
            />
          ))}
        </div>
      </div>
    );
  }

  /* One card. The figures are the same 30-day window the tiles use; the ledger
     below them is every expense ever put on the card, which the footer states. */
  function CardScreen({ data, now, cardId, onNavigate, onCardTransition, onExpenseTransition, onPostComment }) {
    const policy = data.policy;
    const card = data.cards.find((c) => c.id === cardId);
    const holder = data.members.find((m) => m.id === card.holderId) || { name: "Unassigned", avatar: null, email: "" };
    const use = cardUtilisation(card, data.expenses, now, policy);
    const ledger = expensesOnCard(data.expenses, card.id).slice().sort((a, b) => b.occurredAt - a.occurredAt);
    const reviewable = reviewableExpenses(use.rows, policy);
    const buckets = bucketBy(settledExpenses(use.rows), (e) => e.occurredAt, now, CARD_WINDOW_DAYS).map((b) => ({ at: b.at, spend: sumBy(b.rows, "amount"), rows: b.rows }));
    const mix = spendByCategory(use.rows, EXPENSE_CATEGORIES);

    return (
      <div className="fd-page">
        <PageHeader
          path="/cards/:id"
          title={card.label}
          actions={<Transitions transitions={cardTransitions(card)} onRun={(t) => onCardTransition(card, t)} size="default" />}
        />

        {card.status === "frozen" ? (
          <Alert variant="warning">
            <AlertTitle>Frozen {formatWhen(card.frozenAt, now)} by {card.frozenBy}</AlertTitle>
            <AlertDescription>{card.frozenReason}. Authorisations are declined until it is unfrozen; spend already settled still needs reviewing.</AlertDescription>
          </Alert>
        ) : card.status === "cancelled" ? (
          <Alert variant="info">
            <AlertTitle>Cancelled {formatWhen(card.cancelledAt, now)} by {card.cancelledBy}</AlertTitle>
            <AlertDescription>{card.cancelReason}. The ledger below is kept for the record; nothing more can be spent.</AlertDescription>
          </Alert>
        ) : null}

        <div className="fd-columns">
          <div className="fd-col">
            <Card className="fd-card">
              <CardHeader>
                <CardTitle>Spend by day</CardTitle>
                <CardDescription>{formatMoney(use.spent)} settled on this card over the last {CARD_WINDOW_DAYS} days — the window its limit is measured over.</CardDescription>
              </CardHeader>
              <CardPanel>
                <DayChart
                  buckets={buckets}
                  series={[{ key: "spend", label: card.label, rows: [{ key: "rows", noun: "expense" }] }]}
                  format={formatMoney}
                  label={"Spend a day on " + card.label}
                  note={use.rows.length + " expenses in the window; " + formatMoney(use.limit) + " limit."}
                />
              </CardPanel>
            </Card>

            <Card>
              <CardHeader>
                <CardTitle>Everything put on this card</CardTitle>
                <CardDescription>Newest first. {reviewable.length ? reviewable.length + " of them can be approved now." : "Nothing here is waiting on you."}</CardDescription>
              </CardHeader>
              <div className="fd-card-table">
                <Table variant="card">
                  <TableHeader>
                    <TableRow>
                      <TableHead>Expense</TableHead>
                      <TableHead className="fd-drop-md">Category</TableHead>
                      <TableHead>Status</TableHead>
                      <TableHead className="fd-num">Amount</TableHead>
                      <TableHead className="fd-num fd-drop-lg">Occurred</TableHead>
                      <TableHead className="fd-actions-head">Action</TableHead>
                    </TableRow>
                  </TableHeader>
                  <TableBody>
                    {ledger.map((expense) => {
                      const waiting = EXPENSE_FLAGS.waitsOnSpender(expense, policy);
                      return (
                        <TableRow key={expense.id} className={waiting ? "fd-row--recessed" : ""}>
                          <TableCell>
                            <span className="fd-record">
                              <span className="fd-record__name">{expense.merchant}</span>
                              <span className="fd-record__meta"><code>{expense.id}</code></span>
                            </span>
                          </TableCell>
                          <TableCell className="fd-drop-md fd-muted">{labelOf(EXPENSE_CATEGORIES, expense.category)}</TableCell>
                          <TableCell>
                            <span className="fd-status">
                              <StatusBadge values={EXPENSE_STATUS} value={expense.status} />
                              {EXPENSE_FLAGS.needsReceipt(expense, policy) ? <Badge size="sm" variant="warning">No receipt</Badge> : null}
                            </span>
                            {waiting ? <span className="fd-sub">Waiting on {holder.name}</span> : null}
                          </TableCell>
                          <TableCell className="fd-num fd-amount">{formatMoney(expense.amount)}</TableCell>
                          <TableCell className="fd-num fd-muted fd-drop-lg" title={formatAbsolute(expense.occurredAt)}>{formatWhen(expense.occurredAt, now)}</TableCell>
                          <TableCell className="fd-actions">
                            <RowActions transitions={expenseTransitions(expense, policy)} onRun={(t) => onExpenseTransition(expense, t)} label={"Actions for " + expense.id} />
                          </TableCell>
                        </TableRow>
                      );
                    })}
                  </TableBody>
                </Table>
              </div>
              <CardFooter className="fd-card-footer">
                <span className="fd-footnote">{ledger.length} expenses since the card was opened; {use.rows.length} of them inside the {CARD_WINDOW_DAYS}-day window the figures above measure.</span>
                <Button variant="ghost" size="sm" onClick={() => onNavigate("/expenses")}>All expenses<Icon name="arrow-right" /></Button>
              </CardFooter>
            </Card>
          </div>

          <div className="fd-col">
            <Card className="fd-card">
              <CardHeader>
                <CardTitle>The card</CardTitle>
                <CardDescription><span className="fd-pan">•••• {card.last4}</span> · {labelOf(CARD_TYPES, card.type)}</CardDescription>
                <CardAction><StatusBadge values={CARD_STATUS} value={card.status} /></CardAction>
              </CardHeader>
              <CardPanel className="fd-stack">
                {card.status === "pending_activation"
                  ? <span className="fd-waiting">Waiting on {holder.name} to activate it. Nothing has been spent yet.</span>
                  : <LimitMeter use={use} />}
                <dl className="fd-defs">
                  <div><dt>Holder</dt><dd><span className="fd-holder"><Avatar size={20} src={holder.avatar} alt="" fallback={holder.name.slice(0, 1)} />{holder.name}</span></dd></div>
                  <div><dt>{CARD_WINDOW_DAYS}-day limit</dt><dd>{formatMoney(card.spendLimit)}</dd></div>
                  <div><dt>Opened</dt><dd title={formatAbsolute(card.openedAt)}>{formatDay(card.openedAt)}</dd></div>
                  <div><dt>Expires</dt><dd>{formatDay(card.expiresAt)}{CARD_FLAGS.isExpiringSoon(card, now) ? " · " + formatCountdown(card.expiresAt, now) : ""}</dd></div>
                  <div className="fd-defs__total"><dt>Spent in the window</dt><dd>{formatMoney(use.spent)}</dd></div>
                </dl>
              </CardPanel>
            </Card>

            <Card className="fd-card">
              <CardHeader>
                <CardTitle>Where it went</CardTitle>
                <CardDescription>{formatMoney(use.spent)} of settled spend, last {CARD_WINDOW_DAYS} days.</CardDescription>
              </CardHeader>
              <CardPanel>
                {mix.length
                  ? <MixRows rows={mix} total={use.spent} unitLabel="spend on this card" />
                  : <span className="fd-footnote">Nothing has settled on this card in the last {CARD_WINDOW_DAYS} days.</span>}
              </CardPanel>
            </Card>

            <ThreadCard
              record={{ type: "card", id: card.id, holderId: card.holderId }}
              data={data}
              signedInId={MERIDIAN_SIGNED_IN}
              now={now}
              onPost={onPostComment}
              description={card.status === "frozen"
                ? "Between the holder and whoever can unfreeze it. Mention somebody to put it in their queue."
                : "Between the holder and whoever reviews the spend. Mention somebody to put it in their queue."}
            />
          </div>
        </div>
      </div>
    );
  }

  Object.assign(window, { CardsScreen, CardScreen });
})();

})();
</script>
<script type="text/babel" data-presets="react" data-file="ReportsScreen.jsx">
(function(){
/* Reports — chart-led. The one destination whose primary display is a chart
   rather than a table: the question is "what shape was the money this month",
   and the two mix cards and the customer table beneath it are the rows behind
   the bars.
   Commit model: INSTANT — the only controls are the period scope and export.
   Data state: populated.
   Scope (§5.3): the period select sits above every figure on the screen and
   moves all of them; nothing on this screen filters a list. */
(() => {
  const {
    Card, CardHeader, CardTitle, CardDescription, CardPanel, CardFooter,
    Table, TableHeader, TableBody, TableRow, TableHead, TableCell,
    Select, Meter, Button,
  } = window.OriginDesignSystem_7c998b;

  function ReportsScreen({ data, now, period, onPeriodChange, onNavigate, pushToast }) {
    const days = Number(period);
    const periodLabel = PERIODS.find((p) => p.value === period).label;

    const payments = paymentsInPeriod(data.payments, days, now);
    const expenses = expensesInPeriod(data.expenses, days, now);
    const disputes = disputesInPeriod(data.disputes, days, now);
    const succeeded = succeededPayments(payments);

    const volume = netVolume(payments);
    const spend = cardSpendTotal(expenses);
    const payoutsPaid = data.payouts.filter((p) => p.status === "paid" && p.paidAt >= now - days * 24 * 3600e3);
    const movement = movementByDay(payments, data.payouts, data.expenses, now, days);
    const mix = methodMix(payments, PAYMENT_METHODS);
    const categories = spendByCategory(expenses, EXPENSE_CATEGORIES);
    const customers = topCustomers(payments, 8);
    const disputeShare = sumBy(succeeded, "amount") ? sumBy(disputes, "amount") / sumBy(succeeded, "amount") : null;

    return (
      <div className="fd-page">
        <PageHeader
          path="/reports"
          title="Reports"
          actions={<ExportMenu scope={periodLabel} onExport={(format) => pushToast({ title: "Export started", description: periodLabel + " · " + format + " — every figure on this screen, with the rows behind it" })} />}
        />

        <div className="fd-scope">
          <div className="fd-scope__control">
            <label className="fd-scope__label" htmlFor="fd-rep-period">Period</label>
            <Select id="fd-rep-period" size="sm" items={PERIODS} value={period} onValueChange={onPeriodChange} />
          </div>
          <p className="fd-scope__note">Everything on this screen covers the {periodLabel.toLowerCase()} for {MERIDIAN_WORKSPACE.name}. Nothing here is filtered further down.</p>
        </div>

        <div className="fd-metrics">
          <MetricTile
            icon="arrow-down-left" label="Net volume in"
            value={formatMoney(volume)}
            qualifier={succeeded.length + " succeeded payments, less " + formatMoney(refundTotal(payments)) + " refunded"}
          />
          <MetricTile
            icon="arrow-up-right" label="Money out"
            value={formatMoney(spend + sumBy(payoutsPaid, "amount"))}
            qualifier={formatMoney(sumBy(payoutsPaid, "amount")) + " in " + payoutsPaid.length + " payouts that landed, " + formatMoney(spend) + " of settled card spend"}
          />
          <MetricTile
            icon="scale" label="Net position"
            value={formatMoney(volume - spend - sumBy(payoutsPaid, "amount"))}
            qualifier="Volume in, less the payouts that landed and the card spend that settled"
          />
          <MetricTile
            icon="shield-alert" label="Disputed share of volume"
            value={formatPercent(disputeShare)}
            qualifier={disputes.length + " disputes opened, " + formatMoney(sumBy(disputes, "amount")) + " against " + formatMoney(sumBy(succeeded, "amount")) + " succeeded"}
          />
        </div>

        <Card className="fd-card">
          <CardHeader>
            <CardTitle>Money in and out, by day</CardTitle>
            <CardDescription>In is payments that settled. Out is payouts that landed in the bank plus card spend that settled — approvals and authorisations are outside both.</CardDescription>
          </CardHeader>
          <CardPanel>
            <DayChart
              tall
              buckets={movement}
              series={[{ key: "in", label: "Settled in", rows: [{ key: "payments", noun: "payment" }] }, { key: "out", label: "Paid and spent out", tone: "soft", rows: [{ key: "payouts", noun: "payout" }, { key: "expenses", noun: "expense" }] }]}
              format={formatMoney}
              label="Money in and out a day"
              note={"Bars sum to " + formatMoney(sumBy(movement, "in")) + " in and " + formatMoney(sumBy(movement, "out")) + " out over the " + periodLabel.toLowerCase() + "."}
            />
          </CardPanel>
        </Card>

        <div className="fd-columns fd-columns--even">
          <div className="fd-col">
            <Card className="fd-card">
              <CardHeader>
                <CardTitle>How the volume arrived</CardTitle>
                <CardDescription>{formatMoney(sumBy(succeeded, "amount"))} succeeded across {succeeded.length} payments.</CardDescription>
              </CardHeader>
              <CardPanel>
                {mix.length ? <MixRows rows={mix} total={sumBy(succeeded, "amount")} unitLabel="succeeded volume" /> : <span className="fd-footnote">Nothing succeeded in this period.</span>}
              </CardPanel>
              <CardFooter className="fd-card-footer">
                <span className="fd-footnote">Refunded and failed payments are outside this breakdown.</span>
                <Button variant="ghost" size="sm" onClick={() => onNavigate("/payments")}>Open payments<Icon name="arrow-right" /></Button>
              </CardFooter>
            </Card>
          </div>
          <div className="fd-col">
            <Card className="fd-card">
              <CardHeader>
                <CardTitle>Where the spend went</CardTitle>
                <CardDescription>{formatMoney(spend)} of settled card spend across {settledExpenses(expenses).length} expenses.</CardDescription>
              </CardHeader>
              <CardPanel>
                {categories.length ? <MixRows rows={categories} total={spend} unitLabel="settled card spend" /> : <span className="fd-footnote">No card spend settled in this period.</span>}
              </CardPanel>
              <CardFooter className="fd-card-footer">
                <span className="fd-footnote">{pendingExpenses(expenses).length} authorisations have not settled and are outside it.</span>
                <Button variant="ghost" size="sm" onClick={() => onNavigate("/expenses")}>Open expenses<Icon name="arrow-right" /></Button>
              </CardFooter>
            </Card>
          </div>
        </div>

        <Card>
          <CardHeader>
            <CardTitle>Who the volume came from</CardTitle>
            <CardDescription>The {customers.length} largest customers by net volume, {periodLabel.toLowerCase()}. Share decomposes the {formatMoney(volume)} above.</CardDescription>
          </CardHeader>
          <div className="fd-card-table">
            <Table variant="card">
              <TableHeader>
                <TableRow>
                  <TableHead>Customer</TableHead>
                  <TableHead className="fd-drop-md">Payments</TableHead>
                  <TableHead className="fd-num">Net volume</TableHead>
                  <TableHead style={{ width: "12rem" }}>Share</TableHead>
                </TableRow>
              </TableHeader>
              <TableBody>
                {customers.map((row) => (
                  <TableRow key={row.customer}>
                    <TableCell><span className="fd-record__name">{row.customer}</span></TableCell>
                    <TableCell className="fd-drop-md fd-muted">{row.rows.length}</TableCell>
                    <TableCell className="fd-num fd-amount">{formatMoney(row.amount)}</TableCell>
                    <TableCell>
                      <span className="fd-spread">
                        <Meter value={Math.max(0, row.amount)} max={Math.max(volume, 1)} aria-label={row.customer + " share of net volume"} />
                        <span className="fd-footnote">{formatPercent(row.share)}</span>
                      </span>
                    </TableCell>
                  </TableRow>
                ))}
              </TableBody>
            </Table>
          </div>
          <CardFooter className="fd-card-footer">
            <span className="fd-footnote">{formatMoney(sumBy(customers, "amount"))} of the {formatMoney(volume)} net volume, from {customers.length} of the {new Set(payments.map((p) => p.customer)).size} customers who paid in the period.</span>
          </CardFooter>
        </Card>
      </div>
    );
  }

  Object.assign(window, { ReportsScreen });
})();

})();
</script>
<script type="text/babel" data-presets="react" data-file="CustomersScreens.jsx">
(function(){
/* Customers — the companies on the other side of the money.

   Commit model: INSTANT on the list and the record (putting an account on hold
   or resuming it applies immediately and raises an undo toast; closing one is
   irreversible and opens a confirm dialog). /customers/new is its own route and
   is STAGED, because a customer is more than three fields.

   Data state: populated on both. The list's no-results branch is the filter's,
   and its action clears the filter rather than offering a new customer.

   Every figure here is a sum over the array it names in metrics.js: a
   customer's volume is their succeeded payments, what is held back is their own
   open disputes, and neither is stored on the customer record. */
(() => {
  const {
    Card, CardHeader, CardTitle, CardDescription, CardPanel, CardFooter, CardAction,
    Table, TableHeader, TableBody, TableRow, TableHead, TableCell,
    Button, Badge, Select, Alert, AlertTitle, AlertDescription,
    Empty, EmptyHeader, EmptyMedia, EmptyTitle, EmptyDescription, EmptyContent,
    Field, FieldLabel, FieldDescription, FieldError, Input, RadioGroup, Separator, Avatar,
  } = window.OriginDesignSystem_7c998b;

  const ownerOf = (data, customer) => data.members.find((m) => m.id === customer.ownerId) || { id: null, name: "Unassigned", avatar: null, role: "" };

  /* ── The list ─────────────────────────────────────────────────────────── */

  function CustomersScreen({ data, now, onNavigate, onCustomerTransition }) {
    const [query, setQuery] = React.useState("");
    const [status, setStatus] = React.useState("all");
    const customers = customersByVolume(data.customers, data.payments);

    /* Scope-free: the text filter and the status filter narrow the list only
       (AGENTS §5.3), so the figures above read the whole customer array and the
       footer states what the table is showing. */
    const live = customers.filter((c) => c.status === "live");
    const notTrading = customers.filter((c) => c.status === "on_hold" || c.status === "suspended");
    const withDispute = customers.filter((c) => CUSTOMER_FLAGS.hasOpenDispute(c, data.disputes));
    const reserved = withDispute.reduce((total, c) => total + customerReserved(data.disputes, c), 0);

    const shown = customers.filter((c) => {
      const q = query.trim().toLowerCase();
      if (status !== "all" && c.status !== status) return false;
      if (!q) return true;
      return (c.name + " " + c.contact.name + " " + c.contact.email + " " + c.country).toLowerCase().indexOf(q) > -1;
    });
    const dirty = query.trim() !== "" || status !== "all";

    return (
      <div className="fd-page">
        <PageHeader
          path="/customers"
          title="Customers"
          actions={<span className="fd-actions__row"><ExportMenu scope="All customers" onExport={() => {}} /><Button size="sm" onClick={() => onNavigate("/customers/new")}><Icon name="plus" />New customer</Button></span>}
        />

        <div className="fd-metrics">
          <MetricTile icon="building-2" label="Customers" value={formatCount(customers.length)} qualifier={live.length + " live, " + notTrading.length + " on hold or suspended, " + customers.filter((c) => c.status === "closed").length + " closed"} />
          <MetricTile icon="trending-up" label="Volume, all time" value={formatMoney(customers.reduce((t, c) => t + customerVolume(data.payments, c), 0))} qualifier={succeededPayments(data.payments).length + " succeeded payments across every account"} />
          <MetricTile icon="shield-alert" label="Held back against disputes" value={formatMoney(reserved)} qualifier={withDispute.length ? withDispute.length + " " + (withDispute.length === 1 ? "account has" : "accounts have") + " an open dispute" : "No account has an open dispute"} />
          <MetricTile icon="moon" label="Dormant" value={formatCount(customers.filter((c) => CUSTOMER_FLAGS.isDormant(c, data.payments, now)).length)} qualifier="Nothing has arrived from them in 45 days" />
        </div>

        <Card>
          <CardHeader>
            <CardTitle>Every account</CardTitle>
            <CardDescription>Ordered by what they have brought in, not by name.</CardDescription>
          </CardHeader>
          <CardPanel>
            <FilterBar
              query={query}
              onQuery={setQuery}
              placeholder="Search customers, contacts…"
              dirty={dirty}
              onClear={() => { setQuery(""); setStatus("all"); }}
              selects={[{
                id: "status",
                label: "Status",
                value: status,
                onChange: setStatus,
                items: [{ value: "all", label: "All statuses (" + CUSTOMER_STATUS.length + ")" }].concat(CUSTOMER_STATUS.map((s) => ({ value: s.id, label: s.label }))),
              }]}
            />
          </CardPanel>
          {shown.length ? (
            <div className="fd-card-table">
              <Table variant="card">
                <TableHeader>
                  <TableRow>
                    <TableHead>Customer</TableHead>
                    <TableHead className="fd-drop-md">Owner</TableHead>
                    <TableHead>Status</TableHead>
                    <TableHead className="fd-num">Volume</TableHead>
                    <TableHead className="fd-num fd-drop-lg">Last payment</TableHead>
                    <TableHead className="fd-actions-head">Action</TableHead>
                  </TableRow>
                </TableHeader>
                <TableBody>
                  {shown.map((customer) => {
                    const owner = ownerOf(data, customer);
                    const last = customerLastPaymentAt(data.payments, customer);
                    const closed = customer.status === "closed";
                    return (
                      <TableRow key={customer.id} className={closed ? "fd-row--recessed" : ""}>
                        <TableCell>
                          <span className="fd-record">
                            <button type="button" className="fd-link fd-record__name" onClick={() => onNavigate(recordRoute({ type: "customer", id: customer.id }))}>{customer.name}</button>
                            <span className="fd-record__meta">{customer.contact.name} · {customer.country}</span>
                          </span>
                        </TableCell>
                        <TableCell className="fd-drop-md">
                          <span className="fd-holder"><Face person={owner} size={24} />{owner.name}</span>
                        </TableCell>
                        <TableCell>
                          <span className="fd-status">
                            <StatusBadge values={CUSTOMER_STATUS} value={customer.status} />
                            {CUSTOMER_FLAGS.hasOpenDispute(customer, data.disputes) ? <Badge size="sm" variant="warning">Open dispute</Badge> : null}
                          </span>
                        </TableCell>
                        <TableCell className="fd-num fd-amount">{formatMoney(customerVolume(data.payments, customer))}</TableCell>
                        <TableCell className="fd-num fd-muted fd-drop-lg" title={last ? formatAbsolute(last) : undefined}>{last ? formatWhen(last, now) : "Never"}</TableCell>
                        <TableCell className="fd-actions">
                          <RowActions transitions={customerTransitions(customer)} onRun={(t) => onCustomerTransition(customer, t)} label={"Actions for " + customer.name} />
                        </TableCell>
                      </TableRow>
                    );
                  })}
                </TableBody>
              </Table>
            </div>
          ) : (
            <CardPanel>
              <Empty>
                <EmptyHeader>
                  <EmptyMedia variant="icon"><Icon name="building-2" size={18} /></EmptyMedia>
                  <EmptyTitle>No account matches this filter</EmptyTitle>
                  <EmptyDescription>{customers.length} customers are on the desk; none of them match what you have narrowed to.</EmptyDescription>
                </EmptyHeader>
                <EmptyContent>
                  <Button onClick={() => { setQuery(""); setStatus("all"); }}>Clear filters</Button>
                </EmptyContent>
              </Empty>
            </CardPanel>
          )}
          <CardFooter className="fd-card-footer">
            <span className="fd-footnote">Showing {shown.length} of {customers.length} customers. A closed account is recessed: nothing can move to or from it.</span>
          </CardFooter>
        </Card>
      </div>
    );
  }

  /* ── One account ──────────────────────────────────────────────────────── */

  function CustomerScreen({ data, now, customerId, onNavigate, onCustomerTransition, onPaymentTransition, onPostComment }) {
    const customer = customerById(data.customers, customerId);
    const owner = ownerOf(data, customer);
    const payments = paymentsFor(data.payments, customer).slice().sort((a, b) => b.createdAt - a.createdAt);
    const payouts = payoutsFor(data.payouts, customer).slice().sort((a, b) => b.requestedAt - a.requestedAt);
    const disputes = disputesFor(data.disputes, customer);
    const open = openDisputes(disputes);
    const volume = customerVolume(data.payments, customer);
    const last = customerLastPaymentAt(data.payments, customer);

    return (
      <div className="fd-page">
        <PageHeader
          path="/customers/:id"
          params={{ id: customer.id }}
          title={customer.name}
          actions={<Transitions transitions={customerTransitions(customer)} onRun={(t) => onCustomerTransition(customer, t)} size="default" />}
        />

        {customer.status === "on_hold" ? (
          <Alert variant="warning">
            <AlertTitle>Payouts are paused on this account</AlertTitle>
            <AlertDescription>
              {open.length
                ? "We paused them ourselves while " + open.length + " " + (open.length === 1 ? "dispute runs" : "disputes run") + " — " + formatMoney(customerReserved(data.disputes, customer)) + " is held back against the balance. Money still arrives from them; nothing leaves."
                : "We paused them ourselves. Money still arrives from them; nothing leaves until somebody resumes it."}
            </AlertDescription>
          </Alert>
        ) : customer.status === "suspended" ? (
          <Alert variant="error">
            <AlertTitle>Suspended by risk</AlertTitle>
            <AlertDescription>Nothing moves to or from this account. The last payout came back from their bank, and new details have to be confirmed in writing before it can be resumed.</AlertDescription>
          </Alert>
        ) : customer.status === "closed" ? (
          <Alert variant="info">
            <AlertTitle>Closed account</AlertTitle>
            <AlertDescription>The records below are kept for the ledger. Nothing more can be taken or sent, and a closed account cannot be reopened — a new one has to be onboarded.</AlertDescription>
          </Alert>
        ) : null}

        <div className="fd-metrics">
          <MetricTile icon="trending-up" label="Volume, all time" value={formatMoney(volume)} qualifier={succeededPayments(payments).length + " succeeded of " + payments.length + " payments taken from them"} />
          <MetricTile icon="arrow-up-right" label="Paid out to them" value={formatMoney(customerPaidOut(data.payouts, customer))} qualifier={payouts.filter((p) => p.status === "paid").length + " of " + payouts.length + " payouts have landed"} />
          <MetricTile icon="shield-alert" label="Held back" value={formatMoney(customerReserved(data.disputes, customer))} qualifier={open.length ? open.length + " open " + (open.length === 1 ? "dispute" : "disputes") + " of " + disputes.length + " ever" : "Nothing of theirs is disputed"} />
          <MetricTile icon="clock" label="Last payment" value={last ? formatWhen(last, now) : "Never"} qualifier={CUSTOMER_FLAGS.isDormant(customer, data.payments, now) ? "Dormant — nothing in 45 days" : "Trading normally"} />
        </div>

        <div className="fd-columns">
          <div className="fd-col">
            <Card>
              <CardHeader>
                <CardTitle>Payments from {customer.name}</CardTitle>
                <CardDescription>{formatMoney(volume)} of it succeeded. Newest first.</CardDescription>
              </CardHeader>
              {payments.length ? (
                <div className="fd-card-table">
                  <Table variant="card">
                    <TableHeader>
                      <TableRow>
                        <TableHead>Payment</TableHead>
                        <TableHead className="fd-drop-md">Method</TableHead>
                        <TableHead>Status</TableHead>
                        <TableHead className="fd-num">Amount</TableHead>
                        <TableHead className="fd-num fd-drop-lg">Taken</TableHead>
                        <TableHead className="fd-actions-head">Action</TableHead>
                      </TableRow>
                    </TableHeader>
                    <TableBody>
                      {payments.map((payment) => (
                        <TableRow key={payment.id}>
                          <TableCell><code>{payment.id}</code>{payment.failureReason ? <span className="fd-sub">{payment.failureReason}</span> : null}</TableCell>
                          <TableCell className="fd-drop-md fd-muted">{labelOf(PAYMENT_METHODS, payment.method)}</TableCell>
                          <TableCell><StatusBadge values={PAYMENT_STATUS} value={payment.status} /></TableCell>
                          <TableCell className="fd-num fd-amount">{formatMoney(payment.amount)}</TableCell>
                          <TableCell className="fd-num fd-muted fd-drop-lg" title={formatAbsolute(payment.createdAt)}>{formatWhen(payment.createdAt, now)}</TableCell>
                          <TableCell className="fd-actions">
                            <RowActions transitions={paymentTransitions(payment)} onRun={(t) => onPaymentTransition(payment, t)} label={"Actions for " + payment.id} />
                          </TableCell>
                        </TableRow>
                      ))}
                    </TableBody>
                  </Table>
                </div>
              ) : (
                <CardPanel><span className="fd-footnote">Nothing has been taken from this account yet.</span></CardPanel>
              )}
              <CardFooter className="fd-card-footer">
                <span className="fd-footnote">{payments.length} payments, {succeededPayments(payments).length} of them succeeded.</span>
                <Button variant="ghost" size="sm" onClick={() => onNavigate("/payments")}>All payments<Icon name="arrow-right" /></Button>
              </CardFooter>
            </Card>

            <Card>
              <CardHeader>
                <CardTitle>Payouts to their bank</CardTitle>
                <CardDescription>{customer.payoutAccount ? "Landing in " + customer.payoutAccount + "." : "No bank account on file, so nothing can be sent yet."}</CardDescription>
              </CardHeader>
              {payouts.length ? (
                <div className="fd-card-table">
                  <Table variant="card">
                    <TableHeader>
                      <TableRow>
                        <TableHead>Payout</TableHead>
                        <TableHead>Status</TableHead>
                        <TableHead className="fd-num">Amount</TableHead>
                        <TableHead className="fd-num fd-drop-md">Requested</TableHead>
                      </TableRow>
                    </TableHeader>
                    <TableBody>
                      {payouts.map((payout) => (
                        <TableRow key={payout.id} className={PAYOUT_FLAGS.isApprovable(payout, data.approver) ? "" : "fd-row--recessed"}>
                          <TableCell><code>{payout.id}</code>{payout.blockedBy ? <span className="fd-sub">Held by {payout.blockedBy.team} — {payout.blockedBy.reason}</span> : null}</TableCell>
                          <TableCell><StatusBadge values={PAYOUT_STATUS} value={payout.status} /></TableCell>
                          <TableCell className="fd-num fd-amount">{formatMoney(payout.amount)}</TableCell>
                          <TableCell className="fd-num fd-muted fd-drop-md" title={formatAbsolute(payout.requestedAt)}>{formatWhen(payout.requestedAt, now)}</TableCell>
                        </TableRow>
                      ))}
                    </TableBody>
                  </Table>
                </div>
              ) : (
                <CardPanel><span className="fd-footnote">Nothing has been sent to this account.</span></CardPanel>
              )}
              <CardFooter className="fd-card-footer">
                <span className="fd-footnote">{payouts.length} payouts · approving and rejecting them happens on the payouts queue.</span>
                <Button variant="ghost" size="sm" onClick={() => onNavigate("/payouts")}>Payout queue<Icon name="arrow-right" /></Button>
              </CardFooter>
            </Card>
          </div>

          <div className="fd-col">
            <Card className="fd-card">
              <CardHeader>
                <CardTitle>The account</CardTitle>
                <CardDescription>Onboarded {formatDay(customer.since)}.</CardDescription>
                <CardAction><StatusBadge values={CUSTOMER_STATUS} value={customer.status} /></CardAction>
              </CardHeader>
              <CardPanel className="fd-stack">
                <span className="fd-holder">
                  <Face person={owner} size={32} />
                  <span className="fd-record">
                    <span className="fd-record__name">{owner.name}</span>
                    <span className="fd-record__meta">Owns this account · {owner.role}</span>
                  </span>
                </span>
                <dl className="fd-defs">
                  <div><dt>Contact</dt><dd>{customer.contact.name}</dd></div>
                  <div><dt>Email</dt><dd>{customer.contact.email}</dd></div>
                  <div><dt>Country</dt><dd>{customer.country}</dd></div>
                  <div><dt>Settlement currency</dt><dd>{customer.currency}</dd></div>
                  <div><dt>Payout account</dt><dd>{customer.payoutAccount ? <span className="fd-pan">{customer.payoutAccount}</span> : "None on file"}</dd></div>
                  <div className="fd-defs__total"><dt>Volume, all time</dt><dd>{formatMoney(volume)}</dd></div>
                </dl>
              </CardPanel>
              <CardFooter className="fd-card-footer">
                <Button variant="outline" size="sm" onClick={() => onNavigate(recordRoute({ type: "member", id: owner.id }))}>Open {owner.name.split(" ")[0]}'s page<Icon name="arrow-right" /></Button>
              </CardFooter>
            </Card>

            {disputes.length ? (
              <Card className="fd-card">
                <CardHeader>
                  <CardTitle>Disputes</CardTitle>
                  <CardDescription>{formatMoney(customerReserved(data.disputes, customer))} of theirs is held back against the {open.length} still open.</CardDescription>
                </CardHeader>
                <CardPanel>
                  <div className="fd-picklist">
                    {disputes.map((dispute) => (
                      <button type="button" className="fd-pick" key={dispute.id} onClick={() => onNavigate(recordRoute({ type: "dispute", id: dispute.id }))}>
                        <span className="fd-list__main">
                          <span className="fd-record__name">{dispute.reason}</span>
                          <span className="fd-record__meta"><code>{dispute.id}</code></span>
                        </span>
                        <span className="fd-list__side">
                          <span className="fd-amount">{formatMoney(dispute.amount)}</span>
                          <span className="fd-clock">{dispute.evidenceDueAt ? formatCountdown(dispute.evidenceDueAt, now) : "With the network"}</span>
                        </span>
                        <span className="fd-pick__foot"><StatusBadge values={DISPUTE_STATUS} value={dispute.status} /></span>
                      </button>
                    ))}
                  </div>
                </CardPanel>
              </Card>
            ) : null}

            <ThreadCard
              record={{ type: "customer", id: customer.id }}
              data={data}
              signedInId={MERIDIAN_SIGNED_IN}
              now={now}
              onPost={onPostComment}
              description={"What the desk knows about " + customer.name + " that the records do not say. Mention somebody to put it in their queue."}
            />
          </div>
        </div>
      </div>
    );
  }

  /* ── Onboarding one ───────────────────────────────────────────────────── */

  function NewCustomerScreen({ data, onCreate, onNavigate }) {
    const blank = { name: "", contactName: "", contactEmail: "", country: "United States", ownerId: MERIDIAN_SIGNED_IN, payoutAccount: "" };
    const [draft, setDraft] = React.useState(blank);
    const [submitted, setSubmitted] = React.useState(false);
    const set = (fields) => setDraft((d) => Object.assign({}, d, fields));
    const dirty = JSON.stringify(draft) !== JSON.stringify(blank);
    const errors = {};
    if (draft.name.trim().length < 3) errors.name = "Enter the company's name — at least three characters.";
    if (data.customers.some((c) => c.name.toLowerCase() === draft.name.trim().toLowerCase())) errors.name = "That account already exists on the desk.";
    if (!draft.contactName.trim()) errors.contactName = "Somebody there has to own the relationship.";
    if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(draft.contactEmail)) errors.contactEmail = "Enter a valid email address.";
    if (draft.payoutAccount && !/^\d{4}$/.test(draft.payoutAccount.trim())) errors.payoutAccount = "The last four digits, or leave it empty.";
    const valid = Object.keys(errors).length === 0;
    const owner = data.members.find((m) => m.id === draft.ownerId);

    return (
      <div className="fd-page">
        <PageHeader path="/customers/new" title="New customer" />
        <div className="fd-columns">
          <div className="fd-col">
            <Card>
              <CardHeader>
                <CardTitle>Onboard an account</CardTitle>
                <CardDescription>They can be charged as soon as this is saved. A bank account is only needed before the first payout.</CardDescription>
              </CardHeader>
              <CardPanel>
                <div className="fd-fields">
                  <Field>
                    <FieldLabel htmlFor="fd-cus-name">Company name</FieldLabel>
                    <Input id="fd-cus-name" value={draft.name} placeholder="Harborview Freight" invalid={submitted && !!errors.name} onChange={(e) => set({ name: e.target.value })} />
                    {submitted && errors.name ? <FieldError>{errors.name}</FieldError> : <FieldDescription>Exactly as it should read on their statement and on every payout.</FieldDescription>}
                  </Field>
                  <Field>
                    <FieldLabel htmlFor="fd-cus-contact">Contact</FieldLabel>
                    <Input id="fd-cus-contact" value={draft.contactName} placeholder="Ruth Callan" invalid={submitted && !!errors.contactName} onChange={(e) => set({ contactName: e.target.value })} />
                    {submitted && errors.contactName ? <FieldError>{errors.contactName}</FieldError> : null}
                  </Field>
                  <Field>
                    <FieldLabel htmlFor="fd-cus-email">Contact email</FieldLabel>
                    <Input id="fd-cus-email" type="email" value={draft.contactEmail} placeholder="ruth@harborview.example" invalid={submitted && !!errors.contactEmail} onChange={(e) => set({ contactEmail: e.target.value })} />
                    {submitted && errors.contactEmail ? <FieldError>{errors.contactEmail}</FieldError> : <FieldDescription>Where disputes and payout notices are sent.</FieldDescription>}
                  </Field>
                  <Field>
                    <FieldLabel htmlFor="fd-cus-country">Country</FieldLabel>
                    <Input id="fd-cus-country" value={draft.country} onChange={(e) => set({ country: e.target.value })} />
                    <FieldDescription>Settlement is in {MERIDIAN_WORKSPACE.name}'s currency either way.</FieldDescription>
                  </Field>
                  <Field>
                    <FieldLabel>Who owns the account</FieldLabel>
                    <RadioGroup name="fd-cus-owner" value={draft.ownerId} onValueChange={(id) => set({ ownerId: id })}
                      items={data.members.map((m) => ({ value: m.id, label: m.name + " — " + m.role }))} />
                    <FieldDescription>Their name is on every note and every payout decision for this account.</FieldDescription>
                  </Field>
                  <Field>
                    <FieldLabel htmlFor="fd-cus-account">Payout account, last four digits</FieldLabel>
                    <Input id="fd-cus-account" inputMode="numeric" value={draft.payoutAccount} placeholder="4417" invalid={submitted && !!errors.payoutAccount} onChange={(e) => set({ payoutAccount: e.target.value })} />
                    {submitted && errors.payoutAccount ? <FieldError>{errors.payoutAccount}</FieldError> : <FieldDescription>Optional. Without it, no payout can be raised to them.</FieldDescription>}
                  </Field>
                </div>
              </CardPanel>
            </Card>
          </div>

          <div className="fd-col">
            <Card className="fd-card">
              <CardHeader>
                <CardTitle>What this creates</CardTitle>
                <CardDescription>Read from the form as you fill it in.</CardDescription>
              </CardHeader>
              <CardPanel className="fd-stack">
                <span className="fd-holder">
                  <Face person={owner} size={32} />
                  <span className="fd-record">
                    <span className="fd-record__name">{draft.name.trim() || "A new account"}</span>
                    <span className="fd-record__meta">Owned by {owner.name} · {draft.country || "no country yet"}</span>
                  </span>
                </span>
                <Separator />
                <dl className="fd-defs">
                  <div><dt>Status on save</dt><dd><StatusBadge values={CUSTOMER_STATUS} value="live" /></dd></div>
                  <div><dt>Can be charged</dt><dd>Straight away</dd></div>
                  <div><dt>Can be paid out</dt><dd>{draft.payoutAccount.trim() ? "Yes, into •••• " + draft.payoutAccount.trim() : "Not until a bank account is added"}</dd></div>
                  <div className="fd-defs__total"><dt>Accounts on the desk</dt><dd>{data.customers.length} → {data.customers.length + 1}</dd></div>
                </dl>
              </CardPanel>
            </Card>
          </div>
        </div>

        <FormBar
          dirty={dirty}
          note={dirty ? "Nothing exists until this is saved." : "Fill the form in and the account is created on save."}
          onCancel={() => { setDraft(blank); setSubmitted(false); onNavigate("/customers"); }}
          onSave={() => { setSubmitted(true); if (valid) onCreate(draft); }}
          label="New customer"
        />
      </div>
    );
  }

  Object.assign(window, { CustomersScreen, CustomerScreen, NewCustomerScreen });
})();

})();
</script>
<script type="text/babel" data-presets="react" data-file="PeopleScreens.jsx">
(function(){
/* People — the workspace's own team, and one person's page.

   Commit model: INSTANT. Inviting somebody opens a staged dialog from this
   instant screen (three fields), and permissions are not edited here: role,
   approval limit and card issuing belong to /settings/team, which this screen
   links to rather than duplicating. One entry point per action.

   Data state: populated. Every figure is an array filtered by the person's id
   in metrics.js — their cards, their settled expenses, the payouts their name
   is on — so nothing about a person's work is stored on the person. */
(() => {
  const {
    Card, CardHeader, CardTitle, CardDescription, CardPanel, CardFooter, CardAction,
    Table, TableHeader, TableBody, TableRow, TableHead, TableCell,
    Button, Badge, Separator, Alert, AlertTitle, AlertDescription,
  } = window.OriginDesignSystem_7c998b;

  function PersonTile({ member, data, now, signedInId, onNavigate }) {
    const isSelf = member.id === signedInId;
    const cards = cardsHeldBy(data.cards, member).filter((c) => c.status !== "cancelled");
    const spend = spendBy(data.expenses, member);
    const owed = expensesBy(data.expenses, member).filter((e) => EXPENSE_FLAGS.waitsOnSpender(e, data.policy));
    const notes = data.comments.filter((c) => c.authorId === member.id).length;
    const accounts = customersOwnedBy(data.customers, member);
    return (
      <Card className="fd-card">
        <CardHeader>
          <CardTitle>
            <span className="fd-holder">
              <Face person={member} size={40} />
              <span className="fd-record">
                <span className="fd-record__name">{member.name}{member.id === signedInId ? " (you)" : ""}</span>
                <span className="fd-record__meta">{member.role}</span>
              </span>
            </span>
          </CardTitle>
          <CardAction><Badge size="sm" variant={member.teamRole === "member" ? "secondary" : "outline"}>{labelOf(TEAM_ROLES, member.teamRole)}</Badge></CardAction>
        </CardHeader>
        <CardPanel className="fd-stack">
          <dl className="fd-defs">
            <div><dt>Spend, last {CARD_WINDOW_DAYS} days</dt><dd>{formatMoney(spend)}</dd></div>
            <div><dt>Cards held</dt><dd>{cards.length ? cards.map((c) => c.label).join(", ") : "None"}</dd></div>
            <div><dt>Accounts owned</dt><dd>{accounts.length ? formatCount(accounts.length) : "None"}</dd></div>
            <div><dt>Can approve up to</dt><dd>{member.approvalLimit ? formatMoney(member.approvalLimit) : "Nothing"}</dd></div>
          </dl>
          {owed.length ? <span className="fd-waiting">{owed.length} of {isSelf ? "your" : "their"} expenses {owed.length === 1 ? "is" : "are"} waiting on a receipt from {isSelf ? "you" : "them"}.</span> : null}
        </CardPanel>
        <CardFooter className="fd-card-footer">
          <span className="fd-footnote">{notes ? notes + (notes === 1 ? " note" : " notes") + " written across the desk" : (isSelf ? "You have written no notes yet" : "No notes yet")}</span>
          <Button variant="outline" size="sm" onClick={() => onNavigate(recordRoute({ type: "member", id: member.id }))}>Open<Icon name="arrow-right" /></Button>
        </CardFooter>
      </Card>
    );
  }

  function PeopleScreen({ data, now, onNavigate, onInvite }) {
    const members = data.members;
    const canApprove = members.filter((m) => m.approvalLimit > 0);
    const spend = members.reduce((total, m) => total + spendBy(data.expenses, m), 0);
    const held = data.cards.filter((c) => c.status !== "cancelled");

    return (
      <div className="fd-page">
        <PageHeader
          path="/people"
          title="People"
          actions={<Button size="sm" onClick={onInvite}><Icon name="plus" />Invite teammate</Button>}
        />

        <div className="fd-metrics">
          <MetricTile icon="users" label="People" value={formatCount(members.length)} qualifier={canApprove.length + " of them can approve a payout; the other " + (members.length - canApprove.length) + " cannot"} />
          <MetricTile icon="credit-card" label="Cards in their hands" value={formatCount(held.length)} qualifier={(data.cards.length - held.length) + " cancelled " + (data.cards.length - held.length === 1 ? "card is" : "cards are") + " kept for the record"} />
          <MetricTile icon="receipt" label={"Spend, last " + CARD_WINDOW_DAYS + " days"} value={formatMoney(spend)} qualifier={settledExpenses(expensesInPeriod(data.expenses, CARD_WINDOW_DAYS, MERIDIAN_NOW)).length + " settled expenses across every card"} />
          <MetricTile icon="message-square" label="Notes waiting on a reply" value={formatCount(data.comments.filter((c) => c.mentions.some((id) => COMMENT_FLAGS.isAwaitingReply(c, id, data.comments))).length)} qualifier="Somebody was mentioned and has not answered on that record" />
        </div>

        <div className="fd-cardgrid">
          {members.map((member) => (
            <PersonTile key={member.id} member={member} data={data} now={now} signedInId={MERIDIAN_SIGNED_IN} onNavigate={onNavigate} />
          ))}
        </div>

        <Card>
          <CardFooter className="fd-card-footer">
            <span className="fd-footnote">Roles, approval limits and card issuing are edited together, so they live on one form.</span>
            <Button variant="outline" size="sm" onClick={() => onNavigate("/settings/team")}>Team and approvals<Icon name="arrow-right" /></Button>
          </CardFooter>
        </Card>
      </div>
    );
  }

  function PersonScreen({ data, now, memberId, onNavigate, onExpenseTransition, onPostComment }) {
    const member = data.members.find((m) => m.id === memberId);
    const self = member.id === MERIDIAN_SIGNED_IN;
    const cards = cardsHeldBy(data.cards, member);
    const expenses = expensesBy(data.expenses, member).slice().sort((a, b) => b.occurredAt - a.occurredAt);
    const settled = settledExpenses(expenses);
    const approvals = approvalsBy(data.payouts, member);
    const accounts = customersOwnedBy(data.customers, member);
    const owed = expenses.filter((e) => EXPENSE_FLAGS.waitsOnSpender(e, data.policy));

    return (
      <div className="fd-page">
        <PageHeader
          path="/people/:id"
          params={{ id: member.id }}
          title={member.name}
          actions={<Button variant="outline" size="sm" onClick={() => onNavigate(self ? "/settings/profile" : "/settings/team")}>{self ? "Edit your profile" : "Edit permissions"}</Button>}
        />

        {owed.length ? (
          <Alert variant="warning">
            <AlertTitle>{owed.length} {owed.length === 1 ? "expense is" : "expenses are"} waiting on {self ? "you" : member.name.split(" ")[0]}</AlertTitle>
            <AlertDescription>
              {formatMoney(sumBy(owed, "amount"))} cannot be approved by anybody until {self ? "you attach the receipts" : member.name.split(" ")[0] + " attaches the receipts"}. Those rows are recessed below — the work is {self ? "yours" : "theirs"}, not the reviewer's.
            </AlertDescription>
          </Alert>
        ) : null}

        <div className="fd-metrics">
          <MetricTile icon="receipt" label={"Spend, last " + CARD_WINDOW_DAYS + " days"} value={formatMoney(spendBy(data.expenses, member))} qualifier={settledExpenses(expensesInPeriod(expenses, CARD_WINDOW_DAYS, now)).length + " settled expenses on " + cards.filter((c) => c.status !== "cancelled").length + " live " + (cards.filter((c) => c.status !== "cancelled").length === 1 ? "card" : "cards")} />
          <MetricTile icon="check-check" label={self ? "Payouts you approved" : "Payouts they approved"} value={formatCount(approvals.length)} qualifier={approvals.length ? formatMoney(sumBy(approvals, "amount")) + " signed off in " + (self ? "your" : "their") + " name" : (self ? "Nothing has carried your name yet" : "Nothing has carried their name yet")} />
          <MetricTile icon="building-2" label="Accounts owned" value={formatCount(accounts.length)} qualifier={accounts.length ? formatMoney(accounts.reduce((t, c) => t + customerVolume(data.payments, c), 0)) + " of volume sits with " + (self ? "you" : "them") : "No customer accounts"} />
          <MetricTile icon="shield-check" label={self ? "You can approve up to" : "Can approve up to"} value={member.approvalLimit ? formatMoney(member.approvalLimit) : "Nothing"} qualifier={labelOf(TEAM_ROLES, member.teamRole) + " · " + (member.canIssueCards ? "can issue cards" : "cannot issue cards")} />
        </div>

        <div className="fd-columns">
          <div className="fd-col">
            <Card>
              <CardHeader>
                <CardTitle>What {self ? "you put" : "they put"} on a card</CardTitle>
                <CardDescription>Every expense of theirs, newest first. {settled.length} of {expenses.length} have settled.</CardDescription>
              </CardHeader>
              {expenses.length ? (
                <div className="fd-card-table">
                  <Table variant="card">
                    <TableHeader>
                      <TableRow>
                        <TableHead>Expense</TableHead>
                        <TableHead className="fd-drop-md">Card</TableHead>
                        <TableHead>Status</TableHead>
                        <TableHead className="fd-num">Amount</TableHead>
                        <TableHead className="fd-num fd-drop-lg">Occurred</TableHead>
                        <TableHead className="fd-actions-head">Action</TableHead>
                      </TableRow>
                    </TableHeader>
                    <TableBody>
                      {expenses.map((expense) => {
                        const card = data.cards.find((c) => c.id === expense.cardId);
                        const waiting = EXPENSE_FLAGS.waitsOnSpender(expense, data.policy);
                        return (
                          <TableRow key={expense.id} className={waiting ? "fd-row--recessed" : ""}>
                            <TableCell>
                              <span className="fd-record">
                                <span className="fd-record__name">{expense.merchant}</span>
                                <span className="fd-record__meta">{labelOf(EXPENSE_CATEGORIES, expense.category)}</span>
                              </span>
                            </TableCell>
                            <TableCell className="fd-drop-md fd-muted">
                              <button type="button" className="fd-link" onClick={() => onNavigate(recordRoute({ type: "card", id: card.id }))}>{card.label}</button>
                            </TableCell>
                            <TableCell>
                              <span className="fd-status">
                                <StatusBadge values={EXPENSE_STATUS} value={expense.status} />
                                {EXPENSE_FLAGS.needsReceipt(expense, data.policy) ? <Badge size="sm" variant="warning">No receipt</Badge> : null}
                              </span>
                            </TableCell>
                            <TableCell className="fd-num fd-amount">{formatMoney(expense.amount)}</TableCell>
                            <TableCell className="fd-num fd-muted fd-drop-lg" title={formatAbsolute(expense.occurredAt)}>{formatWhen(expense.occurredAt, now)}</TableCell>
                            <TableCell className="fd-actions">
                              <RowActions transitions={expenseTransitions(expense, data.policy)} onRun={(t) => onExpenseTransition(expense, t)} label={"Actions for " + expense.id} />
                            </TableCell>
                          </TableRow>
                        );
                      })}
                    </TableBody>
                  </Table>
                </div>
              ) : (
                <CardPanel><span className="fd-footnote">They have never put anything on a card.</span></CardPanel>
              )}
              <CardFooter className="fd-card-footer">
                <span className="fd-footnote">{expenses.length} expenses · {expenses.filter((e) => e.status === "needs_review").length} still in review.</span>
                <Button variant="ghost" size="sm" onClick={() => onNavigate("/expenses")}>All expenses<Icon name="arrow-right" /></Button>
              </CardFooter>
            </Card>

            <ThreadCard
              record={{ type: "member", id: member.id }}
              data={data}
              signedInId={MERIDIAN_SIGNED_IN}
              now={now}
              onPost={onPostComment}
              description={self ? "Notes about your own work — handovers, cover, anything the records do not say." : "Handover notes about " + member.name.split(" ")[0] + "'s work. Mention somebody to put it in their queue."}
            />
          </div>

          <div className="fd-col">
            <Card className="fd-card">
              <CardHeader>
                <CardTitle>The person</CardTitle>
                <CardDescription>{member.role}</CardDescription>
              </CardHeader>
              <CardPanel className="fd-stack">
                <span className="fd-holder">
                  <Face person={member} size={40} />
                  <span className="fd-record">
                    <span className="fd-record__name">{member.name}{self ? " (you)" : ""}</span>
                    <span className="fd-record__meta">{member.email}</span>
                  </span>
                </span>
                <dl className="fd-defs">
                  <div><dt>Permission</dt><dd>{labelOf(TEAM_ROLES, member.teamRole)}</dd></div>
                  <div><dt>Approval limit</dt><dd>{member.approvalLimit ? formatMoney(member.approvalLimit) : "Cannot approve"}</dd></div>
                  <div><dt>Card issuing</dt><dd>{member.canIssueCards ? "Allowed" : "Not allowed"}</dd></div>
                  <div><dt>Email alerts</dt><dd>{member.notify.length ? member.notify.length + " of " + NOTIFICATION_KINDS.length + " kinds" : "None"}</dd></div>
                </dl>
              </CardPanel>
            </Card>

            {cards.length ? (
              <Card className="fd-card">
                <CardHeader>
                  <CardTitle>Cards in {self ? "your" : "their"} name</CardTitle>
                  <CardDescription>Each meter is spend against its own {CARD_WINDOW_DAYS}-day limit.</CardDescription>
                </CardHeader>
                <CardPanel>
                  <div className="fd-picklist">
                    {cards.map((card) => {
                      const use = cardUtilisation(card, data.expenses, now, data.policy);
                      return (
                        <button type="button" className="fd-pick" key={card.id} onClick={() => onNavigate(recordRoute({ type: "card", id: card.id }))}>
                          <span className="fd-list__main">
                            <span className="fd-record__name">{card.label}</span>
                            <span className="fd-record__meta"><span className="fd-pan">•••• {card.last4}</span> · {labelOf(CARD_TYPES, card.type)}</span>
                          </span>
                          <span className="fd-list__side">
                            <span className="fd-amount">{formatMoney(use.spent)}</span>
                            <span className="fd-clock">of {formatMoney(use.limit)}</span>
                          </span>
                          <span className="fd-pick__foot"><StatusBadge values={CARD_STATUS} value={card.status} /></span>
                        </button>
                      );
                    })}
                  </div>
                </CardPanel>
              </Card>
            ) : null}

            {accounts.length ? (
              <Card className="fd-card">
                <CardHeader>
                  <CardTitle>Accounts {self ? "you own" : "they own"}</CardTitle>
                  <CardDescription>{formatMoney(accounts.reduce((t, c) => t + customerVolume(data.payments, c), 0))} of volume across {accounts.length}.</CardDescription>
                </CardHeader>
                <CardPanel>
                  <div className="fd-picklist">
                    {customersByVolume(accounts, data.payments).map((customer) => (
                      <button type="button" className="fd-pick" key={customer.id} onClick={() => onNavigate(recordRoute({ type: "customer", id: customer.id }))}>
                        <span className="fd-list__main">
                          <span className="fd-record__name">{customer.name}</span>
                          <span className="fd-record__meta">{customer.contact.name}</span>
                        </span>
                        <span className="fd-list__side">
                          <span className="fd-amount">{formatMoney(customerVolume(data.payments, customer))}</span>
                        </span>
                        <span className="fd-pick__foot"><StatusBadge values={CUSTOMER_STATUS} value={customer.status} /></span>
                      </button>
                    ))}
                  </div>
                </CardPanel>
              </Card>
            ) : null}
          </div>
        </div>
      </div>
    );
  }

  Object.assign(window, { PeopleScreen, PersonScreen });
})();

})();
</script>
<script type="text/babel" data-presets="react" data-file="ActivityScreen.jsx">
(function(){
/* Activity — the desk's timeline, read as a feed rather than a table.

   Commit model: INSTANT, and this screen commits nothing but a note: everything
   listed is a reading of the records. Every row is a field on a payment,
   payout, dispute, expense or card, plus the notes — activityFeed() in
   metrics.js does the deriving, so the feed cannot drift from the desk and
   there is no activity fixture to keep in step.

   Layout: a centred reading column with a side rail beside it — the rail is
   filters of the same arrays the feed reads (who has been working, where the
   talking is, what is on a clock), so it cannot say anything the timeline does
   not. The feed itself is read down rather than scanned across, so its width is
   capped at a comfortable measure and the rail carries the actor's face at the
   moment they did something. A note is a POST: it gets a card, its own words at
   reading size, and a reply that lands on the record it was written on.
   Everything the platform did is a QUIET LINE on the same rail: an icon, one
   sentence, the record. The difference in weight is the whole point — a
   sentence somebody wrote outranks a state change. A post still owed a reply
   from you is tinted rather than outlined: it is a nudge, not a warning.

   Data state: populated. The scope control is harmless and applies bare; it
   narrows the feed only, so the figures above it do not move. */
(() => {
  const {
    Card, CardHeader, CardTitle, CardDescription, CardPanel, CardFooter,
    Button, Badge, Tabs, TabsList, TabsTab, Avatar, Separator,
    Empty, EmptyHeader, EmptyMedia, EmptyTitle, EmptyDescription, EmptyContent,
  } = window.OriginDesignSystem_7c998b;

  const KIND_ICON = { note: "message-square", money: "arrow-left-right", spend: "receipt" };
  const initialsOf = (name) => name.split(" ").map((w) => w[0]).slice(0, 2).join("");

  /* A note somebody wrote: the post. */
  function NotePost({ event, data, now, signedInId, onNavigate }) {
    const comment = event.comment;
    const author = data.members.find((m) => m.id === comment.authorId) || { id: null, name: "somebody who has left", avatar: null, role: "" };
    const owed = comment.mentions.filter((id) => COMMENT_FLAGS.isAwaitingReply(comment, id, data.comments));
    const yours = owed.indexOf(signedInId) > -1;
    const mine = comment.authorId === signedInId;
    const people = peopleOn(data.comments, comment.record, data.members);
    return (
      <article className={"fd-post" + (yours ? " fd-post--owed" : "")}>
        <span className="fd-post__face"><Face person={author} size={40} /></span>
        <div className="fd-post__body">
          <header className="fd-post__head">
            <span className="fd-post__who">{author.name}{mine ? " (you)" : ""}</span>
            <span className="fd-post__dot" aria-hidden="true">·</span>
            <span className="fd-post__when" title={formatAbsolute(comment.at)}>{formatWhen(comment.at, now)}</span>
            <button type="button" className="fd-post__on" onClick={() => onNavigate(event.route)}>
              <Icon name={comment.record.type === "dispute" ? "shield-alert" : comment.record.type === "card" ? "credit-card" : comment.record.type === "customer" ? "building-2" : "users"} size={12} />
              {recordTitle(data, comment.record)}
            </button>
          </header>
          <p className="fd-post__text"><MentionText body={comment.body} members={data.members} /></p>
          <footer className="fd-post__foot">
            {owed.length ? (
              <Badge size="sm" variant="warning">
                {yours && owed.length === 1 ? "Waiting on you" : "Waiting on " + owed.map((id) => (id === signedInId ? "you" : (data.members.find((m) => m.id === id) || {}).name.split(" ")[0])).join(" and ")}
              </Badge>
            ) : null}
            <span className="fd-post__people"><FaceStack people={people} size={20} max={3} /></span>
            <span className="fd-post__spacer" />
            <Button variant="ghost" size="sm" onClick={() => onNavigate(event.route)}>
              <Icon name="message-square" size={14} />{yours ? "Reply" : "Open thread"}
            </Button>
          </footer>
        </div>
      </article>
    );
  }

  /* Something the desk did: the quiet line. */
  function EventLine({ event, data, now, onNavigate }) {
    const actor = event.actorId ? data.members.find((m) => m.id === event.actorId) : null;
    return (
      <div className="fd-line">
        {actor
          ? <span className="fd-line__face"><Face person={actor} size={24} /></span>
          : <span className="fd-line__mark" aria-hidden="true"><Icon name={KIND_ICON[event.kind]} size={13} /></span>}
        <p className="fd-line__text">
          <span className="fd-line__who">{actor ? actor.name : "Meridian"}</span>
          {" "}{event.title}
          <span className="fd-line__dot" aria-hidden="true"> · </span>
          <button type="button" className="fd-link" onClick={() => onNavigate(event.route)}>{event.body}</button>
        </p>
        <span className="fd-line__when" title={formatAbsolute(event.at)}>{formatWhen(event.at, now)}</span>
      </div>
    );
  }

  /* The side rail: who is here, what is on a clock, and where the desk's own
     words are. Each card is a filter of the same arrays the feed reads — the
     rail cannot say anything the timeline does not. */
  function FeedAside({ data, now, events, onNavigate }) {
    /* Ranked by what they have actually done in this window, not by name. */
    const ranked = data.members
      .map((m) => ({ member: m, acts: events.filter((e) => e.actorId === m.id).length, notes: data.comments.filter((c) => c.authorId === m.id).length }))
      .filter((row) => row.acts > 0)
      .sort((a, b) => b.acts - a.acts);
    const clocks = disputesNeedingEvidence(data.disputes);
    const busiest = data.comments.reduce((map, c) => {
      const key = c.record.type + ":" + c.record.id;
      map[key] = map[key] || { record: c.record, count: 0, at: 0, people: [] };
      map[key].count += 1;
      map[key].at = Math.max(map[key].at, c.at);
      if (map[key].people.indexOf(c.authorId) === -1) map[key].people.push(c.authorId);
      return map;
    }, {});
    const threads = Object.keys(busiest).map((k) => busiest[k]).sort((a, b) => b.count - a.count || b.at - a.at).slice(0, 4);

    return (
      <aside className="fd-aside">
        <Card className="fd-card">
          <CardHeader>
            <CardTitle>Who has been working</CardTitle>
            <CardDescription>Counted from the records, this window.</CardDescription>
          </CardHeader>
          <CardPanel>
            <div className="fd-rank">
              {ranked.map((row) => (
                <button type="button" className="fd-rank__row" key={row.member.id} onClick={() => onNavigate(recordRoute({ type: "member", id: row.member.id }))}>
                  <Face person={row.member} size={28} />
                  <span className="fd-record">
                    <span className="fd-record__name">{row.member.name}{row.member.id === MERIDIAN_SIGNED_IN ? " (you)" : ""}</span>
                    <span className="fd-record__meta">{row.notes ? row.notes + (row.notes === 1 ? " note" : " notes") : "No notes"} · {row.member.role}</span>
                  </span>
                  <span className="fd-rank__count">{row.acts}</span>
                </button>
              ))}
            </div>
          </CardPanel>
          <CardFooter className="fd-card-footer">
            <span className="fd-footnote">{data.members.length - ranked.length ? (data.members.length - ranked.length) + " of " + data.members.length + " have done nothing in this window." : "Everybody here has done something."}</span>
          </CardFooter>
        </Card>

        {threads.length ? (
          <Card className="fd-card">
            <CardHeader>
              <CardTitle>Where the talking is</CardTitle>
              <CardDescription>Records with more than one note on them.</CardDescription>
            </CardHeader>
            <CardPanel>
              <div className="fd-picklist">
                {threads.map((thread) => (
                  <button type="button" className="fd-pick" key={thread.record.type + thread.record.id} onClick={() => onNavigate(recordRoute(thread.record))}>
                    <span className="fd-list__main">
                      <span className="fd-record__name">{recordTitle(data, thread.record)}</span>
                      <span className="fd-record__meta">{thread.count} {thread.count === 1 ? "note" : "notes"} · last {formatWhen(thread.at, now)}</span>
                    </span>
                    <span className="fd-list__side"><FaceStack people={peopleOn(data.comments, thread.record, data.members)} size={20} max={3} /></span>
                  </button>
                ))}
              </div>
            </CardPanel>
          </Card>
        ) : null}

        {clocks.length ? (
          <Card className="fd-card">
            <CardHeader>
              <CardTitle>On a clock</CardTitle>
              <CardDescription>Evidence the network is still waiting for.</CardDescription>
            </CardHeader>
            <CardPanel>
              <div className="fd-picklist">
                {clocks.map((dispute) => (
                  <button type="button" className="fd-pick" key={dispute.id} onClick={() => onNavigate(recordRoute({ type: "dispute", id: dispute.id }))}>
                    <span className="fd-list__main">
                      <span className="fd-record__name">{dispute.customer}</span>
                      <span className="fd-record__meta">{dispute.reason}</span>
                    </span>
                    <span className="fd-list__side">
                      <span className="fd-amount">{formatMoney(dispute.amount)}</span>
                      <span className="fd-clock">{formatCountdown(dispute.evidenceDueAt, now)}</span>
                    </span>
                  </button>
                ))}
              </div>
            </CardPanel>
          </Card>
        ) : null}
      </aside>
    );
  }

  function ActivityScreen({ data, now, onNavigate }) {
    const [scope, setScope] = React.useState("all");
    const events = activityFeed(data, now);
    const shown = activityInScope(events, scope);
    const awaiting = mentionsAwaiting(data.comments, MERIDIAN_SIGNED_IN);
    const actors = data.members.filter((m) => events.some((e) => e.actorId === m.id));
    const scopeLabel = labelOf(ACTIVITY_SCOPES, scope);
    const notes = events.filter((e) => e.kind === "note");

    /* One divider a day, so the reader can see the shape of a week. */
    const days = [];
    shown.forEach((event) => {
      const key = formatDayFull(event.at);
      const last = days[days.length - 1];
      if (last && last.key === key) last.rows.push(event);
      else days.push({ key: key, at: event.at, rows: [event] });
    });
    const today = formatDayFull(now);

    return (
      <div className="fd-page">
        <PageHeader path="/activity" title="Activity" />

        <div className="fd-metrics">
          <MetricTile icon="message-square" label="Things that happened" value={formatCount(events.length)} qualifier={notes.length + " notes, " + events.filter((e) => e.kind === "money").length + " money movements, " + events.filter((e) => e.kind === "spend").length + " card decisions"} />
          <MetricTile icon="reply" label="Waiting on your reply" value={formatCount(awaiting.length)} qualifier={awaiting.length ? "Notes that mentioned you and have had no answer from you since" : "Nobody is waiting on you"} />
          <MetricTile icon="users" label="People on the desk" value={formatCount(actors.length)} qualifier={"of " + data.members.length + " have done something in this window"} />
        </div>

        <div className="fd-feedlayout">
          <div className="fd-feedwrap">
            <div className="fd-feedhead">
              <Tabs value={scope} onValueChange={setScope}>
                <TabsList size="sm">
                  {ACTIVITY_SCOPES.map((s) => <TabsTab key={s.value} value={s.value} size="sm">{s.label}</TabsTab>)}
                </TabsList>
              </Tabs>
              <span className="fd-footnote">Showing {shown.length} of {events.length}</span>
            </div>

            {/* Writing here is writing on a record: a note needs somewhere to
                live, so this sends you to the thread rather than posting into a
                feed nothing owns. */}
            {awaiting.length ? (
              <div className="fd-feedcall">
                <span className="fd-feedcall__faces"><FaceStack people={awaiting.map((c) => data.members.find((m) => m.id === c.authorId)).filter(Boolean)} size={24} max={3} /></span>
                <p className="fd-feedcall__text">
                  <strong>{awaiting.length} {awaiting.length === 1 ? "person is" : "people are"} waiting on you.</strong>{" "}
                  {awaiting.length === 1 ? "One note mentioned you and has had no answer since." : "Their notes mentioned you and have had no answer since."}
                </p>
                <Button size="sm" onClick={() => onNavigate(recordRoute(awaiting[0].record))}>
                  Reply to {(data.members.find((m) => m.id === awaiting[0].authorId) || {}).name.split(" ")[0]}<Icon name="arrow-right" />
                </Button>
              </div>
            ) : null}

            {shown.length ? (
              <div className="fd-timeline">
                {days.map((day) => (
                  <section className="fd-timeline__day" key={day.key}>
                    <h2 className="fd-timeline__label"><span>{day.key === today ? "Today" : day.key}</span></h2>
                    <div className="fd-timeline__rows">
                      {day.rows.map((event) => (event.comment
                        ? <NotePost key={event.id} event={event} data={data} now={now} signedInId={MERIDIAN_SIGNED_IN} onNavigate={onNavigate} />
                        : <EventLine key={event.id} event={event} data={data} now={now} onNavigate={onNavigate} />))}
                    </div>
                  </section>
                ))}
                <p className="fd-timeline__end">That is everything on the desk. Nothing here is stored — every line is read off the record it names.</p>
              </div>
            ) : (
              <Card>
                <CardPanel>
                  <Empty>
                    <EmptyHeader>
                      <EmptyMedia variant="icon"><Icon name={KIND_ICON[scope] || "message-square"} size={18} /></EmptyMedia>
                      <EmptyTitle>Nothing of that kind yet</EmptyTitle>
                      <EmptyDescription>{events.length} things have happened on the desk; none of them are {scopeLabel.toLowerCase()}.</EmptyDescription>
                    </EmptyHeader>
                    <EmptyContent>
                      <Button onClick={() => setScope("all")}>Show everything</Button>
                    </EmptyContent>
                  </Empty>
                </CardPanel>
              </Card>
            )}
          </div>

          <FeedAside data={data} now={now} events={events} onNavigate={onNavigate} />
        </div>
      </div>
    );
  }

  Object.assign(window, { ActivityScreen });
})();

})();
</script>
<script type="text/babel" data-presets="react" data-file="NewMoneyScreens.jsx">
(function(){
/* Taking a payment and raising a payout — the two records this desk makes
   itself. Both are their own registered route rather than a dialog, because
   both are more than three fields and both move money.

   Commit model: STAGED on both. Nothing exists until Save; Cancel appears once
   the form is dirty; errors render on validation, never on first paint. The
   preview beside each form is the real evaluator over the real fixture — the
   balance line is balanceBreakdown() with the drafted amount taken out of it.

   Data state: populated (the customer list) plus the form's own idle state.
   Entitlements gate controls, not copy: a customer who cannot be paid out is
   not in the options, and the field says why. */
(() => {
  const {
    Card, CardHeader, CardTitle, CardDescription, CardPanel, CardFooter,
    Field, FieldLabel, FieldDescription, FieldError, Input, InputGroup, InputGroupAddon, InputGroupText, InputGroupInput,
    Select, RadioGroup, Button, Badge, Separator, Alert, AlertTitle, AlertDescription,
  } = window.OriginDesignSystem_7c998b;

  const centsOf = (text) => Math.round(Number(String(text).replace(/[^0-9.]/g, "")) * 100) || 0;

  /* ── New payment ──────────────────────────────────────────────────────── */

  function NewPaymentScreen({ data, now, onCreate, onNavigate }) {
    /* Only an account that is trading can be charged. Closed and suspended
       accounts are out of the options rather than in them and rejected. */
    const chargeable = data.customers.filter((c) => c.status === "live" || c.status === "on_hold");
    const blank = { customerId: chargeable.length ? chargeable[0].id : "", method: "card", amount: "", reference: "" };
    const [draft, setDraft] = React.useState(blank);
    const [submitted, setSubmitted] = React.useState(false);
    const set = (fields) => setDraft((d) => Object.assign({}, d, fields));
    const dirty = JSON.stringify(draft) !== JSON.stringify(blank);
    const cents = centsOf(draft.amount);
    const customer = data.customers.find((c) => c.id === draft.customerId) || null;
    const errors = {};
    if (!customer) errors.customerId = "Pick the account being charged.";
    if (cents < 100) errors.amount = "At least $1.00 — anything smaller costs more to process than it collects.";
    if (cents > 100000000) errors.amount = "Over $1,000,000 has to go through treasury as a wire.";
    const valid = Object.keys(errors).length === 0;
    const fee = cents ? methodFee(draft.method, cents) : 0;
    const theirs = customer ? paymentsFor(data.payments, customer) : [];

    return (
      <div className="fd-page">
        <PageHeader path="/payments/new" title="New payment" />
        <div className="fd-columns">
          <div className="fd-col">
            <Card>
              <CardHeader>
                <CardTitle>Charge a customer</CardTitle>
                <CardDescription>It is created as pending and settles on the method's own clock. Nothing is captured here.</CardDescription>
              </CardHeader>
              <CardPanel>
                <div className="fd-fields">
                  <Field>
                    <FieldLabel htmlFor="fd-pay-cus">Customer</FieldLabel>
                    <Select id="fd-pay-cus" value={draft.customerId} onValueChange={(id) => set({ customerId: id })}
                      items={chargeable.map((c) => ({ value: c.id, label: c.name + (c.status === "on_hold" ? " — on hold, money can still arrive" : "") }))} />
                    <FieldDescription>
                      {data.customers.length - chargeable.length} of {data.customers.length} accounts are left out: a suspended or closed account cannot be charged.
                    </FieldDescription>
                  </Field>
                  <Field>
                    <FieldLabel>How they are paying</FieldLabel>
                    <RadioGroup name="fd-pay-method" value={draft.method} onValueChange={(method) => set({ method: method })}
                      items={PAYMENT_METHODS.map((m) => ({ value: m.value, label: m.label }))} />
                    <FieldDescription>The method decides the fee and how long it takes to settle.</FieldDescription>
                  </Field>
                  <Field>
                    <FieldLabel htmlFor="fd-pay-amount">Amount</FieldLabel>
                    <InputGroup data-invalid={submitted && !!errors.amount ? "" : undefined}>
                      <InputGroupAddon><InputGroupText>$</InputGroupText></InputGroupAddon>
                      <InputGroupInput id="fd-pay-amount" inputMode="decimal" placeholder="1,250.00" value={draft.amount}
                        aria-invalid={(submitted && !!errors.amount) || undefined} onChange={(e) => set({ amount: e.target.value })} />
                    </InputGroup>
                    {submitted && errors.amount ? <FieldError>{errors.amount}</FieldError> : <FieldDescription>In {data.balance.currency}, the workspace's settlement currency.</FieldDescription>}
                  </Field>
                  <Field>
                    <FieldLabel htmlFor="fd-pay-ref">Reference</FieldLabel>
                    <Input id="fd-pay-ref" value={draft.reference} placeholder="Invoice 4417" onChange={(e) => set({ reference: e.target.value })} />
                    <FieldDescription>Optional. Shown to the customer beside {MERIDIAN_WORKSPACE.descriptor} on their statement.</FieldDescription>
                  </Field>
                </div>
              </CardPanel>
            </Card>
          </div>

          <div className="fd-col">
            <Card className="fd-card">
              <CardHeader>
                <CardTitle>What this collects</CardTitle>
                <CardDescription>Read from the form, priced off the fee schedule.</CardDescription>
              </CardHeader>
              <CardPanel className="fd-stack">
                <dl className="fd-defs">
                  <div><dt>Charged</dt><dd>{cents ? formatMoney(cents) : "—"}</dd></div>
                  <div><dt>Processing fee</dt><dd>{cents ? formatMoney(fee) + " · " + labelOf(PAYMENT_METHODS, draft.method).toLowerCase() : "—"}</dd></div>
                  <div><dt>Status on save</dt><dd><StatusBadge values={PAYMENT_STATUS} value="pending" /></dd></div>
                  <div className="fd-defs__total"><dt>Reaches the balance</dt><dd>{cents ? formatMoney(cents - fee) : "—"}</dd></div>
                </dl>
                {customer ? (
                  <>
                    <Separator />
                    <span className="fd-footnote">
                      {theirs.length
                        ? customer.name + " has been charged " + theirs.length + " times before, " + formatMoney(customerVolume(data.payments, customer)) + " of it succeeded."
                        : customer.name + " has never been charged."}
                      {CUSTOMER_FLAGS.hasOpenDispute(customer, data.disputes) ? " They have an open dispute — worth a word with the owner first." : ""}
                    </span>
                  </>
                ) : null}
              </CardPanel>
              <CardFooter className="fd-card-footer">
                {customer ? <Button variant="outline" size="sm" onClick={() => onNavigate(recordRoute({ type: "customer", id: customer.id }))}>Open {customer.name}<Icon name="arrow-right" /></Button> : null}
              </CardFooter>
            </Card>
          </div>
        </div>

        <FormBar
          dirty={dirty}
          note={dirty ? "Nothing is charged until this is saved." : "The customer, the method and the amount are all it takes."}
          onCancel={() => { setDraft(blank); setSubmitted(false); onNavigate("/payments"); }}
          onSave={() => { setSubmitted(true); if (valid) onCreate({ customer: customer.name, method: draft.method, amount: cents, reference: draft.reference.trim() }); }}
          label="New payment"
        />
      </div>
    );
  }

  /* ── New payout ───────────────────────────────────────────────────────── */

  function NewPayoutScreen({ data, now, onCreate, onNavigate }) {
    /* A payout needs a live account with a bank account on file. On hold,
       suspended and closed accounts, and anybody without bank details, are out
       of the options — and the field says how many and why. */
    const payable = data.customers.filter((c) => c.status === "live" && !!c.payoutAccount);
    const blank = { customerId: payable.length ? payable[0].id : "", amount: "", reference: "" };
    const [draft, setDraft] = React.useState(blank);
    const [submitted, setSubmitted] = React.useState(false);
    const set = (fields) => setDraft((d) => Object.assign({}, d, fields));
    const dirty = JSON.stringify(draft) !== JSON.stringify(blank);
    const cents = centsOf(draft.amount);
    const customer = data.customers.find((c) => c.id === draft.customerId) || null;
    const balance = balanceBreakdown(data.balance, data.payouts, data.disputes);
    const errors = {};
    if (!customer) errors.customerId = "Pick the account being paid.";
    if (cents < 100) errors.amount = "At least $1.00.";
    else if (cents > balance.available) errors.amount = "Only " + formatMoney(balance.available) + " is available today — " + formatMoney(balance.committed) + " is already committed to payouts in flight.";
    const valid = Object.keys(errors).length === 0;
    const limit = (data.members.find((m) => m.id === MERIDIAN_SIGNED_IN) || {}).approvalLimit || 0;

    return (
      <div className="fd-page">
        <PageHeader path="/payouts/new" title="New payout" />

        {payable.length === 0 ? (
          <Alert variant="warning">
            <AlertTitle>No account can be paid out today</AlertTitle>
            <AlertDescription>A payout needs a live account with bank details on file. Add them on the customer's own page first.</AlertDescription>
          </Alert>
        ) : null}

        <div className="fd-columns">
          <div className="fd-col">
            <Card>
              <CardHeader>
                <CardTitle>Send money to a customer</CardTitle>
                <CardDescription>It is raised awaiting approval, exactly like a payout the platform raises for itself — nobody can approve their own.</CardDescription>
              </CardHeader>
              <CardPanel>
                <div className="fd-fields">
                  <Field>
                    <FieldLabel htmlFor="fd-po-cus">Destination</FieldLabel>
                    <Select id="fd-po-cus" value={draft.customerId} onValueChange={(id) => set({ customerId: id })}
                      items={payable.map((c) => ({ value: c.id, label: c.name + " · " + c.payoutAccount }))} />
                    <FieldDescription>
                      {data.customers.length - payable.length} of {data.customers.length} accounts cannot be paid: no bank details, on hold, suspended or closed.
                    </FieldDescription>
                  </Field>
                  <Field>
                    <FieldLabel htmlFor="fd-po-amount">Amount</FieldLabel>
                    <InputGroup data-invalid={submitted && !!errors.amount ? "" : undefined}>
                      <InputGroupAddon><InputGroupText>$</InputGroupText></InputGroupAddon>
                      <InputGroupInput id="fd-po-amount" inputMode="decimal" placeholder="12,500.00" value={draft.amount}
                        aria-invalid={(submitted && !!errors.amount) || undefined} onChange={(e) => set({ amount: e.target.value })} />
                    </InputGroup>
                    {submitted && errors.amount ? <FieldError>{errors.amount}</FieldError> : <FieldDescription>{formatMoney(balance.available)} is available today.</FieldDescription>}
                  </Field>
                  <Field>
                    <FieldLabel htmlFor="fd-po-ref">Why</FieldLabel>
                    <Input id="fd-po-ref" value={draft.reference} placeholder="Weekly settlement, week to Wednesday" onChange={(e) => set({ reference: e.target.value })} />
                    <FieldDescription>Optional, and read by whoever approves it.</FieldDescription>
                  </Field>
                </div>
              </CardPanel>
            </Card>
          </div>

          <div className="fd-col">
            <Card className="fd-card">
              <CardHeader>
                <CardTitle>What this commits</CardTitle>
                <CardDescription>The balance chain with the drafted amount taken out of it.</CardDescription>
              </CardHeader>
              <CardPanel className="fd-stack">
                <dl className="fd-defs">
                  <div><dt>Available today</dt><dd>{formatMoney(balance.available)}</dd></div>
                  <div><dt>This payout</dt><dd>{cents ? "− " + formatMoney(cents) : "—"}</dd></div>
                  <div><dt>Already committed</dt><dd>{formatMoney(balance.committed)} to {pendingPayouts(data.payouts).length} payouts in flight</dd></div>
                  <div className="fd-defs__total"><dt>Available if approved</dt><dd>{formatMoney(Math.max(0, balance.available - cents))}</dd></div>
                </dl>
                <Separator />
                <span className="fd-footnote">
                  Raised as <strong>awaiting approval</strong>. {limit ? "Your own limit is " + formatMoney(limit) + ", and this one " + (cents && cents > limit ? "is above it, so somebody else has to sign it off." : "sits inside it.") : "You cannot approve payouts yourself, so it goes to whoever can."}
                </span>
              </CardPanel>
              <CardFooter className="fd-card-footer">
                <Button variant="outline" size="sm" onClick={() => onNavigate("/balances")}>Where the balance sits<Icon name="arrow-right" /></Button>
              </CardFooter>
            </Card>
          </div>
        </div>

        <FormBar
          dirty={dirty}
          note={dirty ? "Nothing leaves the balance until this is approved on the queue." : "Pick an account and an amount; approval is a separate step."}
          onCancel={() => { setDraft(blank); setSubmitted(false); onNavigate("/payouts"); }}
          onSave={() => { setSubmitted(true); if (valid) onCreate({ destination: customer.name, account: customer.payoutAccount, amount: cents, reference: draft.reference.trim() }); }}
          label="New payout"
        />
      </div>
    );
  }

  Object.assign(window, { NewPaymentScreen, NewPayoutScreen });
})();

})();
</script>
<script type="text/babel" data-presets="react" data-file="AccountScreens.jsx">
(function(){
/* Account — the screens behind the account menu. The auth screens live in
   AuthScreens.jsx, because they share a shell with each other and nothing with
   these. Originally: the screens behind the account menu, plus the two auth
   screens that menu leads to.

   Commit model: STAGED, on all three settings screens. Every control is a form
   field, nothing applies until Save, Save and Cancel are disabled until the
   form is dirty, Cancel appears only once it is, and errors render on
   validation rather than on first paint.

   Auth shell (AGENTS §7.1): its own shell, no navigation owner — no sidebar, no
   toolbar, no account menu. One layout variant for the product: centred card,
   the product mark above the form as the only navigation. One credential
   strategy: password. No providers, because this project ships no
   `assets/providers/` marks and §7.4 forbids drawing them. Designed states:
   idle, field-invalid, submitting, and signed-out. Rate limiting, lockout, MFA
   and the four password-reset screens are out of scope for this template, so
   nothing links to them. */
(() => {
  const {
    Card, CardHeader, CardTitle, CardDescription, CardPanel, CardFooter,
    Field, FieldLabel, FieldDescription, FieldError, Input, InputGroup, InputGroupAddon, InputGroupInput, RadioGroup, Checkbox, Button, Separator, Badge, Avatar,
  } = window.OriginDesignSystem_7c998b;

  const MAX_PHOTO_BYTES = 2 * 1024 * 1024;

  /* The photo is a form field like any other: picking one stages a data URL on
     the draft and nothing reaches the member record until Save, so Cancel puts
     the old face back. Drop or click — one control, two ways in, and the file
     input stays the real, accessible one rather than being replaced by a div. */
  function PhotoField({ name, value, initial, onChange, error }) {
    const input = React.useRef(null);
    const [over, setOver] = React.useState(false);
    const take = (file) => { if (file) onChange(file); };
    const initials = name.trim().split(/\s+/).map((w) => w[0]).slice(0, 2).join("") || "?";
    return (
      <div
        className={"fd-photo" + (over ? " fd-photo--over" : "")}
        onDragOver={(e) => { e.preventDefault(); setOver(true); }}
        onDragLeave={() => setOver(false)}
        onDrop={(e) => { e.preventDefault(); setOver(false); take(e.dataTransfer.files && e.dataTransfer.files[0]); }}
      >
        <Avatar size={64} src={value} alt="" fallback={initials} />
        <div className="fd-photo__body">
          <span className="fd-photo__note">
            {value === initial
              ? (value ? "The photo on your account." : "No photo yet — your initials stand in wherever a face appears.")
              : (value ? "New photo staged. It is not on your account until you save." : "Your photo will be removed when you save.")}
          </span>
          <span className="fd-photo__actions">
            <Button variant="outline" size="sm" onClick={() => input.current && input.current.click()}>
              <Icon name="upload" size={14} />{value ? "Replace" : "Upload a photo"}
            </Button>
            {value ? <Button variant="ghost" size="sm" onClick={() => onChange(null)}>Remove</Button> : null}
            {value !== initial ? <Button variant="ghost" size="sm" onClick={() => onChange(initial)}>Undo</Button> : null}
          </span>
          {error
            ? <FieldError>{error}</FieldError>
            : <span className="fd-footnote">Drop an image here, or pick one. JPG, PNG or WebP; square reads best; up to 2 MB.</span>}
        </div>
        <input ref={input} className="fd-photo__input" type="file" accept="image/png,image/jpeg,image/webp"
          aria-label="Choose a profile photo"
          onChange={(e) => { take(e.target.files && e.target.files[0]); e.target.value = ""; }} />
      </div>
    );
  }

  /* ── Settings ─────────────────────────────────────────────────────────── */

  function ProfileScreen({ profile, onSave }) {
    const initial = React.useMemo(() => ({ name: profile.name, role: profile.role, email: profile.email, avatar: profile.avatar || null }), [profile]);
    const [draft, setDraft] = React.useState(initial);
    const [submitted, setSubmitted] = React.useState(false);
    const [photoError, setPhotoError] = React.useState(null);
    React.useEffect(() => { setDraft(initial); setSubmitted(false); setPhotoError(null); }, [initial]);
    const set = (fields) => setDraft((d) => Object.assign({}, d, fields));
    const dirty = JSON.stringify(draft) !== JSON.stringify(initial);
    const errors = {};
    if (!draft.name.trim()) errors.name = "Enter the name your team will see.";
    if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(draft.email)) errors.email = "Enter a valid email address.";
    const valid = Object.keys(errors).length === 0;

    /* A File is read into a data URL so the preview is the real image rather
       than a stand-in — and rejected here, not on save, because the person is
       looking at the control at the moment they choose. */
    const takePhoto = (file) => {
      if (file === null || typeof file === "string") { setPhotoError(null); set({ avatar: file }); return; }
      if (!/^image\/(png|jpe?g|webp)$/.test(file.type)) { setPhotoError("That is a " + (file.type || "file") + ". Use a JPG, PNG or WebP."); return; }
      if (file.size > MAX_PHOTO_BYTES) { setPhotoError("That image is " + (file.size / 1048576).toFixed(1) + " MB. The limit is 2 MB."); return; }
      const reader = new FileReader();
      reader.onload = () => { setPhotoError(null); set({ avatar: reader.result }); };
      reader.onerror = () => setPhotoError("That file could not be read. Try another.");
      reader.readAsDataURL(file);
    };

    return (
      <div className="fd-page">
        <PageHeader path="/settings/profile" title="Your profile" />
        <Card>
          <CardHeader>
            <CardTitle>How you appear in {MERIDIAN_WORKSPACE.name}</CardTitle>
            <CardDescription>Your name and role sit on every approval and rejection you make.</CardDescription>
          </CardHeader>
          <CardPanel>
            <div className="fd-fields">
              <Field>
                <FieldLabel>Photo</FieldLabel>
                <PhotoField name={draft.name} value={draft.avatar} initial={initial.avatar} onChange={takePhoto} error={photoError} />
              </Field>
              <Field>
                <FieldLabel htmlFor="fd-name">Name</FieldLabel>
                <Input id="fd-name" value={draft.name} autoComplete="name" invalid={submitted && !!errors.name} onChange={(e) => set({ name: e.target.value })} />
                {submitted && errors.name ? <FieldError>{errors.name}</FieldError> : null}
              </Field>
              <Field>
                <FieldLabel htmlFor="fd-role">Role</FieldLabel>
                <Input id="fd-role" value={draft.role} onChange={(e) => set({ role: e.target.value })} />
                <FieldDescription>Shown under your name in the account menu.</FieldDescription>
              </Field>
              <Field>
                <FieldLabel htmlFor="fd-email">Email</FieldLabel>
                <Input id="fd-email" type="email" autoComplete="email" value={draft.email} invalid={submitted && !!errors.email} onChange={(e) => set({ email: e.target.value })} />
                {submitted && errors.email ? <FieldError>{errors.email}</FieldError> : <FieldDescription>Where exports and alerts are sent.</FieldDescription>}
              </Field>
            </div>
          </CardPanel>
        </Card>
        <FormBar
          dirty={dirty}
          note={dirty ? "Unsaved changes to your profile." : "Everything here is as it was last saved."}
          onCancel={() => { setDraft(initial); setSubmitted(false); setPhotoError(null); }}
          onSave={() => { setSubmitted(true); if (valid) onSave(draft); }}
          label="Save profile"
        />
      </div>
    );
  }

  function WorkspaceSettingsScreen({ workspace, payouts, onSave }) {
    const initial = React.useMemo(() => ({ name: workspace.name, descriptor: workspace.descriptor, schedule: workspace.schedule }), [workspace]);
    const [draft, setDraft] = React.useState(initial);
    const [submitted, setSubmitted] = React.useState(false);
    React.useEffect(() => { setDraft(initial); setSubmitted(false); }, [initial]);
    const set = (fields) => setDraft((d) => Object.assign({}, d, fields));
    const dirty = JSON.stringify(draft) !== JSON.stringify(initial);
    const errors = {};
    if (!draft.name.trim()) errors.name = "Enter the workspace name.";
    if (draft.descriptor.trim().length < 5 || draft.descriptor.length > 22) errors.descriptor = "Between 5 and 22 characters — it has to fit on a card statement.";
    const valid = Object.keys(errors).length === 0;
    const waiting = awaitingApprovalPayouts(payouts);

    return (
      <div className="fd-page">
        <PageHeader path="/settings" title="Workspace settings" />
        <Card>
          <CardHeader>
            <CardTitle>{workspace.name}</CardTitle>
            <CardDescription>On the {workspace.plan} plan. These settings apply to everybody in the workspace.</CardDescription>
          </CardHeader>
          <CardPanel>
            <div className="fd-fields">
              <Field>
                <FieldLabel htmlFor="fd-ws-name">Workspace name</FieldLabel>
                <Input id="fd-ws-name" value={draft.name} invalid={submitted && !!errors.name} onChange={(e) => set({ name: e.target.value })} />
                {submitted && errors.name ? <FieldError>{errors.name}</FieldError> : null}
              </Field>
              <Field>
                <FieldLabel htmlFor="fd-ws-desc">Statement descriptor</FieldLabel>
                <Input id="fd-ws-desc" value={draft.descriptor} invalid={submitted && !!errors.descriptor} onChange={(e) => set({ descriptor: e.target.value.toUpperCase() })} />
                {submitted && errors.descriptor
                  ? <FieldError>{errors.descriptor}</FieldError>
                  : <FieldDescription>What customers see on their statement. {22 - draft.descriptor.length} characters left.</FieldDescription>}
              </Field>
              <Field>
                <FieldLabel>Payout schedule</FieldLabel>
                <RadioGroup name="fd-ws-schedule" items={PAYOUT_SCHEDULES} value={draft.schedule} onValueChange={(schedule) => set({ schedule: schedule })} />
                <FieldDescription>
                  {(PAYOUT_SCHEDULES.find((s) => s.value === draft.schedule) || {}).note}
                  {draft.schedule !== initial.schedule && waiting.length
                    ? " The " + waiting.length + " payouts already waiting on approval keep the old schedule."
                    : ""}
                </FieldDescription>
              </Field>
            </div>
          </CardPanel>
        </Card>
        <FormBar
          dirty={dirty}
          note={dirty ? "Unsaved changes to " + initial.name + "." : "Everything here is as it was last saved."}
          onCancel={() => { setDraft(initial); setSubmitted(false); setPhotoError(null); }}
          onSave={() => { setSubmitted(true); if (valid) onSave(draft); }}
          label="Save workspace"
        />
      </div>
    );
  }

  function NotificationsScreen({ profile, data, onSave }) {
    const initial = React.useMemo(() => ({ notify: profile.notify.slice() }), [profile]);
    const [draft, setDraft] = React.useState(initial);
    React.useEffect(() => { setDraft(initial); }, [initial]);
    const dirty = JSON.stringify(draft) !== JSON.stringify(initial);
    const toggle = (value) => setDraft((d) => ({ notify: d.notify.indexOf(value) > -1 ? d.notify.filter((v) => v !== value) : d.notify.concat([value]) }));
    /* What each preference would have told you, from the real arrays. */
    const counts = {
      payout_approval: approvablePayouts(data.payouts, data.approver).length + " payouts are waiting on you now",
      dispute_evidence: disputesNeedingEvidence(data.disputes).length + " disputes need evidence now",
      payment_failed: failedPayments(data.payments).length + " payments have failed in the fixture",
      balance_low: "The balance is " + formatMoney(data.balance.availableCents) + ", well above the floor",
    };

    return (
      <div className="fd-page">
        <PageHeader path="/settings/notifications" title="Notification preferences" />
        <Card>
          <CardHeader>
            <CardTitle>What we email {profile.name.split(" ")[0]}</CardTitle>
            <CardDescription>Checkboxes, because they are saved with the rest of the form rather than one at a time.</CardDescription>
          </CardHeader>
          <CardPanel>
            <div className="fd-checks">
              {NOTIFICATION_KINDS.map((kind) => (
                <label className="fd-check" key={kind.value}>
                  <Checkbox checked={draft.notify.indexOf(kind.value) > -1} onCheckedChange={() => toggle(kind.value)} />
                  <span className="fd-check__text">
                    <span>{kind.label}</span>
                    <span className="fd-footnote">{counts[kind.value]}.</span>
                  </span>
                </label>
              ))}
            </div>
          </CardPanel>
          <CardFooter className="fd-card-footer">
            <span className="fd-footnote">{draft.notify.length} of {NOTIFICATION_KINDS.length} selected. Nothing is sent for the rest.</span>
          </CardFooter>
        </Card>
        <FormBar
          dirty={dirty}
          note={dirty ? "Unsaved changes to your notifications." : "Everything here is as it was last saved."}
          onCancel={() => setDraft(initial)}
          onSave={() => onSave(draft)}
          label="Save notification preferences"
        />
      </div>
    );
  }

  Object.assign(window, { ProfileScreen, WorkspaceSettingsScreen, NotificationsScreen });
})();

})();
</script>
<script type="text/babel" data-presets="react" data-file="AuthScreens.jsx">
(function(){
/* Auth — the screens outside the product. No shell, no navigation owner.

   Layout: TWO panels side by side, and the primary one is on the RIGHT. The
   form is the work, so it sits on the left where reading starts; the right
   panel is the brand plane — --primary with --primary-foreground text, so it
   inverts correctly in dark mode without a second colour being invented — and
   carries one line per screen saying what this step is for. Below 900px the
   brand panel drops out entirely rather than stacking above the form: on a
   phone the form IS the screen.

   Commit model: STAGED on every one of them. Nothing validates until submit,
   and each screen has exactly one primary action.

   Six screens, one flow: sign in → the 2FA challenge → the product. A password
   that has been forgotten goes /forgot-password → /reset-password, and a person
   who would rather not type a password at all goes /verify-code. Every one of
   them can be reached directly from the page menu, because a person arrives at
   a reset link cold. */
(() => {
  const {
    Card, CardHeader, CardTitle, CardDescription, CardPanel, CardFooter,
    Field, FieldLabel, FieldDescription, FieldError, Input, InputGroup, InputGroupAddon, InputGroupInput,
    Button, Separator, Badge, Alert, AlertTitle, AlertDescription, Checkbox, OTPField,
  } = window.OriginDesignSystem_7c998b;

  const EMAIL = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;

  /* The two-panel shell. `aside` is one sentence about the step, never a second
     navigation surface — there is nowhere else to go from an auth screen. */
  function AuthShell({ children, footer, asideTitle, asideText, asideNote }) {
    return (
      <div className="fd-auth">
        <div className="fd-auth__form">
          <div className="fd-auth__col">
            <div className="fd-auth__mark">
              <img className="fd-auth__logo" src={window.__resources.brandMark} alt="" width="32" height="32" />
              <span className="fd-brand__name">Meridian</span>
            </div>
            {children}
            {footer ? <p className="fd-auth__foot">{footer}</p> : null}
          </div>
        </div>
        <aside className="fd-auth__brand">
          <span className="fd-auth__dots" aria-hidden="true" />
          <div className="fd-auth__brandcol">
            <p className="fd-auth__eyebrow">{MERIDIAN_WORKSPACE.name}</p>
            <h1 className="fd-auth__title">{asideTitle}</h1>
            <p className="fd-auth__text">{asideText}</p>
            {asideNote ? <p className="fd-auth__note">{asideNote}</p> : null}
          </div>
        </aside>
      </div>
    );
  }

  /* ── Sign in ──────────────────────────────────────────────────────────── */

  function SignInScreen({ workspace, profile, onNavigate }) {
    const [values, setValues] = React.useState({ email: "", password: "" });
    const [submitted, setSubmitted] = React.useState(false);
    const [pending, setPending] = React.useState(false);
    const [reveal, setReveal] = React.useState(false);
    const errors = {};
    if (!EMAIL.test(values.email)) errors.email = "Enter a valid email address.";
    if (values.password.length < 8) errors.password = "Your password is at least 8 characters.";
    const valid = Object.keys(errors).length === 0;
    const dirty = values.email !== "" || values.password !== "";
    const submit = () => {
      setSubmitted(true);
      if (!valid) return;
      setPending(true);
      window.setTimeout(() => { setPending(false); onNavigate("/two-factor"); }, 600);
    };

    return (
      <AuthShell
        footer="Payment operations for platforms."
        asideTitle="Every figure on this desk can be checked."
        asideText="Meridian settles payments, sends payouts and holds what a dispute claims — and shows the arithmetic behind each one."
        asideNote={"Signing in to " + workspace.name + " · " + workspace.descriptor}
      >
        <Card>
          <CardHeader>
            <CardTitle>Sign in</CardTitle>
            <CardDescription>To {workspace.name} on Meridian.</CardDescription>
          </CardHeader>
          <CardPanel>
            <form className="fd-fields" onSubmit={(e) => { e.preventDefault(); submit(); }}>
              <Field>
                <FieldLabel htmlFor="fd-signin-email">Email</FieldLabel>
                <Input id="fd-signin-email" type="email" autoComplete="email" placeholder={profile.email} value={values.email}
                  invalid={submitted && !!errors.email} onChange={(e) => setValues(Object.assign({}, values, { email: e.target.value }))} />
                {submitted && errors.email ? <FieldError>{errors.email}</FieldError> : null}
              </Field>
              <Field>
                <FieldLabel htmlFor="fd-signin-password">Password</FieldLabel>
                <InputGroup data-invalid={submitted && !!errors.password ? "" : undefined}>
                  <InputGroupInput id="fd-signin-password" type={reveal ? "text" : "password"} autoComplete="current-password" value={values.password}
                    aria-invalid={(submitted && !!errors.password) || undefined} onChange={(e) => setValues(Object.assign({}, values, { password: e.target.value }))} />
                  <InputGroupAddon align="inline-end">
                    <Button variant="ghost" size="icon-xs" aria-label={reveal ? "Hide password" : "Show password"} onClick={() => setReveal(!reveal)}>
                      <Icon name={reveal ? "eye-off" : "eye"} size={14} />
                    </Button>
                  </InputGroupAddon>
                </InputGroup>
                {submitted && errors.password ? <FieldError>{errors.password}</FieldError> : <FieldDescription>At least 8 characters. A code from your authenticator comes next.</FieldDescription>}
              </Field>
              <Button type="submit" loading={pending} disabled={!dirty || pending}>Sign in</Button>
            </form>
          </CardPanel>
          <CardFooter className="fd-auth__actions">
            <Button variant="ghost" size="sm" onClick={() => onNavigate("/forgot-password")}>Forgot your password?</Button>
            <Button variant="outline" size="sm" onClick={() => onNavigate("/verify-code")}>Email me a code instead</Button>
          </CardFooter>
        </Card>
      </AuthShell>
    );
  }

  /* ── The 2FA challenge ────────────────────────────────────────────────── */

  function TwoFactorScreen({ profile, onNavigate, onSignIn }) {
    const [code, setCode] = React.useState("");
    const [submitted, setSubmitted] = React.useState(false);
    const [pending, setPending] = React.useState(false);
    const [trust, setTrust] = React.useState(false);
    const [recovery, setRecovery] = React.useState(false);
    const [backup, setBackup] = React.useState("");
    const error = recovery
      ? (backup.trim().length < 8 ? "A recovery code is eight characters." : null)
      : (code.length < 6 ? "The code is six digits." : null);
    const submit = () => {
      setSubmitted(true);
      if (error) return;
      setPending(true);
      window.setTimeout(() => { setPending(false); onSignIn(); }, 600);
    };

    return (
      <AuthShell
        footer="Codes rotate every 30 seconds. An old one will not be accepted."
        asideTitle="Money leaves this desk. A password alone is not enough."
        asideText="Approving a payout moves real money, so the second factor is required for everybody here — not offered as a setting."
        asideNote="Lost your device? A recovery code works once, then it is spent."
      >
        <Card>
          <CardHeader>
            <CardTitle>{recovery ? "Use a recovery code" : "Enter your code"}</CardTitle>
            <CardDescription>
              {recovery
                ? "One of the eight you saved when two-factor was switched on."
                : "From your authenticator app, for " + profile.email + "."}
            </CardDescription>
          </CardHeader>
          <CardPanel>
            <form className="fd-fields" onSubmit={(e) => { e.preventDefault(); submit(); }}>
              {recovery ? (
                <Field>
                  <FieldLabel htmlFor="fd-2fa-backup">Recovery code</FieldLabel>
                  <Input id="fd-2fa-backup" value={backup} placeholder="7HQ2-4KFD" invalid={submitted && !!error} onChange={(e) => setBackup(e.target.value)} />
                  {submitted && error ? <FieldError>{error}</FieldError> : <FieldDescription>Using one leaves you {7} of your {8}. Print a new set from your profile afterwards.</FieldDescription>}
                </Field>
              ) : (
                <Field>
                  <FieldLabel>Six-digit code</FieldLabel>
                  <OTPField length={6} size="lg" groupAfter={3} value={code} onValueChange={setCode} />
                  {submitted && error ? <FieldError>{error}</FieldError> : <FieldDescription>Paste the whole code and the cells fill themselves.</FieldDescription>}
                </Field>
              )}
              <label className="fd-check fd-check--inline">
                <Checkbox checked={trust} onCheckedChange={() => setTrust(!trust)} />
                <span className="fd-check__text"><span>Trust this device for 30 days</span></span>
              </label>
              <Button type="submit" loading={pending} disabled={pending}>Verify and continue</Button>
            </form>
          </CardPanel>
          <CardFooter className="fd-auth__actions">
            <Button variant="ghost" size="sm" onClick={() => { setRecovery(!recovery); setSubmitted(false); }}>
              {recovery ? "Use my authenticator instead" : "I do not have my device"}
            </Button>
            <Button variant="ghost" size="sm" onClick={() => onNavigate("/signin")}>Back to sign in</Button>
          </CardFooter>
        </Card>
      </AuthShell>
    );
  }

  /* ── Signing in with an emailed code ──────────────────────────────────── */

  function VerifyCodeScreen({ profile, onNavigate, onSignIn }) {
    const [stage, setStage] = React.useState("email");
    const [email, setEmail] = React.useState("");
    const [code, setCode] = React.useState("");
    const [submitted, setSubmitted] = React.useState(false);
    const [pending, setPending] = React.useState(false);
    const [seconds, setSeconds] = React.useState(0);

    React.useEffect(() => {
      if (stage !== "code" || seconds <= 0) return;
      const id = window.setTimeout(() => setSeconds(seconds - 1), 1000);
      return () => window.clearTimeout(id);
    }, [stage, seconds]);

    const error = stage === "email"
      ? (EMAIL.test(email) ? null : "Enter a valid email address.")
      : (code.length < 6 ? "The code is six digits." : null);

    const submit = () => {
      setSubmitted(true);
      if (error) return;
      setPending(true);
      window.setTimeout(() => {
        setPending(false);
        if (stage === "email") { setStage("code"); setSubmitted(false); setSeconds(30); }
        else onSignIn();
      }, 600);
    };

    return (
      <AuthShell
        footer="A sign-in code is good for ten minutes and can be used once."
        asideTitle="No password to remember, and none to leak."
        asideText="A code is mailed to the address on your account. It expires in ten minutes, works once, and can only be used on the device that asked for it."
      >
        <Card>
          <CardHeader>
            <CardTitle>{stage === "email" ? "Email me a code" : "Check your email"}</CardTitle>
            <CardDescription>
              {stage === "email"
                ? "We will send a six-digit code to the address on your account."
                : "We sent a six-digit code to " + (email || profile.email) + "."}
            </CardDescription>
          </CardHeader>
          <CardPanel>
            <form className="fd-fields" onSubmit={(e) => { e.preventDefault(); submit(); }}>
              {stage === "email" ? (
                <Field>
                  <FieldLabel htmlFor="fd-otp-email">Email</FieldLabel>
                  <Input id="fd-otp-email" type="email" autoComplete="email" placeholder={profile.email} value={email}
                    invalid={submitted && !!error} onChange={(e) => setEmail(e.target.value)} />
                  {submitted && error ? <FieldError>{error}</FieldError> : <FieldDescription>It has to be an address already on this workspace.</FieldDescription>}
                </Field>
              ) : (
                <Field>
                  <FieldLabel>Six-digit code</FieldLabel>
                  <OTPField length={6} size="lg" groupAfter={3} value={code} onValueChange={setCode} />
                  {submitted && error ? <FieldError>{error}</FieldError> : <FieldDescription>Paste the whole code and the cells fill themselves.</FieldDescription>}
                </Field>
              )}
              <Button type="submit" loading={pending} disabled={pending}>{stage === "email" ? "Send the code" : "Sign in"}</Button>
            </form>
          </CardPanel>
          <CardFooter className="fd-auth__actions">
            {stage === "code" ? (
              <Button variant="ghost" size="sm" disabled={seconds > 0} onClick={() => setSeconds(30)}>
                {seconds > 0 ? "Send another in " + seconds + "s" : "Send another code"}
              </Button>
            ) : null}
            <Button variant="ghost" size="sm" onClick={() => onNavigate("/signin")}>Use my password</Button>
          </CardFooter>
        </Card>
      </AuthShell>
    );
  }

  /* ── Forgotten password ───────────────────────────────────────────────── */

  function ForgotPasswordScreen({ profile, onNavigate }) {
    const [email, setEmail] = React.useState("");
    const [submitted, setSubmitted] = React.useState(false);
    const [pending, setPending] = React.useState(false);
    const [sent, setSent] = React.useState(false);
    const error = EMAIL.test(email) ? null : "Enter a valid email address.";
    const submit = () => {
      setSubmitted(true);
      if (error) return;
      setPending(true);
      window.setTimeout(() => { setPending(false); setSent(true); }, 600);
    };

    return (
      <AuthShell
        footer="Resetting a password does not sign you out of your other devices."
        asideTitle="A reset link, not a new password in an email."
        asideText="The link lasts an hour and can be opened once. Nobody at Meridian can read or set your password — including us."
      >
        <Card>
          <CardHeader>
            <CardTitle>{sent ? "Check your email" : "Reset your password"}</CardTitle>
            <CardDescription>
              {sent
                ? "If " + email + " is on this workspace, a reset link is on its way. It lasts an hour."
                : "We will email a link to set a new one."}
            </CardDescription>
          </CardHeader>
          <CardPanel>
            {sent ? (
              <div className="fd-stack">
                <Alert variant="info">
                  <AlertTitle>Nothing has changed yet</AlertTitle>
                  <AlertDescription>Your current password still works until you set a new one through the link.</AlertDescription>
                </Alert>
                <Button variant="outline" onClick={() => onNavigate("/reset-password")}>Open the link</Button>
              </div>
            ) : (
              <form className="fd-fields" onSubmit={(e) => { e.preventDefault(); submit(); }}>
                <Field>
                  <FieldLabel htmlFor="fd-forgot-email">Email</FieldLabel>
                  <Input id="fd-forgot-email" type="email" autoComplete="email" placeholder={profile.email} value={email}
                    invalid={submitted && !!error} onChange={(e) => setEmail(e.target.value)} />
                  {submitted && error ? <FieldError>{error}</FieldError> : <FieldDescription>We do not say whether an address is on the workspace — that would tell anybody who asked.</FieldDescription>}
                </Field>
                <Button type="submit" loading={pending} disabled={pending}>Email the reset link</Button>
              </form>
            )}
          </CardPanel>
          <CardFooter className="fd-auth__actions">
            <Button variant="ghost" size="sm" onClick={() => onNavigate("/signin")}>Back to sign in</Button>
          </CardFooter>
        </Card>
      </AuthShell>
    );
  }

  /* ── Setting the new one ──────────────────────────────────────────────── */

  function ResetPasswordScreen({ profile, onNavigate }) {
    const [values, setValues] = React.useState({ password: "", confirm: "" });
    const [submitted, setSubmitted] = React.useState(false);
    const [pending, setPending] = React.useState(false);
    const [reveal, setReveal] = React.useState(false);
    const set = (fields) => setValues(Object.assign({}, values, fields));

    /* Requirements are shown as they are met, not listed and then failed. */
    const rules = [
      { id: "length", label: "At least 12 characters", met: values.password.length >= 12 },
      { id: "case", label: "An upper and a lower case letter", met: /[a-z]/.test(values.password) && /[A-Z]/.test(values.password) },
      { id: "number", label: "A number or a symbol", met: /[0-9\W]/.test(values.password) },
    ];
    const errors = {};
    if (rules.some((r) => !r.met)) errors.password = "Your password does not meet all three requirements yet.";
    if (values.confirm !== values.password) errors.confirm = "The two do not match.";
    const valid = Object.keys(errors).length === 0;
    const submit = () => {
      setSubmitted(true);
      if (!valid) return;
      setPending(true);
      window.setTimeout(() => { setPending(false); onNavigate("/signin"); }, 600);
    };

    return (
      <AuthShell
        footer="Signing in again on this device will ask for your authenticator code."
        asideTitle="Set it once, and every other session ends."
        asideText="Changing your password signs out every device except this one. Anything you had approved stays approved — a session ending never touches the ledger."
        asideNote={"Resetting for " + profile.email}
      >
        <Card>
          <CardHeader>
            <CardTitle>Set a new password</CardTitle>
            <CardDescription>The link that brought you here is spent once this is saved.</CardDescription>
          </CardHeader>
          <CardPanel>
            <form className="fd-fields" onSubmit={(e) => { e.preventDefault(); submit(); }}>
              <Field>
                <FieldLabel htmlFor="fd-reset-password">New password</FieldLabel>
                <InputGroup data-invalid={submitted && !!errors.password ? "" : undefined}>
                  <InputGroupInput id="fd-reset-password" type={reveal ? "text" : "password"} autoComplete="new-password" value={values.password}
                    aria-invalid={(submitted && !!errors.password) || undefined} onChange={(e) => set({ password: e.target.value })} />
                  <InputGroupAddon align="inline-end">
                    <Button variant="ghost" size="icon-xs" aria-label={reveal ? "Hide password" : "Show password"} onClick={() => setReveal(!reveal)}>
                      <Icon name={reveal ? "eye-off" : "eye"} size={14} />
                    </Button>
                  </InputGroupAddon>
                </InputGroup>
                <span className="fd-rules">
                  {rules.map((rule) => (
                    <span className={"fd-rule" + (rule.met ? " fd-rule--met" : "")} key={rule.id}>
                      <Icon name={rule.met ? "check" : "circle"} size={12} />{rule.label}
                    </span>
                  ))}
                </span>
              </Field>
              <Field>
                <FieldLabel htmlFor="fd-reset-confirm">Type it again</FieldLabel>
                <Input id="fd-reset-confirm" type={reveal ? "text" : "password"} autoComplete="new-password" value={values.confirm}
                  invalid={submitted && !!errors.confirm} onChange={(e) => set({ confirm: e.target.value })} />
                {submitted && errors.confirm ? <FieldError>{errors.confirm}</FieldError> : null}
              </Field>
              <Button type="submit" loading={pending} disabled={pending}>Set password and sign in</Button>
            </form>
          </CardPanel>
          <CardFooter className="fd-auth__actions">
            <Button variant="ghost" size="sm" onClick={() => onNavigate("/signin")}>Back to sign in</Button>
          </CardFooter>
        </Card>
      </AuthShell>
    );
  }

  /* ── Signed out ───────────────────────────────────────────────────────── */

  function SignedOutScreen({ profile, onNavigate }) {
    return (
      <AuthShell
        footer="Closing the tab ends the session everywhere on this device."
        asideTitle="Nothing was left half done."
        asideText="Approvals in flight are exactly where they were, and every figure will read the same when you come back — none of them are stored, so none of them can drift."
      >
        <Card>
          <CardHeader>
            <CardTitle>You are signed out</CardTitle>
            <CardDescription>Nothing in {MERIDIAN_WORKSPACE.name} changed.</CardDescription>
          </CardHeader>
          <CardPanel className="fd-stack">
            <span className="fd-auth__who">
              <Face person={profile} size={40} />
              <span className="fd-record">
                <span className="fd-record__name">{profile.name}</span>
                <span className="fd-record__meta">{profile.role} · {profile.email}</span>
              </span>
            </span>
            <Separator />
            <Button onClick={() => onNavigate("/signin")}>Sign in</Button>
          </CardPanel>
        </Card>
      </AuthShell>
    );
  }

  Object.assign(window, {
    AuthShell, SignInScreen, SignedOutScreen,
    TwoFactorScreen, VerifyCodeScreen, ForgotPasswordScreen, ResetPasswordScreen,
  });
})();

})();
</script>
<script type="text/babel" data-presets="react" data-file="SettingsScreens.jsx">
(function(){
/* Spend controls and the team — the two settings screens the cards and expenses
   destinations brought with them.

   Commit model: STAGED, like every settings screen in this product. Every
   control is a form field, nothing applies until Save, Save and Cancel are
   disabled until the form is dirty, and errors render on validation.

   Spend controls is policy, not preference: the numbers on it are the ones
   EXPENSE_FLAGS and CARD_FLAGS read, so the panel under the fields runs the
   real predicates over the real expenses with the DRAFT values, and saving
   really does move the sidebar badge. */
(() => {
  const {
    Card, CardHeader, CardTitle, CardDescription, CardPanel, CardFooter,
    Table, TableHeader, TableBody, TableRow, TableHead, TableCell,
    Field, FieldLabel, FieldDescription, FieldError, Input, InputGroup, InputGroupAddon, InputGroupText, InputGroupInput,
    Select, Checkbox, Badge, Avatar,
  } = window.OriginDesignSystem_7c998b;

  const toCents = (text) => Math.round(Number(String(text).replace(/[^0-9.]/g, "")) * 100);
  const toDollars = (cents) => String(Math.round(cents) / 100);

  function SpendControlsScreen({ policy, data, now, onSave }) {
    const initial = React.useMemo(() => ({
      receipt: toDollars(policy.receiptThresholdCents),
      receiptCategories: policy.receiptCategories.slice(),
      cardWarnShare: String(policy.cardWarnShare),
      cardDefaultLimit: toDollars(policy.cardDefaultLimitCents),
    }), [policy]);
    const [draft, setDraft] = React.useState(initial);
    const [submitted, setSubmitted] = React.useState(false);
    React.useEffect(() => { setDraft(initial); setSubmitted(false); }, [initial]);
    const set = (fields) => setDraft((d) => Object.assign({}, d, fields));
    const dirty = JSON.stringify(draft) !== JSON.stringify(initial);

    const errors = {};
    const receiptCents = toCents(draft.receipt);
    const limitCents = toCents(draft.cardDefaultLimit);
    if (!(receiptCents >= 1000 && receiptCents <= 100000)) errors.receipt = "Between $10 and $1,000 — below ten dollars every coffee needs a receipt.";
    if (!(limitCents >= 10000)) errors.cardDefaultLimit = "At least $100, or a new card cannot buy anything.";
    const valid = Object.keys(errors).length === 0;

    /* The preview runs the real predicates, with the draft policy. */
    const drafted = { receiptThresholdCents: receiptCents, receiptCategories: draft.receiptCategories, cardWarnShare: Number(draft.cardWarnShare), cardDefaultLimitCents: limitCents };
    const waiting = expensesAwaitingReview(data.expenses);
    const owedNow = expensesMissingReceipt(waiting, policy);
    const owedDraft = valid ? expensesMissingReceipt(waiting, drafted) : owedNow;
    const approvableNow = reviewableExpenses(data.expenses, policy);
    const approvableDraft = valid ? reviewableExpenses(data.expenses, drafted) : approvableNow;
    const riskNow = cardsAtRisk(data.cards, data.expenses, now, policy);
    const riskDraft = valid ? cardsAtRisk(data.cards, data.expenses, now, drafted) : riskNow;

    return (
      <div className="fd-page">
        <PageHeader path="/settings/spend-controls" title="Spend controls" />
        <Card>
          <CardHeader>
            <CardTitle>What the workspace asks of a card expense</CardTitle>
            <CardDescription>These are the numbers the expense queue and the card meters read. Changing them changes what is waiting on you, not what has already been approved.</CardDescription>
          </CardHeader>
          <CardPanel>
            <div className="fd-fields">
              <Field>
                <FieldLabel htmlFor="fd-pol-receipt">Receipt required above</FieldLabel>
                <InputGroup data-invalid={submitted && !!errors.receipt ? "" : undefined}>
                  <InputGroupAddon><InputGroupText>$</InputGroupText></InputGroupAddon>
                  <InputGroupInput id="fd-pol-receipt" inputMode="decimal" value={draft.receipt} aria-invalid={(submitted && !!errors.receipt) || undefined} onChange={(e) => set({ receipt: e.target.value })} />
                </InputGroup>
                {submitted && errors.receipt ? <FieldError>{errors.receipt}</FieldError> : <FieldDescription>An expense at or above this needs its receipt attached before anybody can approve it.</FieldDescription>}
              </Field>
              <Field>
                <FieldLabel>Always need a receipt, whatever the amount</FieldLabel>
                <div className="fd-checks">
                  {EXPENSE_CATEGORIES.map((category) => (
                    <label className="fd-check fd-check--inline" key={category.value}>
                      <Checkbox
                        checked={draft.receiptCategories.indexOf(category.value) > -1}
                        onCheckedChange={() => set({ receiptCategories: draft.receiptCategories.indexOf(category.value) > -1 ? draft.receiptCategories.filter((v) => v !== category.value) : draft.receiptCategories.concat([category.value]) })}
                      />
                      <span className="fd-check__text"><span>{category.label}</span></span>
                    </label>
                  ))}
                </div>
                <FieldDescription>Checkboxes, because they are saved with the rest of this form rather than one at a time.</FieldDescription>
              </Field>
              <Field>
                <FieldLabel htmlFor="fd-pol-warn">Warn when a card reaches</FieldLabel>
                <Select
                  id="fd-pol-warn"
                  items={[{ value: "0.7", label: "70% of its limit" }, { value: "0.8", label: "80% of its limit" }, { value: "0.9", label: "90% of its limit" }]}
                  value={draft.cardWarnShare}
                  onValueChange={(value) => set({ cardWarnShare: value })}
                />
                <FieldDescription>Measured over the same rolling {CARD_WINDOW_DAYS} days the limit is.</FieldDescription>
              </Field>
              <Field>
                <FieldLabel htmlFor="fd-pol-limit">Default limit for a new card</FieldLabel>
                <InputGroup data-invalid={submitted && !!errors.cardDefaultLimit ? "" : undefined}>
                  <InputGroupAddon><InputGroupText>$</InputGroupText></InputGroupAddon>
                  <InputGroupInput id="fd-pol-limit" inputMode="decimal" value={draft.cardDefaultLimit} aria-invalid={(submitted && !!errors.cardDefaultLimit) || undefined} onChange={(e) => set({ cardDefaultLimit: e.target.value })} />
                </InputGroup>
                {submitted && errors.cardDefaultLimit ? <FieldError>{errors.cardDefaultLimit}</FieldError> : <FieldDescription>Per {CARD_WINDOW_DAYS} days. It is filled in for you when a card is issued, and can be changed there.</FieldDescription>}
              </Field>
            </div>
          </CardPanel>
          <CardFooter className="fd-card-footer">
            <span className="fd-footnote">
              {dirty && valid
                ? "At these settings: " + approvableDraft.length + " expenses could be approved now (" + approvableNow.length + " today), " + owedDraft.length + " would be waiting on a receipt (" + owedNow.length + " today), and " + riskDraft.length + " cards would be flagged near their limit (" + riskNow.length + " today)."
                : "Right now: " + approvableNow.length + " expenses can be approved, " + owedNow.length + " are waiting on a receipt, and " + riskNow.length + " cards are flagged near their limit."}
            </span>
          </CardFooter>
        </Card>
        <FormBar
          dirty={dirty}
          note={dirty ? "Unsaved changes. Saving recounts the expenses badge in the sidebar." : "Everything here is as it was last saved."}
          onCancel={() => { setDraft(initial); setSubmitted(false); }}
          onSave={() => { setSubmitted(true); if (valid) onSave(drafted); }}
          label="Save spend controls"
        />
      </div>
    );
  }

  function TeamScreen({ members, signedInId, data, onSave }) {
    const initial = React.useMemo(() => members.map((m) => ({ id: m.id, teamRole: m.teamRole, approvalLimit: toDollars(m.approvalLimit), canIssueCards: m.canIssueCards })), [members]);
    const [draft, setDraft] = React.useState(initial);
    React.useEffect(() => { setDraft(initial); }, [initial]);
    const dirty = JSON.stringify(draft) !== JSON.stringify(initial);
    const set = (id, fields) => setDraft((rows) => rows.map((r) => (r.id === id ? Object.assign({}, r, fields) : r)));
    const rowOf = (id) => draft.find((r) => r.id === id) || initial.find((r) => r.id === id);
    const approvers = draft.filter((r) => r.teamRole !== "member");
    const waiting = awaitingApprovalPayouts(data.payouts);
    /* The preview runs the real predicate over the real payouts with the DRAFT
       limit, so the form says what saving it would do. */
    const draftLimit = toCents((rowOf(signedInId) || {}).approvalLimit) || 0;
    const overYours = waiting.filter((p) => p.amount > draftLimit);

    return (
      <div className="fd-page">
        <PageHeader path="/settings/team" title="Team and approvals" />
        <Card>
          <CardHeader>
            <CardTitle>Who can approve what in {MERIDIAN_WORKSPACE.name}</CardTitle>
            <CardDescription>A role decides what somebody sees; the limit decides how much they can release on their own. Cards are issued by admins.</CardDescription>
          </CardHeader>
          <div className="fd-card-table">
            <Table variant="card">
              <TableHeader>
                <TableRow>
                  <TableHead>Person</TableHead>
                  <TableHead>Role</TableHead>
                  <TableHead className="fd-drop-md">Approves up to</TableHead>
                  <TableHead className="fd-drop-lg">Can issue cards</TableHead>
                  <TableHead className="fd-num">Cards held</TableHead>
                </TableRow>
              </TableHeader>
              <TableBody>
                {members.map((member) => {
                  const row = rowOf(member.id);
                  const isSelf = member.id === signedInId;
                  const approves = row.teamRole !== "member";
                  const held = liveCards(data.cards).filter((c) => c.holderId === member.id);
                  return (
                    <TableRow key={member.id}>
                      <TableCell>
                        <span className="fd-holder">
                          <Avatar size={28} src={member.avatar} alt="" fallback={member.name.slice(0, 1)} />
                          <span className="fd-record">
                            <span className="fd-record__name">{member.name}{isSelf ? " (you)" : ""}</span>
                            <span className="fd-record__meta">{member.email}</span>
                          </span>
                        </span>
                      </TableCell>
                      <TableCell>
                        {isSelf ? (
                          <FloatingTip side="bottom" label="You cannot change your own role — ask another admin.">
                            <span className="fd-status"><Badge size="sm" variant="secondary">{labelOf(TEAM_ROLES, row.teamRole)}</Badge></span>
                          </FloatingTip>
                        ) : (
                          <span className="fd-cell-control">
                            <Select size="sm" items={TEAM_ROLES.map((r) => ({ value: r.value, label: r.label }))} value={row.teamRole}
                              onValueChange={(value) => set(member.id, { teamRole: value, approvalLimit: value === "member" ? "0" : row.approvalLimit, canIssueCards: value === "admin" ? row.canIssueCards : false })} />
                          </span>
                        )}
                        <span className="fd-sub">{(TEAM_ROLES.find((r) => r.value === row.teamRole) || {}).note}</span>
                      </TableCell>
                      <TableCell className="fd-drop-md">
                        {approves ? (
                          <span className="fd-cell-control">
                            <InputGroup className="o-input--sm">
                              <InputGroupAddon><InputGroupText>$</InputGroupText></InputGroupAddon>
                              <InputGroupInput inputMode="decimal" aria-label={"Approval limit for " + member.name} value={row.approvalLimit} onChange={(e) => set(member.id, { approvalLimit: e.target.value })} />
                            </InputGroup>
                          </span>
                        ) : (
                          <span className="fd-waiting">A member approves nothing.</span>
                        )}
                      </TableCell>
                      <TableCell className="fd-drop-lg">
                        {row.teamRole === "admin" ? (
                          <Checkbox checked={row.canIssueCards} aria-label={"Let " + member.name + " issue cards"} onCheckedChange={(checked) => set(member.id, { canIssueCards: !!checked })} />
                        ) : (
                          <FloatingTip side="bottom" label="Only an admin can issue cards. Change the role first.">
                            <span className="fd-waiting">Admins only</span>
                          </FloatingTip>
                        )}
                      </TableCell>
                      <TableCell className="fd-num">
                        {held.length ? held.map((c) => c.label).join(", ") : <span className="fd-muted">—</span>}
                      </TableCell>
                    </TableRow>
                  );
                })}
              </TableBody>
            </Table>
          </div>
          <CardFooter className="fd-card-footer">
            <span className="fd-footnote">
              {approvers.length} of {members.length} people can approve something, together up to {formatMoney(approvers.reduce((total, r) => total + toCents(r.approvalLimit), 0))} each run. {waiting.length} payouts are waiting on approval right now{overYours.length ? ", and " + overYours.length + " of them " + (overYours.length === 1 ? "is" : "are") + " above your own " + formatMoney(draftLimit) + " limit" : ", every one of them inside your own limit"}.
            </span>
          </CardFooter>
        </Card>
        <FormBar
          dirty={dirty}
          note={dirty ? "Unsaved changes to roles and limits." : "Everything here is as it was last saved."}
          onCancel={() => setDraft(initial)}
          onSave={() => onSave(draft.map((r) => ({ id: r.id, teamRole: r.teamRole, approvalLimit: toCents(r.approvalLimit) || 0, canIssueCards: r.canIssueCards })))}
          label="Save team"
        />
      </div>
    );
  }

  Object.assign(window, { SpendControlsScreen, TeamScreen });
})();

})();
</script>
<script type="text/babel" data-presets="react" data-file="BillingScreen.jsx">
(function(){
/* Plan and billing — what Meridian charges this workspace, and what the next
   bill will be. The one settings screen that is a statement rather than a form.
   Commit model: none. There is not a single control on this screen, so there is
   nothing to save and nothing to undo — like /balances, its only actions are
   navigation. A plan change is not offered here rather than offered dead.
   Data state: populated.
   Every figure is computed from the payment, payout, dispute, card and member
   arrays over one named window, at the rates in FEE_SCHEDULE. Nothing about
   money on this screen is stored: a fee typed into a fixture is a fee nobody
   can check against the rows on /payments. */
(() => {
  const {
    Card, CardHeader, CardTitle, CardDescription, CardPanel, CardFooter,
    Table, TableHeader, TableBody, TableRow, TableHead, TableCell, TableFooter,
    Button,
  } = window.OriginDesignSystem_7c998b;

  /* The window the next bill is computed over, named once and stated wherever a
     figure on this screen is. */
  const BILLING_WINDOW_DAYS = 30;

  /* The formula lives in metrics.js, so the fee quoted on /payments/new and the
     fee billed here are the same function. */
  const methodFee = (payment) => window.methodFee(payment.method, payment.amount);
  const feeBasis = (method) => {
    const rate = FEE_SCHEDULE.method[method];
    const parts = [];
    if (rate.rate) parts.push((rate.rate * 100).toFixed(1).replace(/\.0$/, "") + "%");
    if (rate.fixedCents) parts.push(formatMoney(rate.fixedCents) + " each");
    if (rate.capCents) parts.push("capped at " + formatMoney(rate.capCents));
    return parts.join(" + ").replace(" + capped", ", capped");
  };

  function BillingScreen({ data, now, workspace, onNavigate }) {
    const plan = BILLING_PLANS.find((p) => p.value === workspace.plan) || BILLING_PLANS[0];
    const nextChargeAt = new Date(new Date(now).getFullYear(), new Date(now).getMonth() + 1, 1).getTime();

    /* One window, one array, and every line below filters it. */
    const window = paymentsInPeriod(data.payments, BILLING_WINDOW_DAYS, now);
    const charged = succeededPayments(window);
    const volume = sumBy(charged, "amount");
    const methods = PAYMENT_METHODS
      .map((method) => {
        const rows = charged.filter((p) => p.method === method.value);
        return { method: method, rows: rows, volume: sumBy(rows, "amount"), fees: rows.reduce((total, p) => total + methodFee(p), 0) };
      })
      .filter((row) => row.rows.length > 0)
      .sort((a, b) => b.fees - a.fees);
    const processingFees = methods.reduce((total, row) => total + row.fees, 0);

    const payoutsPaid = data.payouts.filter((p) => p.status === "paid");
    const payoutFees = payoutsPaid.length * FEE_SCHEDULE.payoutFixedCents;
    const disputesOpened = disputesInPeriod(data.disputes, BILLING_WINDOW_DAYS, now);
    const disputeFees = disputesOpened.length * FEE_SCHEDULE.disputeFixedCents;

    const cards = liveCards(data.cards);
    const extraCards = Math.max(0, cards.length - plan.cardsIncluded);
    const cardFees = extraCards * FEE_SCHEDULE.extraCardCents;
    const seats = data.members.length;
    const extraSeats = Math.max(0, seats - plan.seatsIncluded);
    const seatFees = extraSeats * FEE_SCHEDULE.extraSeatCents;

    const lines = [
      { key: "plan", label: plan.label + " plan", basis: "One month, billed ahead", amount: plan.monthlyCents, route: null },
      { key: "processing", label: "Processing", basis: charged.length + " charges taking " + formatMoney(volume) + " across " + methods.length + " " + (methods.length === 1 ? "method" : "methods"), amount: processingFees, route: "/payments" },
      { key: "payouts", label: "Payouts", basis: payoutsPaid.length + (payoutsPaid.length === 1 ? " transfer that landed, at " : " transfers that landed, at ") + formatMoney(FEE_SCHEDULE.payoutFixedCents) + " each", amount: payoutFees, route: "/payouts" },
      { key: "disputes", label: "Disputes", basis: disputesOpened.length + " opened in the window, at " + formatMoney(FEE_SCHEDULE.disputeFixedCents) + " each", amount: disputeFees, route: "/disputes" },
      { key: "seats", label: "Extra seats", basis: extraSeats ? extraSeats + " over the " + plan.seatsIncluded + " included, at " + formatMoney(FEE_SCHEDULE.extraSeatCents) + " each" : seats + " of " + plan.seatsIncluded + " seats used", amount: seatFees, route: "/settings/team" },
      { key: "cards", label: "Extra cards", basis: extraCards ? extraCards + " over the " + plan.cardsIncluded + " included, at " + formatMoney(FEE_SCHEDULE.extraCardCents) + " each" : cards.length + " of " + plan.cardsIncluded + " live cards", amount: cardFees, route: "/cards" },
    ];
    const total = lines.reduce((sum, line) => sum + line.amount, 0);
    const effectiveRate = volume ? processingFees / volume : null;

    return (
      <div className="fd-page">
        <PageHeader path="/settings/billing" title="Plan and billing" />

        <div className="fd-metrics">
          <MetricTile icon="file-text" label="Next bill" value={formatMoney(total)}
            qualifier={"Charged on " + formatDayFull(nextChargeAt) + ", computed from the last " + BILLING_WINDOW_DAYS + " days"} />
          <MetricTile icon="package" label="Plan" value={plan.label}
            qualifier={formatMoney(plan.monthlyCents) + " a month · " + plan.seatsIncluded + " seats and " + plan.cardsIncluded + " cards included"} />
          <MetricTile icon="trending-up" label="Volume charged on" value={formatMoney(volume)}
            qualifier={charged.length + " succeeded payments in the window; refunded and failed ones carry no fee"} />
          <MetricTile icon="percent" label="Effective rate" value={effectiveRate === null ? "No volume" : formatPercent(effectiveRate)}
            qualifier={formatMoney(processingFees) + " of processing over the " + formatMoney(volume) + " it was charged on"} />
        </div>

        <Card>
          <CardHeader>
            <CardTitle>The next bill</CardTitle>
            <CardDescription>
              Every line is the rate in the contract over the rows on the destination it names, for the {BILLING_WINDOW_DAYS} days to today. Nothing here is a stored figure.
            </CardDescription>
          </CardHeader>
          <div className="fd-card-table">
            <Table variant="card">
              <TableHeader>
                <TableRow>
                  <TableHead>Line</TableHead>
                  <TableHead>What it is charged on</TableHead>
                  <TableHead className="fd-num">Amount</TableHead>
                </TableRow>
              </TableHeader>
              <TableBody>
                {lines.map((line) => (
                  <TableRow key={line.key}>
                    <TableCell>
                      <span className="fd-record">
                        {line.route
                          ? <button type="button" className="fd-link fd-record__name" onClick={() => onNavigate(line.route)}>{line.label}</button>
                          : <span className="fd-record__name">{line.label}</span>}
                      </span>
                    </TableCell>
                    <TableCell><span className="fd-record__meta">{line.basis}</span></TableCell>
                    <TableCell className="fd-num fd-amount">{line.amount ? formatMoney(line.amount) : <span className="fd-muted">Nothing</span>}</TableCell>
                  </TableRow>
                ))}
              </TableBody>
              <TableFooter>
                <TableRow>
                  <TableCell>Due {formatDay(nextChargeAt)}</TableCell>
                  <TableCell><span className="fd-record__meta">Taken from {data.balance.bank}</span></TableCell>
                  <TableCell className="fd-num fd-amount">{formatMoney(total)}</TableCell>
                </TableRow>
              </TableFooter>
            </Table>
          </div>
          <CardFooter className="fd-card-footer">
            <span className="fd-footnote">A line at nothing is still a line: it says the rate exists and that nothing triggered it this month.</span>
            <Button variant="outline" size="sm" onClick={() => onNavigate("/balances")}>Open balances</Button>
          </CardFooter>
        </Card>

        <div className="fd-columns fd-columns--even">
          <div className="fd-col">
            <Card>
              <CardHeader>
                <CardTitle>Processing, by method</CardTitle>
                <CardDescription>The {formatMoney(processingFees)} above, decomposed by how the money arrived. Share is of the fee, not of the volume — a wire is cheap on a big number and dear on a small one.</CardDescription>
              </CardHeader>
              <CardPanel>
                <div className="fd-stack">
                  {methods.map((row) => (
                    <div className="fd-part" key={row.method.value}>
                      <div className="fd-spread">
                        <span>{row.method.label}<span className="fd-record__meta"> · {feeBasis(row.method.value)}</span></span>
                        <span className="fd-amount">{formatMoney(row.fees)}</span>
                      </div>
                      <span className="fd-bar" role="img" aria-label={row.method.label + ", " + formatMoney(row.fees) + " of fees"}>
                        <span className="fd-bar__fill" style={{ width: (processingFees ? (row.fees / processingFees) * 100 : 0).toFixed(1) + "%" }} />
                      </span>
                      <span className="fd-footnote">
                        {row.rows.length} {row.rows.length === 1 ? "charge" : "charges"} taking {formatMoney(row.volume)} · {formatPercent(row.fees / row.volume)} of what came in that way
                      </span>
                    </div>
                  ))}
                </div>
              </CardPanel>
              <CardFooter className="fd-card-footer">
                <span className="fd-footnote">Sums to {formatMoney(processingFees)}, the processing line above.</span>
                <Button variant="outline" size="sm" onClick={() => onNavigate("/payments")}>Open payments</Button>
              </CardFooter>
            </Card>
          </div>

          <div className="fd-col">
            <Card>
              <CardHeader>
                <CardTitle>What {plan.label} includes</CardTitle>
                <CardDescription>{plan.note}</CardDescription>
              </CardHeader>
              <CardPanel>
                <div className="fd-stack">
                  <div className="fd-part">
                    <div className="fd-spread">
                      <span>Seats</span>
                      <span className="fd-amount">{seats} of {plan.seatsIncluded}{extraSeats ? " + " + extraSeats : ""}</span>
                    </div>
                    <span className="fd-bar" role="img" aria-label={seats + " seats of " + plan.seatsIncluded + " included"}>
                      <span className="fd-bar__fill" style={{ width: Math.min(100, (seats / plan.seatsIncluded) * 100).toFixed(1) + "%" }} />
                    </span>
                    <span className="fd-footnote">
                      {extraSeats
                        ? extraSeats + " beyond the plan, at " + formatMoney(FEE_SCHEDULE.extraSeatCents) + " each a month. Removing somebody on the team screen removes the charge."
                        : plan.seatsIncluded - seats + " still included. The next one after that is " + formatMoney(FEE_SCHEDULE.extraSeatCents) + " a month."}
                    </span>
                  </div>
                  <div className="fd-part">
                    <div className="fd-spread">
                      <span>Live cards</span>
                      <span className="fd-amount">{cards.length} of {plan.cardsIncluded}{extraCards ? " + " + extraCards : ""}</span>
                    </div>
                    <span className="fd-bar" role="img" aria-label={cards.length + " live cards of " + plan.cardsIncluded + " included"}>
                      <span className="fd-bar__fill" style={{ width: Math.min(100, (cards.length / plan.cardsIncluded) * 100).toFixed(1) + "%" }} />
                    </span>
                    <span className="fd-footnote">
                      Cancelled cards keep their record and stop counting. {extraCards ? extraCards + " over, at " + formatMoney(FEE_SCHEDULE.extraCardCents) + " each." : plan.cardsIncluded - cards.length + " still included."}
                    </span>
                  </div>
                  <div className="fd-part">
                    <div className="fd-spread">
                      <span>Payout schedule</span>
                      <span className="fd-amount">{labelOf(PAYOUT_SCHEDULES, workspace.schedule)}</span>
                    </div>
                    <span className="fd-footnote">
                      {PAYOUT_SCHEDULES.find((s) => s.value === workspace.schedule).note} Each transfer that lands costs {formatMoney(FEE_SCHEDULE.payoutFixedCents)}, whatever the schedule.
                    </span>
                  </div>
                </div>
              </CardPanel>
              <CardFooter className="fd-card-footer">
                <span className="fd-footnote">Seats and cards are counted from the team and the card list, not from the plan.</span>
                <Button variant="outline" size="sm" onClick={() => onNavigate("/settings/team")}>Open the team</Button>
              </CardFooter>
            </Card>
          </div>
        </div>
      </div>
    );
  }

  Object.assign(window, { BillingScreen });
})();

})();
</script>
<script type="text/babel" data-presets="react" data-file="App.jsx">
(function(){
/* Meridian — routing and every commit in the product.
   The reversibility tiers are decided here, once: consequential and reversible
   actions apply immediately and raise an undo toast, irreversible ones open a
   confirm dialog, and the one action that needs a form (submitting dispute
   evidence) opens a staged dialog from an instant screen.

   Auth renders outside the shell (AGENTS §7.1): the sign-in and signed-out
   screens have no navigation owner, so they are returned before MeridianShell
   is reached rather than nested inside it. */
function App(props) {
  const options = props || {};
  const [path, setPath] = React.useState(options.startPath || "/");
  const [period, setPeriod] = React.useState("30");
  const [dark, setDark] = React.useState(options.theme === "dark");
  const [read, setRead] = React.useState(false);
  const [toasts, setToasts] = React.useState([]);
  const [confirm, setConfirm] = React.useState(null);
  const [form, setForm] = React.useState(null);
  const [rawData, setData] = React.useState({ payments: PAYMENTS, payouts: PAYOUTS, disputes: DISPUTES, expenses: EXPENSES, cards: CARDS, members: MEMBERS, comments: COMMENTS, customers: CUSTOMERS, balance: BALANCE, policy: MERIDIAN_POLICY_DEFAULTS });
  const [workspace, setWorkspace] = React.useState(MERIDIAN_WORKSPACE);
  /* The signed-in user is a member record, not a second literal: the profile
     screen and the team screen edit the same entry. */
  const palette = useCommandPalette();
  const now = MERIDIAN_NOW;
  const profile = rawData.members.find((m) => m.id === MERIDIAN_SIGNED_IN);
  /* The signed-in approver travels with the data, so every surface that asks
     "can I approve this" is handed the same person (AGENTS §2). Derived, never
     stored: setData writes into rawData. */
  const data = Object.assign({}, rawData, { approver: profile });

  React.useEffect(() => { if (options.startPath) setPath(options.startPath); }, [options.startPath]);
  React.useEffect(() => { setDark(options.theme === "dark"); }, [options.theme]);
  React.useEffect(() => {
    document.documentElement.classList.toggle("dark", dark);
    document.body.classList.toggle("dark", dark);
  }, [dark]);

  const dismiss = (id) => setToasts((list) => list.filter((t) => t.id !== id));
  const pushToast = (toast) => {
    const id = "t" + Date.now() + Math.random().toString(36).slice(2, 6);
    setToasts((list) => list.concat([Object.assign({ id: id }, toast)]));
    setTimeout(() => dismiss(id), 7000);
  };

  const patch = (key, id, fields) => setData((d) => Object.assign({}, d, { [key]: d[key].map((r) => (r.id === id ? Object.assign({}, r, fields) : r)) }));
  const restore = (key, snapshot) => setData((d) => Object.assign({}, d, { [key]: d[key].map((r) => (r.id === snapshot.id ? snapshot : r)) }));
  const memberName = (id) => (data.members.find((m) => m.id === id) || { name: "somebody who has left" }).name;

  /* ── Payouts ───────────────────────────────────────────────────────────── */
  const approvePayout = (payout) => {
    patch("payouts", payout.id, { status: "approved", approvedAt: now, approvedBy: profile.name });
    pushToast({
      variant: "success",
      title: "Payout approved",
      description: formatMoney(payout.amount) + " to " + payout.destination + " · " + payout.id,
      onUndo: () => restore("payouts", payout),
    });
  };

  const rejectPayout = (payout) => setConfirm({
    title: "Reject " + formatMoney(payout.amount) + " to " + payout.destination + "?",
    description: "The payout is closed and the money stays in your balance. " + payout.destination + " is notified, and a new payout has to be raised to pay them.",
    confirmLabel: "Reject payout",
    run: () => {
      patch("payouts", payout.id, { status: "rejected", rejectedAt: now, rejectedBy: profile.name, rejectReason: "Rejected in review" });
      pushToast({ title: "Payout rejected", description: payout.id + " · " + payout.destination });
    },
  });

  /* The bulk bar and the row call the same two functions; nothing about a
     selection changes what a payout may do. */
  const bulkApprove = (payouts) => {
    const approvable = payouts.filter((p) => PAYOUT_FLAGS.isApprovable(p, profile));
    const snapshot = rawData;
    setData((d) => Object.assign({}, d, {
      payouts: d.payouts.map((p) => (approvable.some((a) => a.id === p.id) ? Object.assign({}, p, { status: "approved", approvedAt: now, approvedBy: profile.name }) : p)),
    }));
    pushToast({
      variant: "success",
      title: approvable.length + " payouts approved",
      description: formatMoney(sumBy(approvable, "amount")) + " leaves the balance on the next run.",
      onUndo: () => setData(snapshot),
    });
  };

  const onPayoutTransition = (payout, transition) => {
    if (transition.id === "approve") approvePayout(payout);
    if (transition.id === "reject") rejectPayout(payout);
  };

  /* ── Payments ──────────────────────────────────────────────────────────── */
  const capturePayment = (payment) => {
    patch("payments", payment.id, { status: "succeeded" });
    pushToast({ variant: "success", title: "Payment captured", description: formatMoney(payment.amount) + " from " + payment.customer, onUndo: () => restore("payments", payment) });
  };

  const retryPayment = (payment) => {
    patch("payments", payment.id, { status: "pending", failureReason: undefined });
    pushToast({ title: "Retrying payment", description: payment.id + " · " + payment.customer, onUndo: () => restore("payments", payment) });
  };

  const refundPayment = (payment) => setConfirm({
    title: "Refund " + formatMoney(payment.amount) + " to " + payment.customer + "?",
    description: "The money leaves your available balance today and the payment cannot be captured again. " + payment.id + " stays on the customer's record as refunded.",
    confirmLabel: "Refund payment",
    run: () => {
      patch("payments", payment.id, { status: "refunded", refundedAt: now });
      setData((d) => Object.assign({}, d, { balance: Object.assign({}, d.balance, { availableCents: d.balance.availableCents - payment.amount }) }));
      pushToast({ title: "Refund sent", description: formatMoney(payment.amount) + " to " + payment.customer + " · " + payment.id });
    },
  });

  const onPaymentTransition = (payment, transition) => {
    if (transition.id === "capture") capturePayment(payment);
    if (transition.id === "retry") retryPayment(payment);
    if (transition.id === "refund") refundPayment(payment);
  };

  /* ── Disputes ──────────────────────────────────────────────────────────── */
  const onDisputeTransition = (dispute, transition) => {
    const snapshot = data;
    if (transition.id === "submit_evidence") {
      setForm({
        title: "Send evidence for " + dispute.id,
        description: dispute.customer + " · " + dispute.reason + " · " + formatMoney(dispute.amount) + ". Due " + formatCountdown(dispute.evidenceDueAt, now) + ".",
        submitLabel: "Submit evidence",
        initial: { documents: [] },
        fields: [
          { id: "documents", kind: "checks", label: "What we are sending", options: [
            { value: "receipt", label: "Receipt and order confirmation" },
            { value: "delivery", label: "Proof of delivery" },
            { value: "terms", label: "Terms the customer accepted" },
            { value: "comms", label: "Correspondence with the customer" },
          ] },
          { id: "note", kind: "text", label: "What happened", placeholder: "In your own words — the network reads this before the documents.", hint: "At least a sentence." },
        ],
        validate: (v) => {
          const errors = {};
          if (!v.documents || v.documents.length === 0) errors.documents = "Pick at least one document to send.";
          if (!v.note || v.note.trim().length < 20) errors.note = "Write out what happened — at least a sentence.";
          return errors;
        },
        run: (v) => {
          patch("disputes", dispute.id, { status: "under_review", evidenceSubmittedAt: now, evidenceCount: v.documents.length, evidenceNote: v.note.trim() });
          pushToast({
            variant: "success",
            title: "Evidence sent",
            description: v.documents.length + " documents on " + dispute.id + " · the clock is the network's now",
            onUndo: () => restore("disputes", dispute),
          });
        },
      });
      return;
    }
    if (transition.id === "accept_dispute") {
      setConfirm({
        title: "Accept the " + formatMoney(dispute.amount) + " dispute from " + dispute.customer + "?",
        description: "The money goes back to the customer and the case closes as lost. It cannot be reopened, and the reserve against your balance is released to pay it.",
        confirmLabel: "Accept dispute",
        run: () => {
          patch("disputes", dispute.id, { status: "lost", closedAt: now, evidenceDueAt: null });
          setData((d) => Object.assign({}, d, { balance: Object.assign({}, d.balance, { availableCents: d.balance.availableCents - dispute.amount }) }));
          pushToast({ title: "Dispute accepted", description: formatMoney(dispute.amount) + " returned to " + dispute.customer + " · " + dispute.id });
        },
      });
    }
  };

  /* ── Expenses ──────────────────────────────────────────────────────────── */
  const approveExpense = (expense) => {
    patch("expenses", expense.id, { status: "approved", reviewedAt: now, reviewedBy: profile.name });
    pushToast({
      variant: "success",
      title: "Expense approved",
      description: formatMoney(expense.amount) + " at " + expense.merchant + " · " + expense.id,
      onUndo: () => restore("expenses", expense),
    });
  };

  const bulkApproveExpenses = (expenses) => {
    const snapshot = data;
    const ids = expenses.map((e) => e.id);
    setData((d) => Object.assign({}, d, {
      expenses: d.expenses.map((e) => (ids.indexOf(e.id) > -1 ? Object.assign({}, e, { status: "approved", reviewedAt: now, reviewedBy: profile.name }) : e)),
    }));
    pushToast({
      variant: "success",
      title: expenses.length + " expenses approved",
      description: formatMoney(sumBy(expenses, "amount")) + " signed off across " + new Set(expenses.map((e) => e.cardId)).size + " cards.",
      onUndo: () => setData(snapshot),
    });
  };

  const queryExpense = (expense) => setForm({
    title: "Query " + formatMoney(expense.amount) + " at " + expense.merchant + "?",
    description: "It goes back to " + memberName(expense.spenderId) + " with your question and leaves your queue until they answer.",
    submitLabel: "Query expense",
    initial: { note: "" },
    fields: [{ id: "note", kind: "text", label: "What do you need to know", placeholder: "They see this exactly as you write it.", hint: "A sentence is enough." }],
    validate: (v) => (!v.note || v.note.trim().length < 12 ? { note: "Say what you need from them — at least a few words." } : {}),
    run: (v) => {
      patch("expenses", expense.id, { status: "queried", queriedAt: now, queriedBy: profile.name, queryNote: v.note.trim() });
      pushToast({ title: "Query sent", description: expense.id + " · waiting on " + memberName(expense.spenderId), onUndo: () => restore("expenses", expense) });
    },
  });

  const denyExpense = (expense) => setConfirm({
    title: "Deny " + formatMoney(expense.amount) + " at " + expense.merchant + "?",
    description: "The expense closes against " + memberName(expense.spenderId) + " and the money is recovered through payroll. It cannot be reopened — a new expense would have to be raised.",
    confirmLabel: "Deny expense",
    run: () => {
      patch("expenses", expense.id, { status: "denied", deniedAt: now, deniedBy: profile.name, denyReason: "Denied in review" });
      pushToast({ title: "Expense denied", description: expense.id + " · " + expense.merchant });
    },
  });

  const onExpenseTransition = (expense, transition) => {
    if (transition.id === "approve_expense") approveExpense(expense);
    if (transition.id === "query_expense") queryExpense(expense);
    if (transition.id === "deny_expense") denyExpense(expense);
  };

  /* ── Cards ─────────────────────────────────────────────────────────────── */
  const freezeCard = (card) => {
    patch("cards", card.id, { status: "frozen", frozenAt: now, frozenBy: profile.name, frozenReason: "Frozen in review" });
    pushToast({ title: "Card frozen", description: card.label + " · •••• " + card.last4 + " — authorisations are declined from now", onUndo: () => restore("cards", card) });
  };
  const unfreezeCard = (card) => {
    patch("cards", card.id, { status: "active", frozenAt: undefined, frozenBy: undefined, frozenReason: undefined });
    pushToast({ variant: "success", title: "Card unfrozen", description: card.label + " · •••• " + card.last4 + " can be spent on again", onUndo: () => restore("cards", card) });
  };
  const cancelCard = (card) => {
    const use = cardUtilisation(card, data.expenses, now, data.policy);
    setConfirm({
      title: "Cancel the " + card.label + " card?",
      description: "•••• " + card.last4 + " stops working immediately and cannot be reissued — " + memberName(card.holderId) + " would need a new card. The " + formatMoney(use.spent) + " already spent on it still has to be reviewed.",
      confirmLabel: "Cancel card",
      run: () => {
        patch("cards", card.id, { status: "cancelled", cancelledAt: now, cancelledBy: profile.name, cancelReason: "Cancelled in review" });
        pushToast({ title: "Card cancelled", description: card.label + " · •••• " + card.last4 });
      },
    });
  };

  const onCardTransition = (card, transition) => {
    if (transition.id === "freeze_card") freezeCard(card);
    if (transition.id === "unfreeze_card") unfreezeCard(card);
    if (transition.id === "cancel_card") cancelCard(card);
  };

  /* Issuing a card needs four fields, so this instant screen opens a staged
     dialog. The default limit is the workspace's own spend-control policy. */
  const issueCard = () => setForm({
    title: "New card",
    description: "It arrives inactive: the holder activates it before anything can be spent on it.",
    submitLabel: "New card",
    initial: { label: "", holder: profile.id, type: "virtual", limit: String(data.policy.cardDefaultLimitCents / 100) },
    fields: [
      { id: "label", kind: "input", label: "What the card is for", placeholder: "Cloud vendors", hint: "Shown on every expense put on it." },
      { id: "holder", kind: "radio", label: "Who holds it", options: data.members.map((m) => ({ value: m.id, label: m.name + " — " + m.role })) },
      { id: "type", kind: "radio", label: "Kind of card", options: CARD_TYPES.map((t) => ({ value: t.value, label: t.label + (t.value === "virtual" ? " — usable online straight away" : " — posted to the holder") })) },
      { id: "limit", kind: "input", label: "Limit per 30 days, in dollars", hint: "The default comes from spend controls." },
    ],
    validate: (v) => {
      const errors = {};
      if (!v.label || v.label.trim().length < 3) errors.label = "Name the card — at least three characters.";
      if (!(Math.round(Number(String(v.limit).replace(/[^0-9.]/g, "")) * 100) >= 10000)) errors.limit = "At least $100, or the card cannot buy anything.";
      return errors;
    },
    run: (v) => {
      const digits = String(Math.floor(1000 + Math.random() * 9000));
      const card = {
        id: "crd_" + digits,
        label: v.label.trim(),
        last4: digits,
        holderId: v.holder,
        type: v.type,
        status: "pending_activation",
        spendLimit: Math.round(Number(String(v.limit).replace(/[^0-9.]/g, "")) * 100),
        openedAt: now,
        expiresAt: now + 1095 * 24 * 3600e3,
      };
      setData((d) => Object.assign({}, d, { cards: d.cards.concat([card]) }));
      pushToast({
        variant: "success",
        title: "Card issued",
        description: card.label + " · •••• " + card.last4 + " — waiting on " + memberName(card.holderId) + " to activate it",
        onUndo: () => setData((d) => Object.assign({}, d, { cards: d.cards.filter((c) => c.id !== card.id) })),
      });
    },
  });

  /* ── Customers ──────────────────────────────────────────────────────── */
  const onCustomerTransition = (customer, transition) => {
    if (transition.id === "hold_customer") {
      patch("customers", customer.id, { status: "on_hold" });
      pushToast({ title: "Payouts paused", description: "Nothing leaves for " + customer.name + " until somebody resumes it. Money still arrives.", onUndo: () => restore("customers", customer) });
    }
    if (transition.id === "resume_customer") {
      patch("customers", customer.id, { status: "live" });
      pushToast({ variant: "success", title: "Payouts resumed", description: customer.name + " can be paid again · " + (customer.payoutAccount || "no bank account on file yet"), onUndo: () => restore("customers", customer) });
    }
    if (transition.id === "close_customer") {
      const open = openDisputes(disputesFor(data.disputes, customer));
      const pending = pendingPayouts(payoutsFor(data.payouts, customer));
      setConfirm({
        title: "Close the " + customer.name + " account?",
        description: "Nothing more can be taken from them or sent to them, and a closed account cannot be reopened — onboarding a new one is the only way back. "
          + (open.length ? open.length + " open " + (open.length === 1 ? "dispute" : "disputes") + " worth " + formatMoney(customerReserved(data.disputes, customer)) + " keep running against the balance. " : "")
          + (pending.length ? pending.length + " " + (pending.length === 1 ? "payout" : "payouts") + " already in flight still land." : "Nothing of theirs is in flight."),
        confirmLabel: "Close account",
        run: () => {
          patch("customers", customer.id, { status: "closed", closedAt: now });
          pushToast({ title: "Account closed", description: customer.name + " · " + formatMoney(customerVolume(data.payments, customer)) + " of volume stays on the ledger" });
        },
      });
    }
  };

  /* A create writes into the fixture, so every count it feeds moves. */
  const createCustomer = (draft) => {
    const customer = {
      id: "cus_" + draft.name.trim().toLowerCase().replace(/[^a-z0-9]+/g, "").slice(0, 10) + Math.random().toString(36).slice(2, 5),
      name: draft.name.trim(),
      ownerId: draft.ownerId,
      status: "live",
      country: draft.country.trim() || "United States",
      currency: data.balance.currency,
      since: now,
      payoutAccount: draft.payoutAccount.trim() ? "•••• " + draft.payoutAccount.trim() : null,
      contact: { name: draft.contactName.trim(), email: draft.contactEmail.trim() },
    };
    setData((d) => Object.assign({}, d, { customers: d.customers.concat([customer]) }));
    setPath(recordRoute({ type: "customer", id: customer.id }));
    pushToast({
      variant: "success",
      title: "Customer created",
      description: customer.name + " · owned by " + memberName(customer.ownerId) + " · " + (customer.payoutAccount ? "can be charged and paid" : "can be charged, not paid yet"),
      onUndo: () => { setPath("/customers"); setData((d) => Object.assign({}, d, { customers: d.customers.filter((c) => c.id !== customer.id) })); },
    });
  };

  const createPayment = (draft) => {
    const payment = {
      id: "pay_" + Math.random().toString(36).slice(2, 7).toUpperCase(),
      customer: draft.customer,
      method: draft.method,
      amount: draft.amount,
      status: "pending",
      createdAt: Date.now(),
      reference: draft.reference || undefined,
    };
    setData((d) => Object.assign({}, d, { payments: d.payments.concat([payment]) }));
    setPath("/payments");
    pushToast({
      variant: "success",
      title: "Payment created",
      description: formatMoney(payment.amount) + " from " + payment.customer + " · " + formatMoney(methodFee(payment.method, payment.amount)) + " of that is fees",
      onUndo: () => setData((d) => Object.assign({}, d, { payments: d.payments.filter((p) => p.id !== payment.id) })),
    });
  };

  const createPayout = (draft) => {
    const payout = {
      id: "po_" + Math.floor(7750 + Math.random() * 200),
      destination: draft.destination,
      account: draft.account,
      amount: draft.amount,
      status: "awaiting_approval",
      requestedAt: Date.now(),
      expectedAt: Date.now() + 30 * 3600e3,
      reference: draft.reference || undefined,
    };
    setData((d) => Object.assign({}, d, { payouts: d.payouts.concat([payout]) }));
    setPath("/payouts");
    pushToast({
      variant: "success",
      title: "Payout raised",
      description: formatMoney(payout.amount) + " to " + payout.destination + " · waiting on approval, " + approvablePayouts(data.payouts.concat([payout]), profile).length + " in the queue now",
      onUndo: () => setData((d) => Object.assign({}, d, { payouts: d.payouts.filter((p) => p.id !== payout.id) })),
    });
  };

  /* Three fields, so inviting somebody is a staged dialog rather than a route. */
  const invitePerson = () => setForm({
    title: "Invite teammate",
    description: "They get an email to set a password. Until they accept, they hold no cards and can approve nothing.",
    submitLabel: "Invite teammate",
    initial: { name: "", email: "", teamRole: "member" },
    fields: [
      { id: "name", kind: "input", label: "Name", placeholder: "Rosa Iyer" },
      { id: "email", kind: "input", label: "Work email", placeholder: "rosa@northwind.example" },
      { id: "teamRole", kind: "radio", label: "What they can do", options: TEAM_ROLES.map((r) => ({ value: r.value, label: r.label + " — " + r.note })) },
    ],
    validate: (v) => {
      const errors = {};
      if (!v.name || v.name.trim().length < 3) errors.name = "Enter their name.";
      if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(v.email || "")) errors.email = "Enter a valid work email.";
      else if (data.members.some((m) => m.email.toLowerCase() === v.email.trim().toLowerCase())) errors.email = "Somebody with that address is already here.";
      return errors;
    },
    run: (v) => {
      const member = {
        id: "usr_" + v.name.trim().toLowerCase().replace(/[^a-z]+/g, "").slice(0, 8),
        name: v.name.trim(),
        role: labelOf(TEAM_ROLES, v.teamRole),
        teamRole: v.teamRole,
        email: v.email.trim(),
        avatar: null,
        approvalLimit: 0,
        canIssueCards: false,
        notify: [],
      };
      setData((d) => Object.assign({}, d, { members: d.members.concat([member]) }));
      pushToast({
        variant: "success",
        title: "Invite sent",
        description: member.name + " · " + member.email + " · no approval limit until somebody gives them one",
        onUndo: () => setData((d) => Object.assign({}, d, { members: d.members.filter((m) => m.id !== member.id) })),
      });
    },
  });

  /* ── Notes ─────────────────────────────────────────────────────────────── */
  /* Consequential and reversible, so it applies immediately and raises an undo
     toast. Mentions are read out of the text rather than collected separately —
     one source for who was named, and the queue predicate reads the same array. */
  const addComment = (record, body) => {
    const mentions = (body.match(/@usr_[a-z0-9_]+/g) || []).map((t) => t.slice(1)).filter((id, i, all) => all.indexOf(id) === i && data.members.some((m) => m.id === id));
    const comment = { id: "cmt_" + Math.random().toString(36).slice(2, 7), record: record, authorId: profile.id, at: Date.now(), mentions: mentions, body: body };
    setData((d) => Object.assign({}, d, { comments: d.comments.concat([comment]) }));
    pushToast({
      title: "Note posted",
      description: mentions.length
        ? "It is in " + mentions.map(memberName).join(" and ") + "'s queue until they answer on " + record.id
        : "On " + record.id + " · nobody was mentioned, so nobody was notified",
      onUndo: () => setData((d) => Object.assign({}, d, { comments: d.comments.filter((c) => c.id !== comment.id) })),
    });
  };

  /* ── Settings, staged ──────────────────────────────────────────────────── */
  const saveProfile = (draft) => {
    const snapshot = profile;
    patch("members", profile.id, draft);
    pushToast({ variant: "success", title: "Profile saved", description: draft.name + " · " + draft.role + (draft.avatar !== (snapshot.avatar || null) ? (draft.avatar ? " · new photo" : " · photo removed") : ""), onUndo: () => restore("members", snapshot) });
  };
  const saveWorkspace = (draft) => {
    const snapshot = workspace;
    setWorkspace(Object.assign({}, workspace, draft));
    pushToast({
      variant: "success",
      title: "Workspace saved",
      description: draft.name + " · statements read " + draft.descriptor + " · payouts " + labelOf(PAYOUT_SCHEDULES, draft.schedule).toLowerCase(),
      onUndo: () => setWorkspace(snapshot),
    });
  };
  const saveNotifications = (draft) => {
    const snapshot = profile;
    patch("members", profile.id, { notify: draft.notify });
    pushToast({
      variant: "success",
      title: "Notification preferences saved",
      description: draft.notify.length ? draft.notify.length + " kinds of alert will be emailed to " + profile.email : "Nothing will be emailed to you",
      onUndo: () => restore("members", snapshot),
    });
  };

  /* Policy is what the derived flags read, so saving it recounts the sidebar
     badge, the tiles and every row's action — nothing is stored twice. */
  const savePolicy = (drafted) => {
    const snapshot = data.policy;
    setData((d) => Object.assign({}, d, { policy: drafted }));
    pushToast({
      variant: "success",
      title: "Spend controls saved",
      description: "Receipts required over " + formatMoney(drafted.receiptThresholdCents) + " · " + reviewableExpenses(data.expenses, drafted).length + " expenses can be approved now",
      onUndo: () => setData((d) => Object.assign({}, d, { policy: snapshot })),
    });
  };

  const saveTeam = (rows) => {
    const snapshot = data.members;
    setData((d) => Object.assign({}, d, {
      members: d.members.map((m) => {
        const row = rows.find((r) => r.id === m.id);
        return row ? Object.assign({}, m, { teamRole: row.teamRole, approvalLimit: row.approvalLimit, canIssueCards: row.canIssueCards }) : m;
      }),
    }));
    pushToast({
      variant: "success",
      title: "Team saved",
      description: rows.filter((r) => r.teamRole !== "member").length + " of " + rows.length + " people can approve something",
      onUndo: () => setData((d) => Object.assign({}, d, { members: snapshot })),
    });
  };

  /* ── Auth, outside the shell ───────────────────────────────────────────── */
  /* Harness chrome, not product chrome: the floating page menu is how a reader
     reaches every registered screen, including the auth ones the shell cannot
     render. It sits outside the shell so it is on those screens too. */
  const preview = (
    <TemplateMenu product="Meridian" path={path} onNavigate={setPath} groups={MERIDIAN_PAGES(data)}
      dark={dark} onToggleTheme={() => setDark(!dark)} />
  );

  if (MERIDIAN_AUTH_PATHS.indexOf(path) > -1) {
    const auth = {
      "/signin": <SignInScreen workspace={workspace} profile={profile} onNavigate={setPath} />,
      "/two-factor": <TwoFactorScreen profile={profile} onNavigate={setPath} onSignIn={() => setPath("/")} />,
      "/verify-code": <VerifyCodeScreen profile={profile} onNavigate={setPath} onSignIn={() => setPath("/")} />,
      "/forgot-password": <ForgotPasswordScreen profile={profile} onNavigate={setPath} />,
      "/reset-password": <ResetPasswordScreen profile={profile} onNavigate={setPath} />,
      "/signout": <SignedOutScreen profile={profile} onNavigate={setPath} />,
    };
    return (
      <>
        {auth[path]}
        {preview}
      </>
    );
  }

  const disputeMatch = path.match(/^\/disputes\/([^/]+)$/);
  const selectedDispute = disputeMatch && data.disputes.some((d) => d.id === disputeMatch[1]) ? disputeMatch[1] : null;
  const cardMatch = path.match(/^\/cards\/([^/]+)$/);
  const selectedCard = cardMatch && data.cards.some((c) => c.id === cardMatch[1]) ? cardMatch[1] : null;
  const customerMatch = path.match(/^\/customers\/([^/]+)$/);
  const selectedCustomer = customerMatch && data.customers.some((c) => c.id === customerMatch[1]) ? customerMatch[1] : null;
  const personMatch = path.match(/^\/people\/([^/]+)$/);
  const selectedPerson = personMatch && data.members.some((m) => m.id === personMatch[1]) ? personMatch[1] : null;

  const screen = () => {
    if (path === "/") return <DashboardScreen data={data} pushToast={pushToast} onNavigate={setPath} period={period} onPeriodChange={setPeriod} commits={{ approvePayout, rejectPayout, refundPayment, capturePayment, retryPayment, approveExpense, onExpenseTransition }} />;
    if (path === "/payments") return <PaymentsScreen data={data} now={now} onNavigate={setPath} onPaymentTransition={onPaymentTransition} pushToast={pushToast} />;
    if (path === "/payments/new") return <NewPaymentScreen data={data} now={now} onCreate={createPayment} onNavigate={setPath} />;
    if (path === "/payouts/new") return <NewPayoutScreen data={data} now={now} onCreate={createPayout} onNavigate={setPath} />;
    if (path === "/customers/new") return <NewCustomerScreen data={data} onCreate={createCustomer} onNavigate={setPath} />;
    if (selectedCustomer) return <CustomerScreen data={data} now={now} customerId={selectedCustomer} onNavigate={setPath} onCustomerTransition={onCustomerTransition} onPaymentTransition={onPaymentTransition} onPostComment={addComment} />;
    if (path === "/customers") return <CustomersScreen data={data} now={now} onNavigate={setPath} onCustomerTransition={onCustomerTransition} />;
    if (selectedPerson) return <PersonScreen data={data} now={now} memberId={selectedPerson} onNavigate={setPath} onExpenseTransition={onExpenseTransition} onPostComment={addComment} />;
    if (path === "/people") return <PeopleScreen data={data} now={now} onNavigate={setPath} onInvite={invitePerson} />;
    if (path === "/activity") return <ActivityScreen data={data} now={now} onNavigate={setPath} />;
    if (path === "/payouts") return <PayoutsScreen data={data} now={now} onNavigate={setPath} onPayoutTransition={onPayoutTransition} onBulkApprove={bulkApprove} pushToast={pushToast} />;
    if (path === "/expenses") return <ExpensesScreen data={data} now={now} period={period} onPeriodChange={setPeriod} onExpenseTransition={onExpenseTransition} onBulkApprove={bulkApproveExpenses} pushToast={pushToast} />;
    if (selectedCard) return <CardScreen data={data} now={now} cardId={selectedCard} onNavigate={setPath} onCardTransition={onCardTransition} onExpenseTransition={onExpenseTransition} onPostComment={addComment} />;
    if (path === "/cards") return <CardsScreen data={data} now={now} onNavigate={setPath} onCardTransition={onCardTransition} onIssueCard={issueCard} issuer={profile} />;
    if (path === "/disputes" || selectedDispute) return <DisputesScreen data={data} now={now} selectedId={selectedDispute} onNavigate={setPath} onDisputeTransition={onDisputeTransition} onPostComment={addComment} />;
    if (path === "/balances") return <BalancesScreen data={data} now={now} onNavigate={setPath} />;
    if (path === "/reports") return <ReportsScreen data={data} now={now} period={period} onPeriodChange={setPeriod} onNavigate={setPath} pushToast={pushToast} />;
    if (path === "/settings") return <WorkspaceSettingsScreen workspace={workspace} payouts={data.payouts} onSave={saveWorkspace} />;
    if (path === "/settings/billing") return <BillingScreen data={data} now={now} workspace={workspace} onNavigate={setPath} />;
    if (path === "/settings/profile") return <ProfileScreen profile={profile} onSave={saveProfile} />;
    if (path === "/settings/notifications") return <NotificationsScreen profile={profile} data={data} onSave={saveNotifications} />;
    if (path === "/settings/spend-controls") return <SpendControlsScreen policy={data.policy} data={data} now={now} onSave={savePolicy} />;
    if (path === "/settings/team") return <TeamScreen members={data.members} signedInId={MERIDIAN_SIGNED_IN} data={data} onSave={saveTeam} />;
    return <RouteNotBuilt path={path} onNavigate={setPath} />;
  };

  const recordGroups = [
    { label: "Payouts awaiting approval", items: approvablePayouts(data.payouts, profile).map((p) => ({ value: p.id, label: p.id + " · " + p.destination + " · " + formatMoney(p.amount), route: "/payouts" })) },
    { label: "Expenses you can approve", items: reviewableExpenses(data.expenses, data.policy).map((e) => ({ value: e.id, label: e.id + " · " + e.merchant + " · " + formatMoney(e.amount), route: "/expenses" })) },
    { label: "Disputes needing evidence", items: disputesNeedingEvidence(data.disputes).map((d) => ({ value: d.id, label: d.id + " · " + d.customer + " · " + formatMoney(d.amount), route: "/disputes/" + d.id })) },
    { label: "Notes waiting on your reply", items: mentionsAwaiting(data.comments, MERIDIAN_SIGNED_IN).map((c) => ({ value: c.id, label: memberName(c.authorId) + " on " + recordTitle(data, c.record) + " · " + c.record.id, route: recordRoute(c.record) })) },
    { label: "Customers", items: customersByVolume(data.customers, data.payments).map((c) => ({ value: c.id, label: c.name + " · " + formatMoney(customerVolume(data.payments, c)) + " · " + labelOf(CUSTOMER_STATUS, c.status), route: recordRoute({ type: "customer", id: c.id }) })) },
    { label: "People", items: data.members.map((m) => ({ value: m.id, label: m.name + " · " + m.role, route: recordRoute({ type: "member", id: m.id }) })) },
    { label: "Cards", items: data.cards.map((c) => ({ value: c.id, label: c.label + " · •••• " + c.last4 + " · " + labelOf(CARD_STATUS, c.status), route: "/cards/" + c.id })) },
    { label: "Payments needing attention", items: data.payments.filter((p) => PAYMENT_FLAGS.needsAttention(p, data.disputes)).map((p) => ({ value: p.id, label: p.id + " · " + p.customer + " · " + formatMoney(p.amount), route: "/payments" })) },
  ];

  return (
    <MeridianShell
      path={path}
      onNavigate={setPath}
      dark={dark}
      onToggleTheme={() => setDark(!dark)}
      data={data}
      profile={profile}
      workspace={workspace}
      notifications={NOTIFICATIONS}
      unread={read ? 0 : NOTIFICATIONS.length}
      onRead={() => setRead(true)}
      palette={palette}
      toasts={toasts}
      onDismissToast={dismiss}
    >
      {screen()}
      <CommandPalette
        open={palette.open}
        onClose={() => palette.setOpen(false)}
        routes={MERIDIAN_ROUTES}
        recordGroups={recordGroups}
        onNavigate={setPath}
        placeholder="Search payments, payouts, customers, people…"
      />
      <ConfirmDialog request={confirm} onCancel={() => setConfirm(null)} onConfirm={() => { confirm.run(); setConfirm(null); }} />
      <FormDialog request={form} onCancel={() => setForm(null)} onSubmit={(values) => { form.run(values); setForm(null); }} />
      {preview}
    </MeridianShell>
  );
}
window.__meridianReal = App;
if (!window.MeridianApp) window.MeridianApp = App;

})();
</script>
<script type="text/babel" data-presets="react" data-file="mount.jsx">
(function(){
ReactDOM.createRoot(document.getElementById("root")).render(React.createElement(window.MeridianApp, {}));
})();
</script>
</body>
</html>
