feat: implement Story 2.4 — Dashboard Activity Feed with review fixes

Add role-scoped activity feed to the dashboard showing the 20 most recent
workspace events. Owners/Managers see all activity (declarations, clients,
team changes); Workers see only their assigned declarations. Includes
French descriptions, relative timestamps, responsive layout (desktop
sidebar, tablet inline, mobile collapsible), and 7 passing Pest tests.

Review fixes applied: batch-load declarations/clients/users to eliminate
N+1 queries, consistent soft-delete handling in URL resolution, French
grammar singular/plural fix, missing icon map entry, and corrected tablet
breakpoint per spec.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-22 21:21:07 +01:00
parent 3baf456640
commit a02b5f12d8
13 changed files with 1326 additions and 195 deletions

View File

@@ -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).

View File

@@ -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 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) 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] 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.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] 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] Task 2: Update `Dashboard.vue` for Worker-specific UI (AC: #4, #5, #6, #8)

View File

@@ -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
<div class="grid grid-cols-1 gap-6 lg:grid-cols-3">
<!-- Main content: KPI cards, alerts, table (2 cols) -->
<div class="lg:col-span-2 space-y-6">
<!-- existing dashboard content -->
</div>
<!-- Activity feed sidebar (1 col) -->
<div class="lg:col-span-1">
<ActivityFeed :activities="activities" />
</div>
</div>
```
**Mobile collapsible pattern:**
```vue
<div class="lg:hidden">
<button @click="showFeed = !showFeed" class="...">
Activite recente ({{ activities.length }})
</button>
<div v-show="showFeed">
<ActivityFeed :activities="activities" />
</div>
</div>
```
### 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.

View File

@@ -34,7 +34,7 @@
# - Dev moves story to 'review', then runs code-review (fresh context, different LLM recommended) # - Dev moves story to 'review', then runs code-review (fresh context, different LLM recommended)
generated: 2026-03-11 generated: 2026-03-11
last_updated: 2026-03-20T12:00:00 last_updated: 2026-03-22T00:00:00
project: "l'ami fiduciaire" project: "l'ami fiduciaire"
project_key: NOKEY project_key: NOKEY
tracking_system: file-system tracking_system: file-system
@@ -65,7 +65,7 @@ development_status:
2-1-owner-manager-command-center-dashboard: done 2-1-owner-manager-command-center-dashboard: done
2-2-priority-alerts-panel: done 2-2-priority-alerts-panel: done
2-3-worker-scoped-dashboard: review 2-3-worker-scoped-dashboard: review
2-4-dashboard-activity-feed: backlog 2-4-dashboard-activity-feed: done
epic-2-retrospective: optional epic-2-retrospective: optional
# Epic 3: Collaboration, Nudge System & Notifications # Epic 3: Collaboration, Nudge System & Notifications

View File

@@ -11,6 +11,7 @@ use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Cache;
use Inertia\Inertia; use Inertia\Inertia;
use Inertia\Response; use Inertia\Response;
use Spatie\Activitylog\Models\Activity;
class DashboardController extends Controller class DashboardController extends Controller
{ {
@@ -29,6 +30,7 @@ class DashboardController extends Controller
'statCards' => [], 'statCards' => [],
'declarations' => [], 'declarations' => [],
'alerts' => [], 'alerts' => [],
'activities' => [],
'workspaceName' => null, 'workspaceName' => null,
'roleLabel' => null, 'roleLabel' => null,
'isWorker' => false, 'isWorker' => false,
@@ -73,6 +75,7 @@ class DashboardController extends Controller
->count(); ->count();
$alerts = $this->buildAlerts($baseQuery); $alerts = $this->buildAlerts($baseQuery);
$activities = $this->buildActivityFeed($workspace, $user, $workspaceUser);
return [ return [
'overdue' => $overdue, 'overdue' => $overdue,
@@ -80,6 +83,7 @@ class DashboardController extends Controller
'enAttenteClient' => $enAttenteClient, 'enAttenteClient' => $enAttenteClient,
'enCours' => $enCours, 'enCours' => $enCours,
'alerts' => $alerts, 'alerts' => $alerts,
'activities' => $activities,
]; ];
}); });
@@ -142,6 +146,7 @@ class DashboardController extends Controller
'statCards' => $statCards, 'statCards' => $statCards,
'declarations' => $urgentDeclarations, 'declarations' => $urgentDeclarations,
'alerts' => $dashboardData['alerts'], 'alerts' => $dashboardData['alerts'],
'activities' => $dashboardData['activities'],
'workspaceName' => $workspace->name, 'workspaceName' => $workspace->name,
'roleLabel' => $roleLabel, 'roleLabel' => $roleLabel,
'isWorker' => $isWorker, 'isWorker' => $isWorker,
@@ -223,6 +228,262 @@ class DashboardController extends Controller
return $critical->concat($warning)->concat($info)->take(20)->values()->toArray(); return $critical->concat($warning)->concat($info)->take(20)->values()->toArray();
} }
/**
* Build the activity feed for the dashboard.
*
* @return array<int, array<string, mixed>>
*/
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<string, string> $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. * Get declaration type labels.
* *

22
package-lock.json generated
View File

@@ -1,5 +1,5 @@
{ {
"name": "l'ami fiduciaire", "name": "html",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
@@ -80,7 +80,6 @@
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/code-frame": "^7.29.0", "@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0", "@babel/generator": "^7.29.0",
@@ -590,7 +589,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },
@@ -1595,7 +1593,6 @@
"integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==", "integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": "^14.21.3 || >=16" "node": "^14.21.3 || >=16"
}, },
@@ -2410,7 +2407,6 @@
"integrity": "sha512-BH7YwL6rA93ReqeQS1c4bsPpcfOmJasG+Fkr6Y59q83f9M1WcBRHR2vM+P9eOisYRcN3ujQoiZY8uk5W+1WL8w==", "integrity": "sha512-BH7YwL6rA93ReqeQS1c4bsPpcfOmJasG+Fkr6Y59q83f9M1WcBRHR2vM+P9eOisYRcN3ujQoiZY8uk5W+1WL8w==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"undici-types": "~6.21.0" "undici-types": "~6.21.0"
} }
@@ -2466,7 +2462,6 @@
"integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==", "integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@typescript-eslint/scope-manager": "8.56.1", "@typescript-eslint/scope-manager": "8.56.1",
"@typescript-eslint/types": "8.56.1", "@typescript-eslint/types": "8.56.1",
@@ -3278,7 +3273,6 @@
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"acorn": "bin/acorn" "acorn": "bin/acorn"
}, },
@@ -3723,7 +3717,6 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"baseline-browser-mapping": "^2.9.0", "baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759", "caniuse-lite": "^1.0.30001759",
@@ -4872,7 +4865,6 @@
"integrity": "sha512-VmQ+sifHUbI/IcSopBCF/HO3YiHQx/AVd3UVyYL6weuwW+HvON9VYn5l6Zl1WZzPWXPNZrSQpxwkkZ/VuvJZzg==", "integrity": "sha512-VmQ+sifHUbI/IcSopBCF/HO3YiHQx/AVd3UVyYL6weuwW+HvON9VYn5l6Zl1WZzPWXPNZrSQpxwkkZ/VuvJZzg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1", "@eslint-community/regexpp": "^4.12.1",
@@ -5059,7 +5051,6 @@
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@rtsao/scc": "^1.1.0", "@rtsao/scc": "^1.1.0",
"array-includes": "^3.1.9", "array-includes": "^3.1.9",
@@ -5145,7 +5136,6 @@
"integrity": "sha512-174lJKuNsuDIlLpjeXc5E2Tss8P44uIimAfGD0b90k0NoirJqpG7stLuU9Vp/9ioTOrQdWVREc4mRd1BD+CvGw==", "integrity": "sha512-174lJKuNsuDIlLpjeXc5E2Tss8P44uIimAfGD0b90k0NoirJqpG7stLuU9Vp/9ioTOrQdWVREc4mRd1BD+CvGw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@eslint-community/eslint-utils": "^4.4.0", "@eslint-community/eslint-utils": "^4.4.0",
"globals": "^13.24.0", "globals": "^13.24.0",
@@ -6284,7 +6274,6 @@
"integrity": "sha512-gJnaDHXKDayjt8ue0n8Gs0A007yKXj4Xzb8+cNjZeYsSzzwKc0Lr+OZgYwVfB0pHfUs17EPoLvrOsEaJ9mj+Tg==", "integrity": "sha512-gJnaDHXKDayjt8ue0n8Gs0A007yKXj4Xzb8+cNjZeYsSzzwKc0Lr+OZgYwVfB0pHfUs17EPoLvrOsEaJ9mj+Tg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=16.9.0" "node": ">=16.9.0"
} }
@@ -8352,7 +8341,6 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"nanoid": "^3.3.11", "nanoid": "^3.3.11",
"picocolors": "^1.1.1", "picocolors": "^1.1.1",
@@ -8466,7 +8454,6 @@
"integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"prettier": "bin/prettier.cjs" "prettier": "bin/prettier.cjs"
}, },
@@ -9955,7 +9942,6 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },
@@ -10200,7 +10186,6 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"devOptional": true, "devOptional": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
"tsserver": "bin/tsserver" "tsserver": "bin/tsserver"
@@ -10303,7 +10288,6 @@
"dev": true, "dev": true,
"hasInstallScript": true, "hasInstallScript": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"napi-postinstall": "^0.3.0" "napi-postinstall": "^0.3.0"
}, },
@@ -10395,7 +10379,6 @@
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"esbuild": "^0.27.0", "esbuild": "^0.27.0",
"fdir": "^6.5.0", "fdir": "^6.5.0",
@@ -10497,7 +10480,6 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },
@@ -10517,7 +10499,6 @@
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.29.tgz", "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.29.tgz",
"integrity": "sha512-BZqN4Ze6mDQVNAni0IHeMJ5mwr8VAJ3MQC9FmprRhcBYENw+wOAAjRj8jfmN6FLl0j96OXbR+CjWhmAmM+QGnA==", "integrity": "sha512-BZqN4Ze6mDQVNAni0IHeMJ5mwr8VAJ3MQC9FmprRhcBYENw+wOAAjRj8jfmN6FLl0j96OXbR+CjWhmAmM+QGnA==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@vue/compiler-dom": "3.5.29", "@vue/compiler-dom": "3.5.29",
"@vue/compiler-sfc": "3.5.29", "@vue/compiler-sfc": "3.5.29",
@@ -10904,7 +10885,6 @@
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"funding": { "funding": {
"url": "https://github.com/sponsors/colinhacks" "url": "https://github.com/sponsors/colinhacks"
} }

View File

@@ -0,0 +1,121 @@
<script setup lang="ts">
import { router } from '@inertiajs/vue3';
import {
Activity,
ArrowRightLeft,
FilePlus,
Inbox,
Shield,
Upload,
UserRoundCog,
} from 'lucide-vue-next';
import { formatRelativeTime } from '@/composables/useRelativeTime';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import type { ActivityEvent } from '@/types';
type Props = {
activities: ActivityEvent[];
};
defineProps<Props>();
function navigateToTarget(activity: ActivityEvent): void {
if (activity.targetUrl) {
router.get(activity.targetUrl);
}
}
const eventIconMap: Record<string, typeof Activity> = {
declaration_created: FilePlus,
declaration_updated: Activity,
status_change: ArrowRightLeft,
reassignment: UserRoundCog,
client_updated: Upload,
role_change: Shield,
default: Activity,
};
function getEventIcon(eventType: string) {
return eventIconMap[eventType] ?? eventIconMap.default;
}
function initialsColor(initials: string): string {
const colors = [
'bg-blue-100 text-blue-700',
'bg-green-100 text-green-700',
'bg-amber-100 text-amber-700',
'bg-purple-100 text-purple-700',
'bg-rose-100 text-rose-700',
'bg-teal-100 text-teal-700',
];
let hash = 0;
for (let i = 0; i < initials.length; i++) {
hash = initials.charCodeAt(i) + ((hash << 5) - hash);
}
return colors[Math.abs(hash) % colors.length];
}
</script>
<template>
<Card role="feed" aria-label="Activité récente">
<CardHeader class="pb-3">
<CardTitle class="text-base">Activité récente</CardTitle>
</CardHeader>
<CardContent>
<!-- Empty state -->
<div
v-if="activities.length === 0"
class="flex flex-col items-center justify-center py-8"
>
<Inbox class="mb-2 h-8 w-8 text-muted-foreground" />
<p class="text-sm text-muted-foreground">
Aucune activité récente
</p>
</div>
<!-- Activity list -->
<div v-else class="space-y-1">
<button
v-for="activity in activities"
:key="activity.id"
:aria-label="`${activity.description} ${formatRelativeTime(activity.timestamp)}`"
:class="[
'flex w-full items-start gap-3 rounded-md px-2 py-2.5 text-left text-sm transition-colors',
activity.targetUrl
? 'cursor-pointer hover:bg-muted/50'
: 'cursor-default',
]"
@click="navigateToTarget(activity)"
>
<!-- Actor initials -->
<div
:class="[
'flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-xs font-medium',
initialsColor(activity.actorInitials),
]"
>
{{ activity.actorInitials }}
</div>
<!-- Content -->
<div class="min-w-0 flex-1">
<p class="leading-snug text-foreground">
{{ activity.description }}
</p>
<div
class="mt-0.5 flex items-center gap-1.5 text-xs text-muted-foreground"
>
<component
:is="getEventIcon(activity.eventType)"
class="h-3 w-3"
/>
<span>{{
formatRelativeTime(activity.timestamp)
}}</span>
</div>
</div>
</button>
</div>
</CardContent>
</Card>
</template>

View File

@@ -0,0 +1,43 @@
export function formatRelativeTime(isoTimestamp: string): string {
const date = new Date(isoTimestamp);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMinutes = Math.floor(diffMs / (1000 * 60));
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (diffMinutes < 1) {
return "à l'instant";
}
if (diffMinutes < 60) {
return `il y a ${diffMinutes} min`;
}
if (diffHours < 24) {
return `il y a ${diffHours} h`;
}
const yesterday = new Date(now);
yesterday.setDate(yesterday.getDate() - 1);
if (
date.getDate() === yesterday.getDate() &&
date.getMonth() === yesterday.getMonth() &&
date.getFullYear() === yesterday.getFullYear()
) {
return 'hier';
}
if (diffDays < 7) {
return `il y a ${diffDays} ${diffDays === 1 ? 'jour' : 'jours'}`;
}
const day = String(date.getDate()).padStart(2, '0');
const month = String(date.getMonth() + 1).padStart(2, '0');
const year = date.getFullYear();
return `${day}/${month}/${year}`;
}
export function useRelativeTime() {
return { formatRelativeTime };
}

View File

@@ -3,6 +3,7 @@ import { Head, Link, router } from '@inertiajs/vue3';
import { import {
Briefcase, Briefcase,
Building2, Building2,
ChevronDown,
ClipboardList, ClipboardList,
EllipsisVertical, EllipsisVertical,
Eye, Eye,
@@ -11,12 +12,18 @@ import {
UserRoundCog, UserRoundCog,
Users, Users,
} from 'lucide-vue-next'; } from 'lucide-vue-next';
import { computed } from 'vue'; import { computed, ref } from 'vue';
import ActivityFeed from '@/components/dashboard/ActivityFeed.vue';
import PriorityAlertsPanel from '@/components/dashboard/PriorityAlertsPanel.vue'; import PriorityAlertsPanel from '@/components/dashboard/PriorityAlertsPanel.vue';
import StatCard from '@/components/dashboard/StatCard.vue'; import StatCard from '@/components/dashboard/StatCard.vue';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card'; import { Card, CardContent } from '@/components/ui/card';
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible';
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
@@ -54,11 +61,13 @@ const breadcrumbs: BreadcrumbItem[] = [
]; ];
const hasWorkspace = computed(() => !!props.workspaceName); const hasWorkspace = computed(() => !!props.workspaceName);
const showFeed = ref(false);
const isWorkerEmpty = computed( const isWorkerEmpty = computed(
() => () =>
props.isWorker && props.isWorker &&
props.declarations.length === 0 && props.declarations.length === 0 &&
props.alerts.length === 0 &&
props.statCards.every((c) => c.count === 0), props.statCards.every((c) => c.count === 0),
); );
@@ -158,11 +167,6 @@ function navigateToDeclaration(declaration: DashboardDeclaration): void {
<!-- Workspace dashboard --> <!-- Workspace dashboard -->
<template v-if="hasWorkspace"> <template v-if="hasWorkspace">
<!-- Worker subtitle -->
<p v-if="isWorker" class="text-sm text-muted-foreground">
Mes déclarations
</p>
<!-- Worker empty state --> <!-- Worker empty state -->
<Card v-if="isWorkerEmpty"> <Card v-if="isWorkerEmpty">
<CardContent <CardContent
@@ -175,23 +179,35 @@ function navigateToDeclaration(declaration: DashboardDeclaration): void {
Aucune déclaration assignée Aucune déclaration assignée
</p> </p>
<p class="text-sm text-muted-foreground"> <p class="text-sm text-muted-foreground">
Contactez votre responsable pour recevoir des Contactez votre responsable
déclarations
</p> </p>
</CardContent> </CardContent>
</Card> </Card>
<template v-if="!isWorkerEmpty"> <template v-if="!isWorkerEmpty">
<div class="grid grid-cols-1 gap-6 lg:grid-cols-3">
<!-- Main content (2/3 on desktop) -->
<div class="space-y-6 lg:col-span-2">
<!-- Worker subtitle -->
<p
v-if="isWorker"
class="text-sm text-muted-foreground"
>
Mes déclarations
</p>
<!-- KPI StatCards --> <!-- KPI StatCards -->
<div <div
class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4" class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-2 xl:grid-cols-4"
> >
<StatCard <StatCard
v-for="card in statCards" v-for="card in statCards"
:key="card.label" :key="card.label"
:label="card.label" :label="card.label"
:count="card.count" :count="card.count"
:status="card.status as StatCardLink['status']" :status="
card.status as StatCardLink['status']
"
:href="card.href" :href="card.href"
/> />
</div> </div>
@@ -227,13 +243,19 @@ function navigateToDeclaration(declaration: DashboardDeclaration): void {
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead>Client</TableHead> <TableHead
>Client</TableHead
>
<TableHead>Type</TableHead> <TableHead>Type</TableHead>
<TableHead>Date limite</TableHead> <TableHead
>Date limite</TableHead
>
<TableHead v-if="!isWorker" <TableHead v-if="!isWorker"
>Assigné à</TableHead >Assigné à</TableHead
> >
<TableHead>Statut</TableHead> <TableHead
>Statut</TableHead
>
<TableHead class="w-10" /> <TableHead class="w-10" />
</TableRow> </TableRow>
</TableHeader> </TableHeader>
@@ -248,11 +270,17 @@ function navigateToDeclaration(declaration: DashboardDeclaration): void {
) )
" "
> >
<TableCell class="font-medium"> <TableCell
{{ declaration.clientName }} class="font-medium"
>
{{
declaration.clientName
}}
</TableCell> </TableCell>
<TableCell> <TableCell>
{{ declaration.typeLabel }} {{
declaration.typeLabel
}}
</TableCell> </TableCell>
<TableCell> <TableCell>
<span <span
@@ -351,11 +379,52 @@ function navigateToDeclaration(declaration: DashboardDeclaration): void {
class="mb-3 h-12 w-12 text-muted-foreground" class="mb-3 h-12 w-12 text-muted-foreground"
/> />
<p class="text-muted-foreground"> <p class="text-muted-foreground">
Aucune déclaration urgente pour le moment. Aucune déclaration urgente pour le
moment.
</p> </p>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
<!-- Mobile (<768px): Collapsible activity feed -->
<div class="md:hidden">
<Collapsible v-model:open="showFeed">
<CollapsibleTrigger
class="flex w-full items-center justify-between rounded-lg border px-4 py-3 text-sm font-medium transition-colors hover:bg-muted/50"
>
<span
>Activité récente ({{
activities.length
}})</span
>
<ChevronDown
:class="[
'h-4 w-4 transition-transform',
showFeed ? 'rotate-180' : '',
]"
/>
</CollapsibleTrigger>
<CollapsibleContent>
<div class="pt-3">
<ActivityFeed
:activities="activities"
/>
</div>
</CollapsibleContent>
</Collapsible>
</div>
<!-- Tablet (768-1023px): Inline activity feed below table -->
<div class="hidden md:block lg:hidden">
<ActivityFeed :activities="activities" />
</div>
</div>
<!-- Desktop: Activity feed sidebar (1/3) -->
<div class="hidden lg:col-span-1 lg:block">
<ActivityFeed :activities="activities" />
</div>
</div>
</template> </template>
</template> </template>
</div> </div>

View File

@@ -36,11 +36,23 @@ export type DashboardAlert = {
showUrl: string; showUrl: string;
}; };
export type ActivityEvent = {
id: number;
actorName: string;
actorInitials: string;
description: string;
targetUrl: string | null;
targetLabel: string | null;
timestamp: string;
eventType: string;
};
export type DashboardProps = { export type DashboardProps = {
stats: DashboardStats | null; stats: DashboardStats | null;
statCards: StatCardLink[]; statCards: StatCardLink[];
declarations: DashboardDeclaration[]; declarations: DashboardDeclaration[];
alerts: DashboardAlert[]; alerts: DashboardAlert[];
activities: ActivityEvent[];
workspaceName: string | null; workspaceName: string | null;
roleLabel: string | null; roleLabel: string | null;
isWorker: boolean; isWorker: boolean;

View File

@@ -0,0 +1,211 @@
<?php
use App\Enums\DeclarationStatus;
use App\Models\Client;
use App\Models\Declaration;
use App\Models\User;
use App\Models\Workspace;
function setupActivityWorkspace(string $role = 'owner'): array
{
$user = User::factory()->create();
$workspace = Workspace::factory()->create();
$workspace->users()->attach($user->id, ['role' => $role]);
$client = Client::factory()->create(['workspace_id' => $workspace->id]);
session(['current_workspace_id' => $workspace->id]);
return [$user, $workspace, $client];
}
test('owner sees workspace-wide activity in activities prop', function () {
[$owner, $workspace, $client] = setupActivityWorkspace('owner');
$otherUser = User::factory()->create();
$workspace->users()->attach($otherUser->id, ['role' => 'worker']);
// Act as owner BEFORE creating declaration so Spatie logs the causer
$this->actingAs($owner);
Declaration::factory()->create([
'workspace_id' => $workspace->id,
'client_id' => $client->id,
'assigned_to' => $otherUser->id,
'status' => DeclarationStatus::EnCours,
'due_date' => now()->addDays(10),
]);
$response = $this->get(route('dashboard'));
$response->assertOk();
$response->assertInertia(fn ($page) => $page
->component('Dashboard')
->has('activities')
->where('activities.0.actorName', $owner->name)
);
});
test('worker sees only activity related to their assigned declarations', function () {
[$worker, $workspace, $client] = setupActivityWorkspace('worker');
$otherWorker = User::factory()->create();
$workspace->users()->attach($otherWorker->id, ['role' => 'worker']);
// Declaration assigned to THIS worker — should appear in feed
$assignedDeclaration = Declaration::factory()->create([
'workspace_id' => $workspace->id,
'client_id' => $client->id,
'assigned_to' => $worker->id,
'status' => DeclarationStatus::EnCours,
'due_date' => now()->addDays(10),
]);
// 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()->addDays(5),
]);
$this->actingAs($worker);
$response = $this->get(route('dashboard'));
$response->assertOk();
$response->assertInertia(fn ($page) => $page
->component('Dashboard')
->has('activities')
);
// Verify the worker only sees activities for their own declarations
$activitiesData = $response->original->getData()['page']['props']['activities'];
expect(count($activitiesData))->toBeGreaterThan(0);
// All activities should relate to the worker's assigned client
collect($activitiesData)->each(function ($a) use ($client) {
expect($a['description'])->toContain($client->company_name);
});
});
test('activity entries contain expected fields', function () {
[$owner, $workspace, $client] = setupActivityWorkspace('owner');
Declaration::factory()->create([
'workspace_id' => $workspace->id,
'client_id' => $client->id,
'assigned_to' => $owner->id,
'status' => DeclarationStatus::EnCours,
'due_date' => now()->addDays(10),
]);
$this->actingAs($owner);
$response = $this->get(route('dashboard'));
$response->assertOk();
$response->assertInertia(fn ($page) => $page
->component('Dashboard')
->has('activities.0', fn ($activity) => $activity
->has('id')
->has('actorName')
->has('actorInitials')
->has('description')
->has('targetUrl')
->has('targetLabel')
->has('timestamp')
->has('eventType')
)
);
});
test('feed is limited to 20 most recent events', function () {
[$owner, $workspace, $client] = setupActivityWorkspace('owner');
// Create 25 declarations to generate 25 activity log entries
for ($i = 0; $i < 25; $i++) {
Declaration::factory()->create([
'workspace_id' => $workspace->id,
'client_id' => $client->id,
'assigned_to' => $owner->id,
'status' => DeclarationStatus::EnCours,
'due_date' => now()->addDays($i + 1),
]);
}
$this->actingAs($owner);
$response = $this->get(route('dashboard'));
$response->assertOk();
$activitiesData = $response->original->getData()['page']['props']['activities'];
expect(count($activitiesData))->toBeLessThanOrEqual(20);
});
test('empty activity returns empty array', function () {
$user = User::factory()->create();
$workspace = Workspace::factory()->create();
$workspace->users()->attach($user->id, ['role' => 'owner']);
session(['current_workspace_id' => $workspace->id]);
// No declarations or activity created
$this->actingAs($user);
$response = $this->get(route('dashboard'));
$response->assertOk();
$response->assertInertia(fn ($page) => $page
->component('Dashboard')
->where('activities', [])
);
});
test('activity with deleted subject does not cause errors', function () {
[$owner, $workspace, $client] = setupActivityWorkspace('owner');
// Create a declaration (logs activity), then soft-delete it
$declaration = Declaration::factory()->create([
'workspace_id' => $workspace->id,
'client_id' => $client->id,
'assigned_to' => $owner->id,
'status' => DeclarationStatus::EnCours,
'due_date' => now()->addDays(10),
]);
$declaration->delete();
$this->actingAs($owner);
$response = $this->get(route('dashboard'));
$response->assertOk();
$response->assertInertia(fn ($page) => $page
->component('Dashboard')
->has('activities')
);
});
test('manager sees workspace-wide activity same as owner', function () {
[$manager, $workspace, $client] = setupActivityWorkspace('manager');
$worker = User::factory()->create();
$workspace->users()->attach($worker->id, ['role' => 'worker']);
// Create a declaration by the worker
$this->actingAs($worker);
Declaration::factory()->create([
'workspace_id' => $workspace->id,
'client_id' => $client->id,
'assigned_to' => $worker->id,
'status' => DeclarationStatus::EnCours,
'due_date' => now()->addDays(10),
]);
$this->actingAs($manager);
$response = $this->get(route('dashboard'));
$response->assertOk();
$response->assertInertia(fn ($page) => $page
->component('Dashboard')
->has('activities')
);
$activitiesData = $response->original->getData()['page']['props']['activities'];
expect(count($activitiesData))->toBeGreaterThan(0);
});

View File

@@ -267,6 +267,23 @@ test('owner and manager dashboard returns isWorker false', function () {
); );
}); });
test('worker without workspace session gets isWorker false in fallback', function () {
$worker = User::factory()->create();
$workspace = Workspace::factory()->create();
$workspace->users()->attach($worker->id, ['role' => 'worker']);
// No session(['current_workspace_id' => ...]) — simulate no active workspace
$this->actingAs($worker);
$response = $this->get(route('dashboard'));
$response->assertOk();
$response->assertInertia(fn ($page) => $page
->component('Dashboard')
->where('isWorker', false)
->where('workspaceName', null)
);
});
test('cached data is scoped per user with worker cache key including user id', function () { test('cached data is scoped per user with worker cache key including user id', function () {
[$worker, $workspace, $client] = setupWorkerWorkspace(); [$worker, $workspace, $client] = setupWorkerWorkspace();

View File

@@ -13,6 +13,9 @@
pest()->extend(Tests\TestCase::class) pest()->extend(Tests\TestCase::class)
->use(Illuminate\Foundation\Testing\RefreshDatabase::class) ->use(Illuminate\Foundation\Testing\RefreshDatabase::class)
->beforeEach(function () {
$this->withoutVite();
})
->in('Feature'); ->in('Feature');
/* /*