# SLA Configuration UI — Implementation Plan

**Spec:** docs/specs/2026-05-31-sla-config-ui.md  ·  **Slug:** sla-config-ui  ·  **Wave:** 8
**Depends on:** crm-support-center, ticket-sla-escalation, foundation-auth-rbac, foundation-design-system

## Goal
Spec 91 (`ticket-sla-escalation`) defines the SLA data model (`sla_policies`), cron breach detection, escalation logic, and the SLA settings API but ships no UI. This spec delivers the SLA settings page at `/settings/sla` (Business+ gated, `users:manage`) where staff edit per-priority response and resolution targets plus per-policy breach-notification preferences, and adds SLA status indicators to the ticket list and ticket detail sidebar. It adds one per-ticket SLA-status endpoint. Non-Business+ tenants see an inline upsell.

## Architecture
- **Data:** No new tables and **no new columns**. The `sla_policies` table — including the per-policy notification columns `notify_email` / `notify_in_app`, plus `escalation_email`, `first_response_hours`, `resolution_hours`, `UNIQUE (tenant_id, priority)` — is owned upstream by `ticket-sla-escalation` (spec 91). The `tickets` SLA columns (`due_at`, `first_response_at`, `sla_breached`) and `tenant_settings.sla_enabled` are also owned by spec 91 and consumed here — never re-created.
- **Seeding is NOT ours.** Spec 91 seeds default policies on Business+ upgrade. This UI edits *existing* policies only (1:1 with the four priorities `low/medium/high/urgent`); it never inserts or deletes policies and never seeds defaults.
- **Settings API is owned by spec 91 and consumed here.** Spec 91 declares `GET /api/settings/sla` (returns the four policies + `sla_enabled`) and `PATCH /api/settings/sla/:policyId` (per-policy edit of targets + `escalation_email` + `notify_email` / `notify_in_app`). This spec does **not** redeclare those routes — it consumes them from the page/hooks. The only API this spec declares is the per-ticket status endpoint `GET /api/tickets/:id/sla` (spec 91 has no equivalent).
- **SLA status computation:** derived server-side from `tickets.due_at`, `tickets.first_response_at`, `tickets.sla_breached`, and the matching policy's `first_response_hours` / `resolution_hours`. First-response deadline = `tickets.created_at + first_response_hours hours`; resolution due = `tickets.due_at` (set upstream). The list and detail UIs poll `GET /api/tickets/:id/sla` (or read a precomputed field on the list payload) every 30s — polling, not WebSocket; this spec does NOT depend on `real-time-infrastructure`.
- **UI (in `apps/zync-app`, Vite+React):** new route `/settings/sla` with a policy table and an Edit sheet; modifications to the existing `crm-support-center` ticket list (new SLA column) and ticket detail sidebar (new SLA panel). Tier gating via `requireTier` (server) / `useTierGate` (client); inline upsell rendered directly (no `upgrade-upsell-modal` dependency).
- **Consumes upstream exports:** `requirePermission`, `requireTier`, `requireModuleEnabled`, `tenantQuery`, `createDb`/`Db`, `useTierGate`, design-system `Sheet`, `Input`, `Switch`, `Button`, `Badge`, `Table`, `DataTable`, `EmptyState`, `toast`, `LocaleProvider`/`useDirection` (RTL).

## Tech Stack
- **Apps:** `apps/zync-api` (Hono on Cloudflare Workers), `apps/zync-app` (Vite + React).
- **Packages:** `@zync/db` (Drizzle schema + `tenantQuery`), `@zync/auth` (`requirePermission`, `requireTier`), `@zync/ui` (design-system primitives), `@zync/types`.
- **DB:** Neon Postgres via Cloudflare Hyperdrive, Drizzle ORM.
- **Validation:** Zod (`require-zod-validation-in-routes`).
- **Bindings:** Hyperdrive (`DB`/`Db`) for Postgres; no new bindings.

## Wave Plan
| Sub-wave | Tasks | Files touched | Parallelizable? |
|----------|-------|---------------|-----------------|
| 8a | 1, 2 | SLA-status helper + ticket-status route | 1 then 2 |
| 8b | 3 | client SLA data hooks | After 2 |
| 8c | 4, 5 | `/settings/sla` page + Edit sheet | 4 then 5 |
| 8d | 6, 7 | Ticket list SLA column, ticket detail SLA panel | Parallel, after 2,3 |
| 8e | 8 | i18n strings + a11y/RTL pass | After 4–7 |

## Tasks

### Task 1: SLA-status helper (`getTicketSlaStatus`)
**Blocks:** 2, 6, 7  ·  **Blocked by:** —
**Files:**
- Create: `apps/zync-api/src/lib/sla-status.ts`
**Steps:**
- [ ] Implement `getTicketSlaStatus(ticket, policy, now)` computing first-response and resolution status from `tickets.created_at`, `tickets.first_response_at`, `tickets.due_at`, `tickets.sla_breached` and the policy's `first_response_hours` / `resolution_hours`.
- [ ] First-response deadline = `created_at + first_response_hours * 3600_000`. If `first_response_at` is set → `responded` (with met/breached subflag vs deadline); else `pending` (with remaining ms or breached).
- [ ] Resolution: due = `due_at`. If `sla_breached` or `now > due_at` → `breached` (ms since due); else `remaining` ms.
- [ ] Derive the list-indicator `state` per spec table: `no_sla` (no policy / no `due_at`), `ok` (>25% of resolution window remaining), `warning` (≤25% remaining), `breached`. "More critical of the two targets" wins.
- [ ] Return `{ first_response_status, resolution_status, first_response_due_at, resolution_due_at, is_breached, indicator_state, remaining_ms }`. Pure function (inject `now`), no DB access.
**Schema / Interfaces:**
```ts
export type SlaIndicatorState = 'no_sla' | 'ok' | 'warning' | 'breached';
export type SlaFirstResponseStatus =
  | { kind: 'responded'; met: boolean; responded_at: string }
  | { kind: 'pending'; remaining_ms: number; breached: boolean };
export type SlaResolutionStatus =
  | { kind: 'remaining'; remaining_ms: number }
  | { kind: 'breached'; overdue_ms: number };

export interface TicketSlaStatus {
  first_response_status: SlaFirstResponseStatus;
  resolution_status: SlaResolutionStatus | null;
  first_response_due_at: string | null;
  resolution_due_at: string | null;
  is_breached: boolean;
  indicator_state: SlaIndicatorState;
  remaining_ms: number | null;
}

export function getTicketSlaStatus(
  ticket: { created_at: Date; first_response_at: Date | null; due_at: Date | null; sla_breached: boolean },
  policy: { first_response_hours: number; resolution_hours: number } | null,
  now: Date,
): TicketSlaStatus;
```
**Acceptance:**
- [ ] No policy or no `due_at` → `indicator_state === 'no_sla'`.
- [ ] `>25%` resolution window remaining → `ok`; `≤25%` → `warning`; past due or `sla_breached` → `breached`.
- [ ] Function is pure and unit-testable with an injected `now`.

### Task 2: API route — per-ticket SLA status (`GET /api/tickets/:id/sla`)
**Blocks:** 3, 6, 7  ·  **Blocked by:** 1
**Files:**
- Modify: `apps/zync-api/src/routes/tickets.ts` (add `GET /:id/sla`)
**Steps:**
- [ ] `GET /api/tickets/:id/sla` — authenticated tenant member; load ticket (`tenantQuery`), load the matching `sla_policies` row by `tenant_id` + `ticket.priority`; call `getTicketSlaStatus`; return `{ first_response_status, resolution_status, first_response_due_at, resolution_due_at, is_breached, indicator_state }`.
- [ ] The SLA **settings** routes (`GET /api/settings/sla`, `PATCH /api/settings/sla/:policyId`) are NOT declared here — they are owned by `ticket-sla-escalation` (spec 91). This spec's page/hooks consume them; do not redeclare or mount a second settings/sla router (would double-mount `GET /api/settings/sla`).
- [ ] Uses `tenantQuery` (never raw Drizzle from a route — `no-raw-drizzle-from-routes`) and zod for the param (`require-zod-validation-in-routes`).
**Schema / Interfaces:**
```ts
// GET /api/tickets/:id/sla → 200: TicketSlaStatus (see Task 1)
// SLA policy DTO returned by spec 91's GET /api/settings/sla (consumed by this spec's hooks):
type SlaPolicyDto = {
  id: string; priority: 'low'|'medium'|'high'|'urgent';
  first_response_hours: number; resolution_hours: number;
  escalation_email: string | null; notify_email: boolean; notify_in_app: boolean;
};
```
**Acceptance:**
- [ ] `GET /api/tickets/:id/sla` returns `no_sla` for a ticket whose priority has no policy / no `due_at`.
- [ ] A cross-tenant ticket id → 404 (tenantQuery scoping).
- [ ] No `settings/sla` router is mounted by this spec (single declaration lives in spec 91).

### Task 3: SLA data hooks (client)
**Blocks:** 4, 5, 6, 7  ·  **Blocked by:** 2
**Files:**
- Create: `apps/zync-app/src/features/sla/api.ts`
- Create: `apps/zync-app/src/features/sla/hooks.ts`
**Steps:**
- [ ] `useSlaPolicies()` — react-query `GET /api/settings/sla` (spec 91's route); returns `{ policies: SlaPolicyDto[]; slaEnabled: boolean }`.
- [ ] `useUpdateSlaPolicy()` — mutation `PATCH /api/settings/sla/:policyId` (spec 91's route), invalidates `useSlaPolicies` on success; `toast` on error/success.
- [ ] `useTicketSlaStatus(ticketId)` — react-query `GET /api/tickets/:id/sla` (this spec's route, Task 2) with `refetchInterval: 30_000` (30s polling per spec) and `refetchIntervalInBackground: false`.
**Schema / Interfaces:**
```ts
export function useSlaPolicies(): UseQueryResult<{ policies: SlaPolicyDto[]; slaEnabled: boolean }>;
export function useUpdateSlaPolicy(): UseMutationResult<SlaPolicyDto, ApiError, { policyId: string; body: Partial<UpdateSlaPolicyBody> }>;
export function useTicketSlaStatus(ticketId: string): UseQueryResult<TicketSlaStatus>;

// Mirrors spec 91's patchSlaPolicySchema (client-side validation in the Edit sheet):
type UpdateSlaPolicyBody = {
  first_response_hours?: number; resolution_hours?: number;
  escalation_email?: string | null; notify_email?: boolean; notify_in_app?: boolean;
};
```
**Acceptance:**
- [ ] `useTicketSlaStatus` refetches every 30s while mounted.
- [ ] Mutation invalidates the policy list so the table reflects edits without manual reload.

### Task 4: `/settings/sla` page — policy table + tier gate + upsell
**Blocks:** 5  ·  **Blocked by:** 3
**Files:**
- Create: `apps/zync-app/src/pages/settings/SlaPoliciesPage.tsx`
- Modify: `apps/zync-app/src/router.tsx` (register `/settings/sla`)
- Modify: settings nav config (add "SLA Policies" entry, Business+ only)
**Steps:**
- [ ] Register route `/settings/sla`. Client gate with `useTierGate('BUSINESS')`; server already enforces via `requireTier` on spec 91's settings API.
- [ ] Non-Business+: render the inline upsell directly — heading "SLA Policies — Business+ feature", body "Set response time targets per ticket priority and auto-escalate breaches to your team.", and an "Upgrade to Business+" `Button` linking to the billing/upgrade route. Do NOT import `upgrade-upsell-modal`.
- [ ] Business+: render explanatory header, then a `Table` of policies ordered urgent→high→medium→low with columns: Priority, First Response (`{n}h` / "—" when 0), Resolution (`{n}h` / "—"), Escalation email (value or "(none)"), and an `[Edit]` `Button` per row opening the Edit sheet (Task 5).
- [ ] Show the "Active: SLA tracking enabled for all tickets" status line, reading `slaEnabled` from `useSlaPolicies()` (spec 91 owns the flag) — read-only indicator here (toggling `sla_enabled` is spec 91's concern, auto-set on Business+ upgrade).
- [ ] Empty/loading states via `EmptyState` / `Skeleton`. Use design tokens only (`no-hardcoded-colors`, `no-hardcoded-spacing`).
**Acceptance:**
- [ ] Non-Business+ tenant sees only the upsell; no policy data fetched/leaked.
- [ ] Business+ tenant sees four priority rows with correct values and a working per-row Edit button.
- [ ] "0 hours" renders as "—" (no target), matching the spec hint `(0 = no target)`.

### Task 5: Edit SLA Policy sheet
**Blocks:** —  ·  **Blocked by:** 4
**Files:**
- Create: `apps/zync-app/src/pages/settings/EditSlaPolicySheet.tsx`
**Steps:**
- [ ] Slide-in `Sheet` titled "Edit SLA — {priority} priority".
- [ ] Numeric `Input` "First response target" (hours, helper "(0 = no target)") and "Resolution target" (hours, "(0 = no target)").
- [ ] `Input` "Escalation email (on breach)" with helper "(leave blank for no escalation address)"; blank submits as `null`.
- [ ] "Notify on breach" group with two `Switch` controls: "In-app notification to assignee" (`notify_in_app`) and "Email to escalation address" (`notify_email`).
- [ ] `[Cancel]` closes; `[Save]` calls `useUpdateSlaPolicy()`; on success `toast` + close + list refresh; on error inline `FormError`.
- [ ] Validate locally with the same shape as spec 91's `patchSlaPolicySchema` (`UpdateSlaPolicyBody`); disable Save while pending.
**Schema / Interfaces:**
```ts
interface EditSlaPolicySheetProps {
  policy: SlaPolicyDto;
  open: boolean;
  onOpenChange: (open: boolean) => void;
}
```
**Acceptance:**
- [ ] Saving 0 for a target persists "no target"; blank email persists `null`.
- [ ] Toggling either `Switch` and saving updates `notify_email` / `notify_in_app`.
- [ ] Invalid email shows inline error and blocks save.

### Task 6: Ticket list — SLA status column
**Blocks:** —  ·  **Blocked by:** 2, 3
**Files:**
- Modify: `apps/zync-app/src/features/support/TicketList.tsx` (crm-support-center component)
- Create: `apps/zync-app/src/features/sla/SlaIndicator.tsx`
**Steps:**
- [ ] Add a "SLA" column to the ticket list/table.
- [ ] `SlaIndicator` renders per `indicator_state`: `no_sla` → grey "—" + "No SLA"; `ok` → 🟢 + "{N}h left" with `--success`; `warning` → 🟡 + "{N}h left" with `--warning`; `breached` → 🔴 + "Response breached" or "Breached {N}h ago" with `--danger`.
- [ ] Source the state from the ticket row's precomputed SLA fields (from `tickets.due_at` / `first_response_at` / `sla_breached` + policy) or `useTicketSlaStatus`. Show the more critical of first-response vs resolution.
- [ ] A11y: color is NOT the only signal — every indicator includes the text label and an explicit `aria-label` (e.g. `aria-label="SLA breached, response overdue"`); icons are `aria-hidden` with the label carrying meaning. Tokens only (`no-hardcoded-colors`).
**Schema / Interfaces:**
```ts
interface SlaIndicatorProps {
  state: SlaIndicatorState;
  remainingMs: number | null;
  variant: 'first_response' | 'resolution';
}
```
**Acceptance:**
- [ ] A ticket with no policy shows grey "No SLA"; breached shows red with a text label; ≤25% shows 🟡 "{N}h left".
- [ ] Each indicator exposes a screen-reader label independent of color.

### Task 7: Ticket detail — SLA sidebar panel
**Blocks:** —  ·  **Blocked by:** 2, 3
**Files:**
- Modify: `apps/zync-app/src/features/support/TicketDetailSidebar.tsx` (crm-support-center component)
- Create: `apps/zync-app/src/features/sla/TicketSlaPanel.tsx`
**Steps:**
- [ ] Add an "SLA" section to the ticket detail sidebar using `useTicketSlaStatus(ticketId)` (30s polling).
- [ ] First response row: `responded` → "✓ Responded {time}"; `pending` → remaining or breached.
- [ ] Resolution row: `remaining` → "⏳ {Nh Nm} remaining (due by {time})"; `breached` → "✗ Breached {N}h ago" plus a `[Mark resolved]` `Button` (PATCH ticket status → `resolved` via existing `PATCH /api/tickets/:id`).
- [ ] If the ticket has no policy → render nothing or a muted "No SLA policy" line.
- [ ] A11y: status conveyed by text, not glyph alone; `--success`/`--warning`/`--danger` tokens; `prefers-reduced-motion` respected for any countdown animation (no animation by default — values update on 30s poll).
**Schema / Interfaces:**
```ts
interface TicketSlaPanelProps { ticketId: string; ticketStatus: string; }
```
**Acceptance:**
- [ ] Responded-and-on-track ticket shows both rows with correct due time.
- [ ] Breached ticket shows red resolution row + working "Mark resolved".
- [ ] Panel updates within 30s of an SLA state change without page reload.

### Task 8: i18n strings, RTL & a11y verification
**Blocks:** —  ·  **Blocked by:** 4, 5, 6, 7
**Files:**
- Modify: `packages/i18n/src/locales/en.json`, `packages/i18n/src/locales/he.json` (or app locale catalogs)
**Steps:**
- [ ] Add translation keys for all SLA UI copy (page header/upsell, table headers, sheet labels/helpers, indicator labels "No SLA"/"{n}h left"/"Response breached"/"Breached {n}h ago", panel rows, "Mark resolved").
- [ ] Verify Hebrew RTL: settings table column order mirrors, the Edit sheet slides from the correct side, numeric inputs and email field render LTR-isolated within RTL context using `useDirection`.
- [ ] Confirm indicator/panel colors come only from `--success`/`--warning`/`--danger` tokens and every status has a non-color text cue + `aria-label`.
**Acceptance:**
- [ ] No hardcoded user-facing strings remain in SLA components (all via translation catalog).
- [ ] Hebrew locale renders the SLA settings page and ticket SLA column/panel correctly mirrored with no color-only status signals.
