Files
jeremy bayse 0ad77de412 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>
2026-04-11 20:20:05 +02:00

351 lines
18 KiB
Vue

<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>