user(); $workspaceId = $request->session()->get('current_workspace_id'); $workspace = $workspaceId ? Workspace::query()->find($workspaceId) : null; if (! $workspace || ! $user) { return Inertia::render('Dashboard', [ 'stats' => null, 'statCards' => [], 'declarations' => [], 'alerts' => [], 'activities' => [], 'workspaceName' => null, 'roleLabel' => null, 'isWorker' => false, 'canNudge' => false, 'declarationsUrl' => null, 'clientsUrl' => null, 'viewAllAlertsUrl' => null, ]); } /** @var WorkspaceUser|null $workspaceUser */ $workspaceUser = $workspace->users() ->where('users.id', $user->id) ->first() ?->pivot; if (! $workspaceUser) { abort(404); } $isWorker = $workspaceUser->role->is(WorkspaceUserRole::Worker); $cacheKey = "dashboard:{$workspace->id}:{$user->id}"; $dashboardData = Cache::remember($cacheKey, 300, function () use ($workspace, $user, $workspaceUser) { $baseQuery = fn () => $workspace->declarations() ->active() ->whereNotIn('status', [DeclarationStatus::Termine, DeclarationStatus::MiseEnDemeure, DeclarationStatus::Ferme]) ->forUser($user, $workspaceUser); $overdue = $baseQuery() ->where('due_date', '<', now()->startOfDay()) ->count(); $dueThisWeek = $baseQuery() ->whereBetween('due_date', [now()->startOfDay(), now()->addDays(7)->endOfDay()]) ->count(); $enAttenteClient = $baseQuery() ->where('status', DeclarationStatus::EnAttenteClient) ->count(); $enCours = $baseQuery() ->where('status', DeclarationStatus::EnCours) ->count(); $alerts = $this->buildAlerts($baseQuery); $activities = $this->buildActivityFeed($workspace, $user, $workspaceUser); return [ 'overdue' => $overdue, 'dueThisWeek' => $dueThisWeek, 'enAttenteClient' => $enAttenteClient, 'enCours' => $enCours, 'alerts' => $alerts, 'activities' => $activities, ]; }); $urgentDeclarations = $workspace->declarations() ->active() ->whereNotIn('status', [DeclarationStatus::Termine, DeclarationStatus::MiseEnDemeure, DeclarationStatus::Ferme]) ->forUser($user, $workspaceUser) ->with('client:id,company_name', 'assignee:id,name') ->orderByRaw('CASE WHEN due_date IS NULL THEN 1 ELSE 0 END, due_date ASC') ->limit(15) ->get() ->map(fn (Declaration $d) => [ 'id' => $d->id, 'title' => $d->title, 'type' => $d->type->value, 'typeLabel' => $this->typeLabels()[$d->type->value] ?? $d->type->value, 'clientName' => $d->client?->company_name ?? 'Client supprimé', 'assigneeName' => $d->assignee?->name, 'status' => $d->status->value, 'statusLabel' => DeclarationStatus::labels()[$d->status->value] ?? $d->status->value, 'dueDate' => $d->due_date?->format('Y-m-d'), 'showUrl' => route('declarations.show', $d), 'nudgeUrl' => ! $isWorker ? route('declarations.nudge', $d) : null, ]) ->all(); $roleLabel = $this->roleLabels()[$workspaceUser->role->value] ?? $workspaceUser->role->value; $assigneeParam = $isWorker ? ['assignee' => $user->id] : []; $statCards = [ [ 'label' => 'En retard', 'count' => $dashboardData['overdue'], 'status' => 'danger', 'href' => route('declarations.index', array_merge(['overdue' => 1], $assigneeParam)), ], [ 'label' => 'Cette semaine', 'count' => $dashboardData['dueThisWeek'], 'status' => 'warning', 'href' => route('declarations.index', array_merge(['due_this_week' => 1], $assigneeParam)), ], [ 'label' => 'En attente client', 'count' => $dashboardData['enAttenteClient'], 'status' => 'info', 'href' => route('declarations.index', array_merge(['status' => DeclarationStatus::EnAttenteClient], $assigneeParam)), ], [ 'label' => 'En cours', 'count' => $dashboardData['enCours'], 'status' => 'success', 'href' => route('declarations.index', array_merge(['status' => DeclarationStatus::EnCours], $assigneeParam)), ], ]; return Inertia::render('Dashboard', [ 'stats' => $dashboardData, 'statCards' => $statCards, 'declarations' => $urgentDeclarations, 'alerts' => $dashboardData['alerts'], 'activities' => $dashboardData['activities'], 'workspaceName' => $workspace->name, 'roleLabel' => $roleLabel, 'isWorker' => $isWorker, 'canNudge' => ! $isWorker, 'declarationsUrl' => route('declarations.index'), 'clientsUrl' => route('clients.index'), 'viewAllAlertsUrl' => route('declarations.index', ['filter' => 'alerts']), ]); } /** * Build priority alerts from declarations, sorted by severity and urgency. * * @param \Closure $baseQuery A closure that returns a fresh query builder with workspace/user scoping applied * @return array> */ private function buildAlerts(\Closure $baseQuery): array { $typeLabels = $this->typeLabels(); $today = now()->startOfDay(); // Critical: overdue declarations (past deadline), excluding en_attente_client (handled by info) $critical = $baseQuery() ->whereNotNull('due_date') ->where('due_date', '<', $today) ->where('status', '!=', DeclarationStatus::EnAttenteClient) ->with('client:id,company_name') ->orderBy('due_date', 'asc') ->limit(20) ->get() ->map(fn (Declaration $d) => [ 'id' => $d->id, 'severity' => 'critical', 'clientName' => $d->client?->company_name ?? 'Client supprimé', 'declarationType' => $d->type->value, 'typeLabel' => $typeLabels[$d->type->value] ?? $d->type->value, 'daysValue' => (int) abs($today->diffInDays($d->due_date)), 'daysLabel' => abs($today->diffInDays($d->due_date)) <= 1 ? 'jour en retard' : 'jours en retard', 'showUrl' => route('declarations.show', $d), ]); // Warning: approaching deadlines (due within 3 days) $warning = $baseQuery() ->whereNotNull('due_date') ->whereBetween('due_date', [$today, now()->addDays(3)->endOfDay()]) ->with('client:id,company_name') ->orderBy('due_date', 'asc') ->limit(20) ->get() ->map(fn (Declaration $d) => [ 'id' => $d->id, 'severity' => 'warning', 'clientName' => $d->client?->company_name ?? 'Client supprimé', 'declarationType' => $d->type->value, 'typeLabel' => $typeLabels[$d->type->value] ?? $d->type->value, 'daysValue' => (int) $today->diffInDays($d->due_date), 'daysLabel' => $today->diffInDays($d->due_date) <= 1 ? 'jour restant' : 'jours restants', 'showUrl' => route('declarations.show', $d), ]); // Info: waiting on client for >3 days $info = $baseQuery() ->where('status', DeclarationStatus::EnAttenteClient) ->where('updated_at', '<', now()->subDays(3)) ->with('client:id,company_name') ->orderBy('updated_at', 'asc') ->limit(20) ->get() ->map(fn (Declaration $d) => [ 'id' => $d->id, 'severity' => 'info', 'clientName' => $d->client?->company_name ?? 'Client supprimé', 'declarationType' => $d->type->value, 'typeLabel' => $typeLabels[$d->type->value] ?? $d->type->value, 'daysValue' => (int) abs($today->diffInDays($d->updated_at)), 'daysLabel' => abs($today->diffInDays($d->updated_at)) <= 1 ? 'jour en attente' : 'jours en attente', 'showUrl' => route('declarations.show', $d), ]); 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. * * @return array */ protected function typeLabels(): array { return [ 'vat' => 'TVA', 'vat_monthly' => 'TVA mensuelle', 'vat_quarterly' => 'TVA trimestrielle', 'corporate_tax' => 'IS', 'income_tax' => 'IR', 'cnss' => 'CNSS', 'annual_balance' => 'Bilan', 'other' => 'Autre', ]; } /** * Get workspace user role labels. * * @return array */ protected function roleLabels(): array { return [ WorkspaceUserRole::Owner => 'Propriétaire', WorkspaceUserRole::Manager => 'Manager', WorkspaceUserRole::Worker => 'Collaborateur', ]; } }