# Projects Module — Implementation Plan

**Spec:** docs/specs/2026-05-30-projects-module.md  ·  **Slug:** projects-module  ·  **Wave:** 3
**Depends on:** customers-module, foundation-auth-rbac

## Goal
Deliver the central organizing unit of the platform: projects that belong to a customer and carry a billing type (fixed / hourly / retainer) driving downstream invoice automation. This module is itself a foundation dependency — `tasks-board-engine`, `time-management`, `invoices-core`, and `contractor-payouts` all attach to `projects(id)` via `project_id UUID`. It ships the `projects`, `project_members`, and `retainer_months` tables, their tenant-scoped query helpers, the `/api/projects` route group, and the `/projects` list + `/projects/:id` detail UI.

## Architecture
- **DB (`packages/db`):** Three new tables — `projects`, `project_members`, `retainer_months`. All carry `tenant_id UUID REFERENCES tenants(id)` for multi-tenant row isolation, enforced through the `tenantQuery(db, tenantId)` factory (from `foundation-monorepo`), never RLS. `projects.customer_id` is a nullable `UUID REFERENCES customers(id)` (internal projects have no customer) consuming the upstream `customers` table from `customers-module`. `projects.created_by` and `project_members.user_id` are `UUID REFERENCES users(id)` from `foundation-auth-rbac`. Billing-type config is stored as `JSONB` (three shapes) to avoid sparse nullable columns.
- **Query helpers (`packages/db/src/queries/projects.ts`):** Registered on the `tenantQuery` factory as the `project` domain. Cursor-paginated/filterable list, detail-with-stats, member CRUD, hours summary, retainer-ledger read + increment. Every statement is tenant-filtered through `tenantQuery`. No raw Drizzle from routes (`no-raw-drizzle-from-routes`).
- **API (`apps/zync-api`):** Hono route group mounted at `/api/projects`. Each route runs `authMiddleware` → `requireModuleEnabled('projects')` → `requirePermission(...)` using `projects:read | projects:write | projects:delete` (seeded by `foundation-auth-rbac`). All bodies validated with `zod` (`require-zod-validation-in-routes`). State-mutating routes rely on the shared auth middleware's `Origin` check. Responses use `buildPaginated` + cursor helpers (`encodeCursor`/`decodeCursor`/`clampLimit`) and the `serializeProject` mapper.
- **Retainer engine:** `time-management` time entries increment `retainer_months.hours_used` via the exported `incrementRetainerHours` helper. When `hours_used >= hours_included`, the helper fires the `retainer.depleted` webhook (`webhook.deliver` from `system-communications-notifications`) and, if `auto_invoice = true` and `hour_bank_overflow_action = 'invoice'`, enqueues invoice generation on the `QUEUE` binding. This module owns the ledger + trigger; `invoices-core` (later wave) consumes the queue message.
- **UI (`apps/zync-app`):** `/projects` list route (card/table toggle persisted in `localStorage`, filters, sort, "+ New Project" `Sheet`) and `/projects/:id` detail route (Tabs: Overview, Tasks, Time, Invoices, Files, Settings). The whole module tree is wrapped in `<ModuleGuard moduleId="projects">`. Cross-module tabs (Tasks/Time/Invoices/Files) embed the respective module components when present and otherwise render the design-system `EmptyState`. Built exclusively from `packages/ui` primitives — no raw HTML (`no-raw-html-in-pages`), no hardcoded colors/spacing (`no-hardcoded-colors`, `no-hardcoded-spacing`).

## Tech Stack
- **Packages:** `packages/db` (Drizzle ORM, drizzle-kit, `@neondatabase/serverless` over Hyperdrive binding `DB`), `packages/ui` (primitives: `Sheet`, `DataTable`, `Card`, `Tabs`, `Badge`, `Progress`, `Form`, `Select`, `Radio`, `Input`, `Button`, `EmptyState`, `Skeleton`, `StatCard`), `packages/auth` (`authMiddleware`, `requirePermission`), `packages/types`, `packages/config`.
- **Apps:** `apps/zync-api` (Hono on Cloudflare Workers), `apps/zync-app` (Vite + React, TanStack Query v5, TanStack Table v8).
- **Bindings:** `DB` (Neon via Hyperdrive), `QUEUE` (retainer invoice enqueue). Webhook delivery via `webhook.deliver` (notifications system).
- **Validation:** `zod` schemas shared between API handlers and React forms.
- **Module gating:** `requireModuleEnabled('projects')` (API), `<ModuleGuard moduleId="projects">` + `useModuleEnabled('projects')` (UI) from `module-management`.

## Wave Plan
| Sub-wave | Tasks | Files touched | Parallelizable? |
|----------|-------|---------------|-----------------|
| PR1 — Schema | 1 | `packages/db/src/schema/projects.ts`, migration, schema barrel | No (blocks all) |
| PR2 — Types & validation | 2 | `packages/types/src/projects.ts`, `packages/db/src/validation/projects.ts` | After PR1 |
| PR3 — Queries & serializer | 3, 4 | `packages/db/src/queries/projects.ts`, `packages/db/src/serialize/projects.ts` | Tasks 3 & 4 parallel after PR2 |
| PR4 — Retainer engine | 5 | `packages/db/src/queries/projects.ts` (retainer fns) | After PR3 |
| PR5 — API routes | 6, 7, 8 | `apps/zync-api/src/routes/projects/*` | After PR3; route files parallel |
| PR6 — UI data layer | 9 | `apps/zync-app/src/modules/projects/api.ts`, `hooks.ts` | After PR5 |
| PR7 — UI list & create/edit | 10, 11 | `apps/zync-app/src/modules/projects/list/*`, `form/*` | After PR6; parallel |
| PR8 — UI detail & tabs | 12 | `apps/zync-app/src/modules/projects/detail/*` | After PR6 |
| PR9 — Nav & module guard wiring | 13 | app shell nav, route registration | After PR7, PR8 |

## Tasks

### Task 1: Database schema & migration
**Blocks:** 2, 3, 4, 5  ·  **Blocked by:** —
**Files:**
- Create: `packages/db/src/schema/projects.ts`
- Modify: `packages/db/src/schema/index.ts` (re-export projects schema)
- Create: `packages/db/migrations/<timestamp>_projects_module.sql`
**Steps:**
- [ ] Define the three tables in Drizzle (`pgTable`) matching the DDL below verbatim — UUID PKs, UUID→UUID FKs, TIMESTAMPTZ timestamps, JSONB `billing_config`, NUMERIC money/hours columns, inline CHECK enums.
- [ ] `projects.customer_id` is NULLABLE and references `customers(id)` with `ON DELETE SET NULL` (internal projects survive customer archival; customer rows are soft-archived not deleted, but the FK must not block).
- [ ] `project_members` uses the composite PK `(project_id, user_id)` per spec.
- [ ] `retainer_months` has `UNIQUE (project_id, month)`.
- [ ] Add list/filter indexes and the retainer lookup index.
- [ ] Generate the SQL migration with drizzle-kit; verify it targets Postgres (Neon), not SQLite (no `INTEGER` booleans, no `TEXT` JSON).
- [ ] Re-export the new tables from the schema barrel.
**Schema / Interfaces:**
```sql
CREATE TABLE projects (
  id            UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id     UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
  customer_id   UUID REFERENCES customers(id) ON DELETE SET NULL,   -- nullable: internal projects
  name          TEXT NOT NULL,
  description   TEXT,
  status        TEXT NOT NULL DEFAULT 'active'
                CHECK (status IN ('active', 'on_hold', 'completed', 'archived')),
  billing_type  TEXT NOT NULL
                CHECK (billing_type IN ('fixed', 'hourly', 'retainer')),
  billing_config JSONB,                       -- type-specific shape (see below)
  currency      TEXT NOT NULL DEFAULT 'ILS',
  start_date    DATE,
  end_date      DATE,
  created_by    UUID NOT NULL REFERENCES users(id),
  created_at    TIMESTAMPTZ NOT NULL DEFAULT now(),
  updated_at    TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_projects_tenant_status   ON projects(tenant_id, status, updated_at DESC);
CREATE INDEX idx_projects_tenant_customer ON projects(tenant_id, customer_id);
CREATE INDEX idx_projects_tenant_billing  ON projects(tenant_id, billing_type);
CREATE INDEX idx_projects_tenant_name     ON projects(tenant_id, name);

CREATE TABLE project_members (
  project_id  UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
  user_id     UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  tenant_id   UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
  role        TEXT NOT NULL DEFAULT 'member'
              CHECK (role IN ('owner', 'member', 'viewer')),
  hourly_rate NUMERIC(10,2),                  -- contractor-specific override, nullable
  created_at  TIMESTAMPTZ NOT NULL DEFAULT now(),
  PRIMARY KEY (project_id, user_id)
);
CREATE INDEX idx_project_members_tenant_user ON project_members(tenant_id, user_id);

CREATE TABLE retainer_months (
  id                   UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  project_id           UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
  tenant_id            UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
  month                TEXT NOT NULL,         -- 'YYYY-MM'
  hours_included       NUMERIC(6,2),
  hours_used           NUMERIC(6,2) NOT NULL DEFAULT 0,
  hours_rolled_over    NUMERIC(6,2) NOT NULL DEFAULT 0,
  invoice_triggered_at TIMESTAMPTZ,
  created_at           TIMESTAMPTZ NOT NULL DEFAULT now(),
  UNIQUE (project_id, month)
);
CREATE INDEX idx_retainer_months_project ON retainer_months(tenant_id, project_id, month);
```
`billing_config` JSONB shapes (validated in Task 2, not enforced by DB):
- Fixed: `{ "total_amount": number, "deposit_pct": number }`
- Hourly: `{ "rate_per_hour": number, "overtime_enabled": boolean, "overtime_threshold_hours": number, "overtime_multiplier": number }`
- Retainer: `{ "monthly_amount": number, "monthly_hours_included": number, "auto_invoice": boolean, "hour_bank_overflow_action": "invoice" | "carry_over" }`
**Acceptance:**
- [ ] Migration applies cleanly to a Neon branch; `\d projects` shows UUID PK, JSONB `billing_config`, NUMERIC columns, TIMESTAMPTZ timestamps.
- [ ] `project_members` PK is `(project_id, user_id)`; `retainer_months` has the `(project_id, month)` unique constraint.
- [ ] All FKs are UUID→UUID; no INTEGER booleans, no TEXT-encoded JSON anywhere.

### Task 2: Shared types & zod validation
**Blocks:** 3, 6  ·  **Blocked by:** 1
**Files:**
- Create: `packages/types/src/projects.ts`
- Modify: `packages/types/src/index.ts` (re-export)
- Create: `packages/db/src/validation/projects.ts`
**Steps:**
- [ ] Define `ProjectObject`, `ProjectMemberObject`, `RetainerMonthObject` TypeScript types plus the `ProjectStatus`, `ProjectBillingType`, `ProjectMemberRole`, `RetainerOverflowAction` string-literal unions.
- [ ] Define discriminated `BillingConfig` union (`FixedBillingConfig | HourlyBillingConfig | RetainerBillingConfig`) keyed on `billing_type`.
- [ ] Author `createProjectSchema`, `updateProjectSchema` (partial), `addMemberSchema`, `updateMemberSchema`, and per-type `billingConfigSchema` zod schemas; `createProjectSchema` validates `billing_config` via a discriminated union on `billing_type` so each shape's required fields are enforced.
- [ ] Author `listProjectsQuerySchema` (cursor, limit, `status?`, `billing_type?`, `customer_id?`, `sort` in `'name'|'start_date'|'updated_at'`).
**Schema / Interfaces:**
```typescript
export type ProjectStatus = 'active' | 'on_hold' | 'completed' | 'archived';
export type ProjectBillingType = 'fixed' | 'hourly' | 'retainer';
export type ProjectMemberRole = 'owner' | 'member' | 'viewer';
export type RetainerOverflowAction = 'invoice' | 'carry_over';

export interface FixedBillingConfig { total_amount: number; deposit_pct: number; }
export interface HourlyBillingConfig {
  rate_per_hour: number; overtime_enabled: boolean;
  overtime_threshold_hours: number; overtime_multiplier: number;
}
export interface RetainerBillingConfig {
  monthly_amount: number; monthly_hours_included: number;
  auto_invoice: boolean; hour_bank_overflow_action: RetainerOverflowAction;
}
export type BillingConfig = FixedBillingConfig | HourlyBillingConfig | RetainerBillingConfig;

export interface ProjectObject {
  id: string; tenant_id: string; customer_id: string | null;
  name: string; description: string | null; status: ProjectStatus;
  billing_type: ProjectBillingType; billing_config: BillingConfig | null;
  currency: string; start_date: string | null; end_date: string | null;
  created_by: string; created_at: string; updated_at: string;
}
export interface ProjectMemberObject {
  project_id: string; user_id: string; tenant_id: string;
  role: ProjectMemberRole; hourly_rate: number | null;
}
export interface RetainerMonthObject {
  id: string; project_id: string; tenant_id: string; month: string;
  hours_included: number | null; hours_used: number;
  hours_rolled_over: number; invoice_triggered_at: string | null;
}

// zod (packages/db/src/validation/projects.ts)
export const createProjectSchema: z.ZodType<CreateProjectInput>;   // name, customer_id?, description?, billing_type, billing_config (discriminated), currency?, start_date?, end_date?, members?: AddMemberInput[]
export const updateProjectSchema: z.ZodType<UpdateProjectInput>;   // all optional incl. status, billing_config
export const addMemberSchema: z.ZodType<AddMemberInput>;           // user_id, role?, hourly_rate?
export const updateMemberSchema: z.ZodType<UpdateMemberInput>;     // role?, hourly_rate?
export const listProjectsQuerySchema: z.ZodType<ListProjectsParams>;
```
**Acceptance:**
- [ ] `createProjectSchema` rejects a `billing_type='retainer'` body that omits `monthly_amount`.
- [ ] `listProjectsQuerySchema` clamps `limit` to ≤100 and rejects unknown `sort` values.

### Task 3: Project query helpers on tenantQuery factory
**Blocks:** 6, 7, 8  ·  **Blocked by:** 2
**Files:**
- Create: `packages/db/src/queries/projects.ts`
- Modify: `packages/db/src/queries/index.ts` (register `project` domain on the `tenantQuery` factory)
**Steps:**
- [ ] Implement all helpers below, each pre-bound to `tenant_id` via the `tenantQuery(db, tenantId)` factory so callers never pass `tenant_id` and no statement can leak across tenants.
- [ ] `listProjects` is cursor-paginated using `encodeCursor`/`decodeCursor`/`clampLimit`; applies optional `status`, `billing_type`, `customer_id` filters and `sort`; returns `{ items, nextCursor, total }`.
- [ ] `getProjectWithStats` returns the project plus computed stats: `total_tasks`, `open_tasks`, `hours_tracked` (all-time + this-month), and invoice summary (`paid`, `outstanding`). Where the tasks/time/invoices tables do not yet exist (build order), the helper returns zeroed stats — gate the joins behind table existence so this module builds standalone. Current-month raw SQL MUST pass a UTC month-start ISO 8601 string as its TIMESTAMPTZ parameter; NEVER interpolate a JavaScript `Date` object.
- [ ] `archiveProject` performs the soft delete (`status='archived'`, `updated_at=now()`).
- [ ] Member helpers: `listProjectMembers`, `addProjectMember`, `updateProjectMember`, `removeProjectMember`.
- [ ] `getProjectHours` returns `{ this_month, all_time }` hour totals (reads `time-management` entries when present, else zero). Its current-month raw SQL uses the same UTC month-start ISO 8601 TIMESTAMPTZ parameter contract as `getProjectWithStats`.
- [ ] `listRetainerMonths` returns the retainer ledger ordered by `month DESC`.
- [ ] Member-visibility filter: when the caller's role does not grant tenant-wide `projects:read` visibility, restrict `listProjects` / `getProjectWithStats` to projects where the user is in `project_members`. Accept a `fullVisibility: boolean` argument resolved by the route from the session's role config.
**Schema / Interfaces:**
```typescript
// Registered as tenantQuery(db, tenantId).project
export interface ProjectQueries {
  list(params: ListProjectsParams, opts: { fullVisibility: boolean; userId: string }):
    Promise<{ items: ProjectObject[]; nextCursor: string | null; total: number }>;
  byId(projectId: string): Promise<ProjectObject | null>;
  withStats(projectId: string): Promise<(ProjectObject & { stats: ProjectStats }) | null>;
  create(input: CreateProjectInput, createdBy: string): Promise<ProjectObject>;
  update(projectId: string, patch: UpdateProjectInput): Promise<ProjectObject>;
  archive(projectId: string): Promise<void>;
  listMembers(projectId: string): Promise<ProjectMemberObject[]>;
  addMember(projectId: string, input: AddMemberInput): Promise<ProjectMemberObject>;
  updateMember(projectId: string, userId: string, patch: UpdateMemberInput): Promise<ProjectMemberObject>;
  removeMember(projectId: string, userId: string): Promise<void>;
  hours(projectId: string): Promise<{ this_month: number; all_time: number }>;
  retainerMonths(projectId: string): Promise<RetainerMonthObject[]>;
}
export interface ProjectStats {
  total_tasks: number; open_tasks: number;
  hours_this_month: number; hours_all_time: number;
  invoices_paid: number; invoices_outstanding: number;
}
```
**Acceptance:**
- [ ] Every helper goes through `tenantQuery`; no helper accepts a raw `tenant_id` argument and no route file imports Drizzle directly.
- [ ] `list` with `fullVisibility=false` returns only projects the `userId` is a member of.
- [ ] UTC month-start regression expects `2026-08-01T00:00:00.000Z` for `2026-08-07T12:22:11.000Z`; project detail and hours aggregates bind that string, not a JavaScript `Date`.

### Task 4: Project serializer
**Blocks:** 6  ·  **Blocked by:** 2
**Files:**
- Create: `packages/db/src/serialize/projects.ts`
- Modify: `packages/db/src/serialize/index.ts` (re-export)
**Steps:**
- [ ] Implement `serializeProject(row): ProjectObject` and `serializeProjectMember`, `serializeRetainerMonth` mappers converting DB rows (DATE/NUMERIC/TIMESTAMPTZ) to JSON-safe shapes (ISO strings, numbers).
- [ ] NUMERIC columns (`hourly_rate`, hour fields) are returned as `number`, not string.
**Schema / Interfaces:**
```typescript
export function serializeProject(row: ProjectRow): ProjectObject;
export function serializeProjectMember(row: ProjectMemberRow): ProjectMemberObject;
export function serializeRetainerMonth(row: RetainerMonthRow): RetainerMonthObject;
```
**Acceptance:**
- [ ] `hourly_rate` NUMERIC serializes as a JS `number`; null stays null.

### Task 5: Retainer hour-bank engine
**Blocks:** —  ·  **Blocked by:** 3
**Files:**
- Modify: `packages/db/src/queries/projects.ts` (add retainer engine functions)
- Modify: `packages/db/src/queries/index.ts` (export `incrementRetainerHours`)
**Steps:**
- [ ] Implement `incrementRetainerHours(tenantId, projectId, month, hours)`: upserts the `retainer_months` row for `(project_id, month)` (seeding `hours_included` from the project's `billing_config.monthly_hours_included` plus any `hours_rolled_over` from the prior month), then adds `hours` to `hours_used` in a single atomic statement.
- [ ] After increment, if `hours_used >= hours_included` and `invoice_triggered_at IS NULL`: set `invoice_triggered_at = now()`, fire the `retainer.depleted` webhook via `webhook.deliver`, and — when `billing_config.auto_invoice = true` AND `hour_bank_overflow_action = 'invoice'` — enqueue an invoice-generation message on the `QUEUE` binding (`{ type: 'retainer.invoice', tenant_id, project_id, month, excess_hours }`).
- [ ] Implement `rollOverRetainerHours(tenantId, projectId, fromMonth, toMonth)` for `hour_bank_overflow_action = 'carry_over'`: compute unused hours (`hours_included - hours_used`, floored at 0) and write `hours_rolled_over` onto the next month's row. Intended to be invoked by a monthly cron in a later wave; expose it now so the engine is complete.
- [ ] Wrap the depletion read-modify-write in a transaction so concurrent time entries don't double-trigger (`require-audit-in-transaction` where an audit row is written).
**Schema / Interfaces:**
```typescript
export async function incrementRetainerHours(
  tenantId: string, projectId: string, month: string /* 'YYYY-MM' */, hours: number
): Promise<RetainerMonthObject>;
export async function rollOverRetainerHours(
  tenantId: string, projectId: string, fromMonth: string, toMonth: string
): Promise<RetainerMonthObject>;
// Webhook event name: 'retainer.depleted'
// QUEUE message: { type: 'retainer.invoice', tenant_id, project_id, month, excess_hours }
```
**Acceptance:**
- [ ] Crossing the included-hours threshold fires `retainer.depleted` exactly once (idempotent on `invoice_triggered_at`).
- [ ] With `auto_invoice=true` + `overflow_action='invoice'`, a `QUEUE` message is enqueued; with `carry_over`, no enqueue occurs.

### Task 6: Project CRUD API routes
**Blocks:** 9  ·  **Blocked by:** 3, 4
**Files:**
- Create: `apps/zync-api/src/routes/projects/index.ts` (route group + mount)
- Create: `apps/zync-api/src/routes/projects/crud.ts`
- Modify: `apps/zync-api/src/index.ts` (mount `/api/projects`)
**Steps:**
- [ ] Mount the group at `/api/projects` behind `authMiddleware` then `requireModuleEnabled('projects')`.
- [ ] `GET /api/projects` → `requirePermission('projects:read')`; parse `listProjectsQuerySchema`; resolve `fullVisibility` from the session role config; call `tenantQuery(db, tenantId).project.list(...)`; return `buildPaginated(items, nextCursor, total)`.
- [ ] `POST /api/projects` → `requirePermission('projects:write')`; validate `createProjectSchema`; create project + any initial members in a transaction; return `serializeProject`.
- [ ] `GET /api/projects/:id` → `requirePermission('projects:read')`; return `withStats` (404 if not found / not visible).
- [ ] `PATCH /api/projects/:id` → `requirePermission('projects:write')`; validate `updateProjectSchema`; update; return `serializeProject`.
- [ ] `DELETE /api/projects/:id` → `requirePermission('projects:delete')`; soft-archive via `archiveProject`; return 204.
- [ ] Every handler validates with zod before touching the DB; no raw Drizzle in route files.
**Schema / Interfaces:**
```
GET    /api/projects        → requirePermission('projects:read')  → PaginatedResponse<ProjectObject>
POST   /api/projects        → requirePermission('projects:write') → ProjectObject
GET    /api/projects/:id    → requirePermission('projects:read')  → ProjectObject & { stats: ProjectStats }
PATCH  /api/projects/:id    → requirePermission('projects:write') → ProjectObject
DELETE /api/projects/:id    → requirePermission('projects:delete')→ 204
```
**Acceptance:**
- [ ] Requesting any route with the `projects` module disabled returns the module-guard 403/feature-unavailable response.
- [ ] A user lacking `projects:write` is rejected with 403 on POST/PATCH.
- [ ] List response shape exactly matches `buildPaginated` output.

### Task 7: Project members API routes
**Blocks:** 9  ·  **Blocked by:** 3
**Files:**
- Create: `apps/zync-api/src/routes/projects/members.ts`
**Steps:**
- [ ] `GET /api/projects/:id/members` → `requirePermission('projects:read')`; return `listProjectMembers`.
- [ ] `POST /api/projects/:id/members` → `requirePermission('projects:write')`; validate `addMemberSchema`; return `serializeProjectMember`.
- [ ] `DELETE /api/projects/:id/members/:uid` → `requirePermission('projects:write')`; remove member; return 204.
- [ ] (Member role/rate edits reuse `POST`/upsert semantics or a `PATCH` on the same path — implement `addProjectMember` as upsert on PK `(project_id, user_id)`.)
**Schema / Interfaces:**
```
GET    /api/projects/:id/members        → requirePermission('projects:read')  → ProjectMemberObject[]
POST   /api/projects/:id/members        → requirePermission('projects:write') → ProjectMemberObject
DELETE /api/projects/:id/members/:uid   → requirePermission('projects:write') → 204
```
**Acceptance:**
- [ ] Adding an already-present member upserts (no PK violation) and updates role/rate.

### Task 8: Project hours & retainer-ledger API routes
**Blocks:** 9  ·  **Blocked by:** 3
**Files:**
- Create: `apps/zync-api/src/routes/projects/reports.ts`
**Steps:**
- [ ] `GET /api/projects/:id/hours` → `requirePermission('projects:read')`; return `getProjectHours` (`{ this_month, all_time }`).
- [ ] `GET /api/projects/:id/retainer-months` → `requirePermission('projects:read')`; return `listRetainerMonths` (404 / empty if project isn't a retainer).
**Schema / Interfaces:**
```
GET    /api/projects/:id/hours            → requirePermission('projects:read') → { this_month: number; all_time: number }
GET    /api/projects/:id/retainer-months  → requirePermission('projects:read') → RetainerMonthObject[]
```
**Acceptance:**
- [ ] `/retainer-months` on a non-retainer project returns an empty array, not an error.

### Task 9: UI data layer (API client + hooks)
**Blocks:** 10, 11, 12  ·  **Blocked by:** 6, 7, 8
**Files:**
- Create: `apps/zync-app/src/modules/projects/api.ts`
- Create: `apps/zync-app/src/modules/projects/hooks.ts`
- Create: `apps/zync-app/src/modules/projects/types.ts` (re-export from `@zync/types`)
**Steps:**
- [ ] Typed `fetch` wrappers for every `/api/projects*` route, importing types from `@zync/types`.
- [ ] TanStack Query v5 hooks: `useProjectList(filters)`, `useProject(id)`, `useCreateProject()`, `useUpdateProject()`, `useArchiveProject()`, `useProjectMembers(id)`, `useAddProjectMember()`, `useRemoveProjectMember()`, `useProjectHours(id)`, `useRetainerMonths(id)`.
- [ ] Mutations invalidate the relevant query keys; list hook supports cursor pagination.
**Schema / Interfaces:**
```typescript
export function useProjectList(filters: ProjectListFilters): UseQueryResult<PaginatedResponse<ProjectObject>>;
export function useProject(id: string): UseQueryResult<ProjectObject & { stats: ProjectStats }>;
export function useCreateProject(): UseMutationResult<ProjectObject, Error, CreateProjectInput>;
export function useUpdateProject(): UseMutationResult<ProjectObject, Error, { id: string; patch: UpdateProjectInput }>;
export function useArchiveProject(): UseMutationResult<void, Error, string>;
export function useProjectMembers(id: string): UseQueryResult<ProjectMemberObject[]>;
export function useProjectHours(id: string): UseQueryResult<{ this_month: number; all_time: number }>;
export function useRetainerMonths(id: string): UseQueryResult<RetainerMonthObject[]>;
```
**Acceptance:**
- [ ] Creating a project invalidates `['projects','list']`; archiving invalidates list + detail.

### Task 10: Project list page (`/projects`)
**Blocks:** 13  ·  **Blocked by:** 9
**Files:**
- Create: `apps/zync-app/src/modules/projects/list/ProjectsListPage.tsx`
- Create: `apps/zync-app/src/modules/projects/list/ProjectCard.tsx`
- Create: `apps/zync-app/src/modules/projects/list/ProjectsTable.tsx`
- Create: `apps/zync-app/src/modules/projects/list/ProjectFilters.tsx`
**Steps:**
- [ ] Render a card/table view toggle persisted to `localStorage` (key `projects:view`).
- [ ] Card view shows project name, customer name (link to `/customers/:id`), billing-type `Badge`, status `Badge`, active tasks count, hours-this-month — sourced from list stats.
- [ ] Table view uses the `DataTable` primitive with the same columns.
- [ ] Filters: by `status`, `billing_type`, `customer` (`Select`); sort by name / start date / updated date.
- [ ] "+ New Project" button opens the create `Sheet` (Task 11).
- [ ] Loading → `Skeleton`; empty result → design-system `EmptyState` (catalog context for projects); error → `ErrorState`.
- [ ] Build only from `packages/ui` primitives; no raw HTML, no hardcoded colors/spacing; respect `prefers-reduced-motion` on the sheet transition; correct `aria` roles on the view-toggle and filter controls; layout direction via `useDirection` for RTL/Hebrew.
**Acceptance:**
- [ ] View toggle persists across reloads via `localStorage`.
- [ ] Filtering by billing type updates the list through `useProjectList` without a full reload.
- [ ] No hardcoded color/spacing literals (lint rules pass); page renders correctly under RTL.

### Task 11: Create / edit project sheet form
**Blocks:** 13  ·  **Blocked by:** 9
**Files:**
- Create: `apps/zync-app/src/modules/projects/form/ProjectFormSheet.tsx`
- Create: `apps/zync-app/src/modules/projects/form/BillingConfigFields.tsx`
- Create: `apps/zync-app/src/modules/projects/form/MemberPicker.tsx`
**Steps:**
- [ ] Slide-in `Sheet` form: name, customer `Select` (optional — internal projects), description `Textarea`.
- [ ] Billing type `Radio` (Fixed / Hourly / Retainer); type-specific fields render dynamically in `BillingConfigFields` (currency, fixed: total + deposit %, hourly: rate + overtime toggle/threshold/multiplier, retainer: monthly amount + included hours + auto-invoice toggle + overflow action `Select`).
- [ ] Start/end date pickers.
- [ ] `MemberPicker` to add initial team members with role + optional hourly-rate override.
- [ ] Client-side validation mirrors `createProjectSchema`/`updateProjectSchema`; submit via `useCreateProject`/`useUpdateProject`; toast on success/error.
- [ ] Reuse the same component for edit (pre-populated). Built from `packages/ui` `Form`/`FormField`/`Select`/`Radio`/`Input`/`Switch` primitives; honor `prefers-reduced-motion`; correct focus management and `aria-modal` on the sheet.
**Acceptance:**
- [ ] Switching billing type swaps the dynamic field set and clears stale config.
- [ ] Submitting a retainer without monthly hours surfaces the same validation error as the API.

### Task 12: Project detail page (`/projects/:id`) with tabs
**Blocks:** 13  ·  **Blocked by:** 9
**Files:**
- Create: `apps/zync-app/src/modules/projects/detail/ProjectDetailPage.tsx`
- Create: `apps/zync-app/src/modules/projects/detail/OverviewTab.tsx`
- Create: `apps/zync-app/src/modules/projects/detail/TasksTab.tsx`
- Create: `apps/zync-app/src/modules/projects/detail/TimeTab.tsx`
- Create: `apps/zync-app/src/modules/projects/detail/InvoicesTab.tsx`
- Create: `apps/zync-app/src/modules/projects/detail/FilesTab.tsx`
- Create: `apps/zync-app/src/modules/projects/detail/SettingsTab.tsx`
- Create: `apps/zync-app/src/modules/projects/detail/HourBankGauge.tsx`
**Steps:**
- [ ] Header: project name, customer link, status `Badge`, billing-type `Badge`, actions (edit → opens Task 11 sheet, archive → `useArchiveProject` with confirm `Dialog`).
- [ ] Summary bar of `StatCard`s: total tasks, open tasks, hours tracked, invoice status — from `useProject` stats.
- [ ] `Tabs` primitive with: Overview, Tasks, Time, Invoices, Files, Settings.
- [ ] **Overview:** billing-config summary; `HourBankGauge` (retainer only, `Progress` primitive: used/included this month from `useRetainerMonths`); total hours tracked; invoice summary (paid/outstanding); recent tasks (last 5); team members list.
- [ ] **Tasks:** embed the tasks board component pre-filtered to this `project_id` when the `tasks` module is enabled (`useModuleEnabled('tasks')`); otherwise `EmptyState`.
- [ ] **Time:** embed time-entries table for this project (user, date, duration, task, description) + month/all-time totals when `time_management` enabled; otherwise `EmptyState`.
- [ ] **Invoices:** project invoices list + "Create invoice" action (fixed/hourly) when `invoices` module enabled; otherwise `EmptyState`.
- [ ] **Files:** attachments from task correspondence + manual uploads, invoice PDF viewer; `EmptyState` until upstream attachments exist.
- [ ] **Settings:** edit billing config, overtime toggle (hourly), retainer settings (auto-invoice toggle, overflow action), danger zone (archive / delete) — gated on `projects:write` / `projects:delete`.
- [ ] All cross-module embeds degrade to `EmptyState`/`DegradedModuleAlert` rather than crashing when the dependency module is absent. Built from `packages/ui` primitives; reduced-motion + aria roles on tabs; RTL-aware.
**Acceptance:**
- [ ] Retainer project shows the hour-bank gauge with correct used/included; non-retainer hides it.
- [ ] Disabling the `tasks` module renders the Tasks tab `EmptyState` without errors.
- [ ] Archive action soft-archives and routes back to `/projects`.

### Task 13: Navigation, route registration & module guard
**Blocks:** —  ·  **Blocked by:** 10, 11, 12
**Files:**
- Modify: `apps/zync-app/src/router.tsx` (register `/projects`, `/projects/:id`)
- Modify: app-shell navigation config (add Projects nav item)
**Steps:**
- [ ] Register `/projects` and `/projects/:id` routes, each wrapped in `<ModuleGuard moduleId="projects">`.
- [ ] Add the Projects nav item, gated by `isEnabled('projects')` per `module-management` (`{isEnabled('projects') && <NavItem to="/projects" icon={FolderIcon} label="Projects" />}`).
- [ ] Verify the nav label localizes (i18n) and the icon mirrors under RTL.
**Acceptance:**
- [ ] Projects nav item appears only when the module is enabled for the tenant.
- [ ] Visiting `/projects` with the module disabled renders the module-guard fallback, not the list.
