Files
L-Ami-Fiduciaire/resources/js/components/dashboard/ActivityFeed.vue
Saad Zoubir a02b5f12d8 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>
2026-03-22 21:21:07 +01:00

122 lines
4.0 KiB
Vue

<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>