# Expense Reports UI (`/expenses/reports`) — Implementation Plan

**Spec:** docs/specs/2026-05-31-expense-reports-ui.md  ·  **Slug:** expense-reports-ui  ·  **Wave:** 5
**Depends on:** expenses-module, foundation-auth-rbac

## Goal
Build the `/expenses/reports` screen in the tenant app: a three-tab reporting surface (Expense Detail, VAT Summary / PCN874, Vendor Analysis) over the existing `expenses` data and report API endpoints. This spec owns **only the UI**; the `expenses` table, the report API routes (`/api/expenses/reports/*`), and the Excel exports are owned by `expenses-module` and are consumed here unchanged. The page delivers a shared filter bar (period presets + category/project/user filters), data tables with computed footers, an accessible RTL-aware bar chart for vendor spend, and export buttons that hit the upstream xlsx endpoints.

## Architecture
- **App:** `apps/zync-app` (Vite + React, Cloudflare Workers). New module folder `apps/zync-app/src/modules/expense-reports/`.
- **Routing:** lazy-register `/expenses/reports` in `apps/zync-app/src/routes/index.tsx`; the route is gated by the `reports:read` permission (via the app's permission guard, which reads `SessionPayload` scopes seeded by `foundation-auth-rbac`).
- **Data source (upstream, consumed — do NOT redefine):** the report endpoints from `expenses-module`:
  - `GET /api/expenses/reports/expense` — expense detail rows (filterable).
  - `GET /api/expenses/reports/vat` — PCN874 VAT summary data.
  - `GET /api/expenses/reports/vendors` — vendor analysis aggregation.
  - `GET /api/expenses/reports/expense/xlsx` — Excel download (Expense Detail).
  - `GET /api/expenses/reports/vat/xlsx` — Excel download (PCN874).
  - `GET /api/expenses/:id` — expense detail + corrections, used by the row-click drawer.
- **Underlying entity (upstream `expenses` table, owned by `expenses-module`):** columns this UI reads are `id, tenant_id, project_id, created_by, vendor_name, vendor_tax_id, invoice_number, vat_amount, currency, expense_date, amount, vat_deductible, status, expense_category, deduction_pct, deduction_reasoning_he, is_per_diem`. Reports group by `expense_category` (never a bare `category`) and `SUM(amount)` (ILS-normalized gross). Vendor Analysis groups by the linked `vendors.name` (when `vendor_id` is set upstream) falling back to raw `vendor_name`.
- **Categories (upstream constant, consumed):** the 8 IL tax categories live in `packages/types/src/expense-categories.ts` (owned by `expenses-module`): `office, marketing, professional, vehicle, equipment, finance, welfare, exceptional`. The category filter and the detail table's category column render the Hebrew/English labels from that constant. This plan imports it; it does not author it.
- **Design system:** all primitives (`DataTable`, `Tabs`, `Select`, `Input`, `Button`, `Card`, `EmptyState`, `ErrorState`, `Skeleton`, `Badge`) come from `@zync/ui` (`packages/ui`), already exported upstream. RTL direction via `useDirection()` (from `@zync/ui` / i18n), locale via `LocaleProvider`.
- **Charts:** `recharts` (`ResponsiveContainer`, `BarChart`, `Bar`, `XAxis`, `YAxis`, `Tooltip`) for Vendor Analysis only, wrapped in the accessible-figure pattern from the a11y spec (role="figure", visually-hidden data table, SVG `<title>`/`<desc>`, `aria-live` tooltip echo, keyboard-focusable bars).
- **Data flow:** Filter bar state (period preset + custom from/to, category, project, user) is URL-synced via search params. A `useExpenseReports` hook builds the query string and uses TanStack Query (`@tanstack/react-query`) per active tab; each tab has its own query keyed on the filter object. Footers (Total Amount, Total VAT, Deductible Total) are computed client-side from the returned rows.

## Tech Stack
- **App package:** `apps/zync-app` (React 18, Vite, TanStack Query, TanStack Virtual, `recharts`).
- **Shared:** `@zync/ui` (design-system components + `useDirection`, `cn`), `@zync/types` (`packages/types`, `expense-categories.ts`), `@zync/auth` (session/permission scopes).
- **No new Cloudflare bindings.** No new DB tables. No new API routes (all consumed from `expenses-module`).
- **Runtime:** Cloudflare Workers (app served via Vite SSR/SPA build); API calls go to `apps/zync-api` (Hono).

## Wave Plan
| Sub-wave | Tasks | Files touched | Parallelizable? |
|----------|-------|---------------|-----------------|
| ER-1 — types & data layer | 1, 2 | `modules/expense-reports/types.ts`, `modules/expense-reports/api.ts` | Task 1 then Task 2 |
| ER-2 — shared shell | 3, 4 | `ExpenseReportsPage.tsx`, `ReportFilterBar.tsx`, `usePeriodPresets.ts` | After ER-1 (3 then 4 in parallel) |
| ER-3 — tabs | 5, 6, 7 | `tabs/ExpenseDetailTab.tsx`, `tabs/VatSummaryTab.tsx`, `tabs/VendorAnalysisTab.tsx` | All three parallel after ER-2 |
| ER-4 — chart + drawer | 8, 9 | `VendorSpendChart.tsx`, `ExpenseDetailDrawer.tsx` | Parallel after ER-3 |
| ER-5 — export + route wiring | 10, 11 | `ExportMenu.tsx`, `routes/index.tsx`, secondary nav | After ER-3/ER-4 |
| ER-6 — a11y/RTL/i18n verification | 12 | all module files, locale catalogs | Last |

## Tasks

### Task 1: Module types & filter model
**Blocks:** 2, 3, 4, 5, 6, 7, 8  ·  **Blocked by:** —
**Files:**
- Create: `apps/zync-app/src/modules/expense-reports/types.ts`
**Steps:**
- [ ] Define the `ReportFilters` model used across all tabs and URL sync.
- [ ] Define the row/response shapes returned by the three upstream report endpoints (mirrors of the API contracts in `expenses-module`; transcribed here so no cross-referencing is needed).
- [ ] Define `PeriodPreset` union and `VatLineCode` codes used by the PCN874 tab.
- [ ] Re-export the upstream `EXPENSE_CATEGORIES` constant type from `@zync/types` for the category filter (import, do not redefine).
**Schema / Interfaces:**
```ts
// apps/zync-app/src/modules/expense-reports/types.ts
import type { ExpenseCategoryId } from '@zync/types' // 'office'|'marketing'|'professional'|'vehicle'|'equipment'|'finance'|'welfare'|'exceptional'

export type PeriodPreset =
  | 'this_month' | 'last_month' | 'this_quarter' | 'this_year' | 'custom'

export interface ReportFilters {
  period: PeriodPreset
  from: string | null   // YYYY-MM-DD (only when period === 'custom')
  to: string | null     // YYYY-MM-DD
  category: ExpenseCategoryId | 'all'
  projectId: string | 'all'   // UUID or 'all'
  userId: string | 'all'      // UUID (created_by) or 'all'
}

// GET /api/expenses/reports/expense
export interface ExpenseDetailRow {
  id: string                    // expenses.id (UUID)
  expenseDate: string           // expenses.expense_date (YYYY-MM-DD)
  vendorName: string | null     // canonical vendors.name when linked, else raw vendor_name
  expenseCategory: ExpenseCategoryId | null
  amount: number                // ILS gross (expenses.amount)
  deductionPct: number | null   // 0|25|45|66|100
  vatAmount: number | null      // expenses.vat_amount
  vatDeductible: boolean        // expenses.vat_deductible
  isPerDiem: boolean            // expenses.is_per_diem
  createdBy: string             // user UUID (expenses.created_by)
  createdByName: string         // resolved display name
}
export interface ExpenseDetailReport {
  rows: ExpenseDetailRow[]
  // server-provided totals; UI also recomputes for footer parity
  totalAmount: number
  totalVat: number
  deductibleTotal: number       // SUM(amount * deduction_pct / 100)
}

// GET /api/expenses/reports/vat  (PCN874)
export type VatLineCode = '220' | '225' | '320'
export interface VatLine {
  code: VatLineCode
  descriptionHe: string         // e.g. עסקאות חייבות במס
  amount: number                // ILS
  vat: number                   // ILS
}
export interface VatSummaryReport {
  periodLabel: string           // e.g. "May 2026" / "מאי 2026"
  filingCadence: 'monthly' | 'bimonthly'  // from tenant_settings (expenses-module)
  lines: VatLine[]
  vatToPay: number              // code 220 vat − code 320 vat
}

// GET /api/expenses/reports/vendors
export interface VendorAnalysisRow {
  vendorId: string | null       // vendors.id when linked, else null
  vendorName: string            // canonical vendors.name or raw OCR vendor_name
  count: number
  total: number                 // SUM(amount), ILS gross
  avgDeductionPct: number       // weighted/avg deduction across rows
  vatTotal: number              // SUM(vat_amount)
}
export interface VendorAnalysisReport {
  rows: VendorAnalysisRow[]     // sorted by total desc upstream
}
```
**Acceptance:**
- [ ] `tsc` compiles the module's `types.ts` against `@zync/types`.
- [ ] No bare `category` field exists; the category column/filter uses `expenseCategory` / `ExpenseCategoryId`.

### Task 2: Data layer (query hooks + fetchers)
**Blocks:** 3, 5, 6, 7, 8, 9, 10  ·  **Blocked by:** 1
**Files:**
- Create: `apps/zync-app/src/modules/expense-reports/api.ts`
**Steps:**
- [ ] Implement `buildReportQuery(filters: ReportFilters): URLSearchParams` that serializes the filter model (resolving period presets to concrete `from`/`to` when not custom) into query params; omit `category`/`projectId`/`userId` when value is `'all'`.
- [ ] Implement `fetchExpenseDetail`, `fetchVatSummary`, `fetchVendorAnalysis` calling the upstream endpoints with the built query string and the app's authenticated `fetch` wrapper (credentials/cookie session).
- [ ] Expose TanStack Query hooks `useExpenseDetailReport(filters)`, `useVatSummaryReport(filters)`, `useVendorAnalysisReport(filters)`, each keyed `['expense-reports', <tab>, filters]`, `staleTime: 30_000`, `keepPreviousData: true`.
- [ ] Implement export URL builders `expenseXlsxUrl(filters)` and `vatXlsxUrl(filters)` that return the upstream `/xlsx` endpoint URLs with the same query string (download triggered via anchor, not fetch).
- [ ] On non-2xx, throw a typed error consumed by tab-level `ErrorState`.
**Schema / Interfaces:**
```ts
// apps/zync-app/src/modules/expense-reports/api.ts
export function buildReportQuery(filters: ReportFilters): URLSearchParams
export function useExpenseDetailReport(f: ReportFilters): UseQueryResult<ExpenseDetailReport>
export function useVatSummaryReport(f: ReportFilters): UseQueryResult<VatSummaryReport>
export function useVendorAnalysisReport(f: ReportFilters): UseQueryResult<VendorAnalysisReport>
export function expenseXlsxUrl(f: ReportFilters): string  // -> /api/expenses/reports/expense/xlsx?{query}
export function vatXlsxUrl(f: ReportFilters): string       // -> /api/expenses/reports/vat/xlsx?{query}
```
**Acceptance:**
- [ ] Each hook hits the exact upstream path (`/api/expenses/reports/expense|vat|vendors`).
- [ ] `'all'` filter values are omitted from the query string; period presets resolve to concrete dates server-side or client-side consistently.
- [ ] xlsx URLs carry the identical filter query so the export matches the on-screen view.

### Task 3: Page shell, tabs, and permission gate
**Blocks:** 5, 6, 7, 10, 11  ·  **Blocked by:** 2
**Files:**
- Create: `apps/zync-app/src/modules/expense-reports/ExpenseReportsPage.tsx`
**Steps:**
- [ ] Render an `<h1>` "Expense Reports" (i18n key) and the `Tabs` (`@zync/ui`) with three tab panels: Expense Detail, VAT Summary, Vendor Analysis; active tab synced to the `tab` URL search param (default `detail`).
- [ ] Mount the shared `ReportFilterBar` (Task 4) above the tab panels; filter state lifted to the page and passed to each tab.
- [ ] Mount the `ExportMenu` (Task 10) in the page header (top-right), disabled on the Vendor Analysis tab (no export).
- [ ] Guard the whole page on the `reports:read` permission: if the session scope lacks `reports:read`, render the design-system `ErrorState` (403 variant) instead of the report; the lazy route in Task 11 also enforces this so unauthorized users never load the chunk's data.
- [ ] Lazy-render each tab panel's component (code-split) so an inactive tab does not fetch.
**Acceptance:**
- [ ] Switching tabs updates `?tab=` and only the active tab issues a query.
- [ ] A user without `reports:read` sees the 403 `ErrorState`, never report data.
- [ ] Tab order and labels match the spec: Expense Detail, VAT Summary, Vendor Analysis.

### Task 4: Shared filter bar + period presets
**Blocks:** 5, 6, 7  ·  **Blocked by:** 1
**Files:**
- Create: `apps/zync-app/src/modules/expense-reports/ReportFilterBar.tsx`
- Create: `apps/zync-app/src/modules/expense-reports/usePeriodPresets.ts`
**Steps:**
- [ ] `usePeriodPresets.ts`: pure helper mapping each `PeriodPreset` to a concrete `{ from, to }` date range computed in the tenant timezone (`tenants.default_timezone`); `custom` returns the user-entered `from`/`to`.
- [ ] `ReportFilterBar.tsx`: render Period `Select` (This Month, Last Month, This Quarter, This Year, Custom); when `custom`, reveal two date `Input`s (From / To) with `type="date"`.
- [ ] Render Category `Select` populated from `EXPENSE_CATEGORIES` (`@zync/types`) with an "All" option, showing the locale-appropriate label (Hebrew label when `he-IL`).
- [ ] Render Project `Select` (options from the projects list endpoint, `projects-module`) and User `Select` (tenant members) — both with an "All" default.
- [ ] Persist every change to URL search params (debounced for the date inputs); read initial state from the URL on mount.
- [ ] All `Select`/`Input` come from `@zync/ui`; no raw `<select>`/`<input>`. Labels are i18n keys; bar is `role="search"` with an accessible group label.
**Acceptance:**
- [ ] Changing any filter updates the URL and re-runs the active tab's query.
- [ ] Period presets resolve correct ranges (e.g. "This Quarter" spans the current calendar quarter in tenant tz).
- [ ] Category options render the 8 upstream categories plus "All"; Hebrew labels show under `he-IL`.

### Task 5: Tab — Expense Detail
**Blocks:** —  ·  **Blocked by:** 2, 3, 4
**Files:**
- Create: `apps/zync-app/src/modules/expense-reports/tabs/ExpenseDetailTab.tsx`
**Steps:**
- [ ] Call `useExpenseDetailReport(filters)`; render `Skeleton` rows while loading, `ErrorState` on error, `EmptyState` (one-sentence copy per `error-empty-states`) when zero rows.
- [ ] Render `DataTable` (`@zync/ui`) with columns: Date (`expenseDate`), Vendor (`vendorName`), Category (Hebrew/English label from `EXPENSE_CATEGORIES`), Amount (ILS, `formatCurrency`), Deductibility % (`deductionPct`), VAT (`vatAmount`), Deductible Amount (`amount * deductionPct / 100`), User (`createdByName`).
- [ ] Render a sticky footer row: Total Amount (`totalAmount`), Total VAT (`totalVat`), and a "Deductible total" line (`deductibleTotal`) — prefer server totals, fall back to client recompute from rows; assert parity in dev.
- [ ] Row click opens the `ExpenseDetailDrawer` (Task 9) for that `id`.
- [ ] When returned rows > 200, virtualize the table body with `VirtualList` (TanStack Virtual, `estimateSize: 56`, overscan 5).
- [ ] Format all currency via the i18n currency formatter (ILS, `he-IL`/`en` aware); dates via the locale date formatter.
**Acceptance:**
- [ ] Footer "Deductible total" equals `Σ(amount × deductionPct/100)` and matches the server total.
- [ ] Row click opens the detail drawer for the correct expense.
- [ ] Empty/loading/error states render the design-system components, not ad-hoc markup.

### Task 6: Tab — VAT Summary (PCN874)
**Blocks:** —  ·  **Blocked by:** 2, 3, 4
**Files:**
- Create: `apps/zync-app/src/modules/expense-reports/tabs/VatSummaryTab.tsx`
**Steps:**
- [ ] Render a VAT-period selector whose granularity is monthly or bimonthly per `filingCadence` (from the report payload, sourced from tenant settings in `expenses-module`); selecting a period re-queries `useVatSummaryReport`.
- [ ] Render the PCN874 summary `Card` with a table: columns Code, Description (Hebrew, `descriptionHe`), Amount (ILS), VAT (ILS); rows for line codes 220 (עסקאות חייבות במס), 225 (עסקאות בשיעור אפס), 320 (תשומות חייבות במס) as returned.
- [ ] Render the "VAT to pay (code 220 − code 320)" line using `vatToPay`.
- [ ] Render an "Export PCN874 Excel" `Button` invoking the vat xlsx download (Task 10 helper).
- [ ] Loading → `Skeleton`; error → `ErrorState`; if no expenses/invoices in period → `EmptyState`.
- [ ] Hebrew code descriptions render correctly under both LTR and RTL (descriptions stay Hebrew regardless of UI locale, per Israeli reporting format).
**Acceptance:**
- [ ] Line codes 220/225/320 and the "VAT to pay" delta render with the spec's Hebrew descriptions.
- [ ] Period granularity follows `filingCadence` (monthly vs bimonthly).
- [ ] Export button triggers `/api/expenses/reports/vat/xlsx` with the active period filter.

### Task 7: Tab — Vendor Analysis
**Blocks:** 8  ·  **Blocked by:** 2, 3, 4
**Files:**
- Create: `apps/zync-app/src/modules/expense-reports/tabs/VendorAnalysisTab.tsx`
**Steps:**
- [ ] Call `useVendorAnalysisReport(filters)`; loading → `Skeleton`, error → `ErrorState`, zero rows → `EmptyState`.
- [ ] Render the `VendorSpendChart` (Task 8) above the table, fed the top-10 rows by `total`.
- [ ] Render a `DataTable` with columns: Vendor (`vendorName`), Count (`count`), Total (ILS), Avg Deduct% (`avgDeductionPct`), VAT Total (`vatTotal`); rows already sorted by total desc upstream.
- [ ] Display the canonical `vendors.name` for linked rows and the raw OCR `vendor_name` for unlinked rows exactly as returned (do not re-normalize client-side).
- [ ] No export control on this tab (per spec).
**Acceptance:**
- [ ] Table columns and order match the spec; values formatted as ILS currency / percent.
- [ ] Chart shows the top 10 vendors by total spend.
- [ ] No export button is present on this tab.

### Task 8: Accessible RTL vendor bar chart
**Blocks:** —  ·  **Blocked by:** 7
**Files:**
- Create: `apps/zync-app/src/modules/expense-reports/VendorSpendChart.tsx`
**Steps:**
- [ ] Render a `recharts` `ResponsiveContainer` → `BarChart` of the top-10 vendors (X = vendor name, Y = total spend), `Bar` filled with a design-token color (no hard-coded hex).
- [ ] Apply RTL: read `useDirection()`; set `<YAxis orientation={isRtl ? 'right' : 'left'} />` and mirror the `Tooltip` anchor (`position={{ x: isRtl ? 'left' : 'right' }}`); the `locale === 'he-IL'` check drives `isRtl`.
- [ ] Wrap the chart in `role="figure"` with `aria-labelledby="vendor-spend-title"`; add a `<figcaption id="vendor-spend-title">` whose text matches the visible heading.
- [ ] Add an SVG `<title>` (e.g. "Top vendors by total spend") and `<desc>` (a one-line trend statement) inside the chart's SVG root.
- [ ] Provide a visually-hidden `<table>` sibling containing the same vendor/total data, plus an adjacent "Show data table" toggle `Button` that reveals it visibly.
- [ ] Echo the active tooltip content into an `aria-live="polite"` region on hover/focus.
- [ ] Make bars keyboard-focusable (Tab) and activatable via Enter/Space (focus highlights the bar and announces its value).
- [ ] Honor `prefers-reduced-motion`: disable bar enter/transition animation when the user prefers reduced motion (`isAnimationActive={false}`).
**Acceptance:**
- [ ] Chart wrapper exposes `role="figure"` + labelled caption; SVG has `<title>` and `<desc>`.
- [ ] A visually-hidden data table mirrors the chart, toggleable to visible.
- [ ] Y-axis flips to the right and tooltip anchor mirrors under `he-IL`; bars are Tab-focusable and Enter/Space-activatable.
- [ ] Bar animation is suppressed under `prefers-reduced-motion`.

### Task 9: Expense detail drawer (row click)
**Blocks:** —  ·  **Blocked by:** 2
**Files:**
- Create: `apps/zync-app/src/modules/expense-reports/ExpenseDetailDrawer.tsx`
**Steps:**
- [ ] Implement a `Sheet`-based (`@zync/ui`) side-panel that opens when an Expense Detail row is clicked, fetching `GET /api/expenses/:id` for the clicked `id`.
- [ ] Render a read-only inline view of the expense (vendor, invoice #, vendor tax id, date, amount, VAT, category Hebrew label, deduction %, Hebrew deduction reasoning `deduction_reasoning_he`, notes); this is the report-side view, not the editable expenses-module detail sheet.
- [ ] `Skeleton` while loading, `ErrorState` on failure.
- [ ] Focus trap inside the `Sheet`; `Esc` closes; focus returns to the originating row. Drawer is `role="dialog"` `aria-modal="true"` with an `aria-label` of the vendor + date.
**Acceptance:**
- [ ] Drawer opens with the correct expense's data and is keyboard-dismissable, restoring focus to the row.
- [ ] Hebrew deduction reasoning is shown; the drawer is read-only.

### Task 10: Export menu
**Blocks:** —  ·  **Blocked by:** 3
**Files:**
- Create: `apps/zync-app/src/modules/expense-reports/ExportMenu.tsx`
**Steps:**
- [ ] Render an "Export" `DropdownMenu`/`Button` in the page header offering "Expense Detail (Excel)" and "PCN874 VAT (Excel)".
- [ ] Each item triggers a download of the corresponding upstream xlsx URL (`expenseXlsxUrl` / `vatXlsxUrl`) carrying the current filter query, via a programmatic anchor with `download`.
- [ ] Disable / hide the menu on the Vendor Analysis tab (no export defined).
- [ ] Menu is keyboard-navigable (arrow keys, Enter); trigger has an accessible label.
**Acceptance:**
- [ ] Export items download the correct `/api/expenses/reports/{expense,vat}/xlsx` files reflecting active filters.
- [ ] No export available on Vendor Analysis.

### Task 11: Route registration & secondary nav
**Blocks:** —  ·  **Blocked by:** 3
**Files:**
- Modify: `apps/zync-app/src/routes/index.tsx`
- Modify: `apps/zync-app/src/modules/expenses/` secondary-nav config (the expenses module nav, owned by `expenses-module`; add a "Reports" link)
**Steps:**
- [ ] Lazy-register the `/expenses/reports` route pointing to `ExpenseReportsPage`, wrapped in the app's permission guard for `reports:read` and the module-enabled guard for the expenses module (`requireModuleEnabled`/`useModuleEnabled`).
- [ ] Add a "Reports" entry to the expenses secondary navigation linking to `/expenses/reports` (sibling of the existing All / Needs Review / Recurring / Mileage surfaces).
- [ ] Ensure the route preserves `?tab=` and filter search params on navigation.
**Acceptance:**
- [ ] Navigating to `/expenses/reports` loads the page; unauthorized users are redirected/blocked by the guard.
- [ ] The expenses secondary nav exposes a working "Reports" link.

### Task 12: A11y / RTL / i18n verification pass
**Blocks:** —  ·  **Blocked by:** 5, 6, 7, 8, 9, 10, 11
**Files:**
- Modify: `apps/zync-app/src/modules/expense-reports/*` (fixes surfaced by the pass)
- Modify: i18n locale catalogs (`he-IL`, `en`) for all new UI strings
**Steps:**
- [ ] Add every new UI string (headings, tab labels, column headers, filter labels, empty/error copy, export labels) to the `en` and `he-IL` translation catalogs; no hard-coded English in JSX.
- [ ] Verify the full page mirrors correctly under `dir="rtl"` (`he-IL`): filter bar, tables, footer, chart axis/tooltip, drawer.
- [ ] Run an automated a11y check (axe) on each tab: tables have proper headers, the chart figure pattern passes, the drawer dialog is labelled, focus order is logical, color contrast meets WCAG 2.1 AA against design tokens.
- [ ] Confirm `prefers-reduced-motion` suppresses chart and any tab/drawer transitions.
- [ ] Confirm no hard-coded colors/spacing/radius (design-token lint rules `no-hardcoded-colors`, `no-hardcoded-spacing`, `no-radius-ladder`).
**Acceptance:**
- [ ] axe reports zero serious/critical violations on all three tabs and the drawer.
- [ ] Page renders correctly in `he-IL` RTL with no clipped or mis-anchored elements.
- [ ] All strings resolve from i18n catalogs in both locales; lint passes for design-token rules.
