# Proposal Editor — Implementation Plan

**Spec:** docs/specs/2026-05-31-proposal-editor.md  ·  **Slug:** proposal-editor  ·  **Wave:** 11
**Depends on:** customers-module, foundation-auth-rbac, marketing-catalogs-campaigns, product-service-library

## Goal
Spec 23 (`marketing-catalogs-campaigns`) defines the `proposals` table and the send/accept funnel but provides no UI to author proposal content. This spec delivers the proposal editor: a split-panel create/edit surface that writes the structured section-based `proposals.content` JSONB, computes and denormalizes `proposals.total_amount`, snapshots content on send, emails the public link, and owns the canonical `/proposals/:id` detail route that downstream funnel specs (proposal-to-contract, proposal-to-invoice-direct, proposal-pdf-export) attach actions to. It also adds the `proposal_templates` table for system + tenant-custom starting points.

## Architecture
- **Consumes upstream `proposals` table** (from `marketing-catalogs-campaigns`): columns `id, tenant_id, lead_id, customer_id, template_id, name, content JSONB, status, public_token, expires_at, sent_at, accepted_at, accepted_by_name, customer_email, created_by, created_at, updated_at`. The editor reads/writes `content`, `name`, `customer_id`, `expires_at`, `status`, `total_amount`. NOTE: the spec's API body field `subject` maps to the canonical column `proposals.name` (there is no `subject` column; do not add one).
- **`proposals.total_amount`** is added by THIS spec (Schema Delta). `proposals.contract_id` and `invoices.proposal_id` are owned by `proposal-to-contract` (spec 78) / `proposal-to-invoice-direct` (spec 107) — this plan only READS them for the detail-view action bar and must not create them; guard reads so missing columns/links degrade gracefully.
- **`proposal_templates`** (new, this spec): JSONB content snapshots; `tenant_id IS NULL` = system template, non-null = tenant-custom.
- **Products picker** consumes the `products` table and `GET /api/products` from `product-service-library`: insert a line item with `description = product.name`, `quantity = 1`, `unit_price = product.unit_price`, `product_id = product.id`, tax from product's tax rate.
- **Customer selector** consumes `customers` / `listCustomers` / `useCustomerList` from `customers-module`.
- **Auth/RBAC**: all routes guard `marketing:read` (list/get) or `marketing:write` (create/update/send/delete/template-save) via `requirePermission`, plus `requireModuleEnabled('marketing')` and `authMiddleware`. Tenant scoping via `tenantQuery`.
- **Rich text**: Tiptap v2 in `text` sections, sharing the KB editor security path (`validateTiptapContent` + `ALLOWED_NODE_TYPES` server-side; `generateHTML` → `DOMPurify.sanitize` → `dangerouslySetInnerHTML` client-side, via a proposal-local `renderProposalSectionHTML` helper mirroring `renderArticleContent`).
- **Preview parity**: the editor live-preview renders the SAME React component (`ProposalRenderer`) that `public-proposal-view` (spec 51) mounts at `/p/{token}` — single source of truth. This plan defines `ProposalRenderer` as a shared export; spec 51 consumes it.
- **Email on send**: `sendEmail` (from `@zync/notifications`) via Resend, substituting `{{proposal_link}}` = `zync.is/p/{public_token}` and merge tags.

## Tech Stack
- **DB**: Neon Postgres via Cloudflare Hyperdrive; Drizzle ORM. Schema in `@zync/db`.
- **API**: Hono worker (apps/zync-api). Zod validation, `tenantQuery`/`systemQuery` helpers, `requirePermission`, `requireModuleEnabled`, `authMiddleware`.
- **App**: Vite + React (apps/zync-app) under `/proposals/*`. `@zync/ui` primitives (Button, Dialog, Sheet, Input, Select, Textarea, DataTable, Tabs, Card, Switch, EmptyState, Toast). React Query hooks.
- **Editor**: `@tiptap/react`, `@tiptap/starter-kit`, `@tiptap/extension-text-direction`, `@tiptap/html` (`generateHTML`), `dompurify`.
- **DnD**: section reordering via existing dnd primitive used by spec 128 form builder (`@dnd-kit/core` + `@dnd-kit/sortable`).
- **Email**: `sendEmail` (`@zync/notifications`) over Resend.
- **i18n/RTL**: `LocaleProvider`, `useDirection`; Tiptap `Direction` extension; `he-IL` default RTL.

## Wave Plan
| Sub-wave | Tasks | Files touched | Parallelizable? |
|----------|-------|---------------|-----------------|
| 11a | 1, 2 | `@zync/db` schema + migration; `@zync/types` content types & Zod | No (foundation for all) |
| 11b | 3, 4 | content compute/validation helpers; section validation | After 11a; parallel to each other |
| 11c | 5, 6, 7 | API routes (proposals CRUD, send, templates) | After 11b; routes parallel to each other |
| 11d | 8, 9, 10 | shared ProposalRenderer; editor shell + section editors; send modal & templates modal | After 11b (renderer) / 11c (data) |
| 11e | 11, 12 | detail view (non-DRAFT) + action bar; list page | After 11c, 11d |
| 11f | 13 | a11y, RTL, CSP verification pass | Last |

## Tasks

### Task 1: Schema Delta — `total_amount` column + `proposal_templates` table
**Blocks:** 2,3,5,6,7  ·  **Blocked by:** —
**Files:**
- Modify: `packages/db/src/schema/proposals.ts` (add `total_amount`; this file owns the upstream `proposals` Drizzle table from marketing-catalogs-campaigns)
- Create: `packages/db/src/schema/proposal-templates.ts`
- Modify: `packages/db/src/schema/index.ts` (export `proposalTemplates`)
- Create: `packages/db/migrations/<timestamp>_proposal_editor.sql`
**Steps:**
- [ ] Add `total_amount NUMERIC(10,2)` to the `proposals` Drizzle table (nullable; denormalized on save).
- [ ] Create `proposal_templates` Drizzle table + indexes.
- [ ] Write the raw SQL migration with both statements.
- [ ] Export `proposalTemplates` from the db package barrel.
**Schema / Interfaces:**
```sql
ALTER TABLE proposals ADD COLUMN total_amount NUMERIC(10,2);

CREATE TABLE proposal_templates (
  id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id   UUID REFERENCES tenants(id) ON DELETE CASCADE,  -- NULL = system template
  name        TEXT NOT NULL,
  content     JSONB NOT NULL,
  created_by  UUID REFERENCES users(id),
  created_at  TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_proposal_templates_tenant ON proposal_templates(tenant_id);
```
**Acceptance:**
- [ ] Migration applies cleanly on Neon; `proposals.total_amount` and `proposal_templates` exist.
- [ ] `proposalTemplates` is importable from `@zync/db`.

### Task 2: Content types & Zod schemas in `@zync/types`
**Blocks:** 3,4,5,6,8,9  ·  **Blocked by:** 1
**Files:**
- Create: `packages/types/src/proposals/content.ts`
- Modify: `packages/types/src/index.ts` (export new types/schemas)
**Steps:**
- [ ] Define the `ProposalContent`, `ProposalSection`, `LineItem` TypeScript types verbatim per spec.
- [ ] Define matching Zod schemas (`proposalContentSchema`, `proposalSectionSchema`, `lineItemSchema`) for runtime validation in routes.
- [ ] Constrain `settings.discount_pct` to 0–100, `align` to the literal union, `tax_pct` to a non-negative number, `currency` to ISO-4217 (3 uppercase letters).
- [ ] Export request body schemas: `createProposalSchema`, `updateProposalSchema`, `sendProposalSchema`, `saveProposalTemplateSchema`.
**Schema / Interfaces:**
```ts
export type LineItem = {
  id: string;
  description: string;
  quantity: number;
  unit_price: number;
  tax_pct: number;       // 0 | 17 | other
  product_id?: string;   // omitted for freeform items
};

export type ProposalSection =
  | { type: 'text';         id: string; html: string }
  | { type: 'line_items';   id: string; items: LineItem[] }
  | { type: 'image';        id: string; url: string; alt: string; align: 'left' | 'center' | 'right' }
  | { type: 'divider';      id: string; label?: string }
  | { type: 'testimonials'; id: string; items: { quote: string; author: string; company: string }[] }
  | { type: 'team';         id: string; members: { user_id: string; role_label: string }[] };

export type ProposalContent = {
  sections: ProposalSection[];
  settings: {
    show_line_tax: boolean;
    show_subtotal: boolean;
    discount_pct: number;  // 0-100
    currency: string;      // ISO 4217, default from tenant default_currency
  };
};

// Zod (abridged signatures — implement fully)
export const lineItemSchema: z.ZodType<LineItem>;
export const proposalSectionSchema: z.ZodType<ProposalSection>;
export const proposalContentSchema: z.ZodType<ProposalContent>;
export const createProposalSchema: z.ZodObject<{
  customer_id: z.ZodString;        // UUID
  subject: z.ZodString;            // maps to proposals.name
  content: typeof proposalContentSchema;
  expires_at: z.ZodOptional<z.ZodString>; // ISO date
}>;
export const updateProposalSchema: z.ZodObject<{
  subject: z.ZodOptional<z.ZodString>;
  content: z.ZodOptional<typeof proposalContentSchema>;
  expires_at: z.ZodOptional<z.ZodString>;
}>;
export const sendProposalSchema: z.ZodObject<{
  to: z.ZodArray<z.ZodString>;     // emails, min 1
  subject: z.ZodOptional<z.ZodString>;
  message: z.ZodOptional<z.ZodString>;
}>;
export const saveProposalTemplateSchema: z.ZodObject<{
  proposal_id: z.ZodString;
  name: z.ZodString;
}>;
```
**Acceptance:**
- [ ] Types and Zod schemas exported from `@zync/types`.
- [ ] `proposalContentSchema.parse` rejects unknown section types and out-of-range `discount_pct`.

### Task 3: Total computation + Tiptap node validation helpers
**Blocks:** 5,6  ·  **Blocked by:** 2
**Files:**
- Create: `packages/types/src/proposals/compute.ts` (or co-locate in `@zync/db` server utils if `@zync/types` must stay runtime-light — pick `@zync/types`)
- Create: `apps/zync-api/src/lib/proposal-content-security.ts`
**Steps:**
- [ ] Implement `computeProposalTotal(content): number` — for each `line_items` section: line amount = `quantity * unit_price`; tax = `amount * tax_pct/100`; sum amounts (+ tax) across all line-items sections; apply `settings.discount_pct` to the grand total; round to 2 decimals. Return the denormalized `total_amount`.
- [ ] Implement `validateProposalContent(content)` server-side: walk every `text` section's Tiptap HTML/JSON and reject node types not in `ALLOWED_NODE_TYPES` (mirror `validateTiptapContent` from kb-article-editor spec 101); throw `ApiError` 422 on violation. Validate against `proposalContentSchema` first, then node-type walk.
- [ ] Define `ALLOWED_NODE_TYPES` set identical to the KB editor's (doc, paragraph, heading, bulletList, orderedList, listItem, text, bold, italic, underline, hardBreak) plus none beyond what the toolbar emits.
**Schema / Interfaces:**
```ts
export function computeProposalTotal(content: ProposalContent): number;
// server-side, apps/zync-api:
export const ALLOWED_NODE_TYPES: Set<string>;
export function validateProposalContent(content: ProposalContent): void; // throws ApiError 422
```
**Acceptance:**
- [ ] `computeProposalTotal` matches the spec example (subtotal ₪10,000, VAT 17% → total ₪11,700 at discount 0).
- [ ] `validateProposalContent` rejects a `text` section containing a `script`/`iframe`/disallowed node.

### Task 4: `renderProposalSectionHTML` sanitizer helper (shared)
**Blocks:** 8  ·  **Blocked by:** 2
**Files:**
- Create: `packages/ui/src/proposals/render-section-html.ts`
**Steps:**
- [ ] Implement `renderProposalSectionHTML(tiptapJSON): string` = `generateHTML(json, extensions)` → `DOMPurify.sanitize(html, { ALLOWED_TAGS, ALLOWED_ATTR })` mirroring `renderArticleContent` (spec 101) and `renderContractHTML` (spec 48).
- [ ] Restrict `img src` (for image sections rendered alongside) to the tenant R2 domain via a DOMPurify uponSanitizeAttribute hook (anti-SSRF, per spec security section).
- [ ] Export it for both the live-preview and the shared `ProposalRenderer`.
**Schema / Interfaces:**
```ts
export function renderProposalSectionHTML(tiptapJSON: unknown): string; // sanitized HTML
```
**Acceptance:**
- [ ] Output for a benign doc is unchanged; a `text` body with `onerror`/`<script>` is stripped.

### Task 5: Proposals CRUD API routes
**Blocks:** 9,11,12  ·  **Blocked by:** 2,3
**Files:**
- Create: `apps/zync-api/src/routes/proposals.ts`
- Modify: `apps/zync-api/src/index.ts` (mount router under `/api`)
**Steps:**
- [ ] `GET /api/proposals` — paginated list; query `{ status?, customer_id?, page? }`; `requirePermission('marketing:read')`; tenant-scoped via `tenantQuery`; return `buildPaginated`. Select list-display fields incl. `total_amount`, `status`, `name`, `customer_id`, `expires_at`, `updated_at`.
- [ ] `POST /api/proposals` — `requirePermission('marketing:write')`; body `createProposalSchema`; `validateProposalContent`; insert DRAFT row with `name = body.subject`, `content`, `customer_id`, `expires_at`, `created_by`, `tenant_id`, `total_amount = computeProposalTotal(content)`. `public_token` is generated by the upstream insert path (spec 23) at row creation — generate a URL-safe token here if the upstream default does not (use `generateOpaqueToken`). Return created proposal.
- [ ] `GET /api/proposals/:id` — `marketing:read`; tenant-scoped; 404 if not found; return full row incl. `content`, and (read-only, guarded) `contract_id` / linked-invoice presence for the action bar.
- [ ] `PATCH /api/proposals/:id` — `marketing:write`; **DRAFT only** (409 `ApiError` if status != DRAFT — snapshot rule); body `updateProposalSchema`; if `content` present run `validateProposalContent` and recompute `total_amount`; map `subject`→`name`; bump `updated_at`.
- [ ] `DELETE /api/proposals/:id` — `marketing:write`; **DRAFT only** (409 otherwise); tenant-scoped delete.
- [ ] All routes: `authMiddleware`, `requireModuleEnabled('marketing')`, Zod validation, no raw Drizzle (use tenantQuery helpers per `no-raw-drizzle-from-routes`).
**Schema / Interfaces:**
```
GET    /api/proposals            marketing:read   query {status?,customer_id?,page?}
POST   /api/proposals            marketing:write  body createProposalSchema
GET    /api/proposals/:id        marketing:read
PATCH  /api/proposals/:id        marketing:write  body updateProposalSchema (DRAFT only → 409)
DELETE /api/proposals/:id        marketing:write  (DRAFT only → 409)
```
**Acceptance:**
- [ ] PATCH/DELETE on a SENT proposal returns 409.
- [ ] POST persists `total_amount` equal to `computeProposalTotal`.
- [ ] Cross-tenant id returns 404.

### Task 6: Send route — snapshot, status, email
**Blocks:** 10  ·  **Blocked by:** 2,3
**Files:**
- Modify: `apps/zync-api/src/routes/proposals.ts`
**Steps:**
- [ ] `POST /api/proposals/:id/send` — `marketing:write`; body `sendProposalSchema`; load DRAFT proposal (409 if already non-DRAFT).
- [ ] In a single transaction: re-validate content (`validateProposalContent`), recompute and store `total_amount`, freeze `content` snapshot (content is already the source — set `sent_at = now()`, `status = 'SENT'`), set `expires_at` from request `expires_at`/valid-until if provided.
- [ ] Use existing `public_token` (already populated at row creation); build `proposal_link = https://zync.is/p/{public_token}`.
- [ ] Substitute merge tags in the message (`{{proposal_link}}`, `{{customer_name}}`, `{{business_name}}`, `{{valid_until}}`, `{{total_amount}}`) and send via `sendEmail` (Resend) to each `to[]` recipient; capture first recipient into `customer_email` if unset.
- [ ] Return the updated proposal.
**Schema / Interfaces:**
```
POST /api/proposals/:id/send   marketing:write
  body sendProposalSchema { to: string[], subject?, message? }
  → tx: status DRAFT→SENT, sent_at=now(), total_amount recomputed, email sent
```
**Acceptance:**
- [ ] Sending sets status SENT and emails each recipient with a resolved `{{proposal_link}}`.
- [ ] Sending an already-SENT proposal returns 409.
- [ ] Status flips and email send occur atomically (failure rolls back status).

### Task 7: Proposal templates API
**Blocks:** 10  ·  **Blocked by:** 1,2
**Files:**
- Create: `apps/zync-api/src/routes/proposal-templates.ts`
- Modify: `apps/zync-api/src/index.ts` (mount)
- Create: `packages/db/src/seed/proposal-templates.ts`
**Steps:**
- [ ] `GET /api/proposal-templates` — `marketing:read`; return system templates (`tenant_id IS NULL`) UNION tenant templates (`tenant_id = current`).
- [ ] `POST /api/proposal-templates` — `marketing:write`; body `saveProposalTemplateSchema`; load the source proposal (tenant-scoped), copy its validated `content` into a new `proposal_templates` row with `tenant_id = current`, `name`, `created_by`.
- [ ] Seed four system templates (`tenant_id NULL`): web design, software development, consulting retainer, brand identity — each a valid `ProposalContent` JSONB snapshot with representative sections (intro text, scope text, line_items, terms text, signature placeholder via divider/team).
**Schema / Interfaces:**
```
GET  /api/proposal-templates   marketing:read   → system (tenant_id NULL) + tenant rows
POST /api/proposal-templates   marketing:write  body { proposal_id, name }
```
**Acceptance:**
- [ ] System templates returned for every tenant; tenant-saved templates only for that tenant.
- [ ] Seed produces 4 system templates whose `content` passes `validateProposalContent`.

### Task 8: Shared `ProposalRenderer` component
**Blocks:** 9,11  ·  **Blocked by:** 4
**Files:**
- Create: `packages/ui/src/proposals/ProposalRenderer.tsx`
- Modify: `packages/ui/src/index.ts` (export `ProposalRenderer`, `renderProposalSectionHTML`)
**Steps:**
- [ ] Implement a pure React component that takes `{ content, customer, businessName, validUntil, total }` and renders each section type: `text` (via `renderProposalSectionHTML` → `dangerouslySetInnerHTML`), `line_items` (table with per-line tax/subtotal/VAT/discount per `settings`), `image` (aligned, alt required), `divider` (optional label), `testimonials`, `team` (resolve `user_id`→name/photo from passed-in member map).
- [ ] Resolve merge tags `{{customer_name}}`, `{{business_name}}`, `{{valid_until}}`, `{{total_amount}}` in text output.
- [ ] Respect `useDirection()`/`dir` for RTL; honor `prefers-reduced-motion` (no animated reveals).
- [ ] This is the SINGLE renderer used by the editor preview AND spec 51's `/p/{token}` public view — export it as the canonical `ProposalRenderer`.
**Schema / Interfaces:**
```ts
export interface ProposalRendererProps {
  content: ProposalContent;
  customerName: string;
  businessName: string;
  validUntil?: string;
  total: number;
  teamMembers?: Record<string, { name: string; photoUrl?: string }>;
}
export function ProposalRenderer(props: ProposalRendererProps): JSX.Element;
```
**Acceptance:**
- [ ] Rendering the spec example yields the line-items table with Subtotal ₪10,000 / VAT ₪1,700 / Total ₪11,700.
- [ ] `text` HTML is sanitized; no raw script executes.

### Task 9: Editor shell + section editors (`/proposals/new`, `/proposals/:id/edit`)
**Blocks:** 11  ·  **Blocked by:** 2,5,8
**Files:**
- Create: `apps/zync-app/src/pages/proposals/ProposalEditorPage.tsx`
- Create: `apps/zync-app/src/pages/proposals/sections/TextSectionEditor.tsx`
- Create: `apps/zync-app/src/pages/proposals/sections/LineItemsSectionEditor.tsx`
- Create: `apps/zync-app/src/pages/proposals/sections/ImageSectionEditor.tsx`
- Create: `apps/zync-app/src/pages/proposals/sections/DividerSectionEditor.tsx`
- Create: `apps/zync-app/src/pages/proposals/sections/TestimonialsSectionEditor.tsx`
- Create: `apps/zync-app/src/pages/proposals/sections/TeamSectionEditor.tsx`
- Create: `apps/zync-app/src/pages/proposals/ProductPicker.tsx`
- Create: `apps/zync-app/src/hooks/useProposal.ts`, `apps/zync-app/src/hooks/useProposalMutations.ts`
- Modify: `apps/zync-app/src/router.tsx` (routes `/proposals/new`, `/proposals/:id/edit`, `/proposals/:id`)
**Steps:**
- [ ] Build split-panel layout: left = form (Customer `Select` via `useCustomerList`, Subject `Input`, Valid-until date `Input`, section list), right = live `ProposalRenderer` preview that refreshes on every edit.
- [ ] Section list with drag handle (`@dnd-kit/sortable`), per-row `[Edit]` / `[✕]`, and `[+ Add section ▾]` menu (Text / Line items / Image / Divider / Testimonials / Team).
- [ ] **TextSectionEditor**: Tiptap v2 instance with StarterKit (headings, lists, bold/italic), `Direction` extension (RTL per locale, per-paragraph ↔ toggle), merge-tag insert buttons. Output Tiptap JSON into `section.html`.
- [ ] **LineItemsSectionEditor**: editable rows (description, qty, unit, amount, tax %); `[+ Add line item]`; `[+ Add from library]` opens `ProductPicker` (consumes `GET /api/products`); inserts `description=product.name, quantity=1, unit_price=product.unit_price, product_id=product.id`, tax from product tax rate. Footer: Subtotal / VAT / Total live; toggles `show_line_tax`, `show_subtotal`; `discount_pct` input.
- [ ] **ImageSectionEditor**: upload or URL, required alt text, alignment radio.
- [ ] **DividerSectionEditor**: optional label.
- [ ] **TestimonialsSectionEditor**: up to 3 entries (quote/author/company).
- [ ] **TeamSectionEditor**: pick users (from `users`), set `role_label`, optional photo.
- [ ] `/proposals/new` initializes blank or from a chosen template (Task 10 modal); `/proposals/:id/edit` loads via `GET /api/proposals/:id` and 403/redirects if not DRAFT.
- [ ] `/proposals/:id` route: if DRAFT redirect to `/edit`, else render detail view (Task 11).
- [ ] Save draft → `POST` (new) or `PATCH` (existing); show Toast on success/error; debounce live total recompute client-side (server is source of truth on save).
**Acceptance:**
- [ ] Adding/reordering/removing sections updates the live preview immediately.
- [ ] Add-from-library pre-fills a line item from a product; price remains editable.
- [ ] Editing a non-DRAFT proposal is blocked (redirect to read-only detail).

### Task 10: Send modal + New-proposal/template modal
**Blocks:** 11  ·  **Blocked by:** 6,7,9
**Files:**
- Create: `apps/zync-app/src/pages/proposals/SendProposalModal.tsx`
- Create: `apps/zync-app/src/pages/proposals/NewProposalModal.tsx`
**Steps:**
- [ ] **SendProposalModal** (`Sheet`/slide-in): recipient list with `[+ Add]`, Subject `Input`, Message `Textarea` prefilled with `{{proposal_link}}` + `{business_name}` template, Valid-until date; `[Cancel]` / `[Send proposal]` → `POST /api/proposals/:id/send`; on success route to `/proposals/:id` detail (now SENT) and Toast.
- [ ] **NewProposalModal** (`Dialog`): radio list "Start from scratch" + template names from `GET /api/proposal-templates`; on pick, navigate to `/proposals/new` seeded with chosen template `content` (or blank).
- [ ] "Save as template" action (in editor overflow menu): prompt for name → `POST /api/proposal-templates { proposal_id, name }`.
**Acceptance:**
- [ ] Send modal posts to `/send`, transitions proposal to SENT, and the user lands on the read-only detail view.
- [ ] New-proposal modal lists system + tenant templates and seeds the editor.

### Task 11: Non-DRAFT detail view + state-dependent action bar (`/proposals/:id`)
**Blocks:** —  ·  **Blocked by:** 5,8,10
**Files:**
- Create: `apps/zync-app/src/pages/proposals/ProposalDetailPage.tsx`
**Steps:**
- [ ] Render read-only detail for SENT/VIEWED/ACCEPTED/REJECTED/EXPIRED: status header (status + accepted date/name), value (`total_amount`), valid-until; rendered `ProposalRenderer` preview; tabs `[Details] [Timeline] [Contract]` (Contract tab is a slot owned by spec 78 — render a placeholder mount point, not a parallel surface).
- [ ] Action bar, state-dependent (guard reads of `contract_id` / linked invoice so absence is safe):
  - `[Export PDF ↓]` (any non-DRAFT) → `GET /api/proposals/:id/pdf` (owned by spec 159; wire the button/link only).
  - `[Mark accepted]` (SENT/VIEWED) → confirmation → set ACCEPTED (reuse proposals-list quick action; if endpoint not yet present, call the spec-156 accept endpoint — do not invent a new one here).
  - `[Create Contract →]` (ACCEPTED, no `contract_id`) → `/contracts/new?proposal_id={id}`.
  - `[Create Invoice →]` (ACCEPTED, no linked invoice) → invoice-from-proposal modal (spec 107) trigger.
  - `[View Contract →]` (`contract_id` set) → linked contract.
  - `[View Invoice →]` (linked invoice set) → linked invoice.
- [ ] This is the canonical `/proposals/:id` surface; do NOT create `/marketing/proposals/:id`.
**Acceptance:**
- [ ] Detail view is read-only (no editor) for all non-DRAFT states.
- [ ] Action bar shows exactly the actions allowed by current state and link presence.

### Task 12: Proposals list page (`/proposals`)
**Blocks:** —  ·  **Blocked by:** 5
**Files:**
- Create: `apps/zync-app/src/pages/proposals/ProposalsListPage.tsx`
- Create: `apps/zync-app/src/hooks/useProposalList.ts`
**Steps:**
- [ ] `DataTable` of proposals: columns Name, Customer, Status badge, Value (`total_amount`), Valid until, Updated; sortable by total (denormalized column enables this). Filters: status, customer. Pagination via `GET /api/proposals`.
- [ ] `[+ New proposal]` opens `NewProposalModal`. Row click → `/proposals/:id` (DRAFT auto-redirects to edit).
- [ ] `EmptyState` when no proposals.
**Acceptance:**
- [ ] List paginates and filters by status/customer; sorting by value works.
- [ ] New-proposal entry point launches the template modal.

### Task 13: Accessibility, RTL, and CSP verification pass
**Blocks:** —  ·  **Blocked by:** 9,10,11
**Files:**
- Modify: editor/section/preview components from Tasks 8–11 as needed.
**Steps:**
- [ ] Tiptap editor container: `role="textbox"`, `aria-multiline="true"`, `aria-label="Proposal text editor"`.
- [ ] Toolbar: `role="toolbar"`, `aria-label="Text formatting"`; toggle buttons expose `aria-pressed`.
- [ ] Keyboard: `⌘B`/`⌘I`/`⌘U` formatting not overridden; Tab enters editor, Escape returns focus to last element outside; image-insert trigger `aria-label="Insert image"` with alt text required in the modal.
- [ ] RTL: `Direction.configure({ defaultDirection: locale==='he-IL' ? 'rtl' : 'ltr' })`; per-paragraph ↔ toggle persists `dir` on paragraph nodes in stored JSONB; preview honors `useDirection`.
- [ ] `prefers-reduced-motion`: no animated section reveals in preview/renderer.
- [ ] CSP/content security: confirm all `text` rendering passes through `renderProposalSectionHTML` (DOMPurify) before `dangerouslySetInnerHTML`; confirm server `validateProposalContent` runs on POST/PATCH/send/template-save; `img src` limited to tenant R2 domain.
**Acceptance:**
- [ ] axe/pa11y reports no critical violations on editor and detail pages.
- [ ] Hebrew locale renders editor and preview RTL; direction persists across save/reload.
- [ ] No `text` section can inject script (server validation + DOMPurify both enforced).
