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:
333
tests/Feature/Dashboard/OwnerDashboardTest.php
Normal file
333
tests/Feature/Dashboard/OwnerDashboardTest.php
Normal file
@@ -0,0 +1,333 @@
|
||||
<?php
|
||||
|
||||
use App\Enums\DeclarationStatus;
|
||||
use App\Models\Client;
|
||||
use App\Models\Declaration;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
function setupWorkspaceWithRole(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('unauthenticated user is redirected to login', function () {
|
||||
$response = $this->get(route('dashboard'));
|
||||
|
||||
$response->assertRedirect(route('login'));
|
||||
});
|
||||
|
||||
test('user without workspace sees admin view', function () {
|
||||
$user = User::factory()->create();
|
||||
$this->actingAs($user);
|
||||
|
||||
$response = $this->get(route('dashboard'));
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertInertia(fn ($page) => $page
|
||||
->component('Dashboard')
|
||||
->where('workspaceName', null)
|
||||
->where('stats', null)
|
||||
->where('statCards', [])
|
||||
->where('declarations', [])
|
||||
);
|
||||
});
|
||||
|
||||
test('owner sees all workspace declarations in kpi counts', function () {
|
||||
[$user, $workspace, $client] = setupWorkspaceWithRole('owner');
|
||||
|
||||
$otherUser = User::factory()->create();
|
||||
$workspace->users()->attach($otherUser->id, ['role' => 'worker']);
|
||||
|
||||
// Overdue declaration (assigned to other user)
|
||||
Declaration::factory()->create([
|
||||
'workspace_id' => $workspace->id,
|
||||
'client_id' => $client->id,
|
||||
'assigned_to' => $otherUser->id,
|
||||
'status' => DeclarationStatus::EnCours,
|
||||
'due_date' => now()->subDays(5),
|
||||
]);
|
||||
|
||||
// Due this week (assigned to current user)
|
||||
Declaration::factory()->create([
|
||||
'workspace_id' => $workspace->id,
|
||||
'client_id' => $client->id,
|
||||
'assigned_to' => $user->id,
|
||||
'status' => DeclarationStatus::EnCours,
|
||||
'due_date' => now()->addDays(3),
|
||||
]);
|
||||
|
||||
// En attente client
|
||||
Declaration::factory()->create([
|
||||
'workspace_id' => $workspace->id,
|
||||
'client_id' => $client->id,
|
||||
'assigned_to' => $otherUser->id,
|
||||
'status' => DeclarationStatus::EnAttenteClient,
|
||||
'due_date' => now()->addDays(10),
|
||||
]);
|
||||
|
||||
// En cours
|
||||
Declaration::factory()->create([
|
||||
'workspace_id' => $workspace->id,
|
||||
'client_id' => $client->id,
|
||||
'assigned_to' => $user->id,
|
||||
'status' => DeclarationStatus::EnCours,
|
||||
'due_date' => now()->addDays(15),
|
||||
]);
|
||||
|
||||
// Ferme (should be excluded)
|
||||
Declaration::factory()->create([
|
||||
'workspace_id' => $workspace->id,
|
||||
'client_id' => $client->id,
|
||||
'status' => DeclarationStatus::Ferme,
|
||||
'due_date' => now()->subDays(1),
|
||||
]);
|
||||
|
||||
// Termine (should be excluded)
|
||||
Declaration::factory()->create([
|
||||
'workspace_id' => $workspace->id,
|
||||
'client_id' => $client->id,
|
||||
'status' => DeclarationStatus::Termine,
|
||||
'due_date' => now()->subDays(1),
|
||||
]);
|
||||
|
||||
// Mise en demeure (should be excluded)
|
||||
Declaration::factory()->create([
|
||||
'workspace_id' => $workspace->id,
|
||||
'client_id' => $client->id,
|
||||
'status' => DeclarationStatus::MiseEnDemeure,
|
||||
'due_date' => now()->subDays(1),
|
||||
]);
|
||||
|
||||
$this->actingAs($user);
|
||||
$response = $this->get(route('dashboard'));
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertInertia(fn ($page) => $page
|
||||
->component('Dashboard')
|
||||
->where('stats.overdue', 1)
|
||||
->where('stats.dueThisWeek', 1)
|
||||
->where('stats.enAttenteClient', 1)
|
||||
->where('stats.enCours', 3)
|
||||
->has('statCards', 4)
|
||||
->where('workspaceName', $workspace->name)
|
||||
);
|
||||
});
|
||||
|
||||
test('manager sees all workspace declarations in kpi counts', function () {
|
||||
[$user, $workspace, $client] = setupWorkspaceWithRole('manager');
|
||||
|
||||
$worker = User::factory()->create();
|
||||
$workspace->users()->attach($worker->id, ['role' => 'worker']);
|
||||
|
||||
// Declaration assigned to the worker (manager should still see it)
|
||||
Declaration::factory()->create([
|
||||
'workspace_id' => $workspace->id,
|
||||
'client_id' => $client->id,
|
||||
'assigned_to' => $worker->id,
|
||||
'status' => DeclarationStatus::EnCours,
|
||||
'due_date' => now()->subDays(2),
|
||||
]);
|
||||
|
||||
$this->actingAs($user);
|
||||
$response = $this->get(route('dashboard'));
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertInertia(fn ($page) => $page
|
||||
->component('Dashboard')
|
||||
->where('stats.overdue', 1)
|
||||
);
|
||||
});
|
||||
|
||||
test('worker sees only assigned declarations', function () {
|
||||
[$user, $workspace, $client] = setupWorkspaceWithRole('worker');
|
||||
|
||||
$otherUser = User::factory()->create();
|
||||
$workspace->users()->attach($otherUser->id, ['role' => 'worker']);
|
||||
|
||||
// Assigned to this worker
|
||||
Declaration::factory()->create([
|
||||
'workspace_id' => $workspace->id,
|
||||
'client_id' => $client->id,
|
||||
'assigned_to' => $user->id,
|
||||
'status' => DeclarationStatus::EnCours,
|
||||
'due_date' => now()->subDays(1),
|
||||
]);
|
||||
|
||||
// Assigned to other worker (should NOT be visible)
|
||||
Declaration::factory()->create([
|
||||
'workspace_id' => $workspace->id,
|
||||
'client_id' => $client->id,
|
||||
'assigned_to' => $otherUser->id,
|
||||
'status' => DeclarationStatus::EnCours,
|
||||
'due_date' => now()->subDays(1),
|
||||
]);
|
||||
|
||||
$this->actingAs($user);
|
||||
$response = $this->get(route('dashboard'));
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertInertia(fn ($page) => $page
|
||||
->component('Dashboard')
|
||||
->where('stats.overdue', 1)
|
||||
->where('stats.enCours', 1)
|
||||
->has('declarations', 1)
|
||||
);
|
||||
});
|
||||
|
||||
test('dashboard data uses cache with correct key and ttl', function () {
|
||||
[$user, $workspace, $client] = setupWorkspaceWithRole('owner');
|
||||
|
||||
Declaration::factory()->create([
|
||||
'workspace_id' => $workspace->id,
|
||||
'client_id' => $client->id,
|
||||
'status' => DeclarationStatus::EnCours,
|
||||
'due_date' => now()->addDays(2),
|
||||
]);
|
||||
|
||||
$this->actingAs($user);
|
||||
|
||||
// First request populates cache
|
||||
$this->get(route('dashboard'))->assertOk();
|
||||
|
||||
$cacheKey = "dashboard:{$workspace->id}:{$user->id}";
|
||||
expect(Cache::has($cacheKey))->toBeTrue();
|
||||
|
||||
$cached = Cache::get($cacheKey);
|
||||
expect($cached)->toBeArray()
|
||||
->and($cached)->toHaveKeys(['overdue', 'dueThisWeek', 'enAttenteClient', 'enCours']);
|
||||
});
|
||||
|
||||
test('dashboard excludes archived declarations', function () {
|
||||
[$user, $workspace, $client] = setupWorkspaceWithRole('owner');
|
||||
|
||||
// Active declaration
|
||||
Declaration::factory()->create([
|
||||
'workspace_id' => $workspace->id,
|
||||
'client_id' => $client->id,
|
||||
'status' => DeclarationStatus::EnCours,
|
||||
'due_date' => now()->addDays(2),
|
||||
'archived_at' => null,
|
||||
]);
|
||||
|
||||
// Archived declaration (should NOT be counted)
|
||||
Declaration::factory()->create([
|
||||
'workspace_id' => $workspace->id,
|
||||
'client_id' => $client->id,
|
||||
'status' => DeclarationStatus::EnCours,
|
||||
'due_date' => now()->addDays(2),
|
||||
'archived_at' => now()->subDay(),
|
||||
]);
|
||||
|
||||
$this->actingAs($user);
|
||||
$response = $this->get(route('dashboard'));
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertInertia(fn ($page) => $page
|
||||
->component('Dashboard')
|
||||
->where('stats.enCours', 1)
|
||||
->has('declarations', 1)
|
||||
);
|
||||
});
|
||||
|
||||
test('dashboard returns declarations sorted by due date ascending', function () {
|
||||
[$user, $workspace, $client] = setupWorkspaceWithRole('owner');
|
||||
|
||||
$later = Declaration::factory()->create([
|
||||
'workspace_id' => $workspace->id,
|
||||
'client_id' => $client->id,
|
||||
'status' => DeclarationStatus::EnCours,
|
||||
'due_date' => now()->addDays(10),
|
||||
]);
|
||||
|
||||
$sooner = Declaration::factory()->create([
|
||||
'workspace_id' => $workspace->id,
|
||||
'client_id' => $client->id,
|
||||
'status' => DeclarationStatus::EnCours,
|
||||
'due_date' => now()->addDays(2),
|
||||
]);
|
||||
|
||||
$this->actingAs($user);
|
||||
$response = $this->get(route('dashboard'));
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertInertia(fn ($page) => $page
|
||||
->component('Dashboard')
|
||||
->has('declarations', 2)
|
||||
->where('declarations.0.id', $sooner->id)
|
||||
->where('declarations.1.id', $later->id)
|
||||
);
|
||||
});
|
||||
|
||||
test('dashboard limits declarations to 15 rows', function () {
|
||||
[$user, $workspace, $client] = setupWorkspaceWithRole('owner');
|
||||
|
||||
// Create 20 declarations
|
||||
for ($i = 0; $i < 20; $i++) {
|
||||
Declaration::factory()->create([
|
||||
'workspace_id' => $workspace->id,
|
||||
'client_id' => $client->id,
|
||||
'status' => DeclarationStatus::EnCours,
|
||||
'due_date' => now()->addDays($i),
|
||||
]);
|
||||
}
|
||||
|
||||
$this->actingAs($user);
|
||||
$response = $this->get(route('dashboard'));
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertInertia(fn ($page) => $page
|
||||
->component('Dashboard')
|
||||
->has('declarations', 15)
|
||||
);
|
||||
});
|
||||
|
||||
test('stat cards have correct filter hrefs', function () {
|
||||
[$user, $workspace, $client] = setupWorkspaceWithRole('owner');
|
||||
|
||||
$this->actingAs($user);
|
||||
$response = $this->get(route('dashboard'));
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertInertia(fn ($page) => $page
|
||||
->component('Dashboard')
|
||||
->has('statCards', 4)
|
||||
->where('statCards.0.status', 'danger')
|
||||
->where('statCards.1.status', 'warning')
|
||||
->where('statCards.2.status', 'info')
|
||||
->where('statCards.3.status', 'success')
|
||||
);
|
||||
});
|
||||
|
||||
test('declaration rows include show url and assignee name', function () {
|
||||
[$user, $workspace, $client] = setupWorkspaceWithRole('owner');
|
||||
|
||||
$assignee = User::factory()->create(['name' => 'Ahmed Test']);
|
||||
|
||||
Declaration::factory()->create([
|
||||
'workspace_id' => $workspace->id,
|
||||
'client_id' => $client->id,
|
||||
'assigned_to' => $assignee->id,
|
||||
'status' => DeclarationStatus::EnCours,
|
||||
'due_date' => now()->addDays(2),
|
||||
]);
|
||||
|
||||
$this->actingAs($user);
|
||||
$response = $this->get(route('dashboard'));
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertInertia(fn ($page) => $page
|
||||
->component('Dashboard')
|
||||
->has('declarations', 1)
|
||||
->where('declarations.0.assigneeName', 'Ahmed Test')
|
||||
->where('declarations.0.clientName', $client->company_name)
|
||||
->has('declarations.0.showUrl')
|
||||
);
|
||||
});
|
||||
@@ -50,6 +50,38 @@ test('valid transition: termine to ferme', function () {
|
||||
expect($declaration->fresh()->status->value)->toBe('ferme');
|
||||
});
|
||||
|
||||
test('valid transition: en_attente_client to mise_en_demeure', function () {
|
||||
$declaration = Declaration::factory()->create(['status' => DeclarationStatus::Created]);
|
||||
$declaration->update(['status' => DeclarationStatus::EnCours]);
|
||||
$declaration->update(['status' => DeclarationStatus::EnAttenteClient]);
|
||||
|
||||
$declaration->update(['status' => DeclarationStatus::MiseEnDemeure]);
|
||||
|
||||
expect($declaration->fresh()->status->value)->toBe('mise_en_demeure');
|
||||
});
|
||||
|
||||
test('valid transition: mise_en_demeure to en_cours', function () {
|
||||
$declaration = Declaration::factory()->create(['status' => DeclarationStatus::Created]);
|
||||
$declaration->update(['status' => DeclarationStatus::EnCours]);
|
||||
$declaration->update(['status' => DeclarationStatus::EnAttenteClient]);
|
||||
$declaration->update(['status' => DeclarationStatus::MiseEnDemeure]);
|
||||
|
||||
$declaration->update(['status' => DeclarationStatus::EnCours]);
|
||||
|
||||
expect($declaration->fresh()->status->value)->toBe('en_cours');
|
||||
});
|
||||
|
||||
test('valid transition: mise_en_demeure to ferme', function () {
|
||||
$declaration = Declaration::factory()->create(['status' => DeclarationStatus::Created]);
|
||||
$declaration->update(['status' => DeclarationStatus::EnCours]);
|
||||
$declaration->update(['status' => DeclarationStatus::EnAttenteClient]);
|
||||
$declaration->update(['status' => DeclarationStatus::MiseEnDemeure]);
|
||||
|
||||
$declaration->update(['status' => DeclarationStatus::Ferme]);
|
||||
|
||||
expect($declaration->fresh()->status->value)->toBe('ferme');
|
||||
});
|
||||
|
||||
test('invalid transition: created to ferme throws validation exception', function () {
|
||||
$declaration = Declaration::factory()->create(['status' => DeclarationStatus::Created]);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user