feat: implement Story 2.1 — Owner/Manager Command Center Dashboard
- Rewrite DashboardController with cached role-scoped KPI aggregation (Cache::remember, 5-min TTL, Declaration::forUser scope) - Create StatCard.vue component with CVA status variants and a11y - Rewrite Dashboard.vue with 4-column KPI grid + urgent declarations table - Add mise_en_demeure status to DeclarationStatus enum with transitions - Exclude termine, mise_en_demeure, ferme from dashboard queries - Set deadline proximity red threshold to ≤5 days - Add abort(404) for non-member workspace access per architecture - Fix null-safe client access for soft-deleted clients - Fix hardcoded routes with Wayfinder type-safe imports - Fix DashboardProps.stats type to allow null - Add aria-pressed to StatCard for accessibility - Install shadcn-vue table component (11 files) - Add 11 Pest feature tests + 3 mise_en_demeure transition tests - Fix DeclarationFactory eager workspace creation causing slug collisions - 196 tests pass, 836 assertions, zero regressions
This commit is contained in:
@@ -14,6 +14,8 @@ final class DeclarationStatus extends Enum
|
||||
|
||||
const Termine = 'termine';
|
||||
|
||||
const MiseEnDemeure = 'mise_en_demeure';
|
||||
|
||||
const Ferme = 'ferme';
|
||||
|
||||
/**
|
||||
@@ -28,6 +30,7 @@ final class DeclarationStatus extends Enum
|
||||
self::EnCours => 'En cours',
|
||||
self::EnAttenteClient => 'En attente client',
|
||||
self::Termine => 'Terminé',
|
||||
self::MiseEnDemeure => 'Mise en demeure',
|
||||
self::Ferme => 'Fermé',
|
||||
];
|
||||
}
|
||||
@@ -42,8 +45,9 @@ final class DeclarationStatus extends Enum
|
||||
return [
|
||||
self::Created => [self::EnCours],
|
||||
self::EnCours => [self::EnAttenteClient, self::Termine],
|
||||
self::EnAttenteClient => [self::EnCours],
|
||||
self::EnAttenteClient => [self::EnCours, self::MiseEnDemeure],
|
||||
self::Termine => [self::Ferme],
|
||||
self::MiseEnDemeure => [self::EnCours, self::Ferme],
|
||||
self::Ferme => [],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -3,16 +3,19 @@
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Enums\DeclarationStatus;
|
||||
use App\Enums\WorkspaceUserRole;
|
||||
use App\Models\Declaration;
|
||||
use App\Models\Workspace;
|
||||
use App\Models\WorkspaceUser;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class DashboardController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display the dashboard with assigned declarations and notifications.
|
||||
* Display the command center dashboard with KPI cards and urgent declarations.
|
||||
*/
|
||||
public function __invoke(Request $request): Response
|
||||
{
|
||||
@@ -20,107 +23,152 @@ class DashboardController extends Controller
|
||||
$workspaceId = $request->session()->get('current_workspace_id');
|
||||
$workspace = $workspaceId ? Workspace::query()->find($workspaceId) : null;
|
||||
|
||||
$assignedDeclarations = [];
|
||||
$notifications = [];
|
||||
|
||||
if ($workspace && $user) {
|
||||
$assignedDeclarations = $workspace->declarations()
|
||||
->where('assigned_to', $user->id)
|
||||
->whereNotIn('status', [DeclarationStatus::Ferme])
|
||||
->with('client:id,company_name')
|
||||
->orderByRaw('CASE WHEN due_date IS NULL THEN 1 ELSE 0 END, due_date ASC')
|
||||
->limit(50)
|
||||
->get()
|
||||
->map(fn (Declaration $f) => [
|
||||
'id' => $f->id,
|
||||
'title' => $f->title,
|
||||
'type' => $f->type->value,
|
||||
'client_name' => $f->client->company_name,
|
||||
'status' => $f->status->value,
|
||||
'due_date' => $f->due_date?->format('Y-m-d'),
|
||||
'priority' => $f->priority?->value,
|
||||
'showUrl' => route('declarations.show', $f),
|
||||
])
|
||||
->all();
|
||||
|
||||
$overdue = $workspace->declarations()
|
||||
->where('assigned_to', $user->id)
|
||||
->where('due_date', '<', now()->startOfDay())
|
||||
->whereNotIn('status', [DeclarationStatus::Ferme])
|
||||
->with('client:id,company_name')
|
||||
->orderBy('due_date')
|
||||
->limit(10)
|
||||
->get()
|
||||
->map(fn (Declaration $f) => [
|
||||
'id' => $f->id,
|
||||
'title' => $f->title,
|
||||
'client_name' => $f->client->company_name,
|
||||
'due_date' => $f->due_date?->format('Y-m-d'),
|
||||
'showUrl' => route('declarations.show', $f),
|
||||
])
|
||||
->all();
|
||||
|
||||
$dueSoon = $workspace->declarations()
|
||||
->where('assigned_to', $user->id)
|
||||
->whereBetween('due_date', [now()->startOfDay(), now()->addDays(7)->endOfDay()])
|
||||
->whereNotIn('status', [DeclarationStatus::Ferme])
|
||||
->with('client:id,company_name')
|
||||
->orderBy('due_date')
|
||||
->limit(10)
|
||||
->get()
|
||||
->map(fn (Declaration $f) => [
|
||||
'id' => $f->id,
|
||||
'title' => $f->title,
|
||||
'client_name' => $f->client->company_name,
|
||||
'due_date' => $f->due_date?->format('Y-m-d'),
|
||||
'showUrl' => route('declarations.show', $f),
|
||||
])
|
||||
->all();
|
||||
|
||||
$documentsReceived = $workspace->declarations()
|
||||
->where('assigned_to', $user->id)
|
||||
->where('status', DeclarationStatus::EnCours)
|
||||
->with('client:id,company_name')
|
||||
->orderBy('updated_at', 'desc')
|
||||
->limit(10)
|
||||
->get()
|
||||
->map(fn (Declaration $f) => [
|
||||
'id' => $f->id,
|
||||
'title' => $f->title,
|
||||
'client_name' => $f->client->company_name,
|
||||
'showUrl' => route('declarations.show', $f),
|
||||
])
|
||||
->all();
|
||||
|
||||
$awaitingValidation = $workspace->declarations()
|
||||
->where('assigned_to', $user->id)
|
||||
->where('status', DeclarationStatus::EnAttenteClient)
|
||||
->with('client:id,company_name')
|
||||
->orderBy('confirmation_requested_at', 'desc')
|
||||
->limit(10)
|
||||
->get()
|
||||
->map(fn (Declaration $f) => [
|
||||
'id' => $f->id,
|
||||
'title' => $f->title,
|
||||
'client_name' => $f->client->company_name,
|
||||
'showUrl' => route('declarations.show', $f),
|
||||
])
|
||||
->all();
|
||||
|
||||
$notifications = [
|
||||
'overdue' => $overdue,
|
||||
'due_soon' => $dueSoon,
|
||||
'documents_received' => $documentsReceived,
|
||||
'awaiting_validation' => $awaitingValidation,
|
||||
];
|
||||
if (! $workspace || ! $user) {
|
||||
return Inertia::render('Dashboard', [
|
||||
'stats' => null,
|
||||
'statCards' => [],
|
||||
'declarations' => [],
|
||||
'workspaceName' => null,
|
||||
'roleLabel' => null,
|
||||
'declarationsUrl' => null,
|
||||
'clientsUrl' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
/** @var WorkspaceUser|null $workspaceUser */
|
||||
$workspaceUser = $workspace->users()
|
||||
->where('users.id', $user->id)
|
||||
->first()
|
||||
?->pivot;
|
||||
|
||||
if (! $workspaceUser) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$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();
|
||||
|
||||
return [
|
||||
'overdue' => $overdue,
|
||||
'dueThisWeek' => $dueThisWeek,
|
||||
'enAttenteClient' => $enAttenteClient,
|
||||
'enCours' => $enCours,
|
||||
];
|
||||
});
|
||||
|
||||
$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),
|
||||
])
|
||||
->all();
|
||||
|
||||
$roleLabel = $this->roleLabels()[$workspaceUser->role->value] ?? $workspaceUser->role->value;
|
||||
|
||||
$statCards = [
|
||||
[
|
||||
'label' => 'En retard',
|
||||
'count' => $dashboardData['overdue'],
|
||||
'status' => 'danger',
|
||||
'href' => route('declarations.index', ['overdue' => 1]),
|
||||
],
|
||||
[
|
||||
'label' => 'Cette semaine',
|
||||
'count' => $dashboardData['dueThisWeek'],
|
||||
'status' => 'warning',
|
||||
'href' => route('declarations.index', ['due_this_week' => 1]),
|
||||
],
|
||||
[
|
||||
'label' => 'En attente client',
|
||||
'count' => $dashboardData['enAttenteClient'],
|
||||
'status' => 'info',
|
||||
'href' => route('declarations.index', ['status' => DeclarationStatus::EnAttenteClient]),
|
||||
],
|
||||
[
|
||||
'label' => 'En cours',
|
||||
'count' => $dashboardData['enCours'],
|
||||
'status' => 'success',
|
||||
'href' => route('declarations.index', ['status' => DeclarationStatus::EnCours]),
|
||||
],
|
||||
];
|
||||
|
||||
return Inertia::render('Dashboard', [
|
||||
'assignedDeclarations' => $assignedDeclarations,
|
||||
'notifications' => $notifications,
|
||||
'workspaceName' => $workspace?->name ?? null,
|
||||
'declarationsUrl' => $workspace ? route('declarations.index') : null,
|
||||
'clientsUrl' => $workspace ? route('clients.index') : null,
|
||||
'stats' => $dashboardData,
|
||||
'statCards' => $statCards,
|
||||
'declarations' => $urgentDeclarations,
|
||||
'workspaceName' => $workspace->name,
|
||||
'roleLabel' => $roleLabel,
|
||||
'declarationsUrl' => route('declarations.index'),
|
||||
'clientsUrl' => route('clients.index'),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get declaration type labels.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
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<string, string>
|
||||
*/
|
||||
protected function roleLabels(): array
|
||||
{
|
||||
return [
|
||||
WorkspaceUserRole::Owner => 'Propriétaire',
|
||||
WorkspaceUserRole::Manager => 'Manager',
|
||||
WorkspaceUserRole::Worker => 'Collaborateur',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user