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>
|
||||
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>
|
||||
416
resources/js/Pages/Budgets/Show.vue
Normal file
416
resources/js/Pages/Budgets/Show.vue
Normal file
@@ -0,0 +1,416 @@
|
||||
<script setup>
|
||||
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout.vue'
|
||||
import { Head, Link, useForm, router } from '@inertiajs/vue3'
|
||||
import Modal from '@/Components/Modal.vue'
|
||||
import TextInput from '@/Components/TextInput.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({
|
||||
budget: Object,
|
||||
lignes: Array,
|
||||
totals: Object,
|
||||
communes: Array
|
||||
})
|
||||
|
||||
function formatCurrency(value) {
|
||||
return new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(value)
|
||||
}
|
||||
|
||||
const investissements = computed(() => props.lignes.filter(l => l.type_depense === 'investissement'))
|
||||
const fonctionnements = computed(() => props.lignes.filter(l => l.type_depense === 'fonctionnement'))
|
||||
|
||||
const showAddLigneModal = ref(false)
|
||||
const ligneForm = useForm({
|
||||
id: null,
|
||||
budget_id: props.budget.id,
|
||||
commune_id: '',
|
||||
type_depense: 'investissement',
|
||||
nom: '',
|
||||
description: '',
|
||||
montant_propose: 0
|
||||
})
|
||||
|
||||
const isLocked = computed(() => ['arbitrage_direction', 'valide', 'cloture'].includes(props.budget.statut))
|
||||
|
||||
function submitLigne() {
|
||||
if (ligneForm.id) {
|
||||
ligneForm.put(route('lignes-budget.update', ligneForm.id), {
|
||||
onSuccess: () => {
|
||||
showAddLigneModal.value = false
|
||||
ligneForm.reset()
|
||||
}
|
||||
})
|
||||
} else {
|
||||
ligneForm.post(route('lignes-budget.store'), {
|
||||
onSuccess: () => {
|
||||
showAddLigneModal.value = false
|
||||
ligneForm.reset('nom', 'description', 'montant_propose')
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function openEditLigne(ligne) {
|
||||
ligneForm.id = ligne.id
|
||||
ligneForm.budget_id = props.budget.id
|
||||
ligneForm.commune_id = ligne.commune_id || ''
|
||||
ligneForm.type_depense = ligne.type_depense
|
||||
ligneForm.nom = ligne.nom
|
||||
ligneForm.description = ligne.description
|
||||
ligneForm.montant_propose = ligne.montant_propose
|
||||
showAddLigneModal.value = true
|
||||
}
|
||||
|
||||
function openAddLigne() {
|
||||
ligneForm.reset()
|
||||
ligneForm.id = null
|
||||
ligneForm.budget_id = props.budget.id
|
||||
showAddLigneModal.value = true
|
||||
}
|
||||
|
||||
// Historique
|
||||
const showHistoriqueModal = ref(false)
|
||||
const selectedLigneHistorique = ref(null)
|
||||
|
||||
function openHistorique(ligne) {
|
||||
selectedLigneHistorique.value = ligne
|
||||
showHistoriqueModal.value = true
|
||||
}
|
||||
|
||||
const statusLabelMap = {
|
||||
'brouillon': 'Brouillon', 'propose': 'Proposé', 'accepte_dsi': 'Accepté DSI',
|
||||
'accepte_direction': 'Accepté Direction', 'valide_definitif': 'Validé Définitif',
|
||||
'refuse': 'Refusé', 'reporte': 'Reporté',
|
||||
}
|
||||
function labelStatut(s) { return statusLabelMap[s] ?? s }
|
||||
|
||||
// Arbitrage
|
||||
const showArbitrageModal = ref(false)
|
||||
const selectedLigne = ref(null)
|
||||
const arbitrageForm = useForm({
|
||||
statut_arbitrage: '',
|
||||
montant_arbitre: '',
|
||||
commentaire: '',
|
||||
})
|
||||
|
||||
function openArbitrage(ligne) {
|
||||
selectedLigne.value = ligne
|
||||
arbitrageForm.statut_arbitrage = ligne.statut_arbitrage
|
||||
arbitrageForm.montant_arbitre = ligne.montant_arbitre || ligne.montant_propose
|
||||
showArbitrageModal.value = true
|
||||
}
|
||||
|
||||
function submitArbitrage() {
|
||||
arbitrageForm.patch(route('lignes-budget.arbitrer', selectedLigne.value.id), {
|
||||
onSuccess: () => showArbitrageModal.value = false
|
||||
})
|
||||
}
|
||||
|
||||
const statusOptions = [
|
||||
{ value: 'propose', label: 'Proposé' },
|
||||
{ value: 'accepte_dsi', label: 'Accepté (Pré-arbitrage DSI)' },
|
||||
{ value: 'accepte_direction', label: 'Accepté (Pré-arbitrage Direction)' },
|
||||
{ value: 'valide_definitif', label: 'Validé Définitif' },
|
||||
{ value: 'refuse', label: 'Refusé' },
|
||||
{ value: 'reporte', label: 'Reporté à A+1' }
|
||||
]
|
||||
|
||||
const statusBadgeConfig = {
|
||||
'brouillon': { class: 'bg-gray-100 text-gray-800', label: 'Brouillon' },
|
||||
'propose': { class: 'bg-blue-100 text-blue-800', label: 'Proposé' },
|
||||
'accepte_dsi': { class: 'bg-indigo-100 text-indigo-800 border border-indigo-200', label: 'Accepté (Pré-arbitrage DSI)' },
|
||||
'accepte_direction': { class: 'bg-purple-100 text-purple-800 border border-purple-200', label: 'Accepté (Pré-arbitrage Direction)' },
|
||||
'valide_definitif': { class: 'bg-green-100 text-green-800', label: 'Validé Définitif' },
|
||||
'refuse': { class: 'bg-red-100 text-red-800', label: 'Refusé' },
|
||||
'reporte': { class: 'bg-orange-100 text-orange-800', label: 'Reporté à A+1' },
|
||||
}
|
||||
|
||||
function getStatusClass(status) {
|
||||
return statusBadgeConfig[status]?.class ?? statusBadgeConfig['brouillon'].class
|
||||
}
|
||||
|
||||
function getStatusLabel(status) {
|
||||
return statusBadgeConfig[status]?.label ?? status
|
||||
}
|
||||
|
||||
function updateBudgetStatus(newStatus) {
|
||||
router.put(route('budgets.update', props.budget.id), { statut: newStatus })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Head :title="`Budget ${budget.annee} - ${budget.service.nom}`" />
|
||||
<AuthenticatedLayout>
|
||||
<template #header>
|
||||
<div class="flex items-center gap-3">
|
||||
<Link :href="route('budgets.index')" 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>
|
||||
<div>
|
||||
<h1 class="text-xl font-bold text-gray-900">
|
||||
{{ budget.service.nom }} • Budget {{ budget.annee }}
|
||||
</h1>
|
||||
<p class="text-sm text-gray-500 flex items-center gap-2 mt-1">
|
||||
<span class="uppercase tracking-wider font-semibold text-xs text-indigo-600">{{ budget.type_budget === 'agglo' ? 'Agglo' : 'Mutualisé' }}</span>
|
||||
• Statut :
|
||||
<select v-if="$page.props.auth.user.roles.some(r => ['admin', 'directeur'].includes(r.name))"
|
||||
:value="budget.statut" @change="updateBudgetStatus($event.target.value)"
|
||||
class="text-xs rounded border-gray-300 py-0 pl-2 pr-6 h-6">
|
||||
<option value="preparation">En préparation</option>
|
||||
<option value="arbitrage_dsi">Arbitrage DSI</option>
|
||||
<option value="arbitrage_direction">Arbitrage Direction</option>
|
||||
<option value="valide">Validé</option>
|
||||
<option value="cloture">Clôturé</option>
|
||||
</select>
|
||||
<span v-else class="font-medium text-gray-700 capitalize">{{ budget.statut.replace('_', ' ') }}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ml-auto flex gap-3">
|
||||
<button v-if="!isLocked" @click="openAddLigne" class="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-semibold text-white hover:bg-indigo-500">
|
||||
+ Ajouter une demande
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="space-y-8 pb-12">
|
||||
<!-- Tableaux -->
|
||||
<div class="grid xl:grid-cols-2 gap-8">
|
||||
<!-- Investissement -->
|
||||
<div>
|
||||
<div class="flex justify-between items-end mb-4">
|
||||
<h2 class="text-lg font-bold text-gray-900 border-b-2 border-indigo-500 pb-1 inline-block">Investissement</h2>
|
||||
<div class="text-right">
|
||||
<p class="text-sm text-gray-500 font-medium">Proposé : {{ formatCurrency(totals.invest_propose) }}</p>
|
||||
<p class="text-base text-indigo-700 font-bold">Arbitré : {{ formatCurrency(totals.invest_arbitre) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold text-gray-500 uppercase">Projet / Achat</th>
|
||||
<th class="px-4 py-3 text-right text-xs font-semibold text-gray-500 uppercase">Proposé</th>
|
||||
<th class="px-4 py-3 text-right text-xs font-semibold text-indigo-600 uppercase">Arbitré</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold text-gray-500 uppercase">Statut</th>
|
||||
<th class="px-4 py-3"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200">
|
||||
<tr v-for="ligne in investissements" :key="ligne.id" class="hover:bg-gray-50 group">
|
||||
<td class="px-4 py-4 text-sm text-gray-900">
|
||||
<p class="font-medium">{{ ligne.nom }}</p>
|
||||
<div class="flex items-center gap-2 mt-0.5">
|
||||
<span v-if="ligne.commune_id" class="text-[10px] bg-blue-50 text-blue-600 px-1.5 py-0.5 rounded border border-blue-100 font-bold uppercase">
|
||||
{{ communes.find(c => c.id === ligne.commune_id)?.nom }}
|
||||
</span>
|
||||
<p class="text-xs text-gray-500 truncate max-w-[150px]" :title="ligne.description">{{ ligne.description }}</p>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-4 text-sm text-right text-gray-500 whitespace-nowrap">{{ formatCurrency(ligne.montant_propose) }}</td>
|
||||
<td class="px-4 py-4 text-sm text-right font-bold text-gray-900 whitespace-nowrap">
|
||||
{{ ligne.montant_arbitre ? formatCurrency(ligne.montant_arbitre) : '-' }}
|
||||
</td>
|
||||
<td class="px-4 py-4 text-sm">
|
||||
<span class="px-2 py-1 text-xs rounded-md font-medium" :class="getStatusClass(ligne.statut_arbitrage)">
|
||||
{{ getStatusLabel(ligne.statut_arbitrage) }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-4 text-right whitespace-nowrap">
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<button v-if="ligne.historique?.length" @click="openHistorique(ligne)" class="text-gray-400 hover:text-gray-700 text-xs font-medium" :title="`${ligne.historique.length} arbitrage(s)`">
|
||||
📋 {{ ligne.historique.length }}
|
||||
</button>
|
||||
<button v-if="!isLocked" @click="openEditLigne(ligne)" class="text-gray-600 hover:text-gray-900 text-xs font-medium">Éditer</button>
|
||||
<button v-if="$page.props.auth.user.roles.some(r => ['admin', 'directeur', 'raf'].includes(r.name))" @click="openArbitrage(ligne)" class="text-indigo-600 hover:text-indigo-900 text-xs font-medium">Arbitrer</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="investissements.length === 0">
|
||||
<td colspan="5" class="px-4 py-8 text-center text-sm text-gray-500">Aucun projet d'investissement.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Fonctionnement -->
|
||||
<div>
|
||||
<div class="flex justify-between items-end mb-4">
|
||||
<h2 class="text-lg font-bold text-gray-900 border-b-2 border-emerald-500 pb-1 inline-block">Fonctionnement</h2>
|
||||
<div class="text-right">
|
||||
<p class="text-sm text-gray-500 font-medium">Proposé : {{ formatCurrency(totals.fonct_propose) }}</p>
|
||||
<p class="text-base text-emerald-700 font-bold">Arbitré : {{ formatCurrency(totals.fonct_arbitre) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold text-gray-500 uppercase">Achat / Suivi</th>
|
||||
<th class="px-4 py-3 text-right text-xs font-semibold text-gray-500 uppercase">Proposé</th>
|
||||
<th class="px-4 py-3 text-right text-xs font-semibold text-emerald-600 uppercase">Arbitré</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold text-gray-500 uppercase">Statut</th>
|
||||
<th class="px-4 py-3"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200">
|
||||
<tr v-for="ligne in fonctionnements" :key="ligne.id" class="hover:bg-gray-50 group">
|
||||
<td class="px-4 py-4 text-sm text-gray-900">
|
||||
<p class="font-medium">{{ ligne.nom }}</p>
|
||||
<div class="flex items-center gap-3 mt-0.5">
|
||||
<span v-if="ligne.commune_id" class="text-[10px] bg-blue-50 text-blue-600 px-1.5 py-0.5 rounded border border-blue-100 font-bold uppercase whitespace-nowrap">
|
||||
{{ communes.find(c => c.id === ligne.commune_id)?.nom }}
|
||||
</span>
|
||||
<p class="text-xs text-gray-500 truncate max-w-[150px]" :title="ligne.description">{{ ligne.description }}</p>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-4 text-sm text-right text-gray-500 whitespace-nowrap">{{ formatCurrency(ligne.montant_propose) }}</td>
|
||||
<td class="px-4 py-4 text-sm text-right font-bold text-gray-900 whitespace-nowrap">
|
||||
{{ ligne.montant_arbitre ? formatCurrency(ligne.montant_arbitre) : '-' }}
|
||||
</td>
|
||||
<td class="px-4 py-4 text-sm">
|
||||
<span class="px-2 py-1 text-xs rounded-md font-medium" :class="getStatusClass(ligne.statut_arbitrage)">
|
||||
{{ getStatusLabel(ligne.statut_arbitrage) }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-4 text-right whitespace-nowrap">
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<button v-if="ligne.historique?.length" @click="openHistorique(ligne)" class="text-gray-400 hover:text-gray-700 text-xs font-medium" :title="`${ligne.historique.length} arbitrage(s)`">
|
||||
📋 {{ ligne.historique.length }}
|
||||
</button>
|
||||
<button v-if="!isLocked" @click="openEditLigne(ligne)" class="text-gray-600 hover:text-gray-900 text-xs font-medium">Éditer</button>
|
||||
<button v-if="$page.props.auth.user.roles.some(r => ['admin', 'directeur', 'raf'].includes(r.name))" @click="openArbitrage(ligne)" class="text-indigo-600 hover:text-indigo-900 text-xs font-medium">Arbitrer</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="fonctionnements.length === 0">
|
||||
<td colspan="5" class="px-4 py-8 text-center text-sm text-gray-500">Aucun achat de fonctionnement.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal Ajout Ligne -->
|
||||
<Modal :show="showAddLigneModal" @close="showAddLigneModal = false">
|
||||
<div class="p-6">
|
||||
<h2 class="text-lg font-bold text-gray-900 mb-6">{{ ligneForm.id ? 'Modifier la demande' : 'Ajouter une demande budgétaire' }}</h2>
|
||||
<form @submit.prevent="submitLigne" class="space-y-4">
|
||||
<div>
|
||||
<InputLabel value="Type de dépense" />
|
||||
<select v-model="ligneForm.type_depense" 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="investissement">Investissement (Projets, Nouveaux matériels...)</option>
|
||||
<option value="fonctionnement">Fonctionnement (Licences, Maintenance...)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div v-if="budget.type_budget === 'mutualise'">
|
||||
<InputLabel value="Commune bénéficiaire" />
|
||||
<select v-model="ligneForm.commune_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 une ville</option>
|
||||
<option v-for="c in communes" :key="c.id" :value="c.id">{{ c.nom }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<InputLabel value="Nom de l'achat ou du projet" />
|
||||
<TextInput v-model="ligneForm.nom" class="mt-1 block w-full" required />
|
||||
</div>
|
||||
<div>
|
||||
<InputLabel value="Description (optionnel)" />
|
||||
<textarea v-model="ligneForm.description" rows="3" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm"></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<InputLabel value="Montant estimé / proposé (€)" />
|
||||
<input v-model.number="ligneForm.montant_propose" type="number" step="0.01" min="0"
|
||||
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 class="mt-6 flex justify-end gap-3">
|
||||
<SecondaryButton @click="showAddLigneModal = false">Annuler</SecondaryButton>
|
||||
<PrimaryButton :class="{ 'opacity-25': ligneForm.processing }" :disabled="ligneForm.processing">
|
||||
{{ ligneForm.id ? 'Mettre à jour' : 'Ajouter' }}
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<!-- Modal Arbitrage -->
|
||||
<Modal :show="showArbitrageModal" @close="showArbitrageModal = false" max-width="md">
|
||||
<div class="p-6" v-if="selectedLigne">
|
||||
<h2 class="text-lg font-bold text-gray-900 mb-2">Arbitrer la ligne</h2>
|
||||
<p class="text-sm font-medium mb-1">{{ selectedLigne.nom }}</p>
|
||||
<div class="bg-gray-50 p-3 rounded-lg mb-6 border border-gray-200">
|
||||
<p class="text-xs text-gray-500">Montant initialement proposé :</p>
|
||||
<p class="text-lg font-bold text-gray-900">{{ formatCurrency(selectedLigne.montant_propose) }}</p>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="submitArbitrage" class="space-y-4">
|
||||
<div>
|
||||
<InputLabel value="Décision / Statut" />
|
||||
<select v-model="arbitrageForm.statut_arbitrage" 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 v-for="opt in statusOptions" :key="opt.value" :value="opt.value">{{ opt.label }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<InputLabel value="Montant accordé (arbitré) en €" />
|
||||
<input v-model.number="arbitrageForm.montant_arbitre" type="number" step="0.01" min="0"
|
||||
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 text-lg font-bold text-indigo-700" />
|
||||
<p class="text-xs text-gray-500 mt-1">Le montant ne peut pas dépasser le montant proposé.</p>
|
||||
</div>
|
||||
<div>
|
||||
<InputLabel value="Commentaire (optionnel)" />
|
||||
<textarea v-model="arbitrageForm.commentaire" rows="2" placeholder="Motif de la décision..." class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex justify-end gap-3">
|
||||
<SecondaryButton @click="showArbitrageModal = false">Annuler</SecondaryButton>
|
||||
<PrimaryButton :class="{ 'opacity-25': arbitrageForm.processing }" :disabled="arbitrageForm.processing">Enregistrer l'arbitrage</PrimaryButton>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</Modal>
|
||||
<!-- Modal Historique Arbitrages -->
|
||||
<Modal :show="showHistoriqueModal" @close="showHistoriqueModal = false" max-width="lg">
|
||||
<div class="p-6" v-if="selectedLigneHistorique">
|
||||
<h2 class="text-lg font-bold text-gray-900 mb-1">Historique des arbitrages</h2>
|
||||
<p class="text-sm text-gray-500 mb-5">{{ selectedLigneHistorique.nom }}</p>
|
||||
|
||||
<div v-if="selectedLigneHistorique.historique?.length" class="space-y-3">
|
||||
<div v-for="h in selectedLigneHistorique.historique" :key="h.id"
|
||||
class="bg-gray-50 border border-gray-200 rounded-lg p-4 text-sm">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-medium text-gray-900">{{ h.user }}</span>
|
||||
<span class="text-gray-400">·</span>
|
||||
<span class="text-gray-500 text-xs">{{ h.created_at }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1 text-xs">
|
||||
<span class="text-gray-500">{{ labelStatut(h.ancien_statut) }}</span>
|
||||
<span class="text-gray-400">→</span>
|
||||
<span class="font-semibold text-indigo-700">{{ labelStatut(h.nouveau_statut) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="h.ancien_montant_arbitre !== h.nouveau_montant_arbitre" class="text-xs text-gray-600 mb-1">
|
||||
Montant : {{ formatCurrency(h.ancien_montant_arbitre ?? 0) }} → <span class="font-semibold">{{ formatCurrency(h.nouveau_montant_arbitre ?? 0) }}</span>
|
||||
</div>
|
||||
<p v-if="h.commentaire" class="text-xs text-gray-700 italic mt-1 border-l-2 border-indigo-300 pl-2">{{ h.commentaire }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<p v-else class="text-sm text-gray-500 text-center py-4">Aucun arbitrage enregistré.</p>
|
||||
|
||||
<div class="mt-5 flex justify-end">
|
||||
<SecondaryButton @click="showHistoriqueModal = false">Fermer</SecondaryButton>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</AuthenticatedLayout>
|
||||
</template>
|
||||
@@ -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"
|
||||
|
||||
@@ -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 StatutBadge from '@/Components/Commandes/StatutBadge.vue'
|
||||
import { Head, Link, useForm } from '@inertiajs/vue3'
|
||||
|
||||
@@ -11,6 +13,7 @@ const props = defineProps({
|
||||
communes: Array,
|
||||
categories: Array,
|
||||
articles: Array,
|
||||
lignesBudget: Array,
|
||||
})
|
||||
|
||||
const form = useForm({
|
||||
@@ -20,10 +23,11 @@ const form = useForm({
|
||||
objet: props.commande.objet,
|
||||
description: props.commande.description ?? '',
|
||||
justification: props.commande.justification ?? '',
|
||||
priorite: props.commande.priorite,
|
||||
reference_fournisseur: props.commande.reference_fournisseur ?? '',
|
||||
imputation_budgetaire: props.commande.imputation_budgetaire ?? '',
|
||||
date_demande: props.commande.date_demande,
|
||||
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 ?? '',
|
||||
@@ -31,6 +35,49 @@ const form = useForm({
|
||||
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))
|
||||
}
|
||||
@@ -111,10 +158,21 @@ const showReceived = ['commandee','partiellement_recue','recue_complete'].includ
|
||||
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</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"
|
||||
|
||||
@@ -135,7 +135,13 @@ const transitionColors = {
|
||||
<p class="text-xs text-gray-500">Référence fournisseur</p>
|
||||
<p class="mt-0.5 font-medium text-gray-900">{{ commande.reference_fournisseur }}</p>
|
||||
</div>
|
||||
<div v-if="commande.imputation_budgetaire">
|
||||
<div v-if="commande.ligne_budget_id && commande.ligne_budget">
|
||||
<p class="text-xs text-indigo-600 font-semibold">Ligne Budgétaire</p>
|
||||
<Link :href="route('budgets.show', commande.ligne_budget.budget_id)" class="mt-0.5 font-medium text-indigo-700 hover:underline">
|
||||
{{ commande.ligne_budget.nom }} <span class="text-xs text-gray-500">({{ commande.ligne_budget.budget.annee }})</span>
|
||||
</Link>
|
||||
</div>
|
||||
<div v-else-if="commande.imputation_budgetaire">
|
||||
<p class="text-xs text-gray-500">Imputation budgétaire</p>
|
||||
<p class="mt-0.5 font-medium text-gray-900">{{ commande.imputation_budgetaire }}</p>
|
||||
</div>
|
||||
|
||||
@@ -1,26 +1,106 @@
|
||||
<script setup>
|
||||
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout.vue'
|
||||
import { Head, useForm, router } from '@inertiajs/vue3'
|
||||
import { ref } from 'vue'
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
defineProps({ services: Array })
|
||||
|
||||
const showForm = ref(false)
|
||||
const editTarget = ref(null)
|
||||
const form = useForm({ nom: '', description: '', couleur: '#3B82F6', icone: '' })
|
||||
// ── Icônes suggérées (solid + brands) ───────────────────────────────────────
|
||||
const ICON_SUGGESTIONS = [
|
||||
// Infrastructure / IT
|
||||
{ icon: ['fas', 'server'], label: 'Serveur' },
|
||||
{ icon: ['fas', 'network-wired'], label: 'Réseau' },
|
||||
{ icon: ['fas', 'database'], label: 'Base de données' },
|
||||
{ icon: ['fas', 'shield-halved'], label: 'Sécurité' },
|
||||
{ icon: ['fas', 'cloud'], label: 'Cloud' },
|
||||
{ icon: ['fas', 'laptop'], label: 'Poste de travail' },
|
||||
{ icon: ['fas', 'print'], label: 'Impression' },
|
||||
{ icon: ['fas', 'wifi'], label: 'Wi-Fi' },
|
||||
{ icon: ['fas', 'hard-drive'], label: 'Stockage' },
|
||||
{ icon: ['fas', 'microchip'], label: 'Microchip' },
|
||||
// Métier
|
||||
{ icon: ['fas', 'users'], label: 'Utilisateurs' },
|
||||
{ icon: ['fas', 'building'], label: 'Bâtiment' },
|
||||
{ icon: ['fas', 'folder-open'], label: 'Dossiers' },
|
||||
{ icon: ['fas', 'chart-line'], label: 'Statistiques' },
|
||||
{ icon: ['fas', 'headset'], label: 'Support' },
|
||||
{ icon: ['fas', 'code'], label: 'Développement' },
|
||||
{ icon: ['fas', 'globe'], label: 'Web' },
|
||||
{ icon: ['fas', 'envelope'], label: 'Messagerie' },
|
||||
{ icon: ['fas', 'calendar-days'], label: 'Planning' },
|
||||
{ icon: ['fas', 'file-contract'], label: 'Contrats' },
|
||||
// Logiciels / Marques
|
||||
{ icon: ['fab', 'laravel'], label: 'Laravel' },
|
||||
{ icon: ['fab', 'linux'], label: 'Linux' },
|
||||
{ icon: ['fab', 'windows'], label: 'Windows' },
|
||||
{ icon: ['fab', 'microsoft'], label: 'Microsoft' },
|
||||
{ icon: ['fab', 'docker'], label: 'Docker' },
|
||||
{ icon: ['fab', 'github'], label: 'GitHub' },
|
||||
{ icon: ['fab', 'wordpress'], label: 'WordPress' },
|
||||
{ icon: ['fab', 'python'], label: 'Python' },
|
||||
{ icon: ['fab', 'vuejs'], label: 'Vue.js' },
|
||||
{ icon: ['fab', 'js'], label: 'JavaScript' },
|
||||
]
|
||||
|
||||
// ── Formulaires ──────────────────────────────────────────────────────────────
|
||||
const showForm = ref(false)
|
||||
const editTarget = ref(null)
|
||||
const showPicker = ref(null) // 'create' | 'edit'
|
||||
const iconSearch = ref('')
|
||||
|
||||
const form = useForm({ nom: '', description: '', couleur: '#3B82F6', icone: '' })
|
||||
const editForm = useForm({ nom: '', description: '', couleur: '', icone: '' })
|
||||
|
||||
function openEdit(s) {
|
||||
editTarget.value = s
|
||||
editForm.nom = s.nom; editForm.description = s.description ?? ''; editForm.couleur = s.couleur ?? '#3B82F6'; editForm.icone = s.icone ?? ''
|
||||
editForm.nom = s.nom
|
||||
editForm.description = s.description ?? ''
|
||||
editForm.couleur = s.couleur ?? '#3B82F6'
|
||||
editForm.icone = s.icone ?? ''
|
||||
showPicker.value = null
|
||||
iconSearch.value = ''
|
||||
}
|
||||
|
||||
function submitCreate() { form.post(route('services.store'), { onSuccess: () => { showForm.value = false; form.reset() } }) }
|
||||
function submitEdit() { editForm.put(route('services.update', editTarget.value.id), { onSuccess: () => editTarget.value = null }) }
|
||||
|
||||
function submitCreate() {
|
||||
form.post(route('services.store'), {
|
||||
onSuccess: () => { showForm.value = false; form.reset(); showPicker.value = null }
|
||||
})
|
||||
}
|
||||
function submitEdit() {
|
||||
editForm.put(route('services.update', editTarget.value.id), {
|
||||
onSuccess: () => { editTarget.value = null; showPicker.value = null }
|
||||
})
|
||||
}
|
||||
function deleteService(s) {
|
||||
if (confirm(`Supprimer "${s.nom}" ?`)) router.delete(route('services.destroy', s.id))
|
||||
}
|
||||
|
||||
// ── Picker ───────────────────────────────────────────────────────────────────
|
||||
const filteredIcons = computed(() => {
|
||||
const q = iconSearch.value.toLowerCase()
|
||||
if (!q) return ICON_SUGGESTIONS
|
||||
return ICON_SUGGESTIONS.filter(i => i.label.toLowerCase().includes(q) || i.icon[1].includes(q))
|
||||
})
|
||||
|
||||
function pickIcon(icon, target) {
|
||||
const val = `${icon[0]}:${icon[1]}`
|
||||
if (target === 'create') form.icone = val
|
||||
else editForm.icone = val
|
||||
showPicker.value = null
|
||||
iconSearch.value = ''
|
||||
}
|
||||
|
||||
// Convertit "fas:server" → ['fas','server'] pour font-awesome-icon
|
||||
function parseIcon(str) {
|
||||
if (!str) return ['fas', 'circle-question']
|
||||
const parts = str.split(':')
|
||||
return parts.length === 2 ? parts : ['fas', str]
|
||||
}
|
||||
|
||||
function clearIcon(target) {
|
||||
if (target === 'create') form.icone = ''
|
||||
else editForm.icone = ''
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -29,51 +109,206 @@ function deleteService(s) {
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-xl font-semibold text-gray-900">Services</h1>
|
||||
<button @click="showForm = !showForm" class="rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 transition-colors">+ Ajouter</button>
|
||||
<button @click="showForm = !showForm; editTarget = null"
|
||||
class="rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 transition-colors">
|
||||
+ Ajouter un service
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Formulaire création -->
|
||||
<div v-if="showForm" class="mb-6 rounded-xl bg-white p-5 shadow-sm border border-blue-200">
|
||||
<form @submit.prevent="submitCreate" class="grid gap-3 sm:grid-cols-3">
|
||||
<div><label class="block text-xs font-medium text-gray-600 mb-1">Nom *</label><input v-model="form.nom" type="text" required class="block w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none" /></div>
|
||||
<div><label class="block text-xs font-medium text-gray-600 mb-1">Couleur</label><input v-model="form.couleur" type="color" class="block h-10 w-full rounded-lg border border-gray-300" /></div>
|
||||
<div><label class="block text-xs font-medium text-gray-600 mb-1">Icône</label><input v-model="form.icone" type="text" placeholder="ex: server" class="block w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none" /></div>
|
||||
<div class="sm:col-span-3"><label class="block text-xs font-medium text-gray-600 mb-1">Description</label><input v-model="form.description" type="text" class="block w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none" /></div>
|
||||
<div class="sm:col-span-3 flex gap-2">
|
||||
<button type="submit" :disabled="form.processing" class="rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50 transition-colors">Créer</button>
|
||||
<button type="button" @click="showForm = false" class="rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors">Annuler</button>
|
||||
<h2 class="text-sm font-semibold text-gray-700 mb-4">Nouveau service</h2>
|
||||
<form @submit.prevent="submitCreate" class="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1">Nom *</label>
|
||||
<input v-model="form.nom" type="text" required
|
||||
class="block w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none" />
|
||||
<p v-if="form.errors.nom" class="mt-1 text-xs text-red-600">{{ form.errors.nom }}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1">Couleur</label>
|
||||
<div class="flex items-center gap-2">
|
||||
<input v-model="form.couleur" type="color"
|
||||
class="h-9 w-14 rounded-lg border border-gray-300 cursor-pointer" />
|
||||
<span class="text-sm text-gray-500">{{ form.couleur }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Icône picker -->
|
||||
<div class="sm:col-span-2">
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1">Icône</label>
|
||||
<div class="flex items-center gap-3">
|
||||
<!-- Aperçu -->
|
||||
<div class="flex h-10 w-10 items-center justify-center rounded-lg border border-gray-200 bg-gray-50">
|
||||
<font-awesome-icon v-if="form.icone" :icon="parseIcon(form.icone)" class="text-xl" :style="{ color: form.couleur }" />
|
||||
<span v-else class="text-xs text-gray-300">?</span>
|
||||
</div>
|
||||
<button type="button" @click="showPicker = showPicker === 'create' ? null : 'create'; iconSearch = ''"
|
||||
class="rounded-lg border border-gray-300 px-3 py-2 text-sm text-gray-700 hover:bg-gray-50 transition-colors">
|
||||
{{ form.icone ? 'Changer' : 'Choisir une icône' }}
|
||||
</button>
|
||||
<button v-if="form.icone" type="button" @click="clearIcon('create')"
|
||||
class="text-xs text-gray-400 hover:text-red-500">✕ Retirer</button>
|
||||
<span v-if="form.icone" class="text-xs font-mono text-gray-400">{{ form.icone }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Grille de sélection -->
|
||||
<div v-if="showPicker === 'create'" class="mt-2 rounded-xl border border-gray-200 bg-white shadow-lg p-3 z-10">
|
||||
<input v-model="iconSearch" type="text" placeholder="Rechercher une icône..."
|
||||
class="mb-3 block w-full rounded-lg border border-gray-200 px-3 py-1.5 text-sm focus:border-blue-500 focus:outline-none" />
|
||||
<div class="grid grid-cols-6 sm:grid-cols-10 gap-1 max-h-48 overflow-y-auto">
|
||||
<button v-for="item in filteredIcons" :key="item.icon.join('-')"
|
||||
type="button"
|
||||
@click="pickIcon(item.icon, 'create')"
|
||||
:title="item.label"
|
||||
class="flex flex-col items-center gap-1 rounded-lg p-2 hover:bg-blue-50 transition-colors group">
|
||||
<font-awesome-icon :icon="item.icon" class="text-lg text-gray-600 group-hover:text-blue-600" />
|
||||
<span class="text-xs text-gray-400 truncate w-full text-center">{{ item.label }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sm:col-span-2">
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1">Description</label>
|
||||
<input v-model="form.description" type="text"
|
||||
class="block w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none" />
|
||||
</div>
|
||||
|
||||
<div class="sm:col-span-2 flex gap-2">
|
||||
<button type="submit" :disabled="form.processing"
|
||||
class="rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50 transition-colors">
|
||||
Créer
|
||||
</button>
|
||||
<button type="button" @click="showForm = false; showPicker = null"
|
||||
class="rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors">
|
||||
Annuler
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Grille des services -->
|
||||
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<div v-for="s in services" :key="s.id" class="rounded-xl bg-white p-5 shadow-sm border border-gray-100">
|
||||
<div v-for="s in services" :key="s.id"
|
||||
class="rounded-xl bg-white p-5 shadow-sm border border-gray-100 hover:shadow-md transition-shadow">
|
||||
|
||||
<!-- Mode lecture -->
|
||||
<template v-if="editTarget?.id !== s.id">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="h-4 w-4 rounded-full flex-shrink-0" :style="{ backgroundColor: s.couleur || '#6B7280' }" />
|
||||
<h3 class="font-semibold text-gray-900">{{ s.nom }}</h3>
|
||||
<!-- Icône du service -->
|
||||
<div class="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-xl"
|
||||
:style="{ backgroundColor: (s.couleur || '#6B7280') + '20' }">
|
||||
<font-awesome-icon v-if="s.icone"
|
||||
:icon="parseIcon(s.icone)"
|
||||
class="text-lg"
|
||||
:style="{ color: s.couleur || '#6B7280' }" />
|
||||
<span v-else class="h-3 w-3 rounded-full" :style="{ backgroundColor: s.couleur || '#6B7280' }" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-semibold text-gray-900">{{ s.nom }}</h3>
|
||||
<p v-if="s.description" class="text-xs text-gray-500 mt-0.5">{{ s.description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-1">
|
||||
<button @click="openEdit(s)" class="text-gray-400 hover:text-indigo-600 transition-colors p-1"><svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" /></svg></button>
|
||||
<button @click="deleteService(s)" class="text-gray-400 hover:text-red-600 transition-colors p-1"><svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" /></svg></button>
|
||||
<div class="flex gap-1 flex-shrink-0">
|
||||
<button @click="openEdit(s)"
|
||||
class="text-gray-400 hover:text-indigo-600 transition-colors p-1 rounded">
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button @click="deleteService(s)"
|
||||
class="text-gray-400 hover:text-red-600 transition-colors p-1 rounded">
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="s.description" class="mt-2 text-sm text-gray-500">{{ s.description }}</p>
|
||||
<div class="mt-3 flex gap-4 text-xs text-gray-500">
|
||||
<div class="mt-3 flex gap-4 text-xs text-gray-400 border-t border-gray-50 pt-3">
|
||||
<span>{{ s.users_count }} utilisateur(s)</span>
|
||||
<span>{{ s.commandes_count }} commande(s)</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Mode édition inline -->
|
||||
<form v-else @submit.prevent="submitEdit" class="space-y-3">
|
||||
<div><label class="block text-xs font-medium text-gray-600 mb-1">Nom *</label><input v-model="editForm.nom" type="text" required class="block w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none" /></div>
|
||||
<div><label class="block text-xs font-medium text-gray-600 mb-1">Description</label><input v-model="editForm.description" type="text" class="block w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none" /></div>
|
||||
<div class="flex gap-2">
|
||||
<button type="submit" :disabled="editForm.processing" class="flex-1 rounded-lg bg-blue-600 px-3 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50 transition-colors">Enregistrer</button>
|
||||
<button type="button" @click="editTarget = null" class="rounded-lg border border-gray-300 px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors">Annuler</button>
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<span class="text-sm font-semibold text-gray-700">Modifier le service</span>
|
||||
<button type="button" @click="editTarget = null; showPicker = null"
|
||||
class="text-gray-400 hover:text-gray-600 text-lg leading-none">✕</button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1">Nom *</label>
|
||||
<input v-model="editForm.nom" type="text" required
|
||||
class="block w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1">Description</label>
|
||||
<input v-model="editForm.description" type="text"
|
||||
class="block w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none" />
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3 items-end">
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1">Couleur</label>
|
||||
<input v-model="editForm.couleur" type="color"
|
||||
class="h-9 w-14 rounded-lg border border-gray-300 cursor-pointer" />
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1">Icône</label>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex h-9 w-9 items-center justify-center rounded-lg border border-gray-200 bg-gray-50">
|
||||
<font-awesome-icon v-if="editForm.icone" :icon="parseIcon(editForm.icone)" class="text-base" :style="{ color: editForm.couleur }" />
|
||||
<span v-else class="text-xs text-gray-300">?</span>
|
||||
</div>
|
||||
<button type="button" @click="showPicker = showPicker === 'edit' ? null : 'edit'; iconSearch = ''"
|
||||
class="rounded-lg border border-gray-300 px-2 py-1.5 text-xs text-gray-700 hover:bg-gray-50 transition-colors">
|
||||
{{ editForm.icone ? 'Changer' : 'Choisir' }}
|
||||
</button>
|
||||
<button v-if="editForm.icone" type="button" @click="clearIcon('edit')"
|
||||
class="text-xs text-gray-400 hover:text-red-500">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Picker édition -->
|
||||
<div v-if="showPicker === 'edit'" class="rounded-xl border border-gray-200 bg-white shadow-lg p-3">
|
||||
<input v-model="iconSearch" type="text" placeholder="Rechercher..."
|
||||
class="mb-2 block w-full rounded-lg border border-gray-200 px-3 py-1.5 text-sm focus:border-blue-500 focus:outline-none" />
|
||||
<div class="grid grid-cols-5 gap-1 max-h-40 overflow-y-auto">
|
||||
<button v-for="item in filteredIcons" :key="item.icon.join('-')"
|
||||
type="button"
|
||||
@click="pickIcon(item.icon, 'edit')"
|
||||
:title="item.label"
|
||||
class="flex flex-col items-center gap-1 rounded-lg p-2 hover:bg-blue-50 transition-colors group">
|
||||
<font-awesome-icon :icon="item.icon" class="text-base text-gray-600 group-hover:text-blue-600" />
|
||||
<span class="text-xs text-gray-400 truncate w-full text-center leading-tight">{{ item.label }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 pt-1">
|
||||
<button type="submit" :disabled="editForm.processing"
|
||||
class="flex-1 rounded-lg bg-blue-600 px-3 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50 transition-colors">
|
||||
Enregistrer
|
||||
</button>
|
||||
<button type="button" @click="editTarget = null; showPicker = null"
|
||||
class="rounded-lg border border-gray-300 px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors">
|
||||
Annuler
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div v-if="!services?.length" class="col-span-full py-12 text-center text-sm text-gray-400">
|
||||
Aucun service configuré.
|
||||
</div>
|
||||
</div>
|
||||
</AuthenticatedLayout>
|
||||
</template>
|
||||
|
||||
Reference in New Issue
Block a user