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:
134
resources/js/Components/SearchableSelect.vue
Normal file
134
resources/js/Components/SearchableSelect.vue
Normal file
@@ -0,0 +1,134 @@
|
||||
<script setup>
|
||||
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: [String, Number, null],
|
||||
default: ''
|
||||
},
|
||||
options: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: 'Rechercher...'
|
||||
}
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:modelValue']);
|
||||
|
||||
const isOpen = ref(false);
|
||||
const searchQuery = ref('');
|
||||
const selectContainer = ref(null);
|
||||
|
||||
// Initialize searchQuery based on modelValue
|
||||
watch(() => props.modelValue, (newVal) => {
|
||||
const opt = props.options.find(o => o.value === newVal);
|
||||
if (opt && !isOpen.value) {
|
||||
searchQuery.value = opt.label;
|
||||
} else if (!newVal) {
|
||||
searchQuery.value = '';
|
||||
}
|
||||
}, { immediate: true });
|
||||
|
||||
const filteredOptions = computed(() => {
|
||||
if (!searchQuery.value) return props.options;
|
||||
const lowerQuery = searchQuery.value.toLowerCase();
|
||||
return props.options.filter(opt =>
|
||||
opt.label.toLowerCase().includes(lowerQuery)
|
||||
);
|
||||
});
|
||||
|
||||
function selectOption(option) {
|
||||
if (!option) {
|
||||
emit('update:modelValue', '');
|
||||
searchQuery.value = '';
|
||||
} else {
|
||||
emit('update:modelValue', option.value);
|
||||
searchQuery.value = option.label;
|
||||
}
|
||||
isOpen.value = false;
|
||||
}
|
||||
|
||||
function onClickOutside(event) {
|
||||
if (selectContainer.value && !selectContainer.value.contains(event.target)) {
|
||||
isOpen.value = false;
|
||||
// Revert searchQuery to selected item if clicked outside without selecting
|
||||
if (props.modelValue) {
|
||||
const opt = props.options.find(o => o.value === props.modelValue);
|
||||
searchQuery.value = opt ? opt.label : '';
|
||||
} else {
|
||||
searchQuery.value = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure the dropdown handles tab and escape properly
|
||||
function onKeyDown(event) {
|
||||
if (event.key === 'Escape') {
|
||||
isOpen.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('click', onClickOutside);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('click', onClickOutside);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative" ref="selectContainer" @keydown="onKeyDown">
|
||||
<!-- Input field -->
|
||||
<input
|
||||
type="text"
|
||||
v-model="searchQuery"
|
||||
@focus="isOpen = true; searchQuery = ''"
|
||||
:placeholder="placeholder"
|
||||
class="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm pr-10"
|
||||
/>
|
||||
|
||||
<!-- Clear button / Toggle indicator -->
|
||||
<div class="absolute inset-y-0 right-0 flex items-center pr-2">
|
||||
<button v-if="modelValue" type="button" @click.stop="selectOption(null)" class="text-gray-400 hover:text-gray-600 focus:outline-none p-1 z-10" title="Effacer la sélection">
|
||||
<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="M6 18L18 6M6 6l12 12"></path></svg>
|
||||
</button>
|
||||
<svg v-else class="h-5 w-5 text-gray-400 pointer-events-none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||
<path fill-rule="evenodd" d="M10 3a.75.75 0 01.55.24l3.25 3.5a.75.75 0 11-1.1 1.02L10 4.852 7.3 7.76a.75.75 0 01-1.1-1.02l3.25-3.5A.75.75 0 0110 3zm-3.76 9.2a.75.75 0 011.06.04l2.7 2.908 2.7-2.908a.75.75 0 111.1 1.02l-3.25 3.5a.75.75 0 01-1.1 0l-3.25-3.5a.75.75 0 01.04-1.06z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- Dropdown -->
|
||||
<transition leave-active-class="transition ease-in duration-100" leave-from-class="opacity-100" leave-to-class="opacity-0">
|
||||
<div v-show="isOpen" class="absolute z-20 mt-1 w-full rounded-md bg-white shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none max-h-60 overflow-y-auto">
|
||||
<ul tabindex="-1" role="listbox" aria-labelledby="listbox-label">
|
||||
<!-- If no results -->
|
||||
<li v-if="filteredOptions.length === 0" class="text-gray-500 cursor-default select-none relative py-2 pl-3 pr-9 text-sm">
|
||||
Aucun résultat trouvé.
|
||||
</li>
|
||||
|
||||
<!-- Options -->
|
||||
<li
|
||||
v-for="option in filteredOptions"
|
||||
:key="option.value"
|
||||
@click.stop="selectOption(option)"
|
||||
class="text-gray-900 hover:bg-indigo-600 hover:text-white cursor-pointer select-none relative py-2 pl-3 pr-9 text-sm transition-colors"
|
||||
>
|
||||
<span class="block truncate" :class="{ 'font-semibold': option.value === modelValue, 'font-normal': option.value !== modelValue }">
|
||||
{{ option.label }}
|
||||
</span>
|
||||
<span v-if="option.value === modelValue" class="absolute inset-y-0 right-0 flex items-center pr-4 text-indigo-600">
|
||||
<!-- Adjusted hover states internally since parent hover handles it -->
|
||||
<svg class="h-5 w-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||
<path fill-rule="evenodd" d="M16.704 4.153a.75.75 0 01.143 1.052l-8 10.5a.75.75 0 01-1.127.075l-4.5-4.5a.75.75 0 011.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 011.05-.143z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user