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

@@ -0,0 +1,121 @@
<script setup lang="ts">
import { router } from '@inertiajs/vue3';
import {
Activity,
ArrowRightLeft,
FilePlus,
Inbox,
Shield,
Upload,
UserRoundCog,
} from 'lucide-vue-next';
import { formatRelativeTime } from '@/composables/useRelativeTime';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import type { ActivityEvent } from '@/types';
type Props = {
activities: ActivityEvent[];
};
defineProps<Props>();
function navigateToTarget(activity: ActivityEvent): void {
if (activity.targetUrl) {
router.get(activity.targetUrl);
}
}
const eventIconMap: Record<string, typeof Activity> = {
declaration_created: FilePlus,
declaration_updated: Activity,
status_change: ArrowRightLeft,
reassignment: UserRoundCog,
client_updated: Upload,
role_change: Shield,
default: Activity,
};
function getEventIcon(eventType: string) {
return eventIconMap[eventType] ?? eventIconMap.default;
}
function initialsColor(initials: string): string {
const colors = [
'bg-blue-100 text-blue-700',
'bg-green-100 text-green-700',
'bg-amber-100 text-amber-700',
'bg-purple-100 text-purple-700',
'bg-rose-100 text-rose-700',
'bg-teal-100 text-teal-700',
];
let hash = 0;
for (let i = 0; i < initials.length; i++) {
hash = initials.charCodeAt(i) + ((hash << 5) - hash);
}
return colors[Math.abs(hash) % colors.length];
}
</script>
<template>
<Card role="feed" aria-label="Activité récente">
<CardHeader class="pb-3">
<CardTitle class="text-base">Activité récente</CardTitle>
</CardHeader>
<CardContent>
<!-- Empty state -->
<div
v-if="activities.length === 0"
class="flex flex-col items-center justify-center py-8"
>
<Inbox class="mb-2 h-8 w-8 text-muted-foreground" />
<p class="text-sm text-muted-foreground">
Aucune activité récente
</p>
</div>
<!-- Activity list -->
<div v-else class="space-y-1">
<button
v-for="activity in activities"
:key="activity.id"
:aria-label="`${activity.description} ${formatRelativeTime(activity.timestamp)}`"
:class="[
'flex w-full items-start gap-3 rounded-md px-2 py-2.5 text-left text-sm transition-colors',
activity.targetUrl
? 'cursor-pointer hover:bg-muted/50'
: 'cursor-default',
]"
@click="navigateToTarget(activity)"
>
<!-- Actor initials -->
<div
:class="[
'flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-xs font-medium',
initialsColor(activity.actorInitials),
]"
>
{{ activity.actorInitials }}
</div>
<!-- Content -->
<div class="min-w-0 flex-1">
<p class="leading-snug text-foreground">
{{ activity.description }}
</p>
<div
class="mt-0.5 flex items-center gap-1.5 text-xs text-muted-foreground"
>
<component
:is="getEventIcon(activity.eventType)"
class="h-3 w-3"
/>
<span>{{
formatRelativeTime(activity.timestamp)
}}</span>
</div>
</div>
</button>
</div>
</CardContent>
</Card>
</template>

View File

@@ -0,0 +1,43 @@
export function formatRelativeTime(isoTimestamp: string): string {
const date = new Date(isoTimestamp);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMinutes = Math.floor(diffMs / (1000 * 60));
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (diffMinutes < 1) {
return "à l'instant";
}
if (diffMinutes < 60) {
return `il y a ${diffMinutes} min`;
}
if (diffHours < 24) {
return `il y a ${diffHours} h`;
}
const yesterday = new Date(now);
yesterday.setDate(yesterday.getDate() - 1);
if (
date.getDate() === yesterday.getDate() &&
date.getMonth() === yesterday.getMonth() &&
date.getFullYear() === yesterday.getFullYear()
) {
return 'hier';
}
if (diffDays < 7) {
return `il y a ${diffDays} ${diffDays === 1 ? 'jour' : 'jours'}`;
}
const day = String(date.getDate()).padStart(2, '0');
const month = String(date.getMonth() + 1).padStart(2, '0');
const year = date.getFullYear();
return `${day}/${month}/${year}`;
}
export function useRelativeTime() {
return { formatRelativeTime };
}

View File

@@ -3,6 +3,7 @@ import { Head, Link, router } from '@inertiajs/vue3';
import {
Briefcase,
Building2,
ChevronDown,
ClipboardList,
EllipsisVertical,
Eye,
@@ -11,12 +12,18 @@ import {
UserRoundCog,
Users,
} from 'lucide-vue-next';
import { computed } from 'vue';
import { computed, ref } from 'vue';
import ActivityFeed from '@/components/dashboard/ActivityFeed.vue';
import PriorityAlertsPanel from '@/components/dashboard/PriorityAlertsPanel.vue';
import StatCard from '@/components/dashboard/StatCard.vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible';
import {
DropdownMenu,
DropdownMenuContent,
@@ -54,11 +61,13 @@ const breadcrumbs: BreadcrumbItem[] = [
];
const hasWorkspace = computed(() => !!props.workspaceName);
const showFeed = ref(false);
const isWorkerEmpty = computed(
() =>
props.isWorker &&
props.declarations.length === 0 &&
props.alerts.length === 0 &&
props.statCards.every((c) => c.count === 0),
);
@@ -158,11 +167,6 @@ function navigateToDeclaration(declaration: DashboardDeclaration): void {
<!-- Workspace dashboard -->
<template v-if="hasWorkspace">
<!-- Worker subtitle -->
<p v-if="isWorker" class="text-sm text-muted-foreground">
Mes déclarations
</p>
<!-- Worker empty state -->
<Card v-if="isWorkerEmpty">
<CardContent
@@ -175,186 +179,251 @@ function navigateToDeclaration(declaration: DashboardDeclaration): void {
Aucune déclaration assignée
</p>
<p class="text-sm text-muted-foreground">
Contactez votre responsable pour recevoir des
déclarations
Contactez votre responsable
</p>
</CardContent>
</Card>
<template v-if="!isWorkerEmpty">
<!-- KPI StatCards -->
<div
class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4"
>
<StatCard
v-for="card in statCards"
:key="card.label"
:label="card.label"
:count="card.count"
:status="card.status as StatCardLink['status']"
:href="card.href"
/>
</div>
<!-- Priority Alerts Panel -->
<PriorityAlertsPanel
:alerts="alerts"
:view-all-url="viewAllAlertsUrl"
/>
<!-- Urgent Declarations Table -->
<div class="space-y-4">
<div class="flex items-center justify-between">
<h2 class="text-lg font-semibold">
Déclarations urgentes
</h2>
<Button
v-if="declarationsUrl"
variant="outline"
as-child
<div class="grid grid-cols-1 gap-6 lg:grid-cols-3">
<!-- Main content (2/3 on desktop) -->
<div class="space-y-6 lg:col-span-2">
<!-- Worker subtitle -->
<p
v-if="isWorker"
class="text-sm text-muted-foreground"
>
<Link :href="declarationsUrl">
Toutes les déclarations
</Link>
</Button>
</div>
Mes clarations
</p>
<Card
v-if="declarations.length > 0"
class="overflow-hidden"
>
<div class="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Client</TableHead>
<TableHead>Type</TableHead>
<TableHead>Date limite</TableHead>
<TableHead v-if="!isWorker"
>Assigné à</TableHead
>
<TableHead>Statut</TableHead>
<TableHead class="w-10" />
</TableRow>
</TableHeader>
<TableBody>
<TableRow
v-for="declaration in declarations"
:key="declaration.id"
class="cursor-pointer"
@click="
navigateToDeclaration(
declaration,
)
"
>
<TableCell class="font-medium">
{{ declaration.clientName }}
</TableCell>
<TableCell>
{{ declaration.typeLabel }}
</TableCell>
<TableCell>
<span
:class="
deadlineClass(
declaration.dueDate,
<!-- KPI StatCards -->
<div
class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-2 xl:grid-cols-4"
>
<StatCard
v-for="card in statCards"
:key="card.label"
:label="card.label"
:count="card.count"
:status="
card.status as StatCardLink['status']
"
:href="card.href"
/>
</div>
<!-- Priority Alerts Panel -->
<PriorityAlertsPanel
:alerts="alerts"
:view-all-url="viewAllAlertsUrl"
/>
<!-- Urgent Declarations Table -->
<div class="space-y-4">
<div class="flex items-center justify-between">
<h2 class="text-lg font-semibold">
Déclarations urgentes
</h2>
<Button
v-if="declarationsUrl"
variant="outline"
as-child
>
<Link :href="declarationsUrl">
Toutes les déclarations
</Link>
</Button>
</div>
<Card
v-if="declarations.length > 0"
class="overflow-hidden"
>
<div class="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead
>Client</TableHead
>
<TableHead>Type</TableHead>
<TableHead
>Date limite</TableHead
>
<TableHead v-if="!isWorker"
>Assigné à</TableHead
>
<TableHead
>Statut</TableHead
>
<TableHead class="w-10" />
</TableRow>
</TableHeader>
<TableBody>
<TableRow
v-for="declaration in declarations"
:key="declaration.id"
class="cursor-pointer"
@click="
navigateToDeclaration(
declaration,
)
"
>
{{
declaration.dueDate ??
'—'
}}
</span>
</TableCell>
<TableCell v-if="!isWorker">
{{
declaration.assigneeName ??
''
}}
</TableCell>
<TableCell>
<Badge
:variant="
statusBadgeVariant(
declaration.status,
)
"
>
{{
declaration.statusLabel
}}
</Badge>
</TableCell>
<TableCell>
<DropdownMenu>
<DropdownMenuTrigger
as-child
@click.stop
<TableCell
class="font-medium"
>
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
>
<EllipsisVertical
class="h-4 w-4"
/>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
>
<DropdownMenuItem
@click.stop="
navigateToDeclaration(
declaration,
{{
declaration.clientName
}}
</TableCell>
<TableCell>
{{
declaration.typeLabel
}}
</TableCell>
<TableCell>
<span
:class="
deadlineClass(
declaration.dueDate,
)
"
>
<Eye
class="mr-2 h-4 w-4"
/>
Voir
</DropdownMenuItem>
<DropdownMenuItem
disabled
{{
declaration.dueDate ??
'—'
}}
</span>
</TableCell>
<TableCell v-if="!isWorker">
{{
declaration.assigneeName ??
''
}}
</TableCell>
<TableCell>
<Badge
:variant="
statusBadgeVariant(
declaration.status,
)
"
>
<Send
class="mr-2 h-4 w-4"
/>
Relancer
</DropdownMenuItem>
<DropdownMenuItem
disabled
>
<UserRoundCog
class="mr-2 h-4 w-4"
/>
Réassigner
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
</Card>
{{
declaration.statusLabel
}}
</Badge>
</TableCell>
<TableCell>
<DropdownMenu>
<DropdownMenuTrigger
as-child
@click.stop
>
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
>
<EllipsisVertical
class="h-4 w-4"
/>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
>
<DropdownMenuItem
@click.stop="
navigateToDeclaration(
declaration,
)
"
>
<Eye
class="mr-2 h-4 w-4"
/>
Voir
</DropdownMenuItem>
<DropdownMenuItem
disabled
>
<Send
class="mr-2 h-4 w-4"
/>
Relancer
</DropdownMenuItem>
<DropdownMenuItem
disabled
>
<UserRoundCog
class="mr-2 h-4 w-4"
/>
Réassigner
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
</Card>
<Card v-else>
<CardContent
class="flex flex-col items-center justify-center py-12"
>
<FolderOpen
class="mb-3 h-12 w-12 text-muted-foreground"
/>
<p class="text-muted-foreground">
Aucune déclaration urgente pour le moment.
</p>
</CardContent>
</Card>
<Card v-else>
<CardContent
class="flex flex-col items-center justify-center py-12"
>
<FolderOpen
class="mb-3 h-12 w-12 text-muted-foreground"
/>
<p class="text-muted-foreground">
Aucune déclaration urgente pour le
moment.
</p>
</CardContent>
</Card>
</div>
<!-- Mobile (<768px): Collapsible activity feed -->
<div class="md:hidden">
<Collapsible v-model:open="showFeed">
<CollapsibleTrigger
class="flex w-full items-center justify-between rounded-lg border px-4 py-3 text-sm font-medium transition-colors hover:bg-muted/50"
>
<span
>Activité récente ({{
activities.length
}})</span
>
<ChevronDown
:class="[
'h-4 w-4 transition-transform',
showFeed ? 'rotate-180' : '',
]"
/>
</CollapsibleTrigger>
<CollapsibleContent>
<div class="pt-3">
<ActivityFeed
:activities="activities"
/>
</div>
</CollapsibleContent>
</Collapsible>
</div>
<!-- Tablet (768-1023px): Inline activity feed below table -->
<div class="hidden md:block lg:hidden">
<ActivityFeed :activities="activities" />
</div>
</div>
<!-- Desktop: Activity feed sidebar (1/3) -->
<div class="hidden lg:col-span-1 lg:block">
<ActivityFeed :activities="activities" />
</div>
</div>
</template>
</template>

View File

@@ -36,11 +36,23 @@ export type DashboardAlert = {
showUrl: string;
};
export type ActivityEvent = {
id: number;
actorName: string;
actorInitials: string;
description: string;
targetUrl: string | null;
targetLabel: string | null;
timestamp: string;
eventType: string;
};
export type DashboardProps = {
stats: DashboardStats | null;
statCards: StatCardLink[];
declarations: DashboardDeclaration[];
alerts: DashboardAlert[];
activities: ActivityEvent[];
workspaceName: string | null;
roleLabel: string | null;
isWorker: boolean;