← Dashboards

Disputes Dashboard

The single source of truth for how every number on the payment-disputes dashboard is defined, calculated, and sourced — written for operations staff and the engineers who maintain it.

as of 2026-07-14 · v1 Environment: https://cx-dashboard-iota.vercel.app

This dashboard tracks every payment dispute (chargeback / claim) Revibe receives across four gateways — Tamara, Tabby, Stripe, and Checkout.com — from the moment it opens until it closes, so the CX team can see what needs a reply, how much money is at stake, and how well we are winning.

1 Overview

The dashboard has four tabs backed by one dataset. A daily automated job pulls disputes from each gateway's API, standardizes their statuses, and writes one row per dispute to a Google Sheet (the “Linked Disputes” tab). Every page reads that sheet in the browser. Two of the pages additionally call a serverless endpoint that joins each dispute's order number to live order and claim data straight from the production database.

Numbers fall into two families. Dispute facts (status, amount, dates, gateway, reason) come from the sheet and are as fresh as the last sync. Order & claim context (was it shipped? was it refunded? which claim?) is fetched live from the database on demand, so it is always current but depends on the database being reachable.

Two scoping rules matter throughout. First, the Live Action Items and Operations panels on the Main tab always reflect all disputes and ignore the sidebar filters; everything else on that tab respects the filters. Second, money can be shown in each dispute's original currency or converted to a single currency (USD / AED) using live FX rates.

Timezone. Dates are parsed in the browser with the native date parser and bucketed by the viewer's local calendar day. Sheet timestamps carry their own source offsets (many are UTC, e.g. 2026-07-08T14:10:58Z). Day-level counts can therefore shift by one day for disputes created near midnight in a different timezone. (Exact per-gateway tz normalization: unconfirmed.)

2 Data & Refresh

Where the numbers come from

LayerSourceUsed for
Dispute rowsGoogle Sheet “Linked Disputes” tab (gid 803955238), read as gviz CSV and parsed with PapaParseEvery tab — status, amount, dates, gateway, reason, days-left
Order & claim contextServerless POST /api/dispute-links → MySQL (production RDS), read-only, bound parametersLinked Analysis, Linked Details, and the Line-Items detail modal
FX ratesopen.er-api.com/v6/latest/USD (base USD)Currency conversion on Main, Line Items, Daily, Linked Analysis
The sheet itselfA daily GitHub Actions Python job pulls each gateway API, standardizes statuses, and writes the sheetUpstream of everything above

Refresh & caching

  • Sheet freshness: the sync job runs 3× a day (08:30 / 13:30 / 18:30 UAE) and can be triggered on demand. Open disputes are re-audited every run until they reach a terminal status.
  • Browser cache: pages cache the parsed data in IndexedDB — key revibe_dsp_rows_v2 for sheet rows, revibe_dsp_linked_v2 for enriched order/claim rows — with a 24-hour TTL.
  • When it refetches: on a hard reload (F5) or the “Refresh Data” button it always refetches; on in-app navigation it serves the cache if still valid. Detected via the Navigation Timing API.
  • Fallback: if the live order/claim enrichment fails, pages render the disputes without DB detail and show an amber warning pill with a Retry action. Partial results are never written to cache.
Refresh Data vs Sync Now — they do different things.
Refresh Data (top bar) just reloads the dashboard from the last saved sheet — instant, but shows only what the last sync already fetched.
Sync Now (green button, bottom-left of every disputes page) actually re-pulls from the gateways: it re-checks all open disputes and grabs newly-created ones (last 7 days), then writes the sheet. It runs in the background and takes a few minutes; when it finishes, hit Refresh Data to see the new numbers.
The “Last synced” stamp next to Sync Now tells you how fresh the data is — so if a count differs from the gateway portal, it's sync timing, not missing data.
Currency values shown as ≈ USD under an original-currency figure are indicative conversions; they appear only once FX rates have loaded. If rates fail to load, money is summed in original currencies (labelled “mixed currency” where relevant).

3 Standardized Status Pipeline

Every gateway uses its own status vocabulary. The sync job maps them all to one Standardized Status — the value in column H of the sheet and the basis for nearly every metric. open statuses are still in play and keep getting re-audited; terminal statuses are closed and final.

StatusStateMeaning
Not RepliedopenDispute is open and we have never responded. Tamara: an open dispute with no challenge from us yet.
Awaiting Our ReplyopenWe engaged and the gateway wants more from us — ball back in our court. Tamara: “Awaiting merchant response” / “Challenged by merchant” (and awaiting_merchant_response). Distinct from never-touched “Not Replied”.
Under ReviewopenOur evidence is submitted and the gateway is deciding. Also the catch-all for any unknown raw status.
WonterminalResolved in our favour without a fight (or auto-won).
Won (Fought)terminalWon after we submitted a defence.
LostterminalResolved against us.
Lost (Fought)terminalLost after we submitted a defence.
AcceptedterminalWe chose to accept the dispute / refund the customer.
ExpiredterminalDeadline lapsed with no reply and the gateway auto-refunded the customer (e.g. Tabby auto-refund). Same money outcome as Accepted, but because we ran out of time.
Group definitions used everywhere: OPEN_SET = Not Replied · Awaiting Our Reply · Under Review. CLOSED_SET / TERMINAL = Won · Won (Fought) · Lost · Lost (Fought) · Accepted · Expired. “Wins” = Won + Won (Fought). “Losses” for money purposes = Lost + Lost (Fought) + Expired + Accepted. “Action required by us” = Not Replied + Awaiting Our Reply — these are the two “Needs our reply” cards on Main and the “Needs our reply” side of the Daily Breakdown, and they cover every Tamara case where the portal is awaiting merchant response (including “challenged by merchant”).

4 Tabs & Metrics

The dashboard has four navigable tabs — Main Dashboard, Linked Disputes (an Analysis view and a Detail-Rows view), and Daily Breakdown — plus a shared Line Items drill-down that every clickable number opens. Each metric below follows the same block: what it is → how it's calculated → example → source → caveat.

Example values are an illustrative snapshot taken 2026-07-14 (~8,910 disputes in the sheet); the live dashboard always shows current numbers.

Tab · Main Dashboard index.html · “Disputes Dashboard”

A filterable overview in three bands — Live Action Items, Financial Picture, Performance — plus an Operations row and four charts. Live Action Items and Operations ignore the sidebar filters (whole dataset); everything else respects them.

Band 1 — Live Action Items (unfiltered)

Expiring Soon kpiExpiring
What it is
Un-replied disputes that are at or near their reply deadline.
Calculated
Rows where Standardized Status = Not Replied (or Original Status evidence_required/unanswered) and not “Awaiting Our Reply”, then Days left/SLA ≤ 3. If Days-left is blank, fallback: Date Created age ≥ 4 days.
Example
2 disputes
Source
Sheet columns Standardized Status, Days left/SLA, Date Created; renderDashboard().
Caveat
“Awaiting Our Reply” is deliberately excluded (we already engaged). Daily Breakdown's “Expiring ≤3” uses a looser rule — see Known Quirks.
Not Replied (Open) kpiNotReplied
What it is
Open disputes we have not yet responded to.
Calculated
Count + per-currency amount of rows with Standardized Status = Not Replied (or original evidence_required/unanswered), excluding Awaiting Our Reply.
Example
92 disputes · SAR 67,453 · ZAR 4,299 …
Source
Sheet Standardized Status, Amount, Currency; renderDashboard().
Awaiting Our Reply kpiAwaiting
What it is
Disputes we engaged where the gateway now wants more evidence from us.
Calculated
Rows with Standardized Status = Awaiting Our Reply; count + per-currency amount.
Example
2 disputes · AED 0
Source
Sheet Standardized Status; renderDashboard().
Under Review kpiUnderReview
What it is
Disputes with the gateway for a decision after our evidence.
Calculated
Rows with Standardized Status = Under Review (or evidence_under_review); count + per-currency amount.
Example
331 disputes · SAR 618,184 · AED 19,876 …
Source
Sheet Standardized Status; renderDashboard().

Band 2 — Financial Picture (respects filters)

Total Disputed Value kpiTotalAmount
What it is
Total money under dispute in the current filter, plus the case count.
Calculated
Sum of Amount over all filtered rows, grouped per display currency (each row's own currency, or a single currency if USD/AED is selected). Count = number of filtered rows.
Example
SAR 163,471 · AED 10,817 · 149 disputes
Source
Sheet Amount, Currency; renderDashboard().
Net P&L Status kpiNetPl
What it is
Net money position: won value minus absorbed (lost/accepted) value.
Calculated
Per currency: +Amount for Won / Won (Fought); −Amount for Lost / Lost (Fought) / Expired / Accepted. Open disputes don't count. Green if positive, red if negative.
Example
SAR +4,810 · AED +1,990
Source
Sheet Standardized Status, Amount; footer reads “Total Won GMV − Absorbed GMV”.
Financial Loss Summary kpiMoneyLost + Expired / Lost (fought) / Accepted
What it is
Total money lost, broken into three buckets.
Calculated
Headline = sum of Amount where status is Lost, Lost (Fought), Expired, or Accepted. Sub-tiles: Expired = Expired only · Lost (fought) = Lost + Lost (Fought) · Accepted = Accepted only.
Example
SAR 2,408 · AED 419
Source
Sheet Standardized Status, Amount; sub-tiles link into Line Items.
Caveat
The “Expired” sub-tile is wired to the element id lossNotReplied for historical reasons; it correctly counts Expired, not Not-Replied.

Band 3 — Performance (respects filters)

Win Rate (Closed) kpiWinRate
What it is
Share of closed disputes that we won.
Calculated
round(wins / closed × 100) where wins = Won + Won (Fought), and closed = wins + Lost + Lost (Fought) + Expired + Accepted. Open disputes are excluded from the denominator.
Example
71%
Source
Sheet Standardized Status; sub-tiles “Won” and “Won (Fought)” show winning GMV per currency.
Caveat
Because Accepted and Expired count as closed-not-won, a high accept rate lowers Win Rate — as intended.
Gateway Performance Breakdown gatewayPerformanceTable
What it is
Per-gateway win rate, closed volume, and won/lost GMV.
Calculated
For each Gateway: Win Rate % = round(won/closed×100) (green ≥ 50, else red); Closed Cases = wins+losses+accepted; Won GMV = winning amounts per currency; Lost GMV = loss/accepted amounts per currency. Same status classification as the KPIs.
Example
Tabby (KSA) — 78% · childcare 214 closed · … (illustrative)
Source
Sheet Gateway, Standardized Status, Amount.

Band 4 — Operations (unfiltered)

Avg Resolution Time (Closed) kpiAvgResolution
What it is
Average days from opened to resolved for closed disputes, overall and per gateway family.
Calculated
For terminal rows with valid Date Created and Resolved Date, where resolved ≥ created and duration ≤ 400 days (outlier cap): days = (Resolved − Created)/1 day; overall = mean (1 decimal). Per-family means for Tamara / Tabby / Stripe / Checkout with a sample count.
Example
Overall 6.4 days · Tabby 4.1 · Tamara 9.0 (illustrative)
Source
Sheet Date Created, Resolved Date, Standardized Status; renderOps().
Caveat
Resolved Date exists only for Tamara & Tabby; Stripe/Checkout have no close timestamp, so they contribute little or nothing here.
Open Backlog by Age backlogByAge
What it is
How old the open pile is, in four age buckets.
Calculated
For open rows (Not Replied / Awaiting Our Reply / Under Review), age = (now − Date Created)/1 day, bucketed: 0–7 (≤7) · 8–30 (≤30) · 31–90 (≤90) · 90+ (>90). Total = count of open rows.
Example
0–7: 61 · 8–30: 210 · 31–90: 118 · 90+: 36 (illustrative)
Source
Sheet Standardized Status, Date Created; renderOps().

Charts (respect filters; Chart.js)

  • “Financial Loss Over Time (Gateways)” — grouped bars of filtered Amount per time bucket per gateway. Bucket = per-day when Date Range = This Week, otherwise per-week (Monday start).
  • “Financial Loss Over Time (Category Breakdown)” — same buckets split into Not Replied (=Expired amounts), Denied (=Lost / Lost (Fought)), Accepted/Refunded.
  • “Portal Distribution” — doughnut of filtered row count per gateway.
  • “Value by Reason Category” — horizontal bars of filtered row count per reason category (tooltip shows % of total).
Comparison Mode deltas cmpCount · cmpDisputed · cmpLost · cmpWinRate
What it is
▲/▼ change on four KPIs versus the previous equal-length period.
Calculated
When Comparison Mode is on and a bounded date range (Week/Month/Custom) is set, the previous window is [start − (end−start), start). Both periods are reduced to USD-normalized scalars (count, closed, wins, win rate, disputed USD, lost USD). Badges: count / disputed / money-lost as % change (lower = green/favourable); win rate as points (pp) change (higher = green). Flat if < 0.5.
Example
Count ▲5% · Disputed ▼12% · Lost ▼92% · Win rate ▼12 pp
Source
computeCompareStats() + renderCompareDeltas(); needs FX loaded for the money deltas (else shows “FX loading…”).

Tab · Linked Disputes — Analysis linked-analysis.html

Aggregates closed disputes joined to live order status, claim type, and refund state — to judge whether each Accepted or Lost outcome actually made sense and whether Revibe truly lost money. Only rows that resolve to an outcome of Accepted / Won / Lost appear here.

KPI band

Closed Cases · Linked Claims · Accepted GMV · Won GMV · Double Exposure renderKpis()
What they are
Headline counts and money for the filtered closed set.
Calculated
Closed Cases = row count. Linked Claims = % of rows whose claim type ≠ “No Claim” (sub-line shows the count). Accepted GMV / Won GMV = summed GMV of Accepted / Won rows. Double Exposure = GMV of “risky” rows: an Accepted case that shouldn't have been accepted, or a Lost case where Revibe truly lost money.
Example
Closed 1,240 · Linked 63% · Won GMV SAR 88,120 (illustrative)
Source
Sheet GMV/amount column + live /api/dispute-links (order status, claim, refund); currency = the group's most common currency, FX-converted.
Caveat
GMV uses the first present of gmv_local/gross_sale/GMV/Amount; exact physical column depends on the sheet.

Accepted Decision Logic (3 cards)

Correct To Accept · Needs Reason Review · Should Not Accept renderAcceptedLogic()
What it is
Whether each Accepted dispute was the right call, checked against order status, cancel reason, and refund state.
Calculated
Per Accepted row: if it has a real claim (Return/RTO/Cancellation/Compensation) → Correct; else if already refunded → Should Not Accept; else if the order isn't Cancelled → Should Not Accept; else if the cancel reason is missing/unusable → Needs Reason Review; else → Correct. Rule of thumb: cancelled order + valid cancel reason + no refund = correct accept.
Example
Correct 310 · Review 44 · Should-not 12 (illustrative)
Source
classifyAcceptedDecision(); each card links into Linked Detail Rows filtered to that slice.

Lost Decision Logic (two rows of 3 cards)

Lose Decision + Money Impact renderLostLogic()
What it is
For Lost/Expired disputes: did losing make sense, and did Revibe actually lose product money?
Calculated
Lose Decision → Made Sense To Lose / Loss Needs Review / Should Not Lose. Money Impact → Revibe Lost Money / No Product Money Lost / Money Impact Review. Both weigh claim type, refund state, and whether the order was Delivered/Shipped vs Cancelled. Rule of thumb: delivered/shipped + no refund/RTO = money lost; a refund means the product was recovered.
Example
Made sense 180 · Revibe lost money SAR 21,400 (illustrative)
Source
classifyLostDecision() + classifyMoneyImpact(); cards link into Detail Rows.
Outcome Link Matrix renderMatrix()
What it is
Top 18 combinations of gateway × order status × claim type × refund, with outcome counts.
Calculated
Group filtered rows by gateway|orderStatus|claimType|refund; per group show Accepted / Won / Lost counts, total GMV, and Closed count. Sorted by GMV, top 18.
Source
Sheet + live enrichment; columns Gateway, Order, Claim, Refund, Accepted, Won, Lost, GMV, Closed.
Rank lists renderRankList()
What it is
Four top-6 leaderboards by GMV.
Calculated
Accepted by Cancellation Reason, Accepted by Order Status (Accepted rows grouped by reason / order status); Refund Exposure (refunded rows grouped by outcome/claim type); Claim Coverage (all filtered rows grouped by claim type). Each shows GMV + case count.
Source
Sheet + live enrichment.

Tab · Linked Disputes — Detail Rows linked-details.html

The row-by-row table behind the Analysis view — one line per closed dispute with its live order/claim/refund detail. Opened pre-filtered by clicking any Analysis card. No aggregates; money is shown in each row's own currency (no FX toggle here). Rows are sorted risky-first, then by GMV.

ColumnMeaning / source
Risk“Exposure” (red) or “Normal” — the same risk rule as Double Exposure
DisputeSheet Dispute ID
GatewaySheet Gateway
OrderSheet order_number / live shopify_order_id
Order StatusLive shipment_status → Cancelled / Delivered / Shipped / Other / Unknown
ClaimLive best-claim type + claim_id
ReasonCancel/claim reason
RefundLive has_refund → Yes / No / Unknown (red if Yes)
Refund AmtLive refund_to_cx_amount in the claim's currency
OutcomeSheet Standardized Status → Accepted / Won / Lost
GMVSheet amount column, row's own currency

Tab · Daily Breakdown daily.html

A day-by-day pivot of dispute flow per portal. Choose a view (Open backlog by created day · Resolved by resolved day · Net flow), a window (7/14/30/60 days), and a measure (count or USD amount). “Split by region” separates Tamara/Tabby into UAE vs KSA.

Pivot cell computeMatrix()
What it is
One portal's disputes on one day.
Calculated
Open view buckets open rows by Date Created day into sub-rows Not Replied / Awaiting Our Reply / Under Review. Resolved view buckets by Resolved Date day into Won / Lost / Expired / Accepted. Net = opened that day − resolved that day (red = backlog grew). Measure: count = 1 per row; amount = Amount converted to USD before summing.
Example
Tabby (KSA) · Under Review · 14-07 → 6 (illustrative)
Source
Sheet Standardized Status, Date Created, Resolved Date, Amount, Currency.
Caveat
The Older column collects everything older than the visible window so column sums reconcile with the Total. Until FX loads, the amount view sums mixed currencies (caption says so).
Summary strip renderSummary()
What it is
Four at-a-glance stats over the open pile.
Calculated
Open disputes = count of open rows. Avg age (open) = mean (now − Created) days. Oldest open = max age. Expiring ≤3 days = open rows with Days left/SLA ≤ 3 (+ % of open).
Source
Sheet Standardized Status, Date Created, Days left/SLA.
Caveat
This “Expiring ≤3” uses only Days-left on open rows — looser than the Main tab's Expiring Soon card (see Known Quirks).

Clicking any cell opens Line Items filtered by gw, status (or resolvedBucket), and the exact day/resolvedDay.

Shared · Line Items list.html · “Disputes Line Items”

The paginated raw table every clickable number opens (50 rows/page). Filters come entirely from the URL. Clicking a row opens a detail modal that pulls live order & claim data.

ColumnSource / notes
Dispute IDSheet Dispute ID (+ copy button)
Order #Sheet order_number
Status & ExpireSheet Standardized Status, colored, with an “Expiring soon / ⏳ Nd left” badge for near-deadline Not-Replied rows
GatewaySheet Gateway
AmountSheet Amount + Currency, with a ≈ USD sub-line
ReasonSheet Reason
Days LeftSheet Days left/SLA, shown as max(0,n)d; red ≤0, yellow ≤3
Date CreatedSheet Date Created (date only)
CustomerSheet Customer Email (truncated)
Detail modal openDetail()
What it is
Full context for one dispute.
Calculated
Left column “Dispute” = sheet fields (gateway, status, amount, reason, days-left, created, customer, order #). Right column “Order & Claims (live from DB)” = a live POST /api/dispute-links call for that order: order number, Shopify order, order/shipment status, payment method, cancel reason, refund flag, and each claim's type/status/stage/refund/compensation.
Source
Sheet row + live /api/dispute-links.
Caveat
If the DB is unreachable or the order isn't found, the right column shows a lookup-failed / no-match message; the left (sheet) column always renders.

5 Filters & Controls

The Main tab's sidebar drives every filtered metric on it (not the Live Action Items or Operations panels).

ControlOptionsEffect
Date RangeAll Time · This Week (−7d) · This Month (−1mo) · Custom RangeFilters on Date Created. A bounded range is required for Comparison Mode.
GatewayAll + the six gateway variantsExact match on Gateway.
Case TypeAll · Open Cases · Closed CasesOpen = OPEN_SET; Closed = CLOSED_SET.
Specific StatusAll + each Standardized StatusExact status; selecting “Won (Fought)” also catches bare “Won”.
Reason CategoryAll + 6 categoriesUses Reason Category, falling back to a keyword mapping of Reason.
Comparison Modeon/offAdds ▲/▼ deltas vs the previous equal-length period (needs a bounded date range).
CurrencyOriginal · USD · AEDDisplay only — converts figures via live FX; does not change which rows are counted.
Data SourceLive Sheet / StaticBoth currently point to the same sheet tab.

The Linked Analysis and Detail Rows pages have their own sidebar (Gateway, Order Status, Claim Type, Outcome, Time Range, Reason Category, Search) plus a currency toggle on Analysis. Line Items takes all its filters from URL parameters — filter, gateway, range/start/end, reasonCategory, live, and the precise drill-down set gw/status/day/resolvedDay/resolvedBucket.

6 Access & Roles

All dispute pages are gated behind Firebase authentication — a user must be logged in to the CX portal to view them. There are no in-dashboard role tiers today: any authenticated viewer sees the same data and controls. The pages are read-only; the live database endpoint accepts only lookups and runs with bound parameters, so nothing on the dashboard can modify orders, claims, or disputes. (Finer-grained roles: not implemented.)

7 Known Data Quirks

Honest notes so you can trust the rest of the numbers.

  • “Expiring” is defined two ways. The Main tab's Expiring Soon card and the Line-Items expiring filter require a Not-Replied status and Days-left ≤ 3 (with an age fallback). The Daily Breakdown Expiring ≤3 stat uses only Days-left ≤ 3 on open rows, so it can be higher. Use the Main tab card as the authoritative count.
  • Two panels ignore the filters. Live Action Items and Operations always reflect the whole dataset; the rest of the Main tab respects the sidebar. This is intentional (they are “live pulse” panels) but surprises people comparing numbers.
  • Resolved Date is gateway-limited. Only Tamara and Tabby record a close timestamp, so Avg Resolution Time and the Daily “Resolved” view under-represent Stripe/Checkout.
  • Expired vs Accepted. Both mean the customer was refunded and both count as money lost; Expired specifically means we ran out of time (never replied) and the gateway auto-refunded. Tamara has no dedicated “Expired” mapping today, so a lapsed Tamara case can read as Lost.
  • Some legacy references don't match an order. A minority of older Tabby payment-reference formats never resolve to a current order in the live lookup — those rows show disputes without DB detail. Expected, not a bug.
  • Days-left is clamped at 0. Past-deadline disputes show 0d (and are stored as 0), never a negative countdown.
  • Some Days-left values are estimates. Stripe and Checkout return a real evidence deadline; Tabby exposes one via history. Tamara's API returns no deadline, so open Tamara disputes use an assumed 4-day window (created + 4d) as the countdown. Stripe falls back to an assumed 30-day window only when it doesn't return its own due_by. These assumed windows are team-average SLAs, not gateway-provided — treat Tamara/Stripe-fallback Days-left as approximate.
  • Amounts can read as 0. A few disputes (seen on Tabby) carry a 0 amount from the gateway; they still appear in counts but contribute nothing to money totals.
  • Claim data flows through pre-joined sheet columns. On the Linked pages an internal claimsMap merge path is currently inert; claim type/refund come from the columns written during enrichment. No user-visible effect, but relevant if you extend the code.
  • Currency mixing. Aggregated money on the Linked pages is labelled in the group's most common currency; small cross-currency blends are possible. Use the Original/USD/AED toggle where precision matters.

8 Changelog / What's New

Newest first. Add new entries at the top in the same format: ### YYYY-MM-DD — short title then bullets.

2026-07-14 — Comparison Mode, USD daily amounts, expiring & layout fixes

  • Comparison Mode now renders real ▲/▼ deltas (count, disputed, money lost, win rate) vs the previous equal-length period.
  • Daily Breakdown's “disputed amount” view now converts every row to USD before summing (was a mixed-currency total).
  • Bare “Lost” disputes now count in Win Rate, Money Lost, Net P&L, and the gateway table (were previously excluded, inflating win rate).
  • “Awaiting Our Reply” is excluded from Expiring Soon (card + list) — expiring now tracks only disputes we haven't replied to yet.
  • Days-left is clamped to 0 in the UI and the sync (no negative countdowns).
  • Fixed horizontal page overflow on the Line-Items list (and hardened Daily) so the table scrolls inside its panel.
  • Line-Items copy-ID button no longer opens the detail modal.

Earlier — architecture

  • Moved to a single “Linked Disputes” sheet plus live order/claim enrichment via /api/dispute-links (previously baked into the sheet).
  • Added the shared top-nav tabs, the Daily Breakdown pivot, the drill-down Line-Items detail modal, and the Operations row (avg resolution + backlog aging).
  • Switched browser caching to IndexedDB (localStorage quota was exceeded at ~8,900 rows).