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:
@@ -1,6 +1,8 @@
|
||||
<script setup>
|
||||
import { computed, watch } from 'vue'
|
||||
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout.vue'
|
||||
import LignesCommandeForm from '@/Components/Commandes/LignesCommandeForm.vue'
|
||||
import SearchableSelect from '@/Components/SearchableSelect.vue'
|
||||
import { Head, Link, useForm } from '@inertiajs/vue3'
|
||||
|
||||
const props = defineProps({
|
||||
@@ -9,6 +11,7 @@ const props = defineProps({
|
||||
communes: Array,
|
||||
categories: Array,
|
||||
articles: Array,
|
||||
lignesBudget: Array,
|
||||
})
|
||||
|
||||
const form = useForm({
|
||||
@@ -21,6 +24,7 @@ const form = useForm({
|
||||
priorite: 'normale',
|
||||
reference_fournisseur: '',
|
||||
imputation_budgetaire: '',
|
||||
ligne_budget_id: '',
|
||||
date_demande: new Date().toISOString().slice(0, 10),
|
||||
date_souhaitee: '',
|
||||
date_livraison_prevue: '',
|
||||
@@ -29,6 +33,49 @@ const form = useForm({
|
||||
lignes: [],
|
||||
})
|
||||
|
||||
const ligneBudgetOptions = computed(() => {
|
||||
const targetAnnee = form.date_demande
|
||||
? new Date(form.date_demande).getFullYear()
|
||||
: new Date().getFullYear()
|
||||
|
||||
const targetTypeBudget = (!form.commune_id || String(form.commune_id) === '1')
|
||||
? 'agglo'
|
||||
: 'mutualise'
|
||||
|
||||
return props.lignesBudget?.filter(lb => {
|
||||
// Toujours conserver la ligne actuellement sélectionnée pour l'affichage
|
||||
if (form.ligne_budget_id && lb.id === form.ligne_budget_id) return true
|
||||
|
||||
if (form.service_id && lb.budget?.service_id !== form.service_id) return false
|
||||
if (lb.budget?.annee !== targetAnnee) return false
|
||||
if (lb.budget?.type_budget !== targetTypeBudget) return false
|
||||
|
||||
return true
|
||||
}).map(lb => ({
|
||||
value: lb.id,
|
||||
label: `[${lb.budget?.annee} - ${lb.budget?.service?.nom}] ${lb.nom} (Dispo: ${new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(lb.montant_disponible)})`
|
||||
})) || []
|
||||
})
|
||||
|
||||
watch([() => form.service_id, () => form.commune_id, () => form.date_demande], ([newService, newCommune, newDate]) => {
|
||||
if (form.ligne_budget_id) {
|
||||
const selectedLb = props.lignesBudget?.find(lb => lb.id === form.ligne_budget_id)
|
||||
if (selectedLb) {
|
||||
const targetAnnee = newDate ? new Date(newDate).getFullYear() : new Date().getFullYear()
|
||||
const targetTypeBudget = (!newCommune || String(newCommune) === '1') ? 'agglo' : 'mutualise'
|
||||
|
||||
let isValid = true
|
||||
if (newService && selectedLb.budget?.service_id !== newService) isValid = false
|
||||
if (selectedLb.budget?.annee !== targetAnnee) isValid = false
|
||||
if (selectedLb.budget?.type_budget !== targetTypeBudget) isValid = false
|
||||
|
||||
if (!isValid) {
|
||||
form.ligne_budget_id = ''
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function submit() {
|
||||
form.post(route('commandes.store'))
|
||||
}
|
||||
@@ -117,11 +164,22 @@ function submit() {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700">Imputation budgétaire</label>
|
||||
<label class="block text-sm font-medium text-gray-700">Imputation budgétaire (texte)</label>
|
||||
<input v-model="form.imputation_budgetaire" type="text"
|
||||
class="mt-1 block w-full rounded-lg border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-blue-500 focus:outline-none" />
|
||||
</div>
|
||||
|
||||
<div class="sm:col-span-2">
|
||||
<label class="block text-sm font-medium text-gray-700">Ligne Budgétaire (Module Budget)</label>
|
||||
<SearchableSelect
|
||||
v-model="form.ligne_budget_id"
|
||||
:options="ligneBudgetOptions"
|
||||
placeholder="Rechercher un projet / enveloppe..."
|
||||
:class="form.errors.ligne_budget_id ? 'ring-1 ring-red-500 rounded-lg' : ''"
|
||||
/>
|
||||
<p v-if="form.errors.ligne_budget_id" class="mt-1 text-xs text-red-600">{{ form.errors.ligne_budget_id }}</p>
|
||||
</div>
|
||||
|
||||
<div class="sm:col-span-2">
|
||||
<label class="block text-sm font-medium text-gray-700">Description</label>
|
||||
<textarea v-model="form.description" rows="2"
|
||||
|
||||
Reference in New Issue
Block a user