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:
350
resources/js/Pages/Budgets/Index.vue
Normal file
350
resources/js/Pages/Budgets/Index.vue
Normal file
@@ -0,0 +1,350 @@
|
||||
<script setup>
|
||||
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout.vue'
|
||||
import { Head, Link, useForm, router } from '@inertiajs/vue3'
|
||||
import Modal from '@/Components/Modal.vue'
|
||||
import InputLabel from '@/Components/InputLabel.vue'
|
||||
import PrimaryButton from '@/Components/PrimaryButton.vue'
|
||||
import SecondaryButton from '@/Components/SecondaryButton.vue'
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
budgets: Array,
|
||||
services: Array,
|
||||
communes: Array,
|
||||
filters: Object
|
||||
})
|
||||
|
||||
const yearFilter = ref(props.filters.annee || new Date().getFullYear())
|
||||
|
||||
function filterByYear() {
|
||||
router.get(route('budgets.index'), { annee: yearFilter.value }, { preserveState: true })
|
||||
}
|
||||
|
||||
const showCreateModal = ref(false)
|
||||
const form = useForm({
|
||||
service_id: '',
|
||||
annee: new Date().getFullYear(),
|
||||
type_budget: 'agglo',
|
||||
})
|
||||
|
||||
function submitBudget() {
|
||||
form.post(route('budgets.store'), {
|
||||
onSuccess: () => {
|
||||
showCreateModal.value = false
|
||||
form.reset()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function formatCurrency(value) {
|
||||
if (!value && value !== 0) return '—'
|
||||
return new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR', maximumFractionDigits: 0 }).format(value)
|
||||
}
|
||||
|
||||
const STATUS_LABELS = {
|
||||
preparation: 'En préparation',
|
||||
arbitrage_dsi: 'Arbitrage DSI',
|
||||
arbitrage_direction: 'Arbitrage Direction',
|
||||
valide: 'Validé',
|
||||
cloture: 'Clôturé',
|
||||
}
|
||||
|
||||
const STATUS_CLASSES = {
|
||||
preparation: 'bg-gray-100 text-gray-700',
|
||||
arbitrage_dsi: 'bg-yellow-100 text-yellow-800',
|
||||
arbitrage_direction: 'bg-orange-100 text-orange-800',
|
||||
valide: 'bg-green-100 text-green-800',
|
||||
cloture: 'bg-blue-100 text-blue-800',
|
||||
}
|
||||
|
||||
// Filtres locaux
|
||||
const filterService = ref('')
|
||||
const filterType = ref('')
|
||||
const filterStatut = ref('')
|
||||
const sortKey = ref('service')
|
||||
const sortDir = ref('asc')
|
||||
|
||||
function toggleSort(key) {
|
||||
if (sortKey.value === key) {
|
||||
sortDir.value = sortDir.value === 'asc' ? 'desc' : 'asc'
|
||||
} else {
|
||||
sortKey.value = key
|
||||
sortDir.value = 'asc'
|
||||
}
|
||||
}
|
||||
|
||||
const filtered = computed(() => {
|
||||
let list = props.budgets ?? []
|
||||
|
||||
if (filterService.value)
|
||||
list = list.filter(b => b.service === filterService.value)
|
||||
if (filterType.value)
|
||||
list = list.filter(b => b.type_budget === filterType.value)
|
||||
if (filterStatut.value)
|
||||
list = list.filter(b => b.statut === filterStatut.value)
|
||||
|
||||
const dir = sortDir.value === 'asc' ? 1 : -1
|
||||
list = [...list].sort((a, b) => {
|
||||
let va, vb
|
||||
if (sortKey.value === 'service') { va = a.service; vb = b.service }
|
||||
else if (sortKey.value === 'type') { va = a.type_budget; vb = b.type_budget }
|
||||
else if (sortKey.value === 'statut') { va = a.statut; vb = b.statut }
|
||||
else if (sortKey.value === 'invest') { va = a.total_invest_arbitre ?? 0; vb = b.total_invest_arbitre ?? 0 }
|
||||
else if (sortKey.value === 'fonct') { va = a.total_fonct_arbitre ?? 0; vb = b.total_fonct_arbitre ?? 0 }
|
||||
else if (sortKey.value === 'total') {
|
||||
va = (a.total_invest_arbitre ?? 0) + (a.total_fonct_arbitre ?? 0)
|
||||
vb = (b.total_invest_arbitre ?? 0) + (b.total_fonct_arbitre ?? 0)
|
||||
}
|
||||
if (typeof va === 'string') return va.localeCompare(vb) * dir
|
||||
return (va - vb) * dir
|
||||
})
|
||||
|
||||
return list
|
||||
})
|
||||
|
||||
const uniqueServices = computed(() => [...new Set((props.budgets ?? []).map(b => b.service))].sort())
|
||||
|
||||
const totalInvest = computed(() => filtered.value.reduce((s, b) => s + (b.total_invest_arbitre ?? 0), 0))
|
||||
const totalFonct = computed(() => filtered.value.reduce((s, b) => s + (b.total_fonct_arbitre ?? 0), 0))
|
||||
const totalGlobal = computed(() => totalInvest.value + totalFonct.value)
|
||||
|
||||
function sortIcon(key) {
|
||||
if (sortKey.value !== key) return '↕'
|
||||
return sortDir.value === 'asc' ? '↑' : '↓'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Head title="Suivi Budgétaire" />
|
||||
<AuthenticatedLayout>
|
||||
<template #header>
|
||||
<div class="flex justify-between items-center w-full">
|
||||
<h1 class="text-xl font-bold text-gray-900 flex items-center gap-2">
|
||||
<svg class="h-5 w-5 text-indigo-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
Suivi Budgétaire & Arbitrages
|
||||
</h1>
|
||||
<div class="flex gap-3 items-center">
|
||||
<div class="flex items-center gap-2">
|
||||
<label class="text-sm font-medium text-gray-600">Année</label>
|
||||
<select v-model="yearFilter" @change="filterByYear"
|
||||
class="rounded-md border-gray-300 shadow-sm text-sm py-1.5 focus:border-indigo-500 focus:ring-indigo-500">
|
||||
<option v-for="y in [2024, 2025, 2026, 2027]" :key="y" :value="y">{{ y }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<button
|
||||
v-if="$page.props.auth.user.roles.some(r => ['admin', 'directeur'].includes(r.name))"
|
||||
@click="showCreateModal = true"
|
||||
class="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 transition-colors">
|
||||
+ Ouvrir une enveloppe
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="space-y-4">
|
||||
|
||||
<!-- Barre de filtres -->
|
||||
<div class="flex flex-wrap gap-3 items-center bg-white rounded-xl border border-gray-100 shadow-sm px-4 py-3">
|
||||
<span class="text-xs font-semibold uppercase tracking-wide text-gray-400 mr-1">Filtrer</span>
|
||||
|
||||
<select v-model="filterService"
|
||||
class="rounded-lg border border-gray-200 px-3 py-1.5 text-sm focus:border-indigo-500 focus:outline-none">
|
||||
<option value="">Tous les services</option>
|
||||
<option v-for="s in uniqueServices" :key="s" :value="s">{{ s }}</option>
|
||||
</select>
|
||||
|
||||
<select v-model="filterType"
|
||||
class="rounded-lg border border-gray-200 px-3 py-1.5 text-sm focus:border-indigo-500 focus:outline-none">
|
||||
<option value="">Toutes les enveloppes</option>
|
||||
<option value="agglo">Agglo</option>
|
||||
<option value="mutualise">Mutualisé</option>
|
||||
</select>
|
||||
|
||||
<select v-model="filterStatut"
|
||||
class="rounded-lg border border-gray-200 px-3 py-1.5 text-sm focus:border-indigo-500 focus:outline-none">
|
||||
<option value="">Tous les statuts</option>
|
||||
<option v-for="(label, key) in STATUS_LABELS" :key="key" :value="key">{{ label }}</option>
|
||||
</select>
|
||||
|
||||
<button v-if="filterService || filterType || filterStatut"
|
||||
@click="filterService = ''; filterType = ''; filterStatut = ''"
|
||||
class="text-xs text-gray-400 hover:text-gray-700 underline">
|
||||
Réinitialiser
|
||||
</button>
|
||||
|
||||
<span class="ml-auto text-xs text-gray-400">{{ filtered.length }} enveloppe{{ filtered.length > 1 ? 's' : '' }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Tableau -->
|
||||
<div class="bg-white rounded-xl border border-gray-100 shadow-sm overflow-hidden">
|
||||
<table class="min-w-full divide-y divide-gray-100 text-sm">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left">
|
||||
<button @click="toggleSort('service')" class="flex items-center gap-1 font-semibold text-xs uppercase tracking-wide text-gray-500 hover:text-gray-900">
|
||||
Service <span class="text-gray-300">{{ sortIcon('service') }}</span>
|
||||
</button>
|
||||
</th>
|
||||
<th class="px-4 py-3 text-left">
|
||||
<button @click="toggleSort('type')" class="flex items-center gap-1 font-semibold text-xs uppercase tracking-wide text-gray-500 hover:text-gray-900">
|
||||
Enveloppe <span class="text-gray-300">{{ sortIcon('type') }}</span>
|
||||
</button>
|
||||
</th>
|
||||
<th class="px-4 py-3 text-left">
|
||||
<button @click="toggleSort('statut')" class="flex items-center gap-1 font-semibold text-xs uppercase tracking-wide text-gray-500 hover:text-gray-900">
|
||||
Statut <span class="text-gray-300">{{ sortIcon('statut') }}</span>
|
||||
</button>
|
||||
</th>
|
||||
<th class="px-4 py-3 text-right">
|
||||
<button @click="toggleSort('invest')" class="flex items-center gap-1 font-semibold text-xs uppercase tracking-wide text-gray-500 hover:text-gray-900 ml-auto">
|
||||
Investissement (arbitré) <span class="text-gray-300">{{ sortIcon('invest') }}</span>
|
||||
</button>
|
||||
</th>
|
||||
<th class="px-4 py-3 text-right">
|
||||
<button @click="toggleSort('fonct')" class="flex items-center gap-1 font-semibold text-xs uppercase tracking-wide text-gray-500 hover:text-gray-900 ml-auto">
|
||||
Fonctionnement (arbitré) <span class="text-gray-300">{{ sortIcon('fonct') }}</span>
|
||||
</button>
|
||||
</th>
|
||||
<th class="px-4 py-3 text-right">
|
||||
<button @click="toggleSort('total')" class="flex items-center gap-1 font-semibold text-xs uppercase tracking-wide text-gray-500 hover:text-gray-900 ml-auto">
|
||||
Total <span class="text-gray-300">{{ sortIcon('total') }}</span>
|
||||
</button>
|
||||
</th>
|
||||
<th class="px-4 py-3 w-10"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-50">
|
||||
<tr v-for="budget in filtered" :key="budget.id"
|
||||
class="hover:bg-indigo-50/30 transition-colors group">
|
||||
|
||||
<td class="px-4 py-3 font-medium text-gray-900">
|
||||
{{ budget.service }}
|
||||
</td>
|
||||
|
||||
<td class="px-4 py-3">
|
||||
<span :class="[
|
||||
'inline-flex items-center px-2 py-0.5 rounded text-xs font-medium',
|
||||
budget.type_budget === 'agglo'
|
||||
? 'bg-blue-50 text-blue-700 border border-blue-100'
|
||||
: 'bg-purple-50 text-purple-700 border border-purple-100'
|
||||
]">
|
||||
{{ budget.type_budget === 'agglo' ? 'Agglo' : 'Mutualisé' }}
|
||||
<span v-if="budget.commune" class="ml-1 text-gray-400">· {{ budget.commune }}</span>
|
||||
</span>
|
||||
</td>
|
||||
|
||||
<td class="px-4 py-3">
|
||||
<span :class="['inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium', STATUS_CLASSES[budget.statut]]">
|
||||
{{ STATUS_LABELS[budget.statut] ?? budget.statut }}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
<td class="px-4 py-3 text-right tabular-nums">
|
||||
<span v-if="budget.total_invest_propose > 0" class="text-gray-900 font-medium">
|
||||
{{ formatCurrency(budget.total_invest_arbitre) }}
|
||||
</span>
|
||||
<span v-else class="text-gray-300">—</span>
|
||||
<div v-if="budget.total_invest_propose > 0 && budget.total_invest_propose !== budget.total_invest_arbitre"
|
||||
class="text-xs text-gray-400">
|
||||
proposé : {{ formatCurrency(budget.total_invest_propose) }}
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<td class="px-4 py-3 text-right tabular-nums">
|
||||
<span v-if="budget.total_fonct_propose > 0" class="text-gray-900 font-medium">
|
||||
{{ formatCurrency(budget.total_fonct_arbitre) }}
|
||||
</span>
|
||||
<span v-else class="text-gray-300">—</span>
|
||||
<div v-if="budget.total_fonct_propose > 0 && budget.total_fonct_propose !== budget.total_fonct_arbitre"
|
||||
class="text-xs text-gray-400">
|
||||
proposé : {{ formatCurrency(budget.total_fonct_propose) }}
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<td class="px-4 py-3 text-right tabular-nums font-semibold text-indigo-700">
|
||||
{{ formatCurrency((budget.total_invest_arbitre ?? 0) + (budget.total_fonct_arbitre ?? 0)) }}
|
||||
</td>
|
||||
|
||||
<td class="px-4 py-3 text-right">
|
||||
<Link :href="route('budgets.show', budget.id)"
|
||||
class="inline-flex items-center gap-1 text-xs font-medium text-indigo-600 hover:text-indigo-800 opacity-0 group-hover:opacity-100 transition-opacity bg-indigo-50 hover:bg-indigo-100 px-2.5 py-1 rounded-md">
|
||||
Détails
|
||||
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr v-if="filtered.length === 0">
|
||||
<td colspan="7" class="px-4 py-12 text-center text-sm text-gray-400">
|
||||
Aucun budget ne correspond aux filtres sélectionnés.
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
|
||||
<!-- Ligne totaux -->
|
||||
<tfoot v-if="filtered.length > 1" class="border-t-2 border-gray-200 bg-gray-50">
|
||||
<tr>
|
||||
<td colspan="3" class="px-4 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wide">
|
||||
Total ({{ filtered.length }} enveloppes)
|
||||
</td>
|
||||
<td class="px-4 py-3 text-right tabular-nums font-semibold text-gray-800">
|
||||
{{ formatCurrency(totalInvest) }}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-right tabular-nums font-semibold text-gray-800">
|
||||
{{ formatCurrency(totalFonct) }}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-right tabular-nums font-bold text-indigo-700">
|
||||
{{ formatCurrency(totalGlobal) }}
|
||||
</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal Création d'enveloppe -->
|
||||
<Modal :show="showCreateModal" @close="showCreateModal = false">
|
||||
<div class="p-6">
|
||||
<h2 class="text-lg font-bold text-gray-900 mb-6">Ouvrir une enveloppe budgétaire</h2>
|
||||
<form @submit.prevent="submitBudget" class="space-y-4">
|
||||
<div>
|
||||
<InputLabel value="Service" />
|
||||
<select v-model="form.service_id"
|
||||
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm"
|
||||
required>
|
||||
<option value="">Sélectionner un service</option>
|
||||
<option v-for="s in services" :key="s.id" :value="s.id">{{ s.nom }}</option>
|
||||
</select>
|
||||
<p v-if="form.errors.service_id" class="mt-1 text-xs text-red-600">{{ form.errors.service_id }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<InputLabel value="Année visée" />
|
||||
<input v-model.number="form.annee" type="number" min="2020" max="2035"
|
||||
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm"
|
||||
required />
|
||||
</div>
|
||||
<div>
|
||||
<InputLabel value="Type de budget" />
|
||||
<select v-model="form.type_budget"
|
||||
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm"
|
||||
required>
|
||||
<option value="agglo">Budget Agglomération (Interne)</option>
|
||||
<option value="mutualise">Budget Mutualisation (Communes)</option>
|
||||
</select>
|
||||
<p v-if="form.errors.type_budget" class="mt-1 text-xs text-red-600">{{ form.errors.type_budget }}</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex justify-end gap-3">
|
||||
<SecondaryButton @click="showCreateModal = false">Annuler</SecondaryButton>
|
||||
<PrimaryButton :class="{ 'opacity-25': form.processing }" :disabled="form.processing">Créer</PrimaryButton>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</Modal>
|
||||
</AuthenticatedLayout>
|
||||
</template>
|
||||
Reference in New Issue
Block a user