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:
81
resources/js/components/dashboard/StatCard.vue
Normal file
81
resources/js/components/dashboard/StatCard.vue
Normal file
@@ -0,0 +1,81 @@
|
||||
<script setup lang="ts">
|
||||
import { router } from '@inertiajs/vue3';
|
||||
import { cva } from 'class-variance-authority';
|
||||
import { computed } from 'vue';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type Props = {
|
||||
label: string;
|
||||
count: number;
|
||||
status: 'danger' | 'warning' | 'info' | 'success';
|
||||
href: string;
|
||||
loading?: boolean;
|
||||
};
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
loading: false,
|
||||
});
|
||||
|
||||
const cardVariants = cva(
|
||||
'cursor-pointer transition-colors focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none',
|
||||
{
|
||||
variants: {
|
||||
status: {
|
||||
danger: 'border-red-500/50 bg-red-500/5 hover:bg-red-500/10',
|
||||
warning:
|
||||
'border-amber-500/50 bg-amber-500/5 hover:bg-amber-500/10',
|
||||
info: 'border-blue-500/50 bg-blue-500/5 hover:bg-blue-500/10',
|
||||
success:
|
||||
'border-green-500/50 bg-green-500/5 hover:bg-green-500/10',
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const countColorClass = computed(() => {
|
||||
const map: Record<string, string> = {
|
||||
danger: 'text-red-600',
|
||||
warning: 'text-amber-600',
|
||||
info: 'text-blue-600',
|
||||
success: 'text-green-600',
|
||||
};
|
||||
return map[props.status] ?? '';
|
||||
});
|
||||
|
||||
function navigate(): void {
|
||||
router.get(props.href);
|
||||
}
|
||||
|
||||
function handleKeydown(event: KeyboardEvent): void {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
navigate();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Card
|
||||
:class="cn(cardVariants({ status }))"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
aria-pressed="false"
|
||||
@click="navigate"
|
||||
@keydown="handleKeydown"
|
||||
>
|
||||
<CardHeader class="pb-2">
|
||||
<CardTitle class="text-sm font-medium text-muted-foreground">
|
||||
<Skeleton v-if="loading" class="h-4 w-20" />
|
||||
<template v-else>{{ label }}</template>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Skeleton v-if="loading" class="h-8 w-16" />
|
||||
<div v-else :class="cn('text-3xl font-bold', countColorClass)">
|
||||
{{ count }}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</template>
|
||||
16
resources/js/components/ui/table/Table.vue
Normal file
16
resources/js/components/ui/table/Table.vue
Normal file
@@ -0,0 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div data-slot="table-container" class="relative w-full overflow-auto">
|
||||
<table data-slot="table" :class="cn('w-full caption-bottom text-sm', props.class)">
|
||||
<slot />
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
17
resources/js/components/ui/table/TableBody.vue
Normal file
17
resources/js/components/ui/table/TableBody.vue
Normal file
@@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<tbody
|
||||
data-slot="table-body"
|
||||
:class="cn('[&_tr:last-child]:border-0', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</tbody>
|
||||
</template>
|
||||
17
resources/js/components/ui/table/TableCaption.vue
Normal file
17
resources/js/components/ui/table/TableCaption.vue
Normal file
@@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<caption
|
||||
data-slot="table-caption"
|
||||
:class="cn('text-muted-foreground mt-4 text-sm', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</caption>
|
||||
</template>
|
||||
22
resources/js/components/ui/table/TableCell.vue
Normal file
22
resources/js/components/ui/table/TableCell.vue
Normal file
@@ -0,0 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<td
|
||||
data-slot="table-cell"
|
||||
:class="
|
||||
cn(
|
||||
'p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</td>
|
||||
</template>
|
||||
34
resources/js/components/ui/table/TableEmpty.vue
Normal file
34
resources/js/components/ui/table/TableEmpty.vue
Normal file
@@ -0,0 +1,34 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { cn } from "@/lib/utils"
|
||||
import TableCell from "./TableCell.vue"
|
||||
import TableRow from "./TableRow.vue"
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
colspan?: number
|
||||
}>(), {
|
||||
colspan: 1,
|
||||
})
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TableRow>
|
||||
<TableCell
|
||||
:class="
|
||||
cn(
|
||||
'p-4 whitespace-nowrap align-middle text-sm text-foreground',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
v-bind="delegatedProps"
|
||||
>
|
||||
<div class="flex items-center justify-center py-10">
|
||||
<slot />
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</template>
|
||||
17
resources/js/components/ui/table/TableFooter.vue
Normal file
17
resources/js/components/ui/table/TableFooter.vue
Normal file
@@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<tfoot
|
||||
data-slot="table-footer"
|
||||
:class="cn('bg-muted/50 border-t font-medium [&>tr]:last:border-b-0', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</tfoot>
|
||||
</template>
|
||||
17
resources/js/components/ui/table/TableHead.vue
Normal file
17
resources/js/components/ui/table/TableHead.vue
Normal file
@@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<th
|
||||
data-slot="table-head"
|
||||
:class="cn('text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</th>
|
||||
</template>
|
||||
17
resources/js/components/ui/table/TableHeader.vue
Normal file
17
resources/js/components/ui/table/TableHeader.vue
Normal file
@@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<thead
|
||||
data-slot="table-header"
|
||||
:class="cn('[&_tr]:border-b', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</thead>
|
||||
</template>
|
||||
17
resources/js/components/ui/table/TableRow.vue
Normal file
17
resources/js/components/ui/table/TableRow.vue
Normal file
@@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<tr
|
||||
data-slot="table-row"
|
||||
:class="cn('hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</tr>
|
||||
</template>
|
||||
9
resources/js/components/ui/table/index.ts
Normal file
9
resources/js/components/ui/table/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export { default as Table } from "./Table.vue"
|
||||
export { default as TableBody } from "./TableBody.vue"
|
||||
export { default as TableCaption } from "./TableCaption.vue"
|
||||
export { default as TableCell } from "./TableCell.vue"
|
||||
export { default as TableEmpty } from "./TableEmpty.vue"
|
||||
export { default as TableFooter } from "./TableFooter.vue"
|
||||
export { default as TableHead } from "./TableHead.vue"
|
||||
export { default as TableHeader } from "./TableHeader.vue"
|
||||
export { default as TableRow } from "./TableRow.vue"
|
||||
10
resources/js/components/ui/table/utils.ts
Normal file
10
resources/js/components/ui/table/utils.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import type { Updater } from "@tanstack/vue-table"
|
||||
|
||||
import type { Ref } from "vue"
|
||||
import { isFunction } from "@tanstack/vue-table"
|
||||
|
||||
export function valueUpdater<T>(updaterOrValue: Updater<T>, ref: Ref<T>) {
|
||||
ref.value = isFunction(updaterOrValue)
|
||||
? updaterOrValue(ref.value)
|
||||
: updaterOrValue
|
||||
}
|
||||
@@ -1,57 +1,46 @@
|
||||
<script setup lang="ts">
|
||||
import { Head, Link } from '@inertiajs/vue3';
|
||||
import { Head, Link, router } from '@inertiajs/vue3';
|
||||
import {
|
||||
Briefcase,
|
||||
Building2,
|
||||
Users,
|
||||
EllipsisVertical,
|
||||
Eye,
|
||||
FolderOpen,
|
||||
AlertTriangle,
|
||||
Clock,
|
||||
FileCheck,
|
||||
MessageSquareWarning,
|
||||
ArrowRight,
|
||||
FileStack,
|
||||
Send,
|
||||
UserRoundCog,
|
||||
Users,
|
||||
} from 'lucide-vue-next';
|
||||
import { computed } from 'vue';
|
||||
import PlaceholderPattern from '@/components/PlaceholderPattern.vue';
|
||||
import StatCard from '@/components/dashboard/StatCard.vue';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import AppLayout from '@/layouts/AppLayout.vue';
|
||||
import { dashboard } from '@/routes';
|
||||
import type { BreadcrumbItem } from '@/types';
|
||||
import { index as usersIndex } from '@/routes/users';
|
||||
import { index as workspacesIndex } from '@/routes/workspaces';
|
||||
import type {
|
||||
BreadcrumbItem,
|
||||
DashboardDeclaration,
|
||||
DashboardProps,
|
||||
StatCardLink,
|
||||
} from '@/types';
|
||||
|
||||
type AssignedDeclaration = {
|
||||
id: number;
|
||||
title: string;
|
||||
type: string;
|
||||
client_name: string;
|
||||
status: string;
|
||||
due_date: string | null;
|
||||
priority: string | null;
|
||||
showUrl: string;
|
||||
};
|
||||
|
||||
type NotificationItem = {
|
||||
id: number;
|
||||
title: string;
|
||||
client_name: string;
|
||||
due_date?: string;
|
||||
showUrl: string;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
assignedDeclarations: AssignedDeclaration[];
|
||||
notifications: {
|
||||
overdue: NotificationItem[];
|
||||
due_soon: NotificationItem[];
|
||||
documents_received: NotificationItem[];
|
||||
awaiting_validation: NotificationItem[];
|
||||
};
|
||||
workspaceName: string | null;
|
||||
declarationsUrl: string | null;
|
||||
clientsUrl: string | null;
|
||||
};
|
||||
type Props = DashboardProps;
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
@@ -64,74 +53,53 @@ const breadcrumbs: BreadcrumbItem[] = [
|
||||
|
||||
const hasWorkspace = computed(() => !!props.workspaceName);
|
||||
|
||||
const typeLabels: Record<string, string> = {
|
||||
vat: 'TVA',
|
||||
vat_monthly: 'TVA mensuelle',
|
||||
vat_quarterly: 'TVA trimestrielle',
|
||||
corporate_tax: 'IS',
|
||||
income_tax: 'IR',
|
||||
cnss: 'CNSS',
|
||||
annual_balance: 'Bilan',
|
||||
other: 'Autre',
|
||||
};
|
||||
type DeadlineProximity = 'safe' | 'approaching' | 'urgent' | 'overdue' | 'none';
|
||||
|
||||
const statusLabels: Record<string, string> = {
|
||||
draft: 'Brouillon',
|
||||
waiting_documents: 'En attente documents',
|
||||
documents_received: 'Documents reçus',
|
||||
processing: 'En cours',
|
||||
additional_documents_requested: 'Pièces complémentaires',
|
||||
waiting_client_validation: 'En attente validation',
|
||||
validated: 'Validé',
|
||||
closed: 'Clôturé',
|
||||
cancelled: 'Annulé',
|
||||
};
|
||||
|
||||
const statusVariant: Record<
|
||||
string,
|
||||
'default' | 'secondary' | 'destructive' | 'outline'
|
||||
> = {
|
||||
draft: 'secondary',
|
||||
waiting_documents: 'outline',
|
||||
documents_received: 'default',
|
||||
processing: 'default',
|
||||
additional_documents_requested: 'default',
|
||||
waiting_client_validation: 'outline',
|
||||
validated: 'secondary',
|
||||
closed: 'secondary',
|
||||
cancelled: 'secondary',
|
||||
};
|
||||
|
||||
function statusLabel(s: string): string {
|
||||
return statusLabels[s] ?? s;
|
||||
function deadlineProximity(dueDate: string | null): DeadlineProximity {
|
||||
if (!dueDate) return 'none';
|
||||
const now = new Date();
|
||||
const deadline = new Date(dueDate);
|
||||
const diffDays = Math.ceil(
|
||||
(deadline.getTime() - now.getTime()) / (1000 * 60 * 60 * 24),
|
||||
);
|
||||
if (diffDays < 0) return 'overdue';
|
||||
if (diffDays <= 5) return 'urgent';
|
||||
if (diffDays <= 7) return 'approaching';
|
||||
return 'safe';
|
||||
}
|
||||
|
||||
function typeLabel(t: string): string {
|
||||
return typeLabels[t] ?? t;
|
||||
}
|
||||
|
||||
function progressPercent(status: string): number {
|
||||
const steps: Record<string, number> = {
|
||||
draft: 0,
|
||||
waiting_documents: 10,
|
||||
documents_received: 30,
|
||||
processing: 50,
|
||||
additional_documents_requested: 45,
|
||||
waiting_client_validation: 80,
|
||||
validated: 100,
|
||||
closed: 100,
|
||||
cancelled: 0,
|
||||
function deadlineClass(dueDate: string | null): string {
|
||||
const proximity = deadlineProximity(dueDate);
|
||||
const map: Record<DeadlineProximity, string> = {
|
||||
safe: 'text-green-600',
|
||||
approaching: 'text-amber-600',
|
||||
urgent: 'text-red-600',
|
||||
overdue: 'text-red-600 animate-pulse',
|
||||
none: 'text-muted-foreground',
|
||||
};
|
||||
return steps[status] ?? 50;
|
||||
return map[proximity];
|
||||
}
|
||||
|
||||
const hasAnyNotifications = computed(
|
||||
() =>
|
||||
props.notifications.overdue.length > 0 ||
|
||||
props.notifications.due_soon.length > 0 ||
|
||||
props.notifications.documents_received.length > 0 ||
|
||||
props.notifications.awaiting_validation.length > 0,
|
||||
);
|
||||
function statusBadgeVariant(
|
||||
status: string,
|
||||
): 'default' | 'secondary' | 'destructive' | 'outline' {
|
||||
const map: Record<
|
||||
string,
|
||||
'default' | 'secondary' | 'destructive' | 'outline'
|
||||
> = {
|
||||
created: 'secondary',
|
||||
en_cours: 'default',
|
||||
en_attente_client: 'outline',
|
||||
termine: 'secondary',
|
||||
mise_en_demeure: 'destructive',
|
||||
ferme: 'secondary',
|
||||
};
|
||||
return map[status] ?? 'secondary';
|
||||
}
|
||||
|
||||
function navigateToDeclaration(declaration: DashboardDeclaration): void {
|
||||
router.get(declaration.showUrl);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -141,13 +109,13 @@ const hasAnyNotifications = computed(
|
||||
<div
|
||||
class="flex h-full flex-1 flex-col gap-6 overflow-x-auto rounded-xl p-4"
|
||||
>
|
||||
<!-- Quick links when no workspace -->
|
||||
<!-- Quick links when no workspace (admin view) -->
|
||||
<div
|
||||
v-if="!hasWorkspace"
|
||||
class="grid auto-rows-min gap-4 md:grid-cols-3"
|
||||
>
|
||||
<Link
|
||||
href="/users"
|
||||
:href="usersIndex().url"
|
||||
class="relative flex aspect-video flex-col items-center justify-center gap-2 overflow-hidden rounded-xl border border-sidebar-border/70 transition-colors hover:bg-muted/50 dark:border-sidebar-border"
|
||||
>
|
||||
<Users class="h-8 w-8" />
|
||||
@@ -157,7 +125,7 @@ const hasAnyNotifications = computed(
|
||||
>
|
||||
</Link>
|
||||
<Link
|
||||
href="/workspaces"
|
||||
:href="workspacesIndex().url"
|
||||
class="relative flex aspect-video flex-col items-center justify-center gap-2 overflow-hidden rounded-xl border border-sidebar-border/70 transition-colors hover:bg-muted/50 dark:border-sidebar-border"
|
||||
>
|
||||
<Building2 class="h-8 w-8" />
|
||||
@@ -179,190 +147,27 @@ const hasAnyNotifications = computed(
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="hasWorkspace"
|
||||
class="grid auto-rows-min gap-4 md:grid-cols-3"
|
||||
>
|
||||
<Link
|
||||
v-if="declarationsUrl"
|
||||
:href="declarationsUrl"
|
||||
class="relative flex aspect-video flex-col items-center justify-center gap-2 overflow-hidden rounded-xl border border-sidebar-border/70 transition-colors hover:bg-muted/50 dark:border-sidebar-border"
|
||||
>
|
||||
<FileStack class="h-8 w-8" />
|
||||
<span class="font-medium">Déclarations</span>
|
||||
<span class="text-xs text-muted-foreground"
|
||||
>Gérer les déclarations</span
|
||||
>
|
||||
</Link>
|
||||
<Link
|
||||
v-if="clientsUrl"
|
||||
:href="clientsUrl"
|
||||
class="relative flex aspect-video flex-col items-center justify-center gap-2 overflow-hidden rounded-xl border border-sidebar-border/70 transition-colors hover:bg-muted/50 dark:border-sidebar-border"
|
||||
>
|
||||
<Briefcase class="h-8 w-8" />
|
||||
<span class="font-medium">Clients</span>
|
||||
<span class="text-xs text-muted-foreground"
|
||||
>Manage clients</span
|
||||
>
|
||||
</Link>
|
||||
<div
|
||||
class="relative aspect-video overflow-hidden rounded-xl border border-sidebar-border/70 dark:border-sidebar-border"
|
||||
>
|
||||
<PlaceholderPattern />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Workspace dashboard -->
|
||||
<template v-if="hasWorkspace">
|
||||
<!-- Notifications -->
|
||||
<div v-if="hasAnyNotifications" class="space-y-4">
|
||||
<h2 class="text-lg font-semibold">À traiter</h2>
|
||||
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<Card
|
||||
v-if="notifications.overdue.length > 0"
|
||||
class="border-destructive/50 bg-destructive/5"
|
||||
>
|
||||
<CardHeader class="pb-2">
|
||||
<CardTitle
|
||||
class="flex items-center gap-2 text-base"
|
||||
>
|
||||
<AlertTriangle
|
||||
class="h-4 w-4 text-destructive"
|
||||
/>
|
||||
En retard
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-2">
|
||||
<Link
|
||||
v-for="item in notifications.overdue"
|
||||
:key="item.id"
|
||||
:href="item.showUrl"
|
||||
class="flex items-center justify-between rounded-md p-2 text-sm transition-colors hover:bg-muted/50"
|
||||
>
|
||||
<div class="truncate">
|
||||
<span class="font-medium">{{
|
||||
item.title
|
||||
}}</span>
|
||||
<span
|
||||
class="ml-1 text-muted-foreground"
|
||||
>
|
||||
{{ item.client_name }}
|
||||
</span>
|
||||
</div>
|
||||
<ArrowRight class="h-4 w-4 shrink-0" />
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card
|
||||
v-if="notifications.due_soon.length > 0"
|
||||
class="border-amber-500/50 bg-amber-500/5"
|
||||
>
|
||||
<CardHeader class="pb-2">
|
||||
<CardTitle
|
||||
class="flex items-center gap-2 text-base"
|
||||
>
|
||||
<Clock class="h-4 w-4 text-amber-600" />
|
||||
Échéance proche
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-2">
|
||||
<Link
|
||||
v-for="item in notifications.due_soon"
|
||||
:key="item.id"
|
||||
:href="item.showUrl"
|
||||
class="flex items-center justify-between rounded-md p-2 text-sm transition-colors hover:bg-muted/50"
|
||||
>
|
||||
<div class="truncate">
|
||||
<span class="font-medium">{{
|
||||
item.title
|
||||
}}</span>
|
||||
<span
|
||||
class="ml-1 text-muted-foreground"
|
||||
>
|
||||
{{ item.client_name }} —
|
||||
{{ item.due_date }}
|
||||
</span>
|
||||
</div>
|
||||
<ArrowRight class="h-4 w-4 shrink-0" />
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card
|
||||
v-if="notifications.documents_received.length > 0"
|
||||
class="border-primary/50 bg-primary/5"
|
||||
>
|
||||
<CardHeader class="pb-2">
|
||||
<CardTitle
|
||||
class="flex items-center gap-2 text-base"
|
||||
>
|
||||
<FileCheck class="h-4 w-4 text-primary" />
|
||||
Documents reçus
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-2">
|
||||
<Link
|
||||
v-for="item in notifications.documents_received"
|
||||
:key="item.id"
|
||||
:href="item.showUrl"
|
||||
class="flex items-center justify-between rounded-md p-2 text-sm transition-colors hover:bg-muted/50"
|
||||
>
|
||||
<div class="truncate">
|
||||
<span class="font-medium">{{
|
||||
item.title
|
||||
}}</span>
|
||||
<span
|
||||
class="ml-1 text-muted-foreground"
|
||||
>
|
||||
{{ item.client_name }}
|
||||
</span>
|
||||
</div>
|
||||
<ArrowRight class="h-4 w-4 shrink-0" />
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card
|
||||
v-if="notifications.awaiting_validation.length > 0"
|
||||
class="border-blue-500/50 bg-blue-500/5"
|
||||
>
|
||||
<CardHeader class="pb-2">
|
||||
<CardTitle
|
||||
class="flex items-center gap-2 text-base"
|
||||
>
|
||||
<MessageSquareWarning
|
||||
class="h-4 w-4 text-blue-600"
|
||||
/>
|
||||
En attente validation client
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-2">
|
||||
<Link
|
||||
v-for="item in notifications.awaiting_validation"
|
||||
:key="item.id"
|
||||
:href="item.showUrl"
|
||||
class="flex items-center justify-between rounded-md p-2 text-sm transition-colors hover:bg-muted/50"
|
||||
>
|
||||
<div class="truncate">
|
||||
<span class="font-medium">{{
|
||||
item.title
|
||||
}}</span>
|
||||
<span
|
||||
class="ml-1 text-muted-foreground"
|
||||
>
|
||||
{{ item.client_name }}
|
||||
</span>
|
||||
</div>
|
||||
<ArrowRight class="h-4 w-4 shrink-0" />
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
<!-- 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>
|
||||
|
||||
<!-- My assigned declarations -->
|
||||
<!-- Urgent Declarations Table -->
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-lg font-semibold">
|
||||
Mes déclarations — {{ workspaceName }}
|
||||
Déclarations urgentes
|
||||
</h2>
|
||||
<Button
|
||||
v-if="declarationsUrl"
|
||||
@@ -371,144 +176,117 @@ const hasAnyNotifications = computed(
|
||||
>
|
||||
<Link :href="declarationsUrl">
|
||||
Toutes les déclarations
|
||||
<ArrowRight class="ml-1 h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card
|
||||
v-if="assignedDeclarations.length > 0"
|
||||
v-if="declarations.length > 0"
|
||||
class="overflow-hidden"
|
||||
>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead
|
||||
class="border-b border-sidebar-border/70 bg-muted/50"
|
||||
>
|
||||
<tr>
|
||||
<th
|
||||
class="h-10 px-4 text-left font-medium"
|
||||
>
|
||||
Déclaration / Client
|
||||
</th>
|
||||
<th
|
||||
class="h-10 px-4 text-left font-medium"
|
||||
>
|
||||
Type
|
||||
</th>
|
||||
<th
|
||||
class="h-10 px-4 text-left font-medium"
|
||||
>
|
||||
Statut
|
||||
</th>
|
||||
<th
|
||||
class="h-10 px-4 text-left font-medium"
|
||||
>
|
||||
Progression
|
||||
</th>
|
||||
<th
|
||||
class="h-10 px-4 text-left font-medium"
|
||||
>
|
||||
Date limite
|
||||
</th>
|
||||
<th class="h-10 w-10 px-4"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="declaration in assignedDeclarations"
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Client</TableHead>
|
||||
<TableHead>Type</TableHead>
|
||||
<TableHead>Date limite</TableHead>
|
||||
<TableHead>Assigné à</TableHead>
|
||||
<TableHead>Statut</TableHead>
|
||||
<TableHead class="w-10" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow
|
||||
v-for="declaration in declarations"
|
||||
:key="declaration.id"
|
||||
class="border-b border-sidebar-border/50 transition-colors last:border-0 hover:bg-muted/30"
|
||||
class="cursor-pointer"
|
||||
@click="
|
||||
navigateToDeclaration(declaration)
|
||||
"
|
||||
>
|
||||
<td class="px-4 py-3">
|
||||
<Link
|
||||
:href="declaration.showUrl"
|
||||
class="block font-medium hover:underline"
|
||||
>
|
||||
{{ declaration.title }}
|
||||
</Link>
|
||||
<TableCell class="font-medium">
|
||||
{{ declaration.clientName }}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{{ declaration.typeLabel }}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span
|
||||
class="block text-xs text-muted-foreground"
|
||||
>
|
||||
{{ declaration.client_name }}
|
||||
</span>
|
||||
</td>
|
||||
<td
|
||||
class="px-4 py-3 text-muted-foreground"
|
||||
>
|
||||
{{ typeLabel(declaration.type) }}
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<Badge
|
||||
:variant="
|
||||
statusVariant[
|
||||
declaration.status
|
||||
] ?? 'secondary'
|
||||
:class="
|
||||
deadlineClass(
|
||||
declaration.dueDate,
|
||||
)
|
||||
"
|
||||
>
|
||||
{{
|
||||
statusLabel(
|
||||
{{ declaration.dueDate ?? '—' }}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{{
|
||||
declaration.assigneeName ?? '—'
|
||||
}}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
:variant="
|
||||
statusBadgeVariant(
|
||||
declaration.status,
|
||||
)
|
||||
}}
|
||||
"
|
||||
>
|
||||
{{ declaration.statusLabel }}
|
||||
</Badge>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<div
|
||||
class="flex h-2 w-24 overflow-hidden rounded-full bg-muted"
|
||||
>
|
||||
<div
|
||||
class="h-full bg-primary transition-all"
|
||||
:style="{
|
||||
width: `${progressPercent(declaration.status)}%`,
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
<span
|
||||
class="text-xs text-muted-foreground"
|
||||
>
|
||||
{{
|
||||
progressPercent(
|
||||
declaration.status,
|
||||
)
|
||||
}}%
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<span
|
||||
:class="{
|
||||
'font-medium text-destructive':
|
||||
declaration.due_date &&
|
||||
declaration.due_date <
|
||||
new Date()
|
||||
.toISOString()
|
||||
.slice(0, 10),
|
||||
}"
|
||||
>
|
||||
{{
|
||||
declaration.due_date || '—'
|
||||
}}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
as-child
|
||||
>
|
||||
<Link
|
||||
:href="declaration.showUrl"
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
as-child
|
||||
@click.stop
|
||||
>
|
||||
Voir
|
||||
<ArrowRight
|
||||
class="ml-1 h-3 w-3"
|
||||
/>
|
||||
</Link>
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<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>
|
||||
|
||||
@@ -519,15 +297,9 @@ const hasAnyNotifications = computed(
|
||||
<FolderOpen
|
||||
class="mb-3 h-12 w-12 text-muted-foreground"
|
||||
/>
|
||||
<p class="mb-2 text-muted-foreground">
|
||||
Aucune déclaration ne vous est assignée pour le
|
||||
moment.
|
||||
<p class="text-muted-foreground">
|
||||
Aucune déclaration urgente pour le moment.
|
||||
</p>
|
||||
<Button v-if="declarationsUrl" as-child>
|
||||
<Link :href="declarationsUrl"
|
||||
>Voir toutes les déclarations</Link
|
||||
>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
36
resources/js/types/dashboard.ts
Normal file
36
resources/js/types/dashboard.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
export type DashboardStats = {
|
||||
overdue: number;
|
||||
dueThisWeek: number;
|
||||
enAttenteClient: number;
|
||||
enCours: number;
|
||||
};
|
||||
|
||||
export type DashboardDeclaration = {
|
||||
id: number;
|
||||
title: string;
|
||||
type: string;
|
||||
typeLabel: string;
|
||||
clientName: string;
|
||||
assigneeName: string | null;
|
||||
status: string;
|
||||
statusLabel: string;
|
||||
dueDate: string | null;
|
||||
showUrl: string;
|
||||
};
|
||||
|
||||
export type StatCardLink = {
|
||||
label: string;
|
||||
count: number;
|
||||
status: 'danger' | 'warning' | 'info' | 'success';
|
||||
href: string;
|
||||
};
|
||||
|
||||
export type DashboardProps = {
|
||||
stats: DashboardStats | null;
|
||||
statCards: StatCardLink[];
|
||||
declarations: DashboardDeclaration[];
|
||||
workspaceName: string | null;
|
||||
roleLabel: string | null;
|
||||
declarationsUrl: string | null;
|
||||
clientsUrl: string | null;
|
||||
};
|
||||
@@ -1,4 +1,5 @@
|
||||
export * from './auth';
|
||||
export * from './dashboard';
|
||||
export * from './navigation';
|
||||
export * from './team';
|
||||
export * from './ui';
|
||||
|
||||
Reference in New Issue
Block a user