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

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