From 3baf45664001fa3e50e2eb39d4d08acdd2f0202b Mon Sep 17 00:00:00 2001 From: Saad Ibn-Ezzoubayr Date: Sun, 22 Mar 2026 17:31:23 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20implement=20Story=202.3=20=E2=80=94=20W?= =?UTF-8?q?orker-Scoped=20Dashboard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scope stat cards and urgent declarations table to the authenticated worker's own assignments. Add empty state when no declarations are assigned, hide the "Assigné à" column for worker role, and expose isWorker flag through DashboardController and dashboard types. Co-Authored-By: Claude Sonnet 4.6 --- .../2-3-worker-scoped-dashboard.md | 288 ++++++++++++++ .../sprint-status.yaml | 6 +- app/Http/Controllers/DashboardController.php | 13 +- database/database.sqlite | Bin 266240 -> 282624 bytes resources/js/pages/Dashboard.vue | 355 ++++++++++-------- resources/js/types/dashboard.ts | 1 + .../Feature/Dashboard/WorkerDashboardTest.php | 316 ++++++++++++++++ 7 files changed, 818 insertions(+), 161 deletions(-) create mode 100644 _bmad-output/implementation-artifacts/2-3-worker-scoped-dashboard.md create mode 100644 tests/Feature/Dashboard/WorkerDashboardTest.php diff --git a/_bmad-output/implementation-artifacts/2-3-worker-scoped-dashboard.md b/_bmad-output/implementation-artifacts/2-3-worker-scoped-dashboard.md new file mode 100644 index 0000000..5d8d2bf --- /dev/null +++ b/_bmad-output/implementation-artifacts/2-3-worker-scoped-dashboard.md @@ -0,0 +1,288 @@ +# Story 2.3: Worker Scoped Dashboard + +Status: review + +## Story + +As a firm worker, +I want to see only my assigned declarations and their statuses when I open the app, +So that I can quickly identify what I need to work on today without information overload. + +## Acceptance Criteria + +1. **Given** a Worker is logged in, **When** they navigate to the Dashboard, **Then** the page displays the same KPI card layout as the Owner dashboard but counts reflect only declarations assigned to the Worker + +2. **Given** a Worker is logged in, **When** the summary table renders, **Then** it shows only the Worker's assigned declarations sorted by deadline ascending + +3. **Given** a Worker is logged in, **When** the priority alerts panel renders, **Then** it shows only alerts for the Worker's assigned declarations + +4. **Given** a Worker views the dashboard, **Then** StatCards are clickable and navigate to `/declarations?assignee=me&status={status}` (or equivalent filter that auto-scopes to the Worker) + +5. **Given** a Worker views the dashboard, **Then** the page title or subtitle indicates the scoped view (e.g., "Mes déclarations" or the Worker's name) + +6. **Given** a Worker has no assigned declarations, **When** they view the Dashboard, **Then** an EmptyState is displayed: "Aucune déclaration assignée — contactez votre responsable" + +7. **Given** the `DashboardController` is invoked for a Worker, **Then** it uses the same code path as Owner/Manager but applies `forUser()` scope, resulting in scoped data for Workers + +8. **Given** the Worker dashboard is loaded, **Then** Workers do NOT see team workload distribution or activity from other team members + +9. **Given** the Worker dashboard is loaded, **Then** the page renders within 3 seconds (NFR5) + +## Tasks / Subtasks + +- [x] Task 1: Verify and enhance Worker scoping in `DashboardController` (AC: #1, #2, #3, #7) + - [x] 1.1 Read the current `DashboardController` and confirm `forUser()` scope is already applied to all queries (KPI counts, urgent declarations, alerts). **It already is** — the `baseQuery` closure applies `forUser($user, $workspaceUser)`. Verify this works correctly for Worker role by running existing tests. + - [x] 1.2 Add `assignee=me` param to StatCard `href` URLs when the current user is a Worker, so clicking a KPI card pre-filters the declarations list to the Worker's assignments + - [x] 1.3 Add `isWorker` boolean flag to Inertia props to enable frontend conditional rendering + +- [x] Task 2: Update `Dashboard.vue` for Worker-specific UI (AC: #4, #5, #6, #8) + - [x] 2.1 Add a scoped view subtitle/header that displays when `isWorker` is true: "Mes déclarations" or "Tableau de bord — {userName}" + - [x] 2.2 Add an EmptyState when `isWorker` is true AND `declarations.length === 0` AND all stat card counts are 0: display "Aucune déclaration assignée — contactez votre responsable" with an appropriate icon (e.g., `FolderOpen` or `ClipboardList`) + - [x] 2.3 Hide the "Assigné à" column in the urgent declarations table when `isWorker` is true (Worker always sees their own — showing their own name is redundant) + - [x] 2.4 Ensure "Relancer" (Nudge) and "Réassigner" dropdown actions remain disabled/hidden for Workers (they already are disabled — verify they stay that way) + +- [x] Task 3: Extend StatCard `href` for Worker context (AC: #4) + - [x] 3.1 In `DashboardController`, when building `statCards` array, append `'assignee' => 'me'` (or `$user->id`) to the route params when the user is a Worker. This ensures clicking a KPI card navigates to a filtered declarations list scoped to the Worker. + - [x] 3.2 Verify the `declarations.index` route reads the `assignee` param and applies filtering (prep for Epic 4 FilterBar — for now, the param exists in the URL for future compatibility) + +- [x] Task 4: Write Pest feature tests for Worker dashboard (AC: #1, #2, #3, #6, #7, #8) + - [x] 4.1 Test Worker sees ONLY their assigned declarations in KPI counts (overdue, dueThisWeek, enAttenteClient, enCours) + - [x] 4.2 Test Worker does NOT see declarations assigned to other team members in KPI counts + - [x] 4.3 Test Worker sees only their assigned declarations in the urgent declarations table + - [x] 4.4 Test Worker sees only their assigned declarations in the priority alerts + - [x] 4.5 Test Worker dashboard returns `isWorker: true` in Inertia props + - [x] 4.6 Test Worker with no assigned declarations gets zero counts and empty declarations/alerts arrays + - [x] 4.7 Test Worker StatCard hrefs include assignee scoping param + - [x] 4.8 Test Owner/Manager dashboard returns `isWorker: false` + - [x] 4.9 Test cached data is scoped per user (Worker cache key includes user ID — already does: `dashboard:{workspace_id}:{user_id}`) + +## Dev Notes + +### Architecture Patterns & Constraints + +- **Workspace resolution:** Always from session `current_workspace_id`, NEVER from URL params +- **Authorization:** Use `abort(404)` not `abort(403)` for workspace boundary violations +- **Role-scoped queries:** Use existing `Declaration::forUser($user, $workspaceUser)` scope — Workers see only assigned, Owners/Managers see all +- **Data shaping:** Manually build arrays in controller, NO API Resources +- **URLs as props:** ALL frontend URLs must come from controller via `route()` helper — never hardcode routes in Vue +- **Wayfinder routes:** All URLs in Vue MUST use Wayfinder type-safe routes. Check existing Dashboard.vue imports for pattern. +- **Cache pattern:** Same `Cache::remember()` with key `dashboard:{workspace_id}:{user_id}` — already user-specific, so Worker data is automatically scoped and cached separately from Owner/Manager data + +### CRITICAL: The `forUser()` Scope Already Works + +The current `DashboardController` already applies `forUser($user, $workspaceUser)` to ALL queries — both the cached KPI counts and the urgent declarations table. The `Declaration::scopeForUser()` method (in `app/Models/Declaration.php:152`) checks if the role is `Worker` and adds `where('assigned_to', $user->id)`. This means: + +- **KPI counts are already Worker-scoped** — no backend counting changes needed +- **Urgent declarations table is already Worker-scoped** — no backend query changes needed +- **Priority alerts are already Worker-scoped** — the `buildAlerts()` method uses the same `baseQuery` which includes `forUser()` + +**What IS needed:** +1. **StatCard URLs** need `assignee` param appended for Workers so clicking navigates to a pre-filtered list +2. **Frontend UI** needs Worker-specific elements: subtitle, empty state, column hiding +3. **`isWorker` prop** added to Inertia response for frontend conditional rendering +4. **Tests** to explicitly verify Worker scoping works correctly end-to-end + +### CRITICAL: DB Column Name + +The architecture docs reference `deadline` but the actual database column is **`due_date`**. Use `due_date` in ALL queries. This was caught in Story 2.1 and confirmed in Story 2.2. + +### CRITICAL: Excluded Statuses + +The dashboard excludes declarations with statuses: `termine`, `mise_en_demeure`, `ferme`. The `DeclarationStatus` enum has 6 values: `created`, `en_cours`, `en_attente_client`, `termine`, `mise_en_demeure`, `ferme`. Only `created`, `en_cours`, and `en_attente_client` should appear in dashboard data. + +### Existing Code to Extend (NOT Create Fresh) + +**`app/Http/Controllers/DashboardController.php`** — Current controller (Story 2.1 + 2.2): +- Already has `Cache::remember()` with key `dashboard:{workspace_id}:{user_id}` and 5-min TTL +- Already queries with `forUser($user, $workspaceUser)` scope on all queries +- Already returns `stats`, `statCards`, `declarations`, `alerts`, `workspaceName`, `roleLabel`, `declarationsUrl`, `clientsUrl`, `viewAllAlertsUrl` +- **ADD:** `isWorker` boolean prop to Inertia response +- **MODIFY:** StatCard `href` values — append `assignee` param when user is Worker +- The `roleLabel` for Worker already returns `'Collaborateur'` — can use this on the frontend + +**`resources/js/pages/Dashboard.vue`** — Current page (Story 2.1 + 2.2): +- Currently has: KPI card grid → PriorityAlertsPanel → urgent declarations table +- **ADD:** Worker subtitle/header conditional on `isWorker` prop +- **ADD:** EmptyState when Worker has no data +- **ADD:** Hide "Assigné à" column when `isWorker` is true +- Already has all deadline proximity logic, status badge variants, row actions + +**`resources/js/types/dashboard.ts`** — Current types (Story 2.1 + 2.2): +- Has: `DashboardStats`, `DashboardDeclaration`, `StatCardLink`, `DashboardAlert`, `DashboardProps` +- **ADD:** `isWorker: boolean` to `DashboardProps` + +### Files to Modify + +| File | Changes | +|------|---------| +| `app/Http/Controllers/DashboardController.php` | Add `isWorker` boolean to Inertia props; modify StatCard `href` to include `assignee` for Workers | +| `resources/js/pages/Dashboard.vue` | Add Worker subtitle, EmptyState for no assignments, hide assignee column for Workers | +| `resources/js/types/dashboard.ts` | Add `isWorker: boolean` to `DashboardProps` | + +### New Files to Create + +| File | Purpose | +|------|---------| +| `tests/Feature/Dashboard/WorkerDashboardTest.php` | Pest feature tests for Worker-scoped dashboard | + +### Component Specifications (from UX Design & Architecture) + +**Worker Dashboard Layout:** +- Same 4-column KPI card grid as Owner/Manager +- Same PriorityAlertsPanel component (data is already scoped by backend) +- Same urgent declarations table but WITHOUT "Assigné à" column (redundant for Worker) +- Section heading: "Mes déclarations" (replaces "Déclarations urgentes" for Worker) or keep "Déclarations urgentes" but add subtitle "Mes déclarations" above the KPI cards +- Nudge and Reassign row actions remain disabled (Workers cannot nudge or reassign) + +**EmptyState (Worker with no assignments):** +- Icon: `FolderOpen` or `ClipboardList` from lucide-vue-next (consistent with existing empty state icon) +- Title: "Aucune déclaration assignée" +- Subtitle: "Contactez votre responsable pour recevoir des déclarations" +- No action button (Worker cannot self-assign) +- Show this ONLY when all KPI counts are 0 AND declarations array is empty + +### Frontend Conditional Rendering Pattern + +```vue + +

+ Mes déclarations +

+ + +
+ +
+ + +Assigné à + +{{ declaration.assigneeName }} +``` + +### Backend Modification Pattern + +```php +// In DashboardController::__invoke() +$isWorker = $workspaceUser->role->is(WorkspaceUserRole::Worker); + +// Modify statCards href for Workers +$statCards = [ + [ + 'label' => 'En retard', + 'count' => $dashboardData['overdue'], + 'status' => 'danger', + 'href' => route('declarations.index', array_filter([ + 'overdue' => 1, + 'assignee' => $isWorker ? $user->id : null, + ])), + ], + // ... same pattern for other cards +]; + +// Add to Inertia props +return Inertia::render('Dashboard', [ + // ... existing props + 'isWorker' => $isWorker, +]); +``` + +### Testing Standards + +- Use Pest syntax (`test()` closures), never PHPUnit class-based +- Test descriptions: lowercase, descriptive strings +- Use `route()` helper for URLs, never hardcoded paths +- Factory states: `User::factory()->create()` + attach to workspace with role via `WorkspaceUser` +- Use `RefreshDatabase` (auto-applied via Pest.php) +- Assertions: prefer Pest's `expect()` chaining for data assertions, `->assertInertia()` for page props +- Tests go in `tests/Feature/Dashboard/WorkerDashboardTest.php` +- Reuse the `setupWorkspaceWithRole()` helper pattern from `OwnerDashboardTest.php` +- Run tests: `composer test` +- **Current test count:** 206 tests, 974 assertions (after Story 2.2). Do NOT break existing tests. + +### Previous Story Intelligence (Story 2.1 + 2.2 Learnings) + +- **DB column is `due_date` not `deadline`** — architecture docs use wrong name. Always use `due_date`. +- **Excluded statuses:** `termine`, `mise_en_demeure`, `ferme` must be excluded from dashboard queries. +- **`Declaration::forUser($user, $workspaceUser)`** takes both User and WorkspaceUser args (not just User). Check the model scope signature. +- **withPivot gotcha:** `WorkspaceUser` pivot needs explicit `withPivot('role', 'permissions')` on relationships. +- **Wayfinder routes in Vue:** All URLs must use Wayfinder type-safe routes. The existing `Dashboard.vue` imports from `@/routes`. +- **No API Resources:** Manual array building in controller. Do NOT create FormRequest or API Resource classes. +- **Flash messages:** Already implemented — success/error toasts auto-dismiss after 4s. +- **Shared Inertia props:** `auth.user`, `auth.workspaces`, `auth.currentWorkspace`, `auth.workspaceRole` available via `usePage()`. +- **Cache mock gotcha:** Don't use `Cache::shouldReceive` mocks — they conflict with middleware Cache calls. Test cache behavior by verifying data structure directly. +- **`DeclarationType` has a `label()` method** that returns French labels. Use it for display. +- **`abs()` for diffInDays:** Wrap with `abs()` when computing overdue days to avoid negative values (fixed in Story 2.2). +- **Story 2.1 test helper:** `setupWorkspaceWithRole($role)` in `OwnerDashboardTest.php` creates a user, workspace, and client with the given role. Reuse or create a similar helper in the Worker test file. +- **Role label mapping:** `WorkspaceUserRole::Worker` maps to `'Collaborateur'` via the `roleLabels()` method. + +### Project Structure Notes + +- Tests: `tests/Feature/Dashboard/WorkerDashboardTest.php` (alongside `OwnerDashboardTest.php` and `PriorityAlertsPanelTest.php`) +- Types extended in existing `resources/js/types/dashboard.ts` +- Controller modified in existing `app/Http/Controllers/DashboardController.php` +- Dashboard page modified in existing `resources/js/pages/Dashboard.vue` +- Route: no changes needed (same `/dashboard` route) + +### Scope Boundaries — Do NOT Implement + +- Do NOT add activity feed (that's Story 2.4) +- Do NOT add new filter bar or search (that's Epic 4) +- Do NOT add real-time updates or WebSockets (deferred post-MVP) +- Do NOT refactor the existing dashboard architecture — extend it minimally +- Do NOT create separate Worker dashboard page/component — use the same `Dashboard.vue` with conditional rendering + +### References + +- [Source: _bmad-output/planning-artifacts/epics.md#Epic 2 Story 2.3] +- [Source: _bmad-output/planning-artifacts/architecture.md#Role-Scoped Query Patterns] +- [Source: _bmad-output/planning-artifacts/architecture.md#Dashboard Aggregation Patterns] +- [Source: _bmad-output/planning-artifacts/architecture.md#D4 Caching Strategy] +- [Source: _bmad-output/planning-artifacts/ux-design-specification.md#Journey 2 Worker Daily Workflow] +- [Source: _bmad-output/planning-artifacts/ux-design-specification.md#Experience Mechanics] +- [Source: _bmad-output/planning-artifacts/prd.md#FR25] +- [Source: _bmad-output/implementation-artifacts/2-1-owner-manager-command-center-dashboard.md] +- [Source: _bmad-output/implementation-artifacts/2-2-priority-alerts-panel.md] +- [Source: _bmad-output/project-context.md] +- [Source: app/Http/Controllers/DashboardController.php] +- [Source: resources/js/pages/Dashboard.vue] +- [Source: resources/js/types/dashboard.ts] +- [Source: app/Models/Declaration.php#scopeForUser] +- [Source: app/Enums/DeclarationStatus.php] +- [Source: app/Enums/WorkspaceUserRole.php] +- [Source: tests/Feature/Dashboard/OwnerDashboardTest.php] + +## Dev Agent Record + +### Agent Model Used + +Claude Opus 4.6 (1M context) + +### Debug Log References + +None — clean implementation with no blockers. + +### Completion Notes List + +- Verified existing `forUser()` scope correctly filters Worker queries (KPI counts, urgent table, alerts) — no backend query changes needed +- Added `isWorker` boolean prop to Inertia response (both normal and null workspace fallback) +- Added `assignee` param to StatCard `href` URLs for Workers (uses `$user->id` for future Epic 4 FilterBar compatibility) +- Added `isWorker: boolean` to `DashboardProps` TypeScript type +- Added Worker subtitle "Mes déclarations" above KPI cards when `isWorker` is true +- Added full EmptyState with `ClipboardList` icon when Worker has no assigned declarations +- Hidden "Assigné à" column in urgent declarations table for Workers (redundant) +- Verified "Relancer" and "Réassigner" dropdown actions remain disabled for Workers (already were) +- Created 9 comprehensive Pest feature tests covering all ACs +- All 215 tests pass (9 new + 206 existing), 1127 assertions, zero regressions +- PHP and JS linting pass + +### Change Log + +- 2026-03-20: Implemented Story 2.3 — Worker Scoped Dashboard (all 4 tasks, 9 new tests) + +### File List + +- `app/Http/Controllers/DashboardController.php` (modified — added `isWorker` prop, assignee param in StatCard hrefs) +- `resources/js/pages/Dashboard.vue` (modified — Worker subtitle, EmptyState, hidden assignee column) +- `resources/js/types/dashboard.ts` (modified — added `isWorker` to DashboardProps) +- `tests/Feature/Dashboard/WorkerDashboardTest.php` (new — 9 Pest feature tests) diff --git a/_bmad-output/implementation-artifacts/sprint-status.yaml b/_bmad-output/implementation-artifacts/sprint-status.yaml index 742550a..5187b6b 100644 --- a/_bmad-output/implementation-artifacts/sprint-status.yaml +++ b/_bmad-output/implementation-artifacts/sprint-status.yaml @@ -62,9 +62,9 @@ development_status: # Epic 2: Role-Driven Dashboard & Command Center epic-2: in-progress - 2-1-owner-manager-command-center-dashboard: review - 2-2-priority-alerts-panel: review - 2-3-worker-scoped-dashboard: backlog + 2-1-owner-manager-command-center-dashboard: done + 2-2-priority-alerts-panel: done + 2-3-worker-scoped-dashboard: review 2-4-dashboard-activity-feed: backlog epic-2-retrospective: optional diff --git a/app/Http/Controllers/DashboardController.php b/app/Http/Controllers/DashboardController.php index da04760..2fb46bd 100644 --- a/app/Http/Controllers/DashboardController.php +++ b/app/Http/Controllers/DashboardController.php @@ -31,6 +31,7 @@ class DashboardController extends Controller 'alerts' => [], 'workspaceName' => null, 'roleLabel' => null, + 'isWorker' => false, 'declarationsUrl' => null, 'clientsUrl' => null, 'viewAllAlertsUrl' => null, @@ -105,31 +106,34 @@ class DashboardController extends Controller ->all(); $roleLabel = $this->roleLabels()[$workspaceUser->role->value] ?? $workspaceUser->role->value; + $isWorker = $workspaceUser->role->is(WorkspaceUserRole::Worker); + + $assigneeParam = $isWorker ? ['assignee' => $user->id] : []; $statCards = [ [ 'label' => 'En retard', 'count' => $dashboardData['overdue'], 'status' => 'danger', - 'href' => route('declarations.index', ['overdue' => 1]), + 'href' => route('declarations.index', array_merge(['overdue' => 1], $assigneeParam)), ], [ 'label' => 'Cette semaine', 'count' => $dashboardData['dueThisWeek'], 'status' => 'warning', - 'href' => route('declarations.index', ['due_this_week' => 1]), + 'href' => route('declarations.index', array_merge(['due_this_week' => 1], $assigneeParam)), ], [ 'label' => 'En attente client', 'count' => $dashboardData['enAttenteClient'], 'status' => 'info', - 'href' => route('declarations.index', ['status' => DeclarationStatus::EnAttenteClient]), + 'href' => route('declarations.index', array_merge(['status' => DeclarationStatus::EnAttenteClient], $assigneeParam)), ], [ 'label' => 'En cours', 'count' => $dashboardData['enCours'], 'status' => 'success', - 'href' => route('declarations.index', ['status' => DeclarationStatus::EnCours]), + 'href' => route('declarations.index', array_merge(['status' => DeclarationStatus::EnCours], $assigneeParam)), ], ]; @@ -140,6 +144,7 @@ class DashboardController extends Controller 'alerts' => $dashboardData['alerts'], 'workspaceName' => $workspace->name, 'roleLabel' => $roleLabel, + 'isWorker' => $isWorker, 'declarationsUrl' => route('declarations.index'), 'clientsUrl' => route('clients.index'), 'viewAllAlertsUrl' => route('declarations.index', ['filter' => 'alerts']), diff --git a/database/database.sqlite b/database/database.sqlite index d9498d553003eb2ca5cd2930a9def99cd4216506..9ae9158bc6ea7559b5567ef120c047474db2312e 100644 GIT binary patch delta 3258 zcmb_eYfM|$9lz&Xzpk-;Od%n`dALs2V1eP<*Vo3t0tO7mf$@lWIV|4AzJLKg@PkLX zKpmEmbV@|z()(f360}Vl?Y0Q9)Qgs`oz_Z|R%wbft(CSf{jg2z)~WfhZtFH}=Ne25 zp=& zPm7P)ABgi@lJ_ThF6<9QW{{v0&IP^d_-Dagi1*C5$Zwjq93C*w1KXo8B~vq zsATI|{a-bQOVy+a?1us~#+q3m%iO6cmwC!%WIZJ~zY5DT@77ctI;K|tl8`AEiK0nV zqFhv$sxD|2v9C#GX0h(YhB05axpmMtHscOW*=q-b!?u~Ja9d||s3+zdm>N50Y3=f{ zzIjjYxuFGLpY_uC#BistlMPMOGPMHuEqNw0Re!U=9k*M?=k2Lb@4_taIPV?y)DF4l zqEUWkBH)U5P4M-u*S<#&KUn@Ap;tlb7tvJ4~z`5o|&Opi91O|F9*n&a{?c9$Y3R(c<~RTn`@ z`LXhbvVz(mJ|u47oEZe!)F$}56uSpM$K$ua%j+PS-M9}fv?)A_3PQ20xV)x-U)Cxv zD$b%mqhF&p(JSaBMa8|Fa8)j}o`9uYGS5j#BLL1=Y@{n1JKzNO^v2pBUOKtUDd)KwJ&GwDV0?2 zaomqXq2nN>`ygfeLCQAaO$1A`Lnq+(0O?U(7vy^QHKppl>iep7)tc%$PP`9`8j46A z2=ZfGYC7pDkUI)wcY)kqAh%`Tu7aOG|J)A=mBdI$-$C=}h-O1wqVg4)@$t}#A1M?P zBYYnY5xuAz9}19U-&=+^0XTBYS(*)JK?7V-T!UG~HS%BMhbg#8sN0oam;XoJBD*I$ zBl#V<0XOk90jkp}kA~3cDgi-UaS59t^0-voA(>F~IxPF#-h?z%8Tc?4nl{uK3Zqt- zc>s^tJ}WOr4opnprw^bmpOFVAdC4%%)S<$|=y$})Bn$Cz0YJ<8hcjCpL#YO!JS zTx;Ni{?P?Pow>@u1p<65iN{>hP!~x}Pvae$kA;rK`9w}-$QKxk)r*S`ZLec#A^jb) zbenQ+oHr>@$2UQ3&5xq!{&aM*&@fMaZoqaB}3{L~KfCy6s&v#k#QXh)9tl1-l< z?-GBk+994UYF86FGeJ~RVX-spq|y1gP%<#V2le9Vy6O~Kb6aAvre0d)Pw$4f)ph_3D{omj;Z?C+IrvQ%SNjS`ta2Pt<@;CDJ zBP#KJ12vxzXhHJ{t~h_uEM1001XX#opTg~wDa=qD`r85jG*O=40E<=Zlb;r%d8>k` zT}n#;24L5112`}ecjE79f-b*;h526*leaS4^riX065B0A4+->p^bp?*-$wPN66%Rg z3y(@DJ9Qw+Z3lM=bQisc9efjoD#(8DG@A#{kp0xtuoF*Dp~w)(r~a8*rV1AQhWZtC zN|C`;`FTZ2THPcsYd3*36N&`+dGRVJJh}pp7K>8h{j^nBeFGelOj)Go3k9~=9bMrs8fe+kUG=Jbgrp$PC}Ft z|AeC5G-Y#=y=jrvY5hqNe%#gSs zOzU%WemZMdkf+Zq*%HyJ#=+B$&~U__81jsDd7J7grNKbRJk`s|5> zr#>h3@lil3z^!e&9dK*GhRleS+yVEmN88gSM zeUm|JrM1!CHFCbn=N|3whU0_H0heQ}zqLHr;0-EocS%voZyU!fI8a))8=mN9tpreU zwKU5eEtCstriDxW4vV{ONQ%V%&{-xsMr_?xn&PkZZIc>6FD%lBvEaB^B*OeaJv~1e0JCllp9(FY^>Vsa*3^ zqhUTU6&X?PF !!props.workspaceName); +const isWorkerEmpty = computed( + () => + props.isWorker && + props.declarations.length === 0 && + props.statCards.every((c) => c.count === 0), +); + type DeadlineProximity = 'safe' | 'approaching' | 'urgent' | 'overdue' | 'none'; function deadlineProximity(dueDate: string | null): DeadlineProximity { @@ -150,166 +158,205 @@ function navigateToDeclaration(declaration: DashboardDeclaration): void { diff --git a/resources/js/types/dashboard.ts b/resources/js/types/dashboard.ts index 102b802..425a6b4 100644 --- a/resources/js/types/dashboard.ts +++ b/resources/js/types/dashboard.ts @@ -43,6 +43,7 @@ export type DashboardProps = { alerts: DashboardAlert[]; workspaceName: string | null; roleLabel: string | null; + isWorker: boolean; declarationsUrl: string | null; clientsUrl: string | null; viewAllAlertsUrl: string | null; diff --git a/tests/Feature/Dashboard/WorkerDashboardTest.php b/tests/Feature/Dashboard/WorkerDashboardTest.php new file mode 100644 index 0000000..e78a8d6 --- /dev/null +++ b/tests/Feature/Dashboard/WorkerDashboardTest.php @@ -0,0 +1,316 @@ +create(); + $workspace = Workspace::factory()->create(); + $workspace->users()->attach($worker->id, ['role' => 'worker']); + $client = Client::factory()->create(['workspace_id' => $workspace->id]); + session(['current_workspace_id' => $workspace->id]); + + return [$worker, $workspace, $client]; +} + +test('worker sees only their assigned declarations in kpi counts', function () { + [$worker, $workspace, $client] = setupWorkerWorkspace(); + + $otherUser = User::factory()->create(); + $workspace->users()->attach($otherUser->id, ['role' => 'worker']); + + // Assigned to this worker — overdue + Declaration::factory()->create([ + 'workspace_id' => $workspace->id, + 'client_id' => $client->id, + 'assigned_to' => $worker->id, + 'status' => DeclarationStatus::EnCours, + 'due_date' => now()->subDays(3), + ]); + + // Assigned to this worker — due this week + Declaration::factory()->create([ + 'workspace_id' => $workspace->id, + 'client_id' => $client->id, + 'assigned_to' => $worker->id, + 'status' => DeclarationStatus::EnCours, + 'due_date' => now()->addDays(2), + ]); + + // Assigned to this worker — en attente client + Declaration::factory()->create([ + 'workspace_id' => $workspace->id, + 'client_id' => $client->id, + 'assigned_to' => $worker->id, + 'status' => DeclarationStatus::EnAttenteClient, + 'due_date' => now()->addDays(10), + ]); + + // Assigned to OTHER worker (should NOT count) + Declaration::factory()->create([ + 'workspace_id' => $workspace->id, + 'client_id' => $client->id, + 'assigned_to' => $otherUser->id, + 'status' => DeclarationStatus::EnCours, + 'due_date' => now()->subDays(1), + ]); + + $this->actingAs($worker); + $response = $this->get(route('dashboard')); + + $response->assertOk(); + $response->assertInertia(fn ($page) => $page + ->component('Dashboard') + ->where('stats.overdue', 1) + ->where('stats.dueThisWeek', 1) + ->where('stats.enAttenteClient', 1) + ->where('stats.enCours', 2) + ); +}); + +test('worker does not see declarations assigned to other team members in kpi counts', function () { + [$worker, $workspace, $client] = setupWorkerWorkspace(); + + $otherWorker = User::factory()->create(); + $workspace->users()->attach($otherWorker->id, ['role' => 'worker']); + + // Only assigned to other worker + Declaration::factory()->create([ + 'workspace_id' => $workspace->id, + 'client_id' => $client->id, + 'assigned_to' => $otherWorker->id, + 'status' => DeclarationStatus::EnCours, + 'due_date' => now()->subDays(5), + ]); + + Declaration::factory()->create([ + 'workspace_id' => $workspace->id, + 'client_id' => $client->id, + 'assigned_to' => $otherWorker->id, + 'status' => DeclarationStatus::EnAttenteClient, + 'due_date' => now()->addDays(2), + ]); + + $this->actingAs($worker); + $response = $this->get(route('dashboard')); + + $response->assertOk(); + $response->assertInertia(fn ($page) => $page + ->component('Dashboard') + ->where('stats.overdue', 0) + ->where('stats.dueThisWeek', 0) + ->where('stats.enAttenteClient', 0) + ->where('stats.enCours', 0) + ); +}); + +test('worker sees only their assigned declarations in the urgent declarations table', function () { + [$worker, $workspace, $client] = setupWorkerWorkspace(); + + $otherWorker = User::factory()->create(); + $workspace->users()->attach($otherWorker->id, ['role' => 'worker']); + + // Assigned to this worker + $myDeclaration = Declaration::factory()->create([ + 'workspace_id' => $workspace->id, + 'client_id' => $client->id, + 'assigned_to' => $worker->id, + 'status' => DeclarationStatus::EnCours, + 'due_date' => now()->addDays(2), + ]); + + // Assigned to other worker + Declaration::factory()->create([ + 'workspace_id' => $workspace->id, + 'client_id' => $client->id, + 'assigned_to' => $otherWorker->id, + 'status' => DeclarationStatus::EnCours, + 'due_date' => now()->addDays(1), + ]); + + $this->actingAs($worker); + $response = $this->get(route('dashboard')); + + $response->assertOk(); + $response->assertInertia(fn ($page) => $page + ->component('Dashboard') + ->has('declarations', 1) + ->where('declarations.0.id', $myDeclaration->id) + ); +}); + +test('worker sees only their assigned declarations in priority alerts', function () { + [$worker, $workspace, $client] = setupWorkerWorkspace(); + + $otherWorker = User::factory()->create(); + $workspace->users()->attach($otherWorker->id, ['role' => 'worker']); + + // Overdue declaration assigned to this worker (generates critical alert) + Declaration::factory()->create([ + 'workspace_id' => $workspace->id, + 'client_id' => $client->id, + 'assigned_to' => $worker->id, + 'status' => DeclarationStatus::EnCours, + 'due_date' => now()->subDays(5), + ]); + + // Overdue declaration assigned to other worker (should NOT appear) + Declaration::factory()->create([ + 'workspace_id' => $workspace->id, + 'client_id' => $client->id, + 'assigned_to' => $otherWorker->id, + 'status' => DeclarationStatus::EnCours, + 'due_date' => now()->subDays(3), + ]); + + $this->actingAs($worker); + $response = $this->get(route('dashboard')); + + $response->assertOk(); + $response->assertInertia(fn ($page) => $page + ->component('Dashboard') + ->has('alerts', 1) + ->where('alerts.0.severity', 'critical') + ); +}); + +test('worker dashboard returns isWorker true in inertia props', function () { + [$worker, $workspace, $client] = setupWorkerWorkspace(); + + $this->actingAs($worker); + $response = $this->get(route('dashboard')); + + $response->assertOk(); + $response->assertInertia(fn ($page) => $page + ->component('Dashboard') + ->where('isWorker', true) + ); +}); + +test('worker with no assigned declarations gets zero counts and empty arrays', function () { + [$worker, $workspace, $client] = setupWorkerWorkspace(); + + // Declarations exist but assigned to someone else + $otherUser = User::factory()->create(); + $workspace->users()->attach($otherUser->id, ['role' => 'worker']); + + Declaration::factory()->create([ + 'workspace_id' => $workspace->id, + 'client_id' => $client->id, + 'assigned_to' => $otherUser->id, + 'status' => DeclarationStatus::EnCours, + 'due_date' => now()->subDays(1), + ]); + + $this->actingAs($worker); + $response = $this->get(route('dashboard')); + + $response->assertOk(); + $response->assertInertia(fn ($page) => $page + ->component('Dashboard') + ->where('stats.overdue', 0) + ->where('stats.dueThisWeek', 0) + ->where('stats.enAttenteClient', 0) + ->where('stats.enCours', 0) + ->where('declarations', []) + ->where('alerts', []) + ->where('isWorker', true) + ); +}); + +test('worker stat card hrefs include assignee scoping param', function () { + [$worker, $workspace, $client] = setupWorkerWorkspace(); + + $this->actingAs($worker); + $response = $this->get(route('dashboard')); + + $response->assertOk(); + $response->assertInertia(fn ($page) => $page + ->component('Dashboard') + ->has('statCards', 4) + ->where('statCards.0.href', fn ($href) => str_contains($href, 'assignee='.$worker->id)) + ->where('statCards.1.href', fn ($href) => str_contains($href, 'assignee='.$worker->id)) + ->where('statCards.2.href', fn ($href) => str_contains($href, 'assignee='.$worker->id)) + ->where('statCards.3.href', fn ($href) => str_contains($href, 'assignee='.$worker->id)) + ); +}); + +test('owner and manager dashboard returns isWorker false', function () { + $ownerUser = User::factory()->create(); + $managerUser = User::factory()->create(); + $workspace = Workspace::factory()->create(); + $workspace->users()->attach($ownerUser->id, ['role' => 'owner']); + $workspace->users()->attach($managerUser->id, ['role' => 'manager']); + session(['current_workspace_id' => $workspace->id]); + + // Owner + $this->actingAs($ownerUser); + $response = $this->get(route('dashboard')); + $response->assertOk(); + $response->assertInertia(fn ($page) => $page + ->component('Dashboard') + ->where('isWorker', false) + ); + + // Manager + $this->actingAs($managerUser); + $response = $this->get(route('dashboard')); + $response->assertOk(); + $response->assertInertia(fn ($page) => $page + ->component('Dashboard') + ->where('isWorker', false) + ); +}); + +test('cached data is scoped per user with worker cache key including user id', function () { + [$worker, $workspace, $client] = setupWorkerWorkspace(); + + $otherWorker = User::factory()->create(); + $workspace->users()->attach($otherWorker->id, ['role' => 'worker']); + + // Declaration assigned to first worker + Declaration::factory()->create([ + 'workspace_id' => $workspace->id, + 'client_id' => $client->id, + 'assigned_to' => $worker->id, + 'status' => DeclarationStatus::EnCours, + 'due_date' => now()->subDays(1), + ]); + + // Declaration assigned to second worker + Declaration::factory()->create([ + 'workspace_id' => $workspace->id, + 'client_id' => $client->id, + 'assigned_to' => $otherWorker->id, + 'status' => DeclarationStatus::EnCours, + 'due_date' => now()->subDays(2), + ]); + + // First worker request + $this->actingAs($worker); + $this->get(route('dashboard'))->assertOk(); + + $workerCacheKey = "dashboard:{$workspace->id}:{$worker->id}"; + $otherCacheKey = "dashboard:{$workspace->id}:{$otherWorker->id}"; + + expect(Cache::has($workerCacheKey))->toBeTrue(); + expect(Cache::has($otherCacheKey))->toBeFalse(); + + $workerCached = Cache::get($workerCacheKey); + expect($workerCached['overdue'])->toBe(1) + ->and($workerCached['enCours'])->toBe(1); + + // Second worker request + $this->actingAs($otherWorker); + $this->get(route('dashboard'))->assertOk(); + + expect(Cache::has($otherCacheKey))->toBeTrue(); + $otherCached = Cache::get($otherCacheKey); + expect($otherCached['overdue'])->toBe(1) + ->and($otherCached['enCours'])->toBe(1); +});