feat: module budgets complet avec sécurité, performance et métier
## Fonctionnalités - Module Budgets : enveloppes, lignes budgétaires, arbitrage DSI/Direction - Suivi de l'exécution budgétaire avec alertes visuelles (dépassement, seuil 80%) - Blocage des commandes si budget insuffisant (store + update) - Audit trail complet des arbitrages via HistoriqueBudget - Page d'index budgets refaite en tableau avec filtres et tri côté client - Page Services avec sélecteur d'icônes FontAwesome (solid + regular + brands) ## Sécurité - BudgetPolicy centralisée (viewAny, view, create, update, addLigne, updateLigne, deleteLigne, arbitrerLigne) - Autorisation sur tous les endpoints LigneBudget et Budget - Protection XSS : remplacement v-html par classes dynamiques - Validation des paramètres d'export (type, envelope) - Validation montant_arbitre ≤ montant_propose côté serveur ## Performance - Eager loading lignes.commandes.commune dans execution() et exportPdf() - Calculs montant_consomme/engage en mémoire sur collections déjà chargées - Null-safety sur montant_arbitre dans getMontantDisponibleAttribute ## Technique - Migration historique_budgets, budgets, ligne_budgets, rôle raf - SearchableSelect avec affichage du disponible budgétaire - FontAwesome enregistré globalement (fas, far, fab) - 33 tests Feature (sécurité, performance, métier) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,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