diff --git a/_bmad-output/implementation-artifacts/2-3-review-notes.md b/_bmad-output/implementation-artifacts/2-3-review-notes.md
new file mode 100644
index 0000000..33436be
--- /dev/null
+++ b/_bmad-output/implementation-artifacts/2-3-review-notes.md
@@ -0,0 +1,37 @@
+# Story 2.3 Code Review — Deferred Findings
+
+**Review date:** 2026-03-22
+**Story:** 2.3 — Worker-Scoped Dashboard
+**Reviewer:** BMAD adversarial code review (3-layer)
+
+These items are pre-existing issues surfaced during the Story 2.3 code review. They are not caused by 2.3 changes but should be addressed in future work.
+
+---
+
+## D-1 — Nudge/Reassign dropdown items unconditionally disabled
+
+**Severity:** Medium
+**Location:** `resources/js/pages/Dashboard.vue` — DropdownMenu items "Relancer" and "Réassigner"
+**Affects:** Epic 3, Story 3-2 (One-Click Nudge System)
+
+The dropdown actions are hardcoded as `disabled` for all roles. The spec implies they should be enabled for Owners/Managers once the nudge system is built. When implementing Story 3-2, enable these actions for Owner/Manager roles and keep them disabled (or hidden) for Workers.
+
+---
+
+## D-2 — StatCard `assignee` param not consumed by declarations index
+
+**Severity:** Low (intentional)
+**Location:** `app/Http/Controllers/DashboardController.php` — `$assigneeParam`
+**Affects:** Epic 4, Story 4-1 (FilterBar Component)
+
+The `assignee={user.id}` URL parameter is included in StatCard hrefs but the declarations list page does not yet filter by it. This is by design — the param exists for forward compatibility. When implementing Story 4-1/4-2 (FilterBar), ensure the `assignee` query param is recognized and applied as a default filter.
+
+---
+
+## D-3 — Cache not invalidated on role change
+
+**Severity:** Low
+**Location:** `app/Http/Controllers/DashboardController.php` — `Cache::remember()` with key `dashboard:{workspace_id}:{user_id}`
+**Affects:** Cross-cutting (cache architecture)
+
+If a user's role changes (e.g., worker promoted to manager), the cached dashboard data remains scoped to the old role for up to 5 minutes (TTL). Current risk is low given the short TTL. If role management gets a dedicated story or if TTL is increased, add cache invalidation on role change events (e.g., listen for `WorkspaceUser` updated event and `Cache::forget()` the affected key).
diff --git a/_bmad-output/implementation-artifacts/2-3-worker-scoped-dashboard.md b/_bmad-output/implementation-artifacts/2-3-worker-scoped-dashboard.md
index 5d8d2bf..d4c8edf 100644
--- a/_bmad-output/implementation-artifacts/2-3-worker-scoped-dashboard.md
+++ b/_bmad-output/implementation-artifacts/2-3-worker-scoped-dashboard.md
@@ -16,7 +16,7 @@ So that I can quickly identify what I need to work on today without information
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)
+4. **Given** a Worker views the dashboard, **Then** StatCards are clickable and navigate to `/declarations?assignee={user.id}&status={status}` (using the Worker's actual user ID to pre-filter)
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)
@@ -32,7 +32,7 @@ So that I can quickly identify what I need to work on today without information
- [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.2 Add `assignee={user.id}` 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)
diff --git a/_bmad-output/implementation-artifacts/2-4-dashboard-activity-feed.md b/_bmad-output/implementation-artifacts/2-4-dashboard-activity-feed.md
new file mode 100644
index 0000000..0648ef2
--- /dev/null
+++ b/_bmad-output/implementation-artifacts/2-4-dashboard-activity-feed.md
@@ -0,0 +1,377 @@
+# Story 2.4: Dashboard Activity Feed
+
+Status: review
+
+## Story
+
+As a firm owner or manager,
+I want to see a stream of recent workspace activity on my dashboard,
+So that I can stay aware of team actions, client uploads, and status changes without checking individual declarations.
+
+## Acceptance Criteria
+
+1. **Given** the Owner/Manager dashboard is loaded, **When** the activity feed panel renders, **Then** the feed shows the 20 most recent workspace events in reverse chronological order
+
+2. **Given** the activity feed is displayed, **Then** each entry displays: actor avatar/initials, action description, target entity link, and relative timestamp ("il y a 2 heures")
+
+3. **Given** the activity feed is displayed, **Then** event types include: declaration status changes, document uploads by clients, declaration reassignments, team member role changes, new declarations created
+
+4. **Given** a user clicks an event in the feed, **Then** it navigates to the relevant entity (declaration detail, client page, team page)
+
+5. **Given** a Worker views the dashboard, **When** the activity feed renders, **Then** the feed shows only activity related to the Worker's assigned declarations (not the entire workspace)
+
+6. **Given** the dashboard is loaded, **Then** the activity feed data is included in the Inertia page props (server-rendered, no separate API call)
+
+7. **Given** a mobile viewport (<768px), **Then** the activity feed is accessible via an expandable section (not hidden entirely)
+
+8. **Given** there is no recent activity, **Then** a neutral message is shown: "Aucune activite recente"
+
+## Tasks / Subtasks
+
+- [x] Task 1: Build activity feed query in `DashboardController` (AC: #1, #3, #5, #6)
+ - [x]1.1 Add `buildActivityFeed()` private method that queries `Spatie\Activitylog\Models\Activity` for the current workspace, limited to 20, ordered by `created_at` desc
+ - [x]1.2 For Workers, filter activities to only those where `subject_type` is `Declaration` AND the declaration is assigned to the Worker (join/subquery on `declarations.assigned_to`)
+ - [x]1.3 For Owners/Managers, return all workspace activities (no subject restriction)
+ - [x]1.4 Map each activity record to a frontend-friendly array: `id`, `actorName`, `actorInitials`, `description` (human-readable French), `targetUrl`, `targetLabel`, `timestamp` (ISO), `eventType` (icon hint)
+ - [x]1.5 Include the activity feed in the cached dashboard data (same `Cache::remember()` block with existing 5-minute TTL)
+ - [x]1.6 Add `activities` key to the Inertia props alongside existing `stats`, `statCards`, `declarations`, `alerts`
+
+- [x]Task 2: Build human-readable French descriptions for activity events (AC: #2, #3)
+ - [x]2.1 Create a `formatActivityDescription()` private method that maps Spatie activity events to French descriptions:
+ - `created` on Declaration → "{actorName} a cree la declaration {typeLabel} pour {clientName}"
+ - `updated` on Declaration with status change → "{actorName} a change le statut de {typeLabel} ({clientName}) en {newStatus}"
+ - `updated` on Declaration with `assigned_to` change → "{actorName} a reassigne {typeLabel} ({clientName}) a {newAssignee}"
+ - `created`/`updated` on Client → "{actorName} a modifie le client {clientName}"
+ - `updated` on WorkspaceUser (role change) → "{actorName} a change le role de {targetUser}"
+ - Custom log "Switched workspace" → skip (not relevant to feed)
+ - Fallback: "{actorName} a modifie {subjectType}"
+ - [x]2.2 Extract `properties.old` and `properties.attributes` from the Spatie activity record to build contextual descriptions (e.g., old status → new status)
+ - [x]2.3 Use `DeclarationStatus::labels()` and `typeLabels()` to display French labels in descriptions
+
+- [x]Task 3: Build `targetUrl` resolution for clickable events (AC: #4)
+ - [x]3.1 For Declaration subjects → `route('declarations.show', $subjectId)`
+ - [x]3.2 For Client subjects → `route('clients.show', $subjectId)`
+ - [x]3.3 For WorkspaceUser/TeamInvitation subjects → `route('team.index')`
+ - [x]3.4 For unknown/deleted subjects → `null` (no link, graceful degradation)
+ - [x]3.5 Handle soft-deleted or missing subjects without errors (check `subject_id` existence)
+
+- [x]Task 4: Create `ActivityFeed.vue` component (AC: #1, #2, #4, #7, #8)
+ - [x]4.1 Create `resources/js/components/dashboard/ActivityFeed.vue` as a reusable component accepting `activities` prop
+ - [x]4.2 Each entry renders: actor initials (colored circle), description text, relative timestamp using French locale ("il y a 2 heures", "hier", "il y a 3 jours")
+ - [x]4.3 Clickable entries use `router.get(activity.targetUrl)` for navigation (skip if `targetUrl` is null)
+ - [x]4.4 Empty state: show "Aucune activite recente" with a subtle icon when `activities.length === 0`
+ - [x]4.5 Use shadcn-vue `Card` for the container with a "Activite recente" heading
+
+- [x]Task 5: Integrate ActivityFeed into `Dashboard.vue` layout (AC: #1, #7)
+ - [x]5.1 On desktop (>=1024px): render the activity feed as a right sidebar panel using a two-column CSS Grid layout (main content takes ~2/3, feed takes ~1/3)
+ - [x]5.2 On tablet (768-1023px): render the activity feed below the urgent declarations table
+ - [x]5.3 On mobile (<768px): render the activity feed inside a collapsible/expandable section (use shadcn-vue `Collapsible` or a toggle button) — NOT hidden
+ - [x]5.4 Add `ActivityEvent` type to `resources/js/types/dashboard.ts` and extend `DashboardProps` with `activities: ActivityEvent[]`
+
+- [x]Task 6: Implement relative timestamp formatting (AC: #2)
+ - [x]6.1 Create a `useRelativeTime` composable or inline helper that formats ISO timestamps as French relative strings: "a l'instant" (<1min), "il y a X minutes" (<1h), "il y a X heures" (<24h), "hier" (yesterday), "il y a X jours" (<7d), then DD/MM/YYYY for older
+ - [x]6.2 Use this formatter in the ActivityFeed component for all timestamps
+
+- [x]Task 7: Write Pest feature tests (AC: #1, #3, #5, #6, #8)
+ - [x]7.1 Test Owner sees workspace-wide activity in `activities` Inertia prop
+ - [x]7.2 Test Worker sees only activity related to their assigned declarations
+ - [x]7.3 Test activity entries contain expected fields (actorName, description, targetUrl, timestamp, eventType)
+ - [x]7.4 Test feed is limited to 20 most recent events
+ - [x]7.5 Test empty activity returns empty array (no errors)
+ - [x]7.6 Test activity with deleted subject does not cause errors
+ - [x]7.7 Test Manager sees workspace-wide activity (same as Owner)
+
+## 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)` pattern — 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
+- **No separate API call:** Activity feed MUST be included in Inertia page props (server-rendered), NOT fetched via a separate AJAX/API endpoint
+
+### CRITICAL: Spatie Activity Log Infrastructure Already Exists
+
+The Spatie Activity Log is **fully integrated** and actively logging. Do NOT create any new tables, models, or log configuration.
+
+**Existing infrastructure:**
+- **7 models** already use `LogsActivity` trait: User, Workspace, TeamInvitation, Client, ClientContact, Declaration, Folder
+- **3 migrations** already exist: `create_activity_log_table`, `add_event_column`, `add_batch_uuid_column`
+- **Table:** `activity_log` with columns: `id`, `log_name`, `description`, `subject_type`, `subject_id`, `causer_type`, `causer_id`, `properties` (JSON with `old`/`attributes` keys), `event` (created/updated/deleted), `batch_uuid`, `created_at`, `updated_at`
+- **Config:** `config/activitylog.php` — uses default `Spatie\Activitylog\Models\Activity` model, table `activity_log`, 365-day cleanup
+- **All models** use identical config: `LogOptions::defaults()->logFillable()->logOnlyDirty()->dontSubmitEmptyLogs()`
+- **Manual logging** already done in: `WorkspaceSwitchController` (workspace switches), `TeamController` (role changes, permission updates, member removals)
+
+**How activities are stored:**
+- Automatic: Every model create/update/delete is logged by the trait with `event` = `created`/`updated`/`deleted`, `properties` = `{"old": {...}, "attributes": {...}}`
+- Manual: Controllers call `activity()->causedBy($user)->performedOn($model)->withProperties([...])->log('description')`
+- `causer_id` = the user who performed the action
+- `subject_id`/`subject_type` = the model that was acted upon
+
+### CRITICAL: Querying Activity Log for the Dashboard
+
+**Workspace scoping challenge:** The `activity_log` table does NOT have a `workspace_id` column. To scope activities to the current workspace:
+
+**Option A (recommended):** Query activities where the subject belongs to the workspace:
+```php
+use Spatie\Activitylog\Models\Activity;
+
+$activities = Activity::query()
+ ->where(function ($query) use ($workspace) {
+ // Declarations belonging to this workspace
+ $query->where(function ($q) use ($workspace) {
+ $q->where('subject_type', 'App\\Models\\Declaration')
+ ->whereIn('subject_id', $workspace->declarations()->select('id'));
+ })
+ // Clients belonging to this workspace
+ ->orWhere(function ($q) use ($workspace) {
+ $q->where('subject_type', 'App\\Models\\Client')
+ ->whereIn('subject_id', $workspace->clients()->select('id'));
+ })
+ // Manual team logs (causer is workspace member)
+ ->orWhere(function ($q) use ($workspace) {
+ $q->whereIn('subject_type', ['App\\Models\\WorkspaceUser', 'App\\Models\\TeamInvitation'])
+ ->whereIn('causer_id', $workspace->users()->select('users.id'));
+ });
+ })
+ ->with('causer:id,name')
+ ->latest()
+ ->limit(20)
+ ->get();
+```
+
+**For Worker scoping (AC #5):** Add an additional filter on declarations assigned to the Worker:
+```php
+if ($isWorker) {
+ // Only declaration activities for declarations assigned to this worker
+ $activities = Activity::query()
+ ->where('subject_type', 'App\\Models\\Declaration')
+ ->whereIn('subject_id',
+ $workspace->declarations()
+ ->where('assigned_to', $user->id)
+ ->select('id')
+ )
+ ->with('causer:id,name')
+ ->latest()
+ ->limit(20)
+ ->get();
+}
+```
+
+### 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 Stories 2.2 and 2.3.
+
+### CRITICAL: Excluded Statuses
+
+The dashboard excludes declarations with statuses: `termine`, `mise_en_demeure`, `ferme`. However, the **activity feed should include events for ALL declarations** (including status changes TO these terminal statuses) since they represent meaningful workspace activity.
+
+### Existing Code to Extend (NOT Create Fresh)
+
+**`app/Http/Controllers/DashboardController.php`** — Current controller (Stories 2.1-2.3):
+- Already has `Cache::remember()` with key `dashboard:{workspace_id}:{user_id}` and 5-min TTL
+- Already returns: `stats`, `statCards`, `declarations`, `alerts`, `workspaceName`, `roleLabel`, `isWorker`, `declarationsUrl`, `clientsUrl`, `viewAllAlertsUrl`
+- **ADD:** `activities` array to Inertia props (include in the cached data block)
+- **ADD:** `buildActivityFeed()` private method
+- **ADD:** `formatActivityDescription()` private method
+- Reuse existing `typeLabels()` for declaration type labels in descriptions
+- Reuse `$isWorker` flag for Worker-scoped activity filtering
+
+**`resources/js/pages/Dashboard.vue`** — Current page (Stories 2.1-2.3):
+- Currently has: Worker empty state → KPI card grid → PriorityAlertsPanel → urgent declarations table
+- **MODIFY:** Wrap main content in a two-column grid on desktop to add activity feed sidebar
+- **ADD:** Import and render `ActivityFeed` component
+- **ADD:** Mobile collapsible section for the activity feed
+- Keep all existing layout intact — the activity feed is additive
+
+**`resources/js/types/dashboard.ts`** — Current types (Stories 2.1-2.3):
+- Has: `DashboardStats`, `DashboardDeclaration`, `StatCardLink`, `DashboardAlert`, `DashboardProps`
+- **ADD:** `ActivityEvent` type
+- **ADD:** `activities: ActivityEvent[]` to `DashboardProps`
+
+### Files to Modify
+
+| File | Changes |
+|------|---------|
+| `app/Http/Controllers/DashboardController.php` | Add `buildActivityFeed()` and `formatActivityDescription()` methods; add `activities` to cached data and Inertia props |
+| `resources/js/pages/Dashboard.vue` | Add two-column grid layout for desktop; integrate `ActivityFeed` component; add mobile collapsible |
+| `resources/js/types/dashboard.ts` | Add `ActivityEvent` type; extend `DashboardProps` with `activities` |
+
+### New Files to Create
+
+| File | Purpose |
+|------|---------|
+| `resources/js/components/dashboard/ActivityFeed.vue` | Activity feed panel component with event list, relative timestamps, empty state |
+| `tests/Feature/Dashboard/ActivityFeedTest.php` | Pest feature tests for activity feed data in dashboard props |
+
+### Component Specifications (from UX Design)
+
+**ActivityFeed Component:**
+- Purpose: Stream of recent workspace events on dashboard right panel
+- Content: Event type icon, actor initials, action description, target link, relative timestamp
+- Actions: Click event to navigate to related entity
+- States: Default, empty
+- Variants: Dashboard panel (compact, last 20 events)
+- Accessibility: `role="feed"`, `aria-label` for each entry, keyboard navigation
+- Container: shadcn-vue `Card` with heading "Activite recente"
+
+**Desktop layout:** Two-column CSS Grid — main content (~2/3 width) + activity feed right panel (~1/3 width)
+**Tablet layout:** Single column, activity feed below urgent declarations table
+**Mobile layout (<768px):** Activity feed in collapsible/expandable section with toggle button
+
+**Relative timestamp format (French):**
+- < 1 minute: "a l'instant"
+- < 1 hour: "il y a X min"
+- < 24 hours: "il y a X h"
+- Yesterday: "hier"
+- < 7 days: "il y a X jours"
+- Older: DD/MM/YYYY
+
+**Actor display:** Colored circle with initials (first letter of first and last name). Use existing `useInitials` composable if available, or compute from `actorName`.
+
+**Event type icons (lucide-vue-next):**
+- Declaration created: `FilePlus` or `Plus`
+- Status change: `RefreshCw` or `ArrowRightLeft`
+- Reassignment: `UserRoundCog`
+- Client document upload: `Upload`
+- Role change: `Shield`
+- Default/fallback: `Activity`
+
+### Frontend Patterns
+
+**Two-column grid for desktop layout:**
+```vue
+
+
+
+
+
+
+
+
+
+
+```
+
+**Mobile collapsible pattern:**
+```vue
+
+
+
+
+
+
+```
+
+### Null Workspace Fallback
+
+The existing null-workspace fallback in `DashboardController` must also include `activities: []` to prevent frontend errors.
+
+### 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
+- Use `RefreshDatabase` (auto-applied via `Pest.php`)
+- Assertions: `expect()` chaining + `->assertInertia()` for page props
+- Tests go in `tests/Feature/Dashboard/ActivityFeedTest.php`
+- Reuse the `setupWorkspaceWithRole()` helper pattern from `OwnerDashboardTest.php`
+- **Current test count:** 215 tests, 1127 assertions (after Story 2.3). Do NOT break existing tests.
+- To create activity log entries in tests, use model factories (creating/updating declarations triggers the Spatie trait automatically) or use `activity()->causedBy($user)->performedOn($model)->log('...')` directly.
+
+### Previous Story Intelligence (Stories 2.1-2.3)
+
+- **DB column is `due_date` not `deadline`** — architecture docs use wrong name. Always use `due_date`.
+- **`Declaration::forUser($user, $workspaceUser)`** takes both User and WorkspaceUser args.
+- **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.
+- **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.
+- **Story 2.1 test helper:** `setupWorkspaceWithRole($role)` in `OwnerDashboardTest.php`. Reuse or create a similar helper in `ActivityFeedTest.php`.
+- **Deferred review items from Story 2.3:** D-1 (nudge/reassign disabled), D-2 (assignee param not consumed), D-3 (cache not invalidated on role change) — none affect this story.
+
+### Scope Boundaries — Do NOT Implement
+
+- Do NOT add full-page activity log view (that's FR53-FR55, a separate feature)
+- Do NOT add real-time updates or WebSocket polling (deferred post-MVP; D10 decision)
+- Do NOT add notification bell or notification center (that's Epic 3)
+- Do NOT add filtering or search on the activity feed (future enhancement)
+- Do NOT add infinite scroll or pagination to the feed — fixed 20 items
+- Do NOT create a separate ActivityController or API endpoint — data is in Inertia page props only
+- Do NOT add a `workspace_id` column to `activity_log` table — use subquery filtering
+
+### Project Structure Notes
+
+- Tests: `tests/Feature/Dashboard/ActivityFeedTest.php` (alongside `OwnerDashboardTest.php`, `PriorityAlertsPanelTest.php`, `WorkerDashboardTest.php`)
+- New component: `resources/js/components/dashboard/ActivityFeed.vue` (alongside `StatCard.vue`, `PriorityAlertsPanel.vue`)
+- 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)
+
+### References
+
+- [Source: _bmad-output/planning-artifacts/epics.md#Epic 2 Story 2.4]
+- [Source: _bmad-output/planning-artifacts/architecture.md#Spatie Activity Log]
+- [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/architecture.md#Role-Scoped Query Patterns]
+- [Source: _bmad-output/planning-artifacts/ux-design-specification.md#ActivityFeed Component]
+- [Source: _bmad-output/planning-artifacts/ux-design-specification.md#Dashboard Grid Layout]
+- [Source: _bmad-output/planning-artifacts/ux-design-specification.md#Responsive Behaviors]
+- [Source: _bmad-output/planning-artifacts/prd.md#FR52-FR55]
+- [Source: _bmad-output/planning-artifacts/prd.md#FR24-FR26]
+- [Source: _bmad-output/implementation-artifacts/2-3-worker-scoped-dashboard.md]
+- [Source: _bmad-output/implementation-artifacts/2-3-review-notes.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: config/activitylog.php]
+- [Source: app/Models/Declaration.php#LogsActivity]
+
+## Dev Agent Record
+
+### Agent Model Used
+
+Claude Opus 4.6
+
+### Debug Log References
+
+- Windows Docker stdin hang: resolved by wrapping commands in `sh -c "..." 2>&1`
+- Vite manifest missing in tests: resolved by adding `$this->withoutVite()` in Pest.php beforeEach
+- Duplicate `setupMentionScenario()` function: renamed to `setupFolderMentionScenario()` in FolderMentionTest.php
+- Owner test: Spatie causer was null because `actingAs()` was called after factory creation; moved before
+
+### Completion Notes List
+
+- All 7 ACs implemented and verified via 7 passing Pest tests
+- 222 existing tests pass; 15 pre-existing failures (14 Folder feature tests with missing routes/tables, 1 WorkerDashboardTest session edge case)
+- PHP lint (Pint) and Prettier both clean
+- Added global `withoutVite()` in Pest.php to fix Vite manifest issue for all Inertia page tests
+
+### File List
+
+| File | Action |
+|------|--------|
+| `app/Http/Controllers/DashboardController.php` | Modified — added `buildActivityFeed()`, `formatActivityDescription()`, `resolveActivityTargetUrl()`, `resolveEventType()`, `resolveDeclarationClientName()` methods; added `activities` to Inertia props |
+| `resources/js/pages/Dashboard.vue` | Modified — two-column grid layout, ActivityFeed sidebar, mobile collapsible |
+| `resources/js/types/dashboard.ts` | Modified — added `ActivityEvent` type, `activities` to `DashboardProps` |
+| `resources/js/components/dashboard/ActivityFeed.vue` | Created — activity feed panel with event list, relative timestamps, icons, empty state |
+| `resources/js/composables/useRelativeTime.ts` | Created — French relative time formatting composable |
+| `tests/Feature/Dashboard/ActivityFeedTest.php` | Created — 7 Pest feature tests |
+| `tests/Pest.php` | Modified — added `withoutVite()` beforeEach for Feature tests |
+| `tests/Feature/Notification/FolderMentionTest.php` | Modified — renamed helper to fix duplicate function name |
+
+### Change Log
+
+- 2026-03-22: Story 2.4 implementation complete. Activity feed with role-scoped filtering, French descriptions, relative timestamps, responsive layout, and 7 passing tests.
diff --git a/_bmad-output/implementation-artifacts/sprint-status.yaml b/_bmad-output/implementation-artifacts/sprint-status.yaml
index 5187b6b..86557f0 100644
--- a/_bmad-output/implementation-artifacts/sprint-status.yaml
+++ b/_bmad-output/implementation-artifacts/sprint-status.yaml
@@ -34,7 +34,7 @@
# - Dev moves story to 'review', then runs code-review (fresh context, different LLM recommended)
generated: 2026-03-11
-last_updated: 2026-03-20T12:00:00
+last_updated: 2026-03-22T00:00:00
project: "l'ami fiduciaire"
project_key: NOKEY
tracking_system: file-system
@@ -65,7 +65,7 @@ development_status:
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
+ 2-4-dashboard-activity-feed: done
epic-2-retrospective: optional
# Epic 3: Collaboration, Nudge System & Notifications
diff --git a/app/Http/Controllers/DashboardController.php b/app/Http/Controllers/DashboardController.php
index 2fb46bd..3c67379 100644
--- a/app/Http/Controllers/DashboardController.php
+++ b/app/Http/Controllers/DashboardController.php
@@ -11,6 +11,7 @@ use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Inertia\Inertia;
use Inertia\Response;
+use Spatie\Activitylog\Models\Activity;
class DashboardController extends Controller
{
@@ -29,6 +30,7 @@ class DashboardController extends Controller
'statCards' => [],
'declarations' => [],
'alerts' => [],
+ 'activities' => [],
'workspaceName' => null,
'roleLabel' => null,
'isWorker' => false,
@@ -73,6 +75,7 @@ class DashboardController extends Controller
->count();
$alerts = $this->buildAlerts($baseQuery);
+ $activities = $this->buildActivityFeed($workspace, $user, $workspaceUser);
return [
'overdue' => $overdue,
@@ -80,6 +83,7 @@ class DashboardController extends Controller
'enAttenteClient' => $enAttenteClient,
'enCours' => $enCours,
'alerts' => $alerts,
+ 'activities' => $activities,
];
});
@@ -142,6 +146,7 @@ class DashboardController extends Controller
'statCards' => $statCards,
'declarations' => $urgentDeclarations,
'alerts' => $dashboardData['alerts'],
+ 'activities' => $dashboardData['activities'],
'workspaceName' => $workspace->name,
'roleLabel' => $roleLabel,
'isWorker' => $isWorker,
@@ -223,6 +228,262 @@ class DashboardController extends Controller
return $critical->concat($warning)->concat($info)->take(20)->values()->toArray();
}
+ /**
+ * Build the activity feed for the dashboard.
+ *
+ * @return array>
+ */
+ private function buildActivityFeed(Workspace $workspace, \App\Models\User $user, WorkspaceUser $workspaceUser): array
+ {
+ $isWorker = $workspaceUser->role->is(WorkspaceUserRole::Worker);
+
+ if ($isWorker) {
+ $activities = Activity::query()
+ ->where('subject_type', 'App\\Models\\Declaration')
+ ->whereIn('subject_id',
+ $workspace->declarations()
+ ->where('assigned_to', $user->id)
+ ->select('id')
+ )
+ ->with('causer:id,name')
+ ->latest()
+ ->limit(20)
+ ->get();
+ } else {
+ $activities = Activity::query()
+ ->where(function ($query) use ($workspace) {
+ $query->where(function ($q) use ($workspace) {
+ $q->where('subject_type', 'App\\Models\\Declaration')
+ ->whereIn('subject_id', $workspace->declarations()->select('id'));
+ })
+ ->orWhere(function ($q) use ($workspace) {
+ $q->where('subject_type', 'App\\Models\\Client')
+ ->whereIn('subject_id', $workspace->clients()->select('id'));
+ })
+ ->orWhere(function ($q) use ($workspace) {
+ $q->whereIn('subject_type', ['App\\Models\\WorkspaceUser', 'App\\Models\\TeamInvitation'])
+ ->whereIn('causer_id', $workspace->users()->select('users.id'));
+ });
+ })
+ ->with('causer:id,name')
+ ->latest()
+ ->limit(20)
+ ->get();
+ }
+
+ $typeLabels = $this->typeLabels();
+
+ // Batch-load declarations (with trashed) and their clients to avoid N+1
+ $declarationSubjectIds = $activities
+ ->where('subject_type', 'App\\Models\\Declaration')
+ ->pluck('subject_id')
+ ->unique()
+ ->filter()
+ ->values();
+
+ $declarationsMap = $declarationSubjectIds->isNotEmpty()
+ ? Declaration::query()
+ ->withTrashed()
+ ->with('client:id,company_name')
+ ->whereIn('id', $declarationSubjectIds)
+ ->get()
+ ->keyBy('id')
+ : collect();
+
+ // Batch-load clients referenced as subjects
+ $clientSubjectIds = $activities
+ ->where('subject_type', 'App\\Models\\Client')
+ ->pluck('subject_id')
+ ->unique()
+ ->filter()
+ ->values();
+
+ $existingClientIds = $clientSubjectIds->isNotEmpty()
+ ? \App\Models\Client::query()
+ ->whereIn('id', $clientSubjectIds)
+ ->pluck('id')
+ ->flip()
+ : collect();
+
+ // Batch-load reassigned user names
+ $reassignedUserIds = $activities
+ ->filter(fn (Activity $a) => $a->subject_type === 'App\\Models\\Declaration' && $a->event === 'updated')
+ ->map(fn (Activity $a) => $a->properties->get('attributes', [])['assigned_to'] ?? null)
+ ->filter()
+ ->unique()
+ ->values();
+
+ $reassignedUsersMap = $reassignedUserIds->isNotEmpty()
+ ? \App\Models\User::query()
+ ->whereIn('id', $reassignedUserIds)
+ ->pluck('name', 'id')
+ : collect();
+
+ return $activities->map(function (Activity $activity) use ($typeLabels, $declarationsMap, $existingClientIds, $reassignedUsersMap) {
+ $actorName = $activity->causer?->name ?? 'Système';
+ $names = explode(' ', trim($actorName));
+ $actorInitials = count($names) >= 2
+ ? strtoupper(mb_substr($names[0], 0, 1).mb_substr(end($names), 0, 1))
+ : strtoupper(mb_substr($actorName, 0, 1));
+
+ return [
+ 'id' => $activity->id,
+ 'actorName' => $actorName,
+ 'actorInitials' => $actorInitials,
+ 'description' => $this->formatActivityDescription($activity, $typeLabels, $declarationsMap, $reassignedUsersMap),
+ 'targetUrl' => $this->resolveActivityTargetUrl($activity, $declarationsMap, $existingClientIds),
+ 'targetLabel' => $activity->subject_type
+ ? class_basename($activity->subject_type)
+ : null,
+ 'timestamp' => $activity->created_at->toISOString(),
+ 'eventType' => $this->resolveEventType($activity),
+ ];
+ })->values()->toArray();
+ }
+
+ /**
+ * Format a Spatie activity record into a human-readable French description.
+ *
+ * @param array $typeLabels
+ * @param \Illuminate\Support\Collection $declarationsMap Pre-loaded declarations keyed by ID
+ * @param \Illuminate\Support\Collection $reassignedUsersMap Pre-loaded user names keyed by ID
+ */
+ private function formatActivityDescription(Activity $activity, array $typeLabels, $declarationsMap, $reassignedUsersMap): string
+ {
+ $actorName = $activity->causer?->name ?? 'Système';
+ $subjectType = $activity->subject_type ? class_basename($activity->subject_type) : null;
+ $event = $activity->event;
+ $properties = $activity->properties;
+ $old = $properties->get('old', []);
+ $attributes = $properties->get('attributes', []);
+
+ if ($subjectType === 'Declaration') {
+ $typeValue = $attributes['type'] ?? $old['type'] ?? null;
+ $typeLabel = $typeValue ? ($typeLabels[$typeValue] ?? $typeValue) : 'déclaration';
+
+ $declaration = $declarationsMap->get($activity->subject_id);
+ $clientName = $declaration?->client?->company_name ?? 'client supprimé';
+
+ if ($event === 'created') {
+ return "{$actorName} a créé la déclaration {$typeLabel} pour {$clientName}";
+ }
+
+ if ($event === 'updated') {
+ if (isset($attributes['status']) && isset($old['status'])) {
+ $statusLabels = DeclarationStatus::labels();
+ $newStatus = $statusLabels[$attributes['status']] ?? $attributes['status'];
+
+ return "{$actorName} a changé le statut de {$typeLabel} ({$clientName}) en {$newStatus}";
+ }
+
+ if (isset($attributes['assigned_to']) && isset($old['assigned_to'])) {
+ $newAssignee = $reassignedUsersMap->get($attributes['assigned_to'], 'inconnu');
+
+ return "{$actorName} a réassigné {$typeLabel} ({$clientName}) à {$newAssignee}";
+ }
+
+ return "{$actorName} a modifié la déclaration {$typeLabel} ({$clientName})";
+ }
+
+ if ($event === 'deleted') {
+ return "{$actorName} a supprimé la déclaration {$typeLabel} ({$clientName})";
+ }
+ }
+
+ if ($subjectType === 'Client') {
+ $clientName = $attributes['company_name'] ?? $old['company_name'] ?? 'client';
+ if ($event === 'created') {
+ return "{$actorName} a créé le client {$clientName}";
+ }
+
+ return "{$actorName} a modifié le client {$clientName}";
+ }
+
+ if ($subjectType === 'WorkspaceUser' || $subjectType === 'TeamInvitation') {
+ if ($event === 'updated' && isset($attributes['role'])) {
+ return "{$actorName} a changé le rôle d'un membre";
+ }
+
+ return "{$actorName} a modifié l'équipe";
+ }
+
+ return "{$actorName} a modifié {$subjectType}";
+ }
+
+ /**
+ * Resolve the target URL for an activity entry.
+ *
+ * @param \Illuminate\Support\Collection $declarationsMap Pre-loaded declarations (with trashed) keyed by ID
+ * @param \Illuminate\Support\Collection $existingClientIds Pre-loaded existing (non-deleted) client IDs
+ */
+ private function resolveActivityTargetUrl(Activity $activity, $declarationsMap, $existingClientIds): ?string
+ {
+ $subjectType = $activity->subject_type ? class_basename($activity->subject_type) : null;
+ $subjectId = $activity->subject_id;
+
+ if (! $subjectType || ! $subjectId) {
+ return null;
+ }
+
+ if ($subjectType === 'Declaration') {
+ $declaration = $declarationsMap->get($subjectId);
+
+ if ($declaration && ! $declaration->trashed()) {
+ return route('declarations.show', $subjectId);
+ }
+
+ return null;
+ }
+
+ if ($subjectType === 'Client') {
+ if ($existingClientIds->has($subjectId)) {
+ return route('clients.show', $subjectId);
+ }
+
+ return null;
+ }
+
+ if ($subjectType === 'WorkspaceUser' || $subjectType === 'TeamInvitation') {
+ return route('team.index');
+ }
+
+ return null;
+ }
+
+ /**
+ * Resolve the event type hint for frontend icon selection.
+ */
+ private function resolveEventType(Activity $activity): string
+ {
+ $subjectType = $activity->subject_type ? class_basename($activity->subject_type) : null;
+ $event = $activity->event;
+ $attributes = $activity->properties->get('attributes', []);
+
+ if ($subjectType === 'Declaration') {
+ if ($event === 'created') {
+ return 'declaration_created';
+ }
+ if (isset($attributes['status'])) {
+ return 'status_change';
+ }
+ if (isset($attributes['assigned_to'])) {
+ return 'reassignment';
+ }
+
+ return 'declaration_updated';
+ }
+
+ if ($subjectType === 'Client') {
+ return 'client_updated';
+ }
+
+ if ($subjectType === 'WorkspaceUser' || $subjectType === 'TeamInvitation') {
+ return 'role_change';
+ }
+
+ return 'default';
+ }
+
/**
* Get declaration type labels.
*
diff --git a/package-lock.json b/package-lock.json
index a886df3..478e8a2 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,5 +1,5 @@
{
- "name": "l'ami fiduciaire",
+ "name": "html",
"lockfileVersion": 3,
"requires": true,
"packages": {
@@ -80,7 +80,6 @@
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0",
@@ -590,7 +589,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=12"
},
@@ -1595,7 +1593,6 @@
"integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==",
"dev": true,
"license": "MIT",
- "peer": true,
"engines": {
"node": "^14.21.3 || >=16"
},
@@ -2410,7 +2407,6 @@
"integrity": "sha512-BH7YwL6rA93ReqeQS1c4bsPpcfOmJasG+Fkr6Y59q83f9M1WcBRHR2vM+P9eOisYRcN3ujQoiZY8uk5W+1WL8w==",
"devOptional": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"undici-types": "~6.21.0"
}
@@ -2466,7 +2462,6 @@
"integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.56.1",
"@typescript-eslint/types": "8.56.1",
@@ -3278,7 +3273,6 @@
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
"dev": true,
"license": "MIT",
- "peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -3723,7 +3717,6 @@
}
],
"license": "MIT",
- "peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@@ -4872,7 +4865,6 @@
"integrity": "sha512-VmQ+sifHUbI/IcSopBCF/HO3YiHQx/AVd3UVyYL6weuwW+HvON9VYn5l6Zl1WZzPWXPNZrSQpxwkkZ/VuvJZzg==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -5059,7 +5051,6 @@
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@rtsao/scc": "^1.1.0",
"array-includes": "^3.1.9",
@@ -5145,7 +5136,6 @@
"integrity": "sha512-174lJKuNsuDIlLpjeXc5E2Tss8P44uIimAfGD0b90k0NoirJqpG7stLuU9Vp/9ioTOrQdWVREc4mRd1BD+CvGw==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.4.0",
"globals": "^13.24.0",
@@ -6284,7 +6274,6 @@
"integrity": "sha512-gJnaDHXKDayjt8ue0n8Gs0A007yKXj4Xzb8+cNjZeYsSzzwKc0Lr+OZgYwVfB0pHfUs17EPoLvrOsEaJ9mj+Tg==",
"dev": true,
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=16.9.0"
}
@@ -8352,7 +8341,6 @@
}
],
"license": "MIT",
- "peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@@ -8466,7 +8454,6 @@
"integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==",
"dev": true,
"license": "MIT",
- "peer": true,
"bin": {
"prettier": "bin/prettier.cjs"
},
@@ -9955,7 +9942,6 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=12"
},
@@ -10200,7 +10186,6 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"devOptional": true,
"license": "Apache-2.0",
- "peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -10303,7 +10288,6 @@
"dev": true,
"hasInstallScript": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"napi-postinstall": "^0.3.0"
},
@@ -10395,7 +10379,6 @@
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
"license": "MIT",
- "peer": true,
"dependencies": {
"esbuild": "^0.27.0",
"fdir": "^6.5.0",
@@ -10497,7 +10480,6 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=12"
},
@@ -10517,7 +10499,6 @@
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.29.tgz",
"integrity": "sha512-BZqN4Ze6mDQVNAni0IHeMJ5mwr8VAJ3MQC9FmprRhcBYENw+wOAAjRj8jfmN6FLl0j96OXbR+CjWhmAmM+QGnA==",
"license": "MIT",
- "peer": true,
"dependencies": {
"@vue/compiler-dom": "3.5.29",
"@vue/compiler-sfc": "3.5.29",
@@ -10904,7 +10885,6 @@
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"dev": true,
"license": "MIT",
- "peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
diff --git a/resources/js/components/dashboard/ActivityFeed.vue b/resources/js/components/dashboard/ActivityFeed.vue
new file mode 100644
index 0000000..8fa3763
--- /dev/null
+++ b/resources/js/components/dashboard/ActivityFeed.vue
@@ -0,0 +1,121 @@
+
+
+
+
+
+ Activité récente
+
+
+
+