# Staff Management & Repair Technician Integration

**Date:** 2026-11-07 · **Branch:** `techtitans/repair-r1` (backend + frontend) · Local commits only (no push, no deploy).

Repair technician assignment now reuses the **existing** Staff/Workforce (`Employee` + `User` + RBAC) system. No separate technician system, no duplicate staff database, no change to the `repair_tickets.assigned_employee_id → employees` foreign key. All existing repairs, customer data, and Smart Repair Intake behaviour are preserved. NH POS and the legacy PHP portal were not touched.

---

## 1. Audit findings (Steps 1–2)

- **Staff = `App\Models\Employee`** (tenant-owned; `status` ∈ invited | active | suspended | archived; nullable `user_id` → `users`; `job_title`; `display_name`; assigned to locations via the `employee_locations` pivot). Created/edited through `EmployeeController` + `EmployeeManagementService`. The staff↔login-user relationship is **one optional account per employee**: an `Employee` row can exist with no login (`user_id` null); a login account is only needed when the person signs in (and is required before a role can be assigned, since roles attach to the user).
- **Staff Management is complete and functional** — it was NOT rebuilt. It is reachable at **Workforce → Employees** (`/employees`, gated on `employees.manage`). It was never actually hidden; in the audit it simply sat below the fold of the scrollable sidebar. The "Workforce / Employees" labelling is already generic (no restaurant-specific wording to strip), so no nav relabel was required.
- **Why the technician dropdown always showed "Unassigned":** verified it was **not** a permission or query bug. Both tenants had **0 employees** (confirmed live: `GET /employees` = 0 and `Employee::count()` = 0 for both `kings-biryani` and `techtitans`). The dropdown also loaded from `GET /employees`, which requires HR permissions (`employees.manage`) that a repair-only user does not hold — a second, real gap.
- **Two real gaps identified and fixed:** (a) `RepairTicketService::resolveTechnician` only checked the employee *existed* (tenant-scoped) — no active/location validation; (b) the dropdown's data source required HR permissions.

## 2. Backend changes

**New**
- `app/Http/Controllers/Api/V1/Repairs/RepairTechnicianController.php` — thin controller for the repairs-gated technician directory.
- `tests/Feature/Repairs/RepairTechnicianApiTest.php` — 9 feature tests.
- Route `GET repairs/technicians` under `['resolve.location', 'permission.any:repairs.view']`.

**Edited**
- `app/Repairs/RepairTicketService.php` — hardened eligibility: new `isEligibleTechnician` (single source of truth), `eligibleTechnicians`, `technicianWorkload`, `employeesById`; `resolveTechnician(mixed $id, string $locationId)`; `assignTechnician` resolves/validates under the row lock against the ticket's current `location_id`; `create` validates against the location being stamped.
- `app/Repairs/Exceptions/RepairException.php` — new `ineligibleTechnician()` (**HTTP 422**, code `repair_ineligible_technician`).
- `app/Repairs/RepairResource.php` — `technician()` / `technicians()` shapers (`{ id, display_name, job_title }`).
- `app/Http/Controllers/Api/V1/Repairs/RepairTicketController.php` — `assign` passes the raw id to the service; `dashboard` now also returns `technician_workload` + `unassigned_open`.
- `app/Authorization/RoleTemplates.php` — new **`TECHNICIAN`** template (location_scoped) granting `repairs.view` + `repairs.manage` + `time_clock.use`.
- `tests/Support/ProvisionsRepairs.php`, `tests/Feature/Repairs/RepairTicketApiTest.php` — harness + existing-test updates.

**Eligibility rule (single source of truth):** a technician is eligible ⇔ `status === 'active'` **AND** has an `employee_locations` row for `(tenant_id, employee_id, location_id)`. The same rule drives both the directory list and the assignment validation. The `Technician` role grants app access but is **not** a prerequisite for the dropdown — eligibility is active + location. Employees may hold multiple roles (existing `role_user`), so Technician composes with any other role.

**History preservation:** validation runs **only** on a new incoming assignment (`create` / `assign` / reassign) via `resolveTechnician`. Reads (`show`/`search`/resource) and `update()` never call it and never touch `assigned_employee_id`; the dashboard workload applies no eligibility filter. So a ticket assigned to someone who later goes inactive **keeps** that assignment and still renders. Clearing (null) is always allowed. The `assigned_employee_id → employees` FK was **not** changed.

**Response shapes**
- `GET repairs/technicians` → `data.technicians: [{ id, display_name, job_title }]` — active, at the effective location, ordered by name; tenant/location isolated (403 if the location is outside the caller's `repairs.view` scope).
- `GET repairs/dashboard` → existing `counts_by_status`, plus `technician_workload: [{ employee_id, display_name, open_count }]` (techs with ≥1 non-terminal ticket) and `unassigned_open: int`.

**Role sync:** `php artisan cacart:roles-sync` succeeded — the new Technician role's permissions linked across both tenants (additive; 0 permission rows added). Works on `techpos` via the existing dev-DB carve-out.

## 3. Frontend changes

**New**
- `src/components/repairs/TechnicianSelect.tsx` — searchable, id-based technician combobox (modeled on `LookupSelect`: capture-phase pointer-inside flag, `justClosedRef` focus guard, Arrow/Enter/Escape nav, `role=combobox/listbox`). Always-present **Unassigned** option; filters by name + job title; options show job title as muted secondary text; empty state shows a hint + optional **Manage staff** link; load-error retry row.
- `src/components/repairs/TechnicianSelect.test.tsx`, `src/pages/repairs/RepairsDashboardPage.test.tsx`.

**Edited**
- `src/lib/api/repairs.ts` — `RepairTechnician` + `RepairDashboard` types; `repairsApi.technicians()`; `dashboard()` now returns the full object.
- `src/pages/repairs/RepairDetailPage.tsx` — technician source repointed to `repairsApi.technicians`; `TechnicianSelect` replaces the plain select; `techLoadError`/retry; **current assignment stays prominent** — the Handling card resolves the name from the active list and falls back to **"Assigned"** (never "Unassigned") when a set id isn't in the active list, so a historical/inactive assignee still reads as assigned.
- `src/pages/repairs/NewRepairPage.tsx` — intake wizard technician picker repointed + `TechnicianSelect`; AI-draft technician matching preserved.
- `src/lib/repairs/intakeMatching.ts` — `matchTechnician` now accepts a structural `TechnicianLike`, satisfied by both `EmployeeSummary` and `RepairTechnician`.
- `src/pages/repairs/RepairsDashboardPage.tsx` — "Technician workload" section (per-tech open counts + Unassigned), each row deep-links to the ticket list filtered by technician.
- `src/pages/repairs/RepairsListPage.tsx` — reads an `assigned` query param (mirroring `status`) so the workload deep-link filters.
- `src/pages/repairs/repairs.css` — minimal styles for the empty hint + workload list.

## 4. Testing

**Backend** (Xdebug off): `php artisan test --filter=Repair` → **65 passed (234 assertions)**, incl. the 9 new technician tests — directory returns active location-eligible and excludes suspended / cross-location / cross-tenant; readable with only `repairs.view` (denied without it); assign eligible works + audits `repairs.assigned`; assign suspended → 422; assign off-location → 422; create with ineligible → 422; existing assignment preserved after the assignee is suspended; dashboard `technician_workload`. Authorization/role suites → **90 passed (648 assertions)**. Pint `--dirty` ✓, `--test --dirty` ✓. PHPStan on `app/Repairs` + `app/Http/Controllers/Api/V1/Repairs` → **no errors**.

**Frontend:** `vitest run src/pages/repairs src/components/repairs src/lib/repairs` → **73/73 passed**. `tsc --noEmit` ✓, `eslint` ✓ (0 errors), `npm run build` ✓. (One pre-existing environmental flake in `NewRepairSmartIntake.test.tsx` under CPU contention — proven present on the unmodified baseline too; not caused by this work.)

## 5. Live browser verification (dev stack, workspace `techtitans`)

Screenshots: `docs/techtitans/screenshots/staff-technician/`. All 8 checks PASS:

1. Log in, Main Store location resolved (`X-Location-Id`) — `01-dashboard.png`.
2. Created **Tariq Technician** + **Sara Fixer** via Workforce → Employees (active, assigned to Main Store); the new **Technician role appears** in the role picker and was assignable — `02-employee-tariq.png`.
3. Both appear in the `TechnicianSelect` on New Repair and on ticket detail — `03-newrepair-dropdown.png`, `04-detail-dropdown.png`.
4. Assigned Tariq; persisted on reload; Handling card shows "Tariq Technician" — `05-assigned-tariq.png`.
5. Reassigned to Sara; Timeline shows the "Technician assigned" audit events — `06-timeline.png`.
6. Suspended Sara → ticket **still shows her as assigned** (history preserved); she **disappears** from the dropdown; direct re-assign → **HTTP 422** "That technician is not available for this location…"; then restored to active — `07-inactive-excluded.png`.
7. Dashboard "Technician workload" lists **Sara Fixer — 1 open** and **Unassigned — 6 open** — `08-dashboard-workload.png`.
8. `TechnicianSelect` renders the current assignment + Unassigned cleanly; the Manage-staff empty-state affordance is wired.

Cross-tenant / cross-location isolation and unauthorized-cannot-manage-staff are covered by the green `RepairTechnicianApiTest` feature tests (not re-done in the browser).

**Dev data:** additive only — Tariq and Sara remain active; ticket `R-2026-0001` is assigned to an eligible active tech. Nothing wiped or reset.

## 6. Constraints honoured

NH POS untouched · legacy PHP portal untouched · no duplicate staff database · no employees deleted · existing repairs/customer data unaffected · Smart Repair Intake preserved · `assigned_employee_id → employees` FK unchanged (schema verified first).

## 7. Local commits

Recorded on `techtitans/repair-r1` in each repo (no push, no deploy):

- **Backend** (`backend/`): `5cb484a` — *feat(repairs): integrate technician assignment with existing staff/RBAC*
- **Frontend** (`frontend/`): `dd454aa` — *feat(repairs): searchable technician selector wired to staff directory*
