# Contractor Management UI — Implementation Plan

**Spec:** docs/specs/2026-05-31-contractor-management-ui.md  ·  **Slug:** contractor-management-ui  ·  **Wave:** 8
**Depends on:** contractor-payouts, foundation-auth-rbac, projects-module, time-management

## Goal
Deliver the staff-facing `/contractors` management UI for OWNER/ADMIN to administer contractor records, assign contractors to projects with per-project hourly-rate overrides, review and approve submitted time entries inline, and initiate payouts. Spec 21 (`contractor-payouts`) already owns the contractor data model, the `@zync/payouts` package, and the `/api/contractors` backend; this spec adds the spec-134 management surface on top of it — a tabbed contractor detail (Overview / Projects / Time Entries / Payouts), an "Add contractor" sheet with an optional magic-link portal invite, an assign-to-project modal, inline rate-override editing, inline time approval, and a deactivate flow. No new tables. The one genuine backend gap — a `PATCH` route to edit an existing assignment's `rate_override` — is added here.

## Architecture
This is a UI-first delta. It consumes upstream by exact name and modifies the pages P051 already created rather than redefining them.

Upstream consumption (exact names — never redefine):
- **contractor-payouts (P051, wave 7, already built when this runs)** —
  - Tables: `contractors` (`hourly_rate NUMERIC(10,2)`, `email`, `phone`, `billing_type`, `currency`, `tax_id`, `user_id` linked Zync account, withholding columns), `contractor_assignments` (`contractor_id`, `project_id`, `role`, `rate_override NUMERIC(10,2)`), `payout_bills`, `payout_bill_lines`.
  - Package `@zync/payouts` — `generatePayoutBill`, `resolveWithholdingRate`, `computeWithholding`.
  - Routes: `GET/POST /api/contractors`, `GET/PATCH/DELETE /api/contractors/:id`, `GET/POST /api/contractors/:id/assignments`, `DELETE /api/contractors/:id/assignments/:aid`, `GET /api/contractors/:id/time`, `GET/POST /api/contractors/:id/bills`, `PATCH /api/contractors/:id/bills/:bid`, `POST /api/contractors/:id/bills/:bid/void`.
  - App pages/hooks: `ContractorsListPage`, `ContractorDetailPage`, `useContractors`, `useContractor`, `useContractorTime`, `usePayoutBill`, `usePayoutLedger`. Payout-bill detail UI and withholding pages already exist; this plan does NOT touch them except to surface "Initiate payout" entry points.
  - Permissions `payouts:read` / `payouts:write` (already seeded by P051).
- **time-management** — `time_entries` (`contractor_id`, `user_id`, `project_id`, `task_id`, `description`, `duration_seconds`, `billable`, `approval_status`). Single-row inline edit via `PATCH /api/time/:id`.
- **time-approval-workflow (owns approval routes, stable contract)** — `POST /api/time/:id/approve` (single approve, optional `{ note }`) and `POST /api/time/approvals/bulk` (`{ action: 'approve', entry_ids: string[], note? }`) for "Approve all". `time_entries.approval_status` enum `auto_approved|pending|approved|rejected|locked`; only `pending` entries are approvable; `locked` entries are read-only.
- **projects-module** — `projects` table (`id`, `name`, `status`) for the assign-to-project picker.
- **foundation-auth-rbac** — `authMiddleware`, `requirePermission('payouts:read'|'payouts:write')`, `requireModuleEnabled('payouts')`, `tenantQuery`, `createDb`, `Db`, `buildPaginated`; React: `useModuleEnabled`, `useTierGate` (not gated here — All tiers), `useDirection` (RTL).
- **@zync/ui** — `Sheet`, `Dialog`, `Tabs`, `DataTable`, `DataTablePagination`, `Button`, `Input`, `Select`, `Radio`, `Badge`, `Form`, `FormField`, `FormLabel`, `FormError`, `Stack`, `Card`, `EmptyState`, `Skeleton`, `Toast`/`toast`, `Switch`.

Data flow:
- List: `useContractors({ status?, project_id? })` → `GET /api/contractors`. Row shows Name, Rate (`contractors.hourly_rate`), Projects count, Hours(Mo) (current-month approved+pending sum from contractor detail stats), Status.
- Detail sheet opens with `Tabs`. **Overview** reads `useContractor(id)` stats. **Projects** reads `GET /api/contractors/:id/assignments`; assign via `POST /api/contractors/:id/assignments`; rate edit via the new `PATCH /api/contractors/:id/assignments/:aid`; remove via `DELETE /api/contractors/:id/assignments/:aid`. **Time Entries** reads `useContractorTime(id, { period, status })` → `GET /api/contractors/:id/time`; per-row approve → `POST /api/time/:id/approve`; "Approve all" → `POST /api/time/approvals/bulk`. **Payouts** reads `GET /api/contractors/:id/bills`; "Initiate payout" navigates into P051's existing bill-generation flow.
- Effective rate displayed per project assignment = `COALESCE(contractor_assignments.rate_override, contractors.hourly_rate)`.

## Tech Stack
- **App:** `apps/zync-app` (Vite + React on Cloudflare Workers) — pages, `@tanstack/react-query` hooks, `@zync/ui` components, `react-router` routes. Route `/contractors` (guarded `payouts:read`; write actions `payouts:write`).
- **API:** `apps/zync-api` (Hono on Cloudflare Workers) — one new route `PATCH /api/contractors/:id/assignments/:aid` mounted in P051's existing contractors router module. Zod validation. Drizzle ORM over Neon Postgres via Hyperdrive.
- **Bindings:** Hyperdrive (DB). No new bindings.
- **Cross-cutting:** RTL/Hebrew via `useDirection` + logical CSS properties; design tokens only (no hardcoded colors/spacing/radius); `aria` roles on tabs/dialogs; `prefers-reduced-motion` honored by `Sheet`/`Dialog` transitions; all write routes Zod-validated and permission-guarded.

## Wave Plan
| Sub-wave | Tasks | Files touched | Parallelizable? |
|----------|-------|---------------|-----------------|
| 8a (API gap) | 1 | `apps/zync-api/src/routes/contractors.ts` | — |
| 8b (data hooks) | 2 | `apps/zync-app/src/hooks/useContractorAssignments.ts`, `useContractorTimeApproval.ts`, `useContractorList.ts` | Yes (after 1) |
| 8c (UI surfaces) | 3, 4, 5, 6, 7 | contractor pages + tab components + sheets/modals | Tasks 3–7 parallel after 2 |
| 8d (routing/nav) | 8 | app router + nav | After 3–7 |
| 8e (verification) | 9 | test file | Last |

## Tasks

### Task 1: Add `PATCH /api/contractors/:id/assignments/:aid` route (rate-override edit)
**Blocks:** 2  ·  **Blocked by:** —
**Files:**
- Modify: `apps/zync-api/src/routes/contractors.ts` (the contractors router created by P051)
**Steps:**
- [ ] Add a `PATCH /api/contractors/:id/assignments/:aid` handler to the existing router, mounted after the existing `POST` and `DELETE` assignment routes.
- [ ] Guard with `authMiddleware`, `requireModuleEnabled('payouts')`, `requirePermission('payouts:write')`.
- [ ] Validate body with `updateAssignmentSchema` (Zod): `{ rateOverride: z.number().nonnegative().nullable(), role: z.string().max(120).optional() }`. A `null` `rateOverride` clears the override (falls back to `contractors.hourly_rate`).
- [ ] Use `tenantQuery` to `UPDATE contractor_assignments SET rate_override = $1, role = COALESCE($2, role) WHERE id = :aid AND contractor_id = :id AND tenant_id = :tenant` — scope by both `tenant_id` and `contractor_id`; return 404 if no row.
- [ ] Return the updated assignment row (id, contractor_id, project_id, role, rate_override).
**Schema / Interfaces:**
```ts
// apps/zync-api/src/routes/contractors.ts (added)
export const updateAssignmentSchema = z.object({
  rateOverride: z.number().nonnegative().nullable(),
  role: z.string().max(120).optional(),
});
// PATCH /api/contractors/:id/assignments/:aid  (payouts:write)
// body: { rateOverride: number | null, role?: string }
// 200 -> { id, contractorId, projectId, role, rateOverride }
// 404 -> assignment not found for tenant+contractor
```
**Acceptance:**
- [ ] `PATCH` with `{ rateOverride: 140 }` sets `contractor_assignments.rate_override = 140.00` for the matching row only.
- [ ] `PATCH` with `{ rateOverride: null }` nulls the column; subsequent effective rate equals `contractors.hourly_rate`.
- [ ] A different tenant's assignment id returns 404 (tenant isolation via `tenantQuery`).
- [ ] Missing `payouts:write` returns 403; missing `payouts` module returns the module-disabled response.

### Task 2: Data hooks for assignments, list filters, and inline time approval
**Blocks:** 3, 4, 5, 6  ·  **Blocked by:** 1
**Files:**
- Create: `apps/zync-app/src/hooks/useContractorAssignments.ts`
- Create: `apps/zync-app/src/hooks/useContractorTimeApproval.ts`
- Create: `apps/zync-app/src/hooks/useContractorList.ts`
**Steps:**
- [ ] `useContractorAssignments(contractorId)` — react-query query hitting `GET /api/contractors/:id/assignments`; returns assignment rows with `projectId`, `projectName`, `role`, `rateOverride`, and a derived `effectiveRate = rateOverride ?? contractor.hourlyRate`.
- [ ] `useAssignProject(contractorId)` mutation → `POST /api/contractors/:id/assignments` (`{ projectId, role?, rateOverride? }`); invalidate assignments + contractor.
- [ ] `useUpdateAssignment(contractorId)` mutation → `PATCH /api/contractors/:id/assignments/:aid` (`{ rateOverride, role? }`); invalidate assignments.
- [ ] `useRemoveAssignment(contractorId)` mutation → `DELETE /api/contractors/:id/assignments/:aid`; invalidate assignments + contractor.
- [ ] `useContractorTimeApproval(contractorId)` — exposes `approveOne(entryId, note?)` → `POST /api/time/:id/approve`; and `approveAll(entryIds, note?)` → `POST /api/time/approvals/bulk` with `{ action: 'approve', entry_ids, note? }`. On success invalidate `useContractorTime` and `useContractor` (stats change).
- [ ] `useContractorList(filters)` — thin wrapper over P051's `useContractors` adding the spec-134 filter state `{ status: 'active'|'inactive'|'all', projectId?: string }`; default `status='active'`.
**Schema / Interfaces:**
```ts
export function useContractorAssignments(contractorId: string): UseQueryResult<ContractorAssignmentRow[]>;
export interface ContractorAssignmentRow {
  id: string; projectId: string; projectName: string;
  role: string | null; rateOverride: number | null; effectiveRate: number;
}
export function useAssignProject(contractorId: string): UseMutationResult<ContractorAssignmentRow, Error, { projectId: string; role?: string; rateOverride?: number | null }>;
export function useUpdateAssignment(contractorId: string): UseMutationResult<ContractorAssignmentRow, Error, { aid: string; rateOverride: number | null; role?: string }>;
export function useRemoveAssignment(contractorId: string): UseMutationResult<void, Error, { aid: string }>;
export function useContractorTimeApproval(contractorId: string): {
  approveOne: (entryId: string, note?: string) => Promise<void>;
  approveAll: (entryIds: string[], note?: string) => Promise<void>;
};
export function useContractorList(filters: { status: 'active' | 'inactive' | 'all'; projectId?: string }): UseQueryResult<ContractorListRow[]>;
```
**Acceptance:**
- [ ] Approving an entry flips its `approval_status` from `pending` to `approved` and the Time Entries tab pending total decreases.
- [ ] Editing a rate override re-renders the Projects tab with the new effective rate without a full reload.
- [ ] List filter `status='inactive'` queries `GET /api/contractors?status=inactive`.

### Task 3: Contractor List page (spec-134 columns + filters + Add entry point)
**Blocks:** 8  ·  **Blocked by:** 2
**Files:**
- Modify: `apps/zync-app/src/pages/contractors/ContractorsListPage.tsx` (created by P051)
- Create: `apps/zync-app/src/pages/contractors/AddContractorSheet.tsx`
**Steps:**
- [ ] Render `DataTable` with columns: Name, Rate (`₪{hourly_rate}/h`), Projects (active assignment count), Hours(Mo) (current-month approved+pending hours), Status (`Active`/`Inactive` `Badge`).
- [ ] Add filter bar: `Select` for status (`Active`/`Inactive`/`All`, default Active) and `Select` for project (`All projects` + project list); wire both to `useContractorList`.
- [ ] Header `[+ Add]` `Button` (visible only with `payouts:write`) opens `AddContractorSheet`.
- [ ] Clicking a row opens the contractor detail sheet (Task 4) for that contractor id.
- [ ] Empty state via `EmptyState` ("No contractors yet") when the list is empty; `Skeleton` rows while loading.
- [ ] `AddContractorSheet`: `Sheet` with `Form` fields Name (required), Email, Phone, Default rate (numeric, `₪ per hour`), and a `Radio` group "Has Zync account": `No — time entered by staff` (default) / `Yes — invite via magic link to contractor portal`. When "Yes", submit sets `inviteToPortal: true`.
- [ ] Submit → P051 create mutation `POST /api/contractors` with `{ name, email, phone?, hourlyRate, inviteToPortal? }`; on success `toast` success, close sheet, invalidate list.
**Acceptance:**
- [ ] List shows all five spec-134 columns with `₪` formatting and an Active/Inactive badge.
- [ ] Status and project filters change the fetched result set.
- [ ] `[+ Add]` is hidden for users lacking `payouts:write`.
- [ ] Adding a contractor with "Yes — invite" sends `inviteToPortal: true` and the row appears in the list.

### Task 4: Contractor Detail sheet shell with tabs
**Blocks:** 5, 6, 7, 8  ·  **Blocked by:** 2
**Files:**
- Modify: `apps/zync-app/src/pages/contractors/ContractorDetailPage.tsx` (created by P051) — wrap content in a `Sheet` + `Tabs` shell, or
- Create: `apps/zync-app/src/pages/contractors/ContractorDetailSheet.tsx` (if P051's page is a standalone route, render this sheet from the list)
**Steps:**
- [ ] Render header: `{contractor.name} — Contractor`, with `[Edit]` (opens editable info — P051 owns the edit form; reuse it) and close `[✕]`.
- [ ] Render `Tabs` with four tabs in order: **Overview**, **Projects**, **Time Entries**, **Payouts** (each tab `role="tab"`, panel `role="tabpanel"`, accessible labels).
- [ ] Load `useContractor(id)`; show `Skeleton` while loading, `ErrorState` on failure.
- [ ] Each tab body delegates to the components built in Tasks 5–7 (Overview inline below); pass `contractorId` and the loaded contractor object down.
**Acceptance:**
- [ ] Detail sheet opens from a list row and renders four tabs, defaulting to Overview.
- [ ] Tabs are keyboard-navigable (arrow keys) and expose correct aria roles.

### Task 5: Overview tab + Deactivate flow
**Blocks:** 8  ·  **Blocked by:** 4
**Files:**
- Create: `apps/zync-app/src/pages/contractors/tabs/OverviewTab.tsx`
- Create: `apps/zync-app/src/pages/contractors/DeactivateContractorDialog.tsx`
**Steps:**
- [ ] Overview body: Email, Rate (`₪{hourly_rate}/h (default)`), Status with `[Deactivate]` action (when Active, `payouts:write` only).
- [ ] Show stats from `useContractor(id)`: "This month: {hours}h · ₪{amount}", "Pending payout: ₪{pendingAmount}".
- [ ] `[Initiate payout →]` `Button` navigates to P051's existing bill-generation flow for this contractor (route into the Payouts tab / bill draft create).
- [ ] `[Deactivate]` opens `DeactivateContractorDialog`: confirmation copy "Deactivate {name}? Active project assignments will be removed. Time entries and payout history are preserved." with `[Cancel]` / `[Deactivate]`.
- [ ] Confirm → `DELETE /api/contractors/:id` (P051 deactivate mutation); on success `toast`, invalidate contractor + list; sheet reflects Inactive status.
**Acceptance:**
- [ ] Overview shows email, default rate, status, this-month hours/amount, and pending payout.
- [ ] Deactivate confirmation dialog matches the spec copy; confirming flips status to Inactive and the contractor's active assignments are removed (verified via Projects tab now empty), while Time Entries history remains.
- [ ] `[Initiate payout →]` reaches the bill-generation flow.

### Task 6: Projects tab — assignments, assign modal, inline rate override
**Blocks:** 8  ·  **Blocked by:** 2, 4
**Files:**
- Create: `apps/zync-app/src/pages/contractors/tabs/ProjectsTab.tsx`
- Create: `apps/zync-app/src/pages/contractors/AssignProjectModal.tsx`
**Steps:**
- [ ] List assigned projects from `useContractorAssignments(id)`: each row shows project name and `Hourly: ₪{effectiveRate}/h ({rateOverride != null ? 'override' : 'default'})`, plus `[Edit rate]` and `[Remove]`.
- [ ] `[+ Assign to project]` (`payouts:write`) opens `AssignProjectModal` (`Dialog`): a project picker (`Select`/`Command` over `projects` where `status='active'`, excluding already-assigned projects) and optional rate override + role. Confirm → `useAssignProject` (`POST /api/contractors/:id/assignments`).
- [ ] `[Edit rate]` switches the row's rate cell to an inline numeric `Input` (₪/h); blur/enter commits via `useUpdateAssignment` (`PATCH /api/contractors/:id/assignments/:aid`) with the new `rateOverride`; clearing the input sends `rateOverride: null` (revert to default).
- [ ] `[Remove]` → `useRemoveAssignment` (`DELETE /api/contractors/:id/assignments/:aid`); soft removal — keeps time entries (no cascade); `toast` on success.
- [ ] Empty state when no assignments.
**Acceptance:**
- [ ] Assigning a project creates a `contractor_assignments` row and the project appears with `(default)` rate label.
- [ ] Editing a rate to a non-null value shows `(override)` and the new ₪/h; clearing it reverts to `(default)` at `contractors.hourly_rate`.
- [ ] Remove drops the assignment but the contractor's time entries on that project still appear in the Time Entries tab.
- [ ] The assign picker excludes already-assigned and non-active projects.

### Task 7: Time Entries tab — period nav, inline approve, Approve all; Payouts tab
**Blocks:** 8  ·  **Blocked by:** 2, 4
**Files:**
- Create: `apps/zync-app/src/pages/contractors/tabs/TimeEntriesTab.tsx`
- Create: `apps/zync-app/src/pages/contractors/tabs/PayoutsTab.tsx`
**Steps:**
- [ ] TimeEntriesTab: month header with `[← {prevMonth}]` / `[{nextMonth} →]` navigation, controlling a `period=YYYY-MM` state passed to `useContractorTime(id, { period })`.
- [ ] Table columns: Date, Project, Task, Hours (`{duration_seconds/3600}h`, 1dp), Status (`Pending`/`Approved`/`Locked` `Badge`).
- [ ] Per-row approve action on `pending` rows → `useContractorTimeApproval(id).approveOne(entryId)`; `locked` rows are read-only (no approve action).
- [ ] `[Approve all]` button (visible with `payouts:write` and when ≥1 pending entry) → `approveAll(pendingEntryIds)`; confirm via `toast` on success.
- [ ] Footer summary: "{period} total: {total}h · Approved: {approved}h · Pending: {pending}h".
- [ ] PayoutsTab: payout history table (Date, Amount `₪`, Method, Status) from `GET /api/contractors/:id/bills`; below it the pending summary line "Pending: ₪{pending} ({approvedHours} approved hours × ₪{rate}/h, minus ₪{prevPaid})" and `[Initiate payout for ₪{pending} →]` routing into P051's bill draft create.
**Acceptance:**
- [ ] Period nav moves between months and re-fetches the correct entries.
- [ ] Approving a pending entry updates its badge to Approved and decrements the Pending total; `Approve all` approves every pending entry in one bulk call.
- [ ] Locked entries render read-only with no approve control.
- [ ] Payouts tab lists prior bills and the pending amount, and `[Initiate payout …]` reaches the bill-generation flow.

### Task 8: Route + navigation wiring
**Blocks:** 9  ·  **Blocked by:** 3, 4, 5, 6, 7
**Files:**
- Modify: `apps/zync-app/src/router.tsx` (or the app's route registry)
- Modify: `apps/zync-app/src/components/nav/` nav config (the file P051/app-shell registers module nav in)
**Steps:**
- [ ] Ensure top-level route `/contractors` renders `ContractorsListPage`; detail opens as a sheet over it (or `/contractors/:id` deep-link opening the sheet on that contractor).
- [ ] Guard the route: `payouts:read` minimum; write actions inside guarded by `payouts:write` checks (already enforced per-component).
- [ ] Ensure the nav entry for Contractors is present and gated by `useModuleEnabled('payouts')` + `payouts:read` (de-duplicate with any entry P051 already added — keep a single entry).
**Acceptance:**
- [ ] Navigating to `/contractors` with `payouts:read` shows the list; without it, access is denied/redirected.
- [ ] Exactly one Contractors nav entry appears, gated by module + permission.

### Task 9: Integration test — assign, rate override, approve, deactivate
**Blocks:** —  ·  **Blocked by:** 1, 8
**Files:**
- Create: `apps/zync-api/test/contractors-assignment-patch.test.ts`
**Steps:**
- [ ] Seed a tenant, a project, a contractor with `hourly_rate = 120`, and an assignment with `rate_override = NULL`.
- [ ] `PATCH /api/contractors/:id/assignments/:aid` with `{ rateOverride: 140 }` → assert row `rate_override = 140`.
- [ ] `PATCH` with `{ rateOverride: null }` → assert `rate_override IS NULL` and effective rate resolves to 120.
- [ ] Seed two `pending` contractor `time_entries`; call `POST /api/time/approvals/bulk` with both ids → assert both become `approved`.
- [ ] `DELETE /api/contractors/:id` (deactivate) → assert active assignments removed and `time_entries` rows preserved.
- [ ] Assert 403 on `PATCH /api/contractors/:id/assignments/:aid` without `payouts:write`, and 404 for a foreign-tenant assignment id.
**Acceptance:**
- [ ] All assertions pass against a Neon Postgres test database (Hyperdrive path), with tenant isolation verified.
