## 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>
217 lines
12 KiB
Vue
217 lines
12 KiB
Vue
<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 StatutBadge from '@/Components/Commandes/StatutBadge.vue'
|
|
import { Head, Link, useForm } from '@inertiajs/vue3'
|
|
|
|
const props = defineProps({
|
|
commande: Object,
|
|
services: Array,
|
|
fournisseurs: Array,
|
|
communes: Array,
|
|
categories: Array,
|
|
articles: Array,
|
|
lignesBudget: Array,
|
|
})
|
|
|
|
const form = useForm({
|
|
service_id: props.commande.service_id,
|
|
fournisseur_id: props.commande.fournisseur_id ?? '',
|
|
commune_id: props.commande.commune_id ?? '',
|
|
objet: props.commande.objet,
|
|
description: props.commande.description ?? '',
|
|
justification: props.commande.justification ?? '',
|
|
priorite: props.commande.priorite || 'normale',
|
|
reference_fournisseur: props.commande.reference_fournisseur || '',
|
|
imputation_budgetaire: props.commande.imputation_budgetaire || '',
|
|
ligne_budget_id: props.commande.ligne_budget_id || '',
|
|
date_demande: props.commande.date_demande ? props.commande.date_demande.substring(0, 10) : '',
|
|
date_souhaitee: props.commande.date_souhaitee ?? '',
|
|
date_livraison_prevue: props.commande.date_livraison_prevue ?? '',
|
|
notes: props.commande.notes ?? '',
|
|
notes_fournisseur: props.commande.notes_fournisseur ?? '',
|
|
lignes: props.commande.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.put(route('commandes.update', props.commande.id))
|
|
}
|
|
|
|
const showReceived = ['commandee','partiellement_recue','recue_complete'].includes(props.commande.statut)
|
|
</script>
|
|
|
|
<template>
|
|
<Head :title="`Modifier — ${commande.numero_commande}`" />
|
|
<AuthenticatedLayout>
|
|
<template #header>
|
|
<div class="flex items-center gap-3">
|
|
<Link :href="route('commandes.show', commande.id)" class="text-gray-400 hover:text-gray-600">
|
|
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
|
|
</svg>
|
|
</Link>
|
|
<h1 class="text-xl font-semibold text-gray-900">Modifier {{ commande.numero_commande }}</h1>
|
|
<StatutBadge :statut="commande.statut" />
|
|
</div>
|
|
</template>
|
|
|
|
<form @submit.prevent="submit" class="space-y-6 max-w-5xl">
|
|
<div class="rounded-xl bg-white p-6 shadow-sm border border-gray-100">
|
|
<h2 class="mb-4 text-sm font-semibold uppercase tracking-wide text-gray-500">Informations générales</h2>
|
|
<div class="grid gap-4 sm:grid-cols-2">
|
|
<div class="sm:col-span-2">
|
|
<label class="block text-sm font-medium text-gray-700">Objet <span class="text-red-500">*</span></label>
|
|
<input v-model="form.objet" type="text" required
|
|
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" />
|
|
<p v-if="form.errors.objet" class="mt-1 text-xs text-red-600">{{ form.errors.objet }}</p>
|
|
</div>
|
|
<div>
|
|
<label class="block text-sm font-medium text-gray-700">Service <span class="text-red-500">*</span></label>
|
|
<select v-model="form.service_id" required
|
|
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">
|
|
<option v-for="s in services" :key="s.id" :value="s.id">{{ s.nom }}</option>
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label class="block text-sm font-medium text-gray-700">Fournisseur</label>
|
|
<select v-model="form.fournisseur_id"
|
|
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">
|
|
<option value="">— Non défini —</option>
|
|
<option v-for="f in fournisseurs" :key="f.id" :value="f.id">{{ f.nom }}</option>
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label class="block text-sm font-medium text-gray-700">Ville / Commune</label>
|
|
<select v-model="form.commune_id"
|
|
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">
|
|
<option value="">— Non défini —</option>
|
|
<option v-for="c in communes" :key="c.id" :value="c.id">{{ c.nom }}</option>
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label class="block text-sm font-medium text-gray-700">Priorité</label>
|
|
<select v-model="form.priorite"
|
|
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">
|
|
<option value="normale">Normale</option>
|
|
<option value="haute">Haute</option>
|
|
<option value="urgente">Urgente</option>
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label class="block text-sm font-medium text-gray-700">Date de demande</label>
|
|
<input v-model="form.date_demande" type="date"
|
|
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>
|
|
<label class="block text-sm font-medium text-gray-700">Date souhaitée</label>
|
|
<input v-model="form.date_souhaitee" type="date"
|
|
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>
|
|
<label class="block text-sm font-medium text-gray-700">Référence fournisseur</label>
|
|
<input v-model="form.reference_fournisseur" 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>
|
|
<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"
|
|
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">Justification</label>
|
|
<textarea v-model="form.justification" rows="2"
|
|
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>
|
|
<label class="block text-sm font-medium text-gray-700">Notes internes</label>
|
|
<textarea v-model="form.notes" rows="2"
|
|
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>
|
|
<label class="block text-sm font-medium text-gray-700">Notes fournisseur</label>
|
|
<textarea v-model="form.notes_fournisseur" rows="2"
|
|
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>
|
|
</div>
|
|
|
|
<div class="rounded-xl bg-white p-6 shadow-sm border border-gray-100">
|
|
<h2 class="mb-4 text-sm font-semibold uppercase tracking-wide text-gray-500">Lignes de commande</h2>
|
|
<LignesCommandeForm v-model="form.lignes" :categories="categories" :articles="articles" :show-received="showReceived" />
|
|
</div>
|
|
|
|
<div class="flex justify-end gap-3">
|
|
<Link :href="route('commandes.show', commande.id)"
|
|
class="rounded-lg border border-gray-300 px-5 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors">
|
|
Annuler
|
|
</Link>
|
|
<button type="submit" :disabled="form.processing"
|
|
class="rounded-lg bg-blue-600 px-5 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50 transition-colors">
|
|
{{ form.processing ? 'Enregistrement...' : 'Enregistrer les modifications' }}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</AuthenticatedLayout>
|
|
</template>
|