feat: module budgets complet avec sécurité, performance et métier
## Fonctionnalités - Module Budgets : enveloppes, lignes budgétaires, arbitrage DSI/Direction - Suivi de l'exécution budgétaire avec alertes visuelles (dépassement, seuil 80%) - Blocage des commandes si budget insuffisant (store + update) - Audit trail complet des arbitrages via HistoriqueBudget - Page d'index budgets refaite en tableau avec filtres et tri côté client - Page Services avec sélecteur d'icônes FontAwesome (solid + regular + brands) ## Sécurité - BudgetPolicy centralisée (viewAny, view, create, update, addLigne, updateLigne, deleteLigne, arbitrerLigne) - Autorisation sur tous les endpoints LigneBudget et Budget - Protection XSS : remplacement v-html par classes dynamiques - Validation des paramètres d'export (type, envelope) - Validation montant_arbitre ≤ montant_propose côté serveur ## Performance - Eager loading lignes.commandes.commune dans execution() et exportPdf() - Calculs montant_consomme/engage en mémoire sur collections déjà chargées - Null-safety sur montant_arbitre dans getMontantDisponibleAttribute ## Technique - Migration historique_budgets, budgets, ligne_budgets, rôle raf - SearchableSelect avec affichage du disponible budgétaire - FontAwesome enregistré globalement (fas, far, fab) - 33 tests Feature (sécurité, performance, métier) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
298
resources/js/Pages/Budgets/Execution.vue
Normal file
298
resources/js/Pages/Budgets/Execution.vue
Normal file
@@ -0,0 +1,298 @@
|
||||
<script setup>
|
||||
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout.vue'
|
||||
import { Head, Link, router } from '@inertiajs/vue3'
|
||||
import { ref, watch, computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
lignes: Array,
|
||||
services: Array,
|
||||
filters: Object
|
||||
})
|
||||
|
||||
const annee = ref(props.filters.annee)
|
||||
|
||||
const formatCurrency = (v) => {
|
||||
return new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(v)
|
||||
}
|
||||
|
||||
const calculatePercent = (consumed, total) => {
|
||||
if (!total || total <= 0) return 0
|
||||
return Math.min(Math.round((consumed / total) * 100), 100)
|
||||
}
|
||||
|
||||
const getProgressBarColor = (percent) => {
|
||||
if (percent < 50) return 'bg-blue-500'
|
||||
if (percent < 80) return 'bg-yellow-500'
|
||||
if (percent < 100) return 'bg-orange-500'
|
||||
return 'bg-red-500'
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
watch(annee, (val) => {
|
||||
router.get(route('budgets.execution'), { annee: val }, { preserveState: true })
|
||||
})
|
||||
|
||||
const filterType = ref('all')
|
||||
const filterEnvelope = ref('all')
|
||||
const filterService = ref('all')
|
||||
|
||||
const expandedLines = ref([])
|
||||
|
||||
const toggleLine = (id) => {
|
||||
if (expandedLines.value.includes(id)) {
|
||||
expandedLines.value = expandedLines.value.filter(i => i !== id)
|
||||
} else {
|
||||
expandedLines.value.push(id)
|
||||
}
|
||||
}
|
||||
|
||||
const lignesEnDepassement = computed(() => props.lignes.filter(l => l.reste < 0))
|
||||
|
||||
const filteredLignes = computed(() => {
|
||||
return props.lignes.filter(l => {
|
||||
const matchType = filterType.value === 'all' || l.type_depense === filterType.value
|
||||
const matchEnvelope = filterEnvelope.value === 'all' || l.type_budget === filterEnvelope.value
|
||||
const matchService = filterService.value === 'all' || l.service === filterService.value
|
||||
return matchType && matchEnvelope && matchService
|
||||
})
|
||||
})</script>
|
||||
|
||||
<template>
|
||||
<Head title="Exécution Budgétaire" />
|
||||
|
||||
<AuthenticatedLayout>
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900 tracking-tight">Exécution Budgétaire</h1>
|
||||
<p class="text-sm text-gray-500 mt-1">Suivi du consommé (TTC) par rapport aux enveloppes arbitrées.</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="relative group">
|
||||
<button class="flex items-center gap-2 px-4 py-2 bg-white border border-gray-200 rounded-lg text-sm font-bold text-gray-700 hover:bg-gray-50 transition-colors shadow-sm">
|
||||
<svg class="h-4 w-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /></svg>
|
||||
Exporter
|
||||
<svg class="h-4 w-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" /></svg>
|
||||
</button>
|
||||
<div class="absolute right-0 mt-2 w-48 bg-white border border-gray-100 rounded-xl shadow-xl opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all z-50 py-2">
|
||||
<a :href="route('budgets.export.excel', { annee: annee, type: filterType, envelope: filterEnvelope, service: filterService })" class="flex items-center gap-3 px-4 py-2 text-sm text-gray-700 hover:bg-blue-50 hover:text-blue-700 transition-colors">
|
||||
<span class="p-1.5 bg-green-100 text-green-700 rounded-lg">
|
||||
<svg class="h-4 w-4" fill="currentColor" viewBox="0 0 24 24"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8l-6-6zM13 3.5L18.5 9H13V3.5zM6 20V4h6V10h6v10H6z"/></svg>
|
||||
</span>
|
||||
Excel (.xlsx)
|
||||
</a>
|
||||
<a :href="route('budgets.export.pdf', { annee: annee, type: filterType, envelope: filterEnvelope, service: filterService })" class="flex items-center gap-3 px-4 py-2 text-sm text-gray-700 hover:bg-red-50 hover:text-red-700 transition-colors">
|
||||
<span class="p-1.5 bg-red-100 text-red-700 rounded-lg">
|
||||
<svg class="h-4 w-4" fill="currentColor" viewBox="0 0 24 24"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 14h-2v-2h2v2zm0-4h-2V7h2v5z"/></svg>
|
||||
</span>
|
||||
PDF (.pdf)
|
||||
</a>
|
||||
<a :href="route('budgets.export.ods', { annee: annee, type: filterType, envelope: filterEnvelope, service: filterService })" class="flex items-center gap-3 px-4 py-2 text-sm text-gray-700 hover:bg-indigo-50 hover:text-indigo-700 transition-colors">
|
||||
<span class="p-1.5 bg-indigo-100 text-indigo-700 rounded-lg">
|
||||
<svg class="h-4 w-4" fill="currentColor" viewBox="0 0 24 24"><path d="M14 2H6c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V8l-6-6zm-1 7V3.5L18.5 9H13z"/></svg>
|
||||
</span>
|
||||
OpenDoc (.ods)
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<select v-model="annee" class="rounded-lg border-gray-300 text-sm font-bold text-gray-700 focus:ring-blue-500 focus:border-blue-500 shadow-sm">
|
||||
<option :value="new Date().getFullYear() + 1">{{ new Date().getFullYear() + 1 }}</option>
|
||||
<option :value="new Date().getFullYear()">{{ new Date().getFullYear() }}</option>
|
||||
<option :value="new Date().getFullYear() - 1">{{ new Date().getFullYear() - 1 }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="space-y-6">
|
||||
<!-- Filters -->
|
||||
<div class="flex flex-wrap items-center gap-4 bg-white p-4 rounded-xl shadow-sm border border-gray-100">
|
||||
<div class="flex items-center gap-2">
|
||||
<label class="text-xs font-bold text-gray-500 uppercase">Type :</label>
|
||||
<select v-model="filterType" class="rounded-lg border-gray-200 text-xs focus:ring-blue-500 focus:border-blue-500">
|
||||
<option value="all">Tous les types</option>
|
||||
<option value="investissement">Investissement</option>
|
||||
<option value="fonctionnement">Fonctionnement</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<label class="text-xs font-bold text-gray-500 uppercase">Enveloppe :</label>
|
||||
<select v-model="filterEnvelope" class="rounded-lg border-gray-200 text-xs focus:ring-blue-500 focus:border-blue-500">
|
||||
<option value="all">Toutes les enveloppes</option>
|
||||
<option value="agglo">Agglo</option>
|
||||
<option value="mutualise">Mutualisé</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<label class="text-xs font-bold text-gray-500 uppercase">Service :</label>
|
||||
<select v-model="filterService" class="rounded-lg border-gray-200 text-xs focus:ring-blue-500 focus:border-blue-500 max-w-[150px]">
|
||||
<option value="all">Tous les services</option>
|
||||
<option v-for="s in services" :key="s.id" :value="s.nom">{{ s.nom }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="ml-auto flex items-center gap-3">
|
||||
<span v-if="lignesEnDepassement.length" class="flex items-center gap-1.5 px-3 py-1.5 bg-red-50 border border-red-200 rounded-lg text-xs font-semibold text-red-700">
|
||||
<svg class="h-3.5 w-3.5" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd"/></svg>
|
||||
{{ lignesEnDepassement.length }} dépassement(s)
|
||||
</span>
|
||||
<span class="text-xs text-gray-400">{{ filteredLignes.length }} ligne(s) affichée(s)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Table of execution -->
|
||||
<div class="bg-white rounded-2xl shadow-sm border border-gray-100 overflow-hidden">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-100">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-6 py-4 text-left text-xs font-bold text-gray-500 uppercase tracking-wider">Service / Ligne</th>
|
||||
<th class="px-6 py-4 text-center text-xs font-bold text-gray-500 uppercase tracking-wider">Type</th>
|
||||
<th class="px-6 py-4 text-right text-xs font-bold text-gray-500 uppercase tracking-wider">Enveloppe</th>
|
||||
<th class="px-6 py-4 text-right text-xs font-bold text-gray-500 uppercase tracking-wider">Engagé (Brouillon)</th>
|
||||
<th class="px-6 py-4 text-right text-xs font-bold text-gray-500 uppercase tracking-wider">Consommé (Validé)</th>
|
||||
<th class="px-6 py-4 text-right text-xs font-bold text-gray-500 uppercase tracking-wider">Reste à dépenser</th>
|
||||
<th class="px-6 py-4 text-left text-xs font-bold text-gray-500 uppercase tracking-wider w-40">Progression</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-50">
|
||||
<template v-for="lb in filteredLignes" :key="lb.id">
|
||||
<tr :class="['transition-colors cursor-pointer', lb.reste < 0 ? 'bg-red-50 hover:bg-red-100' : 'hover:bg-gray-50']" @click="toggleLine(lb.id)">
|
||||
<td class="px-6 py-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<svg :class="['h-4 w-4 text-gray-400 transition-transform', expandedLines.includes(lb.id) ? 'rotate-180' : '']" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7 7" />
|
||||
</svg>
|
||||
<div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm font-bold text-gray-900">{{ lb.nom }}</span>
|
||||
<span v-if="lb.reste < 0" class="inline-flex items-center gap-1 px-1.5 py-0.5 bg-red-100 text-red-700 text-[10px] font-bold rounded uppercase">
|
||||
⚠ Dépassement {{ formatCurrency(Math.abs(lb.reste)) }}
|
||||
</span>
|
||||
<span v-else-if="lb.montant_arbitre > 0 && calculatePercent(lb.total_cumule, lb.montant_arbitre) >= 80" class="inline-flex items-center gap-1 px-1.5 py-0.5 bg-orange-100 text-orange-700 text-[10px] font-bold rounded uppercase">
|
||||
⚡ {{ calculatePercent(lb.total_cumule, lb.montant_arbitre) }}%
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-xs text-gray-500">{{ lb.service }} • {{ lb.type_budget === 'agglo' ? 'Agglo' : 'Mutualisé' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 text-center">
|
||||
<span :class="['inline-flex items-center px-2 py-0.5 rounded text-[10px] font-bold uppercase tracking-wider',
|
||||
lb.type_depense === 'investissement' ? 'bg-purple-100 text-purple-700' : 'bg-teal-100 text-teal-700']">
|
||||
{{ lb.type_depense }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 text-right text-sm font-medium text-gray-900">
|
||||
{{ formatCurrency(lb.montant_arbitre) }}
|
||||
</td>
|
||||
<td class="px-6 py-4 text-right text-sm font-medium text-orange-600">
|
||||
{{ formatCurrency(lb.engage) }}
|
||||
</td>
|
||||
<td class="px-6 py-4 text-right text-sm font-medium text-blue-600">
|
||||
{{ formatCurrency(lb.consomme) }}
|
||||
</td>
|
||||
<td class="px-6 py-4 text-right text-sm font-bold" :class="lb.reste < 0 ? 'text-red-600' : 'text-green-600'">
|
||||
{{ formatCurrency(lb.reste) }}
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<div class="flex items-center justify-end gap-3">
|
||||
<span class="text-sm font-black text-gray-900 leading-none">{{ calculatePercent(lb.total_cumule, lb.montant_arbitre) }}%</span>
|
||||
<div class="relative w-14 h-8 flex items-center justify-center">
|
||||
<svg class="w-full h-full" viewBox="0 0 100 60">
|
||||
<!-- Cadre -->
|
||||
<path d="M 12 55 A 38 38 0 0 1 88 55" fill="#edf2f7" stroke="#2d3748" stroke-width="2" />
|
||||
<!-- Secteurs -->
|
||||
<path d="M 12 55 A 38 38 0 0 1 50 17 L 50 55 Z" fill="#48bb78" opacity="0.6" />
|
||||
<path d="M 50 17 A 38 38 0 0 1 88 55 L 50 55 Z" fill="#f6ad55" opacity="0.6" />
|
||||
<!-- Aiguille -->
|
||||
<g :style="{ transform: `rotate(${(calculatePercent(lb.total_cumule, lb.montant_arbitre) * 1.8) - 90}deg)`, transformOrigin: '50px 55px' }" class="transition-transform duration-1000">
|
||||
<line x1="50" y1="55" x2="50" y2="22" stroke="#e53e3e" stroke-width="4" stroke-linecap="round" />
|
||||
<circle cx="50" cy="55" r="4" fill="#2d3748" />
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<!-- Détail ventilation par commune et liste commandes -->
|
||||
<tr v-if="expandedLines.includes(lb.id)" class="bg-gray-50/50">
|
||||
<td colspan="7" class="px-12 py-6">
|
||||
<div class="space-y-6">
|
||||
<!-- Ventilation -->
|
||||
<div class="bg-white rounded-lg border border-gray-100 shadow-sm p-4 max-w-4xl">
|
||||
<h4 class="text-xs font-bold text-gray-500 uppercase mb-3 px-1">Répartition par commune</h4>
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div v-for="v in lb.ventilation" :key="v.commune" class="flex flex-col border-l-2 border-indigo-200 pl-3">
|
||||
<span class="text-[10px] text-gray-500 truncate" :title="v.commune">{{ v.commune }}</span>
|
||||
<span class="text-sm font-bold text-gray-900">{{ formatCurrency(v.total) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Liste des commandes -->
|
||||
<div class="bg-white rounded-lg border border-gray-100 shadow-sm overflow-hidden">
|
||||
<div class="px-4 py-3 bg-gray-50 border-b border-gray-100 flex justify-between items-center">
|
||||
<h4 class="text-xs font-bold text-gray-500 uppercase">Détail des commandes liées</h4>
|
||||
<span class="text-[10px] font-bold text-gray-400 bg-gray-100 px-2 py-0.5 rounded-full">{{ lb.commandes.length }} commande(s)</span>
|
||||
</div>
|
||||
<table class="min-w-full divide-y divide-gray-100">
|
||||
<thead class="bg-gray-50/50">
|
||||
<tr>
|
||||
<th class="px-4 py-2 text-left text-[10px] font-bold text-gray-400 uppercase">Réf / Objet</th>
|
||||
<th class="px-4 py-2 text-left text-[10px] font-bold text-gray-400 uppercase">Commune</th>
|
||||
<th class="px-4 py-2 text-center text-[10px] font-bold text-gray-400 uppercase">Statut</th>
|
||||
<th class="px-4 py-2 text-right text-[10px] font-bold text-gray-400 uppercase">Montant TTC</th>
|
||||
<th class="px-4 py-2"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-50">
|
||||
<tr v-for="cmd in lb.commandes" :key="cmd.id" class="hover:bg-gray-50 transition-colors">
|
||||
<td class="px-4 py-3">
|
||||
<div class="text-xs font-bold text-indigo-600">{{ cmd.reference }}</div>
|
||||
<div class="text-[10px] text-gray-500 truncate max-w-[200px]" :title="cmd.nom">{{ cmd.nom }}</div>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-xs text-gray-600 italic">
|
||||
{{ cmd.commune || '—' }}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-center">
|
||||
<span class="text-[9px] font-bold uppercase tracking-tight px-1.5 py-0.5 rounded bg-gray-100 text-gray-600">
|
||||
{{ cmd.statut.replace('_', ' ') }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-right text-xs font-bold text-gray-900">
|
||||
{{ formatCurrency(cmd.montant_ttc) }}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-right">
|
||||
<Link :href="route('commandes.show', cmd.id)" class="text-indigo-500 hover:text-indigo-700">
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" /></svg>
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="!lb.commandes.length">
|
||||
<td colspan="5" class="px-4 py-8 text-center text-xs text-gray-400 italic">Aucune commande liée.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
<tr v-if="!filteredLignes.length">
|
||||
<td colspan="7" class="px-6 py-12 text-center text-gray-500 italic">
|
||||
Aucune ligne budgétaire trouvée pour cette année.
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AuthenticatedLayout>
|
||||
</template>
|
||||
Reference in New Issue
Block a user