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:
121
resources/js/components/dashboard/ActivityFeed.vue
Normal file
121
resources/js/components/dashboard/ActivityFeed.vue
Normal 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>
|
||||
43
resources/js/composables/useRelativeTime.ts
Normal file
43
resources/js/composables/useRelativeTime.ts
Normal 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 };
|
||||
}
|
||||
@@ -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 dé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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user