+
+
+
+ {{ getDescription(notification) }}
+
+
{{ notification.created_at }}
-
+
-
-
- {{ notification.data.declaration_title }}
-
- {{
- notification.data?.message
- ? ` — ${notification.data.message}`
- : ''
- }}
-
+
-
+
- Marquer tout comme lu
+ Voir toutes les notifications
diff --git a/resources/js/pages/notifications/Index.vue b/resources/js/pages/notifications/Index.vue
new file mode 100644
index 0000000..f1fe5dc
--- /dev/null
+++ b/resources/js/pages/notifications/Index.vue
@@ -0,0 +1,199 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Aucune notification
+
+
+
+
+
+
+
+
+ {{ getDescription(notification) }}
+
+
+ {{ notification.created_at }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/resources/js/types/index.ts b/resources/js/types/index.ts
index c4adb53..b3d5ade 100644
--- a/resources/js/types/index.ts
+++ b/resources/js/types/index.ts
@@ -1,5 +1,6 @@
export * from './auth';
export * from './dashboard';
export * from './navigation';
+export * from './notification';
export * from './team';
export * from './ui';
diff --git a/resources/js/types/notification.ts b/resources/js/types/notification.ts
new file mode 100644
index 0000000..48f8223
--- /dev/null
+++ b/resources/js/types/notification.ts
@@ -0,0 +1,26 @@
+export type NotificationType =
+ | 'nudge'
+ | 'declaration_overdue'
+ | 'document_uploaded'
+ | 'bulk_notification'
+ | 'status_changed';
+
+export type NotificationData = {
+ workspace_id: number;
+ notification_type: NotificationType;
+ declaration_id?: number;
+ sender_id?: number;
+ client_id?: number;
+ declaration_title?: string;
+ sender_name?: string;
+ url?: string;
+};
+
+export type AppNotification = {
+ id: string;
+ type: string;
+ data: NotificationData;
+ read_at: string | null;
+ created_at: string;
+ updated_at: string;
+};
diff --git a/tests/Feature/Notifications/NotificationCenterTest.php b/tests/Feature/Notifications/NotificationCenterTest.php
new file mode 100644
index 0000000..44388ca
--- /dev/null
+++ b/tests/Feature/Notifications/NotificationCenterTest.php
@@ -0,0 +1,156 @@
+create();
+ $workspace = Workspace::factory()->create();
+ $workspace->users()->attach($user->id, ['role' => 'owner', 'permissions' => []]);
+
+ session(['current_workspace_id' => $workspace->id]);
+
+ $declaration = Declaration::factory()->forWorkspace($workspace)->create();
+
+ return [$user, $workspace, $declaration];
+}
+
+// ── AC #1: Dropdown rendering ────────────────────────────
+
+test('notification dropdown renders with unread count badge via shared props', function () {
+ [$user, $workspace, $declaration] = setupNotificationCenterTest();
+ $sender = User::factory()->create();
+
+ $user->notify(new NudgeNotification($declaration, $sender));
+ $user->notify(new NudgeNotification($declaration, $sender));
+
+ $response = $this->actingAs($user)->get(route('dashboard'));
+
+ $response->assertOk();
+ $response->assertInertia(fn (AssertableInertia $page) => $page
+ ->has('userNotifications')
+ ->where('userNotifications.unread_count', 2)
+ ->has('userNotifications.readUrl')
+ ->has('userNotifications.readAllUrl')
+ ->has('userNotifications.notificationsUrl')
+ );
+});
+
+// ── AC #1: Clicking notification marks as read ───────────
+
+test('clicking notification marks it as read via POST to notifications.read', function () {
+ [$user, $workspace, $declaration] = setupNotificationCenterTest();
+ $sender = User::factory()->create();
+
+ $user->notify(new NudgeNotification($declaration, $sender));
+ $notification = $user->notifications()->first();
+ expect($notification->read_at)->toBeNull();
+
+ $response = $this->actingAs($user)->post(route('notifications.read', $notification->id));
+
+ $response->assertRedirect();
+ $notification->refresh();
+ expect($notification->read_at)->not->toBeNull();
+});
+
+// ── AC #1: Mark all as read ──────────────────────────────
+
+test('mark all as read clears unread count for current workspace', function () {
+ [$user, $workspace, $declaration] = setupNotificationCenterTest();
+ $sender = User::factory()->create();
+
+ $user->notify(new NudgeNotification($declaration, $sender));
+ $user->notify(new DocumentUploadedNotification($declaration, 1));
+
+ expect($user->unreadNotifications()->count())->toBe(2);
+
+ $response = $this->actingAs($user)->post(route('notifications.readAll'));
+
+ $response->assertRedirect();
+ $user->refresh();
+ expect($user->unreadNotifications()->count())->toBe(0);
+});
+
+// ── AC #2: Full page renders with workspace-scoped notifications ──
+
+test('notifications page renders with workspace-scoped notifications', function () {
+ [$user, $workspace, $declaration] = setupNotificationCenterTest();
+ $sender = User::factory()->create();
+
+ $user->notify(new NudgeNotification($declaration, $sender));
+
+ $response = $this->actingAs($user)->get(route('notifications.index'));
+
+ $response->assertOk();
+ $response->assertInertia(fn (AssertableInertia $page) => $page
+ ->component('notifications/Index')
+ ->has('notifications.data', 1)
+ ->has('markAllReadUrl')
+ ->has('readUrl')
+ );
+});
+
+// ── AC #2: Pagination at 25 per page ─────────────────────
+
+test('notifications page pagination works at 25 per page', function () {
+ [$user, $workspace, $declaration] = setupNotificationCenterTest();
+
+ for ($i = 0; $i < 30; $i++) {
+ $user->notify(new DocumentUploadedNotification($declaration, 1));
+ }
+
+ $response = $this->actingAs($user)->get(route('notifications.index'));
+
+ $response->assertOk();
+ $response->assertInertia(fn (AssertableInertia $page) => $page
+ ->component('notifications/Index')
+ ->has('notifications.data', 25)
+ ->where('notifications.last_page', 2)
+ );
+});
+
+// ── AC #3: Empty state renders when no notifications ─────
+
+test('empty state renders when no notifications exist', function () {
+ [$user, $workspace, $declaration] = setupNotificationCenterTest();
+
+ $response = $this->actingAs($user)->get(route('notifications.index'));
+
+ $response->assertOk();
+ $response->assertInertia(fn (AssertableInertia $page) => $page
+ ->component('notifications/Index')
+ ->has('notifications.data', 0)
+ );
+});
+
+// ── AC #2: Cross-workspace isolation ─────────────────────
+
+test('notifications are scoped to current workspace and do not leak across workspaces', function () {
+ [$user, $workspace1, $declaration1] = setupNotificationCenterTest();
+
+ $workspace2 = Workspace::factory()->create();
+ $workspace2->users()->attach($user->id, ['role' => 'owner', 'permissions' => []]);
+ $declaration2 = Declaration::factory()->forWorkspace($workspace2)->create();
+
+ // Notify for both workspaces
+ $user->notify(new DocumentUploadedNotification($declaration1, 1));
+ $user->notify(new DocumentUploadedNotification($declaration2, 1));
+
+ // Session is set to workspace1
+ $response = $this->actingAs($user)->get(route('notifications.index'));
+
+ $response->assertOk();
+ $response->assertInertia(fn (AssertableInertia $page) => $page
+ ->component('notifications/Index')
+ ->has('notifications.data', 1)
+ ->where('notifications.data.0.data.workspace_id', $workspace1->id)
+ );
+});