Files
dsi-commander/resources/js/Pages/Dashboard/Index.vue
jeremy bayse 04fc56cd70 feat: dashboard amélioré, exports budgets, alertes expiration et correctifs
## Dashboard
- Refonte complète du tableau de bord avec widgets budgets, commandes, contrats
- Intégration des données d'exécution budgétaire en temps réel

## Exports & Rapports
- BudgetExecutionExport : export Excel de l'exécution budgétaire
- Template PDF budgets (budgets_pdf.blade.php)
- Routes d'export PDF et Excel

## Alertes & Notifications
- Commande CheckExpirations : détection des contrats/assets arrivant à échéance
- Mail ExpiringElementsMail avec template Blade
- Planification via routes/console.php

## Correctifs
- CommandePolicy et ContratPolicy : ajustements des règles d'autorisation
- ContratController : corrections mineures
- Commande model : ajustements relations/casts
- AuthenticatedLayout : refonte navigation avec icônes budgets
- Assets/Form.vue : corrections formulaire
- Seeder rôles/permissions mis à jour
- Dépendances composer mises à jour (barryvdh/laravel-dompdf, maatwebsite/excel)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 20:20:49 +02:00

282 lines
19 KiB
Vue

<script setup>
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout.vue'
import StatutBadge from '@/Components/Commandes/StatutBadge.vue'
import PrioriteBadge from '@/Components/Commandes/PrioriteBadge.vue'
import { Link, Head } from '@inertiajs/vue3'
const props = defineProps({
stats: Object,
commandesRecentes: Array,
commandesEnRetard: Array,
commandesUrgentes: Array,
statsContrats: Object,
statsDomaines: Object,
budgetsStats: Object,
})
const formatDate = (d) => {
if (!d) return '—'
return new Intl.DateTimeFormat('fr-FR').format(new Date(d))
}
const formatCurrency = (v) => {
if (v == null) return '0 €'
return new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR', maximumFractionDigits: 0 }).format(v)
}
const calculatePercent = (consumed, total) => {
if (!total || total <= 0) return 0
return Math.min(Math.round((consumed / total) * 100), 100)
}
const getProgressBarHexColor = (percent) => {
if (percent < 50) return '#3b82f6' // Blue 500
if (percent < 80) return '#eab308' // Yellow 500
if (percent < 100) return '#f97316' // Orange 500
return '#ef4444' // Red 500
}
</script>
<template>
<Head title="Tableau de bord" />
<AuthenticatedLayout>
<template #header>
<div class="flex items-center justify-between">
<h1 class="text-2xl font-bold text-gray-900 tracking-tight">Tableau de bord <span class="text-gray-400 font-normal">/ {{ new Date().getFullYear() }}</span></h1>
<div class="flex items-center gap-2">
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
Système Opérationnel
</span>
</div>
</div>
</template>
<div class="space-y-8 pb-10">
<!-- Top Stats Row -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<div class="bg-white p-6 rounded-2xl shadow-sm border border-gray-100">
<p class="text-sm font-medium text-gray-500">Commandes en cours</p>
<div class="mt-2 flex items-baseline gap-2">
<span class="text-3xl font-bold text-gray-900">{{ stats.en_cours }}</span>
<span class="text-sm text-red-600 font-medium" v-if="stats.en_retard > 0">{{ stats.en_retard }} en retard</span>
</div>
</div>
<div class="bg-white p-6 rounded-2xl shadow-sm border border-gray-100">
<p class="text-sm font-medium text-gray-500">Budget Consommé (Agglo)</p>
<div class="mt-2 flex items-baseline gap-2">
<span class="text-3xl font-bold text-blue-600">{{ formatCurrency(budgetsStats.agglo.consomme) }}</span>
<span class="text-xs text-gray-400">/ {{ formatCurrency(budgetsStats.agglo.total_arbitre) }}</span>
</div>
</div>
<div class="bg-white p-6 rounded-2xl shadow-sm border border-gray-100">
<p class="text-sm font-medium text-gray-500">Budget Consommé (Mutualisé)</p>
<div class="mt-2 flex items-baseline gap-2">
<span class="text-3xl font-bold text-indigo-600">{{ formatCurrency(budgetsStats.mutualise.consomme) }}</span>
<span class="text-xs text-gray-400">/ {{ formatCurrency(budgetsStats.mutualise.total_arbitre) }}</span>
</div>
</div>
<div class="bg-white p-6 rounded-2xl shadow-sm border border-gray-100">
<p class="text-sm font-medium text-gray-500">Contrats à renouveler</p>
<div class="mt-2 flex items-baseline gap-2">
<span class="text-3xl font-bold text-orange-600">{{ statsContrats.proches + statsContrats.en_retard }}</span>
<span class="text-sm text-gray-400">échéances</span>
</div>
</div>
</div>
<!-- Budget Gauges -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- Agglo Card -->
<div class="bg-white p-6 rounded-3xl shadow-sm border border-gray-100">
<h3 class="font-bold text-gray-900 mb-6 flex items-center gap-2">
<span class="w-2 h-2 rounded-full bg-blue-500"></span>
Exécution Agglomération
</h3>
<div class="grid grid-cols-2 gap-8">
<!-- Fonctionnement -->
<div class="flex flex-col items-center">
<div class="relative w-full h-24 flex items-center justify-center">
<svg class="w-full h-full" viewBox="0 0 100 60">
<path d="M 12 55 A 38 38 0 0 1 88 55" fill="#edf2f7" stroke="#2d3748" stroke-width="1.5" />
<path d="M 12 55 A 38 38 0 0 1 31 22 L 50 55 Z" fill="#48bb78" />
<path d="M 31 22 A 38 38 0 0 1 69 22 L 50 55 Z" fill="#ecc94b" />
<path d="M 69 22 A 38 38 0 0 1 88 55 L 50 55 Z" fill="#f6ad55" />
<g :style="{ transform: `rotate(${(calculatePercent(budgetsStats.agglo.fonctionnement.consomme, budgetsStats.agglo.fonctionnement.total_arbitre) * 1.8) - 90}deg)`, transformOrigin: '50px 55px' }" class="transition-transform duration-1000 ease-out">
<path d="M 48.5 55 L 50 20 L 51.5 55 Z" fill="#e53e3e" stroke="#2d3748" stroke-width="0.3" />
<circle cx="50" cy="55" r="2" fill="#2d3748" />
</g>
</svg>
<div class="absolute -bottom-1 left-0 flex items-baseline gap-1">
<span class="text-xl font-black text-gray-900">{{ calculatePercent(budgetsStats.agglo.fonctionnement.consomme, budgetsStats.agglo.fonctionnement.total_arbitre) }}%</span>
</div>
</div>
<div class="mt-4 w-full">
<p class="text-[10px] uppercase font-black text-gray-400 tracking-widest leading-none mb-1">Fonctionnement</p>
<p class="text-sm font-bold text-gray-900">{{ formatCurrency(budgetsStats.agglo.fonctionnement.consomme) }}</p>
</div>
</div>
<!-- Investissement -->
<div class="flex flex-col items-center">
<div class="relative w-full h-24 flex items-center justify-center">
<svg class="w-full h-full" viewBox="0 0 100 60">
<path d="M 12 55 A 38 38 0 0 1 88 55" fill="#edf2f7" stroke="#2d3748" stroke-width="1.5" />
<path d="M 12 55 A 38 38 0 0 1 31 22 L 50 55 Z" fill="#48bb78" />
<path d="M 31 22 A 38 38 0 0 1 69 22 L 50 55 Z" fill="#ecc94b" />
<path d="M 69 22 A 38 38 0 0 1 88 55 L 50 55 Z" fill="#f6ad55" />
<g :style="{ transform: `rotate(${(calculatePercent(budgetsStats.agglo.investissement.consomme, budgetsStats.agglo.investissement.total_arbitre) * 1.8) - 90}deg)`, transformOrigin: '50px 55px' }" class="transition-transform duration-1000 ease-out">
<path d="M 48.5 55 L 50 20 L 51.5 55 Z" fill="#e53e3e" stroke="#2d3748" stroke-width="0.3" />
<circle cx="50" cy="55" r="2" fill="#2d3748" />
</g>
</svg>
<div class="absolute -bottom-1 left-0 flex items-baseline gap-1">
<span class="text-xl font-black text-gray-900">{{ calculatePercent(budgetsStats.agglo.investissement.consomme, budgetsStats.agglo.investissement.total_arbitre) }}%</span>
</div>
</div>
<div class="mt-4 w-full">
<p class="text-[10px] uppercase font-black text-gray-400 tracking-widest leading-none mb-1">Investissement</p>
<p class="text-sm font-bold text-gray-900">{{ formatCurrency(budgetsStats.agglo.investissement.consomme) }}</p>
</div>
</div>
</div>
</div>
<!-- Mutualise Card -->
<div class="bg-white p-6 rounded-3xl shadow-sm border border-gray-100">
<h3 class="font-bold text-gray-900 mb-6 flex items-center gap-2">
<span class="w-2 h-2 rounded-full bg-indigo-500"></span>
Exécution Mutualisation
</h3>
<div class="grid grid-cols-2 gap-8">
<!-- Fonctionnement -->
<div class="flex flex-col items-center">
<div class="relative w-full h-24 flex items-center justify-center">
<svg class="w-full h-full" viewBox="0 0 100 60">
<path d="M 12 55 A 38 38 0 0 1 88 55" fill="#edf2f7" stroke="#2d3748" stroke-width="1.5" />
<path d="M 12 55 A 38 38 0 0 1 31 22 L 50 55 Z" fill="#48bb78" />
<path d="M 31 22 A 38 38 0 0 1 69 22 L 50 55 Z" fill="#ecc94b" />
<path d="M 69 22 A 38 38 0 0 1 88 55 L 50 55 Z" fill="#f6ad55" />
<g :style="{ transform: `rotate(${(calculatePercent(budgetsStats.mutualise.fonctionnement.consomme, budgetsStats.mutualise.fonctionnement.total_arbitre) * 1.8) - 90}deg)`, transformOrigin: '50px 55px' }" class="transition-transform duration-1000 ease-out">
<path d="M 48.5 55 L 50 20 L 51.5 55 Z" fill="#e53e3e" stroke="#2d3748" stroke-width="0.3" />
<circle cx="50" cy="55" r="2" fill="#2d3748" />
</g>
</svg>
<div class="absolute -bottom-1 left-0 flex items-baseline gap-1">
<span class="text-xl font-black text-gray-900">{{ calculatePercent(budgetsStats.mutualise.fonctionnement.consomme, budgetsStats.mutualise.fonctionnement.total_arbitre) }}%</span>
</div>
</div>
<div class="mt-4 w-full">
<p class="text-[10px] uppercase font-black text-gray-400 tracking-widest leading-none mb-1">Fonctionnement</p>
<p class="text-sm font-bold text-gray-900">{{ formatCurrency(budgetsStats.mutualise.fonctionnement.consomme) }}</p>
</div>
</div>
<!-- Investissement -->
<div class="flex flex-col items-center">
<div class="relative w-full h-24 flex items-center justify-center">
<svg class="w-full h-full" viewBox="0 0 100 60">
<path d="M 12 55 A 38 38 0 0 1 88 55" fill="#edf2f7" stroke="#2d3748" stroke-width="1.5" />
<path d="M 12 55 A 38 38 0 0 1 31 22 L 50 55 Z" fill="#48bb78" />
<path d="M 31 22 A 38 38 0 0 1 69 22 L 50 55 Z" fill="#ecc94b" />
<path d="M 69 22 A 38 38 0 0 1 88 55 L 50 55 Z" fill="#f6ad55" />
<g :style="{ transform: `rotate(${(calculatePercent(budgetsStats.mutualise.investissement.consomme, budgetsStats.mutualise.investissement.total_arbitre) * 1.8) - 90}deg)`, transformOrigin: '50px 55px' }" class="transition-transform duration-1000 ease-out">
<path d="M 48.5 55 L 50 20 L 51.5 55 Z" fill="#e53e3e" stroke="#2d3748" stroke-width="0.3" />
<circle cx="50" cy="55" r="2" fill="#2d3748" />
</g>
</svg>
<div class="absolute -bottom-1 left-0 flex items-baseline gap-1">
<span class="text-xl font-black text-gray-900">{{ calculatePercent(budgetsStats.mutualise.investissement.consomme, budgetsStats.mutualise.investissement.total_arbitre) }}%</span>
</div>
</div>
<div class="mt-4 w-full">
<p class="text-[10px] uppercase font-black text-gray-400 tracking-widest leading-none mb-1">Investissement</p>
<p class="text-sm font-bold text-gray-900">{{ formatCurrency(budgetsStats.mutualise.investissement.consomme) }}</p>
</div>
</div>
</div>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<!-- Main Content: Recent Orders -->
<div class="lg:col-span-2 space-y-6">
<div class="bg-white rounded-2xl shadow-sm border border-gray-100 overflow-hidden">
<div class="px-6 py-4 border-b border-gray-50 flex items-center justify-between">
<h3 class="font-bold text-gray-900">Dernières commandes</h3>
<Link :href="route('commandes.index')" class="text-sm text-blue-600 font-medium hover:underline">Voir tout</Link>
</div>
<div class="divide-y divide-gray-50">
<div v-for="cmd in commandesRecentes" :key="cmd.id" class="px-6 py-4 hover:bg-gray-50 transition-colors flex items-center justify-between">
<div class="flex items-center gap-4">
<div class="p-2 rounded-lg bg-gray-50">
<svg class="h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 11V7a4 4 0 00-8 0v4M5 9h14l1 12H4L5 9z" /></svg>
</div>
<div>
<Link :href="route('commandes.show', cmd.id)" class="text-sm font-bold text-gray-900 hover:text-blue-600">{{ cmd.numero_commande }}</Link>
<p class="text-xs text-gray-500 truncate max-w-[200px]">{{ cmd.objet }}</p>
</div>
</div>
<div class="flex items-center gap-4">
<StatutBadge :statut="cmd.statut" size="sm" />
<span class="text-sm font-bold text-gray-900 min-w-[80px] text-right">{{ formatCurrency(cmd.montant_ttc) }}</span>
</div>
</div>
</div>
</div>
</div>
<!-- Sidebar: Action alerts -->
<div class="space-y-6">
<!-- Urgent/Late Orders -->
<div class="bg-white rounded-2xl shadow-sm border border-gray-100 border-l-4 border-l-red-500 overflow-hidden">
<div class="px-5 py-4 border-b border-gray-50">
<h3 class="font-bold text-gray-900 text-sm">Alertes prioritaires</h3>
</div>
<div class="p-2">
<div v-for="cmd in commandesEnRetard" :key="cmd.id" class="p-3 mb-1 rounded-xl bg-red-50 hover:bg-red-100 transition-colors">
<Link :href="route('commandes.show', cmd.id)" class="block">
<div class="flex justify-between items-start mb-1">
<span class="text-xs font-bold text-red-700">{{ cmd.numero_commande }}</span>
<span class="text-[10px] uppercase font-bold text-red-600">Retard</span>
</div>
<p class="text-xs text-red-800 line-clamp-1">{{ cmd.objet }}</p>
</Link>
</div>
<div v-if="!commandesEnRetard.length" class="p-10 text-center">
<div class="inline-flex p-3 rounded-full bg-green-50 text-green-600 mb-2">
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" /></svg>
</div>
<p class="text-sm text-gray-500 font-medium">Tout est à jour !</p>
</div>
</div>
</div>
<!-- Contract Expiry -->
<div class="bg-white rounded-2xl shadow-sm border border-gray-100 overflow-hidden">
<div class="px-5 py-4 border-b border-gray-50">
<h3 class="font-bold text-gray-900 text-sm">Échéancier Contrats</h3>
</div>
<div class="p-5 space-y-4">
<div class="flex items-center justify-between">
<span class="text-xs text-gray-500">Expirés</span>
<span class="px-2 py-0.5 rounded-full bg-red-100 text-red-700 text-xs font-bold">{{ statsContrats.en_retard }}</span>
</div>
<div class="flex items-center justify-between">
<span class="text-xs text-gray-500">À renouveler (90j)</span>
<span class="px-2 py-0.5 rounded-full bg-orange-100 text-orange-700 text-xs font-bold">{{ statsContrats.proches }}</span>
</div>
<Link :href="route('contrats.index')" class="block text-center py-2 text-xs font-bold text-indigo-600 bg-indigo-50 rounded-lg hover:bg-indigo-100">Gérer les contrats</Link>
</div>
</div>
</div>
</div>
</div>
</AuthenticatedLayout>
</template>