feat: implement user management system and integrate AI services with Ollama and Gemini

This commit is contained in:
jeremy bayse
2026-06-21 00:12:37 +02:00
parent 648749a69a
commit b85c2ba5a1
17 changed files with 1074 additions and 7 deletions
@@ -60,6 +60,13 @@ const showingNavigationDropdown = ref(false);
>
Gestion des Devis
</NavLink>
<NavLink
v-if="$page.props.auth.user.role === 'chef_service'"
:href="route('users.index')"
:active="route().current('users.*')"
>
Utilisateurs
</NavLink>
</div>
</div>
@@ -191,6 +198,13 @@ const showingNavigationDropdown = ref(false);
>
Gestion des Devis
</ResponsiveNavLink>
<ResponsiveNavLink
v-if="$page.props.auth.user.role === 'chef_service'"
:href="route('users.index')"
:active="route().current('users.*')"
>
Utilisateurs
</ResponsiveNavLink>
</div>
<!-- Responsive Settings Options -->
+136 -2
View File
@@ -1,11 +1,15 @@
<script setup>
import { computed } from 'vue';
import { Head, Link, useForm } from '@inertiajs/vue3';
import { ref, computed } from 'vue';
import { Head, Link, useForm, usePage } from '@inertiajs/vue3';
const page = usePage();
const ollamaAvailable = computed(() => page.props.ollamaAvailable);
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout.vue';
import InputLabel from '@/Components/InputLabel.vue';
import TextInput from '@/Components/TextInput.vue';
import InputError from '@/Components/InputError.vue';
import PrimaryButton from '@/Components/PrimaryButton.vue';
import axios from 'axios';
const props = defineProps({
order: {
@@ -77,6 +81,112 @@ const submit = () => {
}
};
const isOcrLoading = ref(false);
const ocrError = ref(null);
const ocrSuccess = ref(false);
const selectedModel = ref('gemini');
const loadPdfJs = () => {
return new Promise((resolve, reject) => {
if (window.pdfjsLib) {
resolve(window.pdfjsLib);
return;
}
const script = document.createElement('script');
script.src = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.4.120/pdf.min.js';
script.onload = () => {
window.pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.4.120/pdf.worker.min.js';
resolve(window.pdfjsLib);
};
script.onerror = reject;
document.head.appendChild(script);
});
};
const convertPdfBlobToImageFile = async (file) => {
const pdfjsLib = await loadPdfJs();
const arrayBuffer = await file.arrayBuffer();
const loadingTask = pdfjsLib.getDocument({ data: arrayBuffer });
const pdf = await loadingTask.promise;
const page = await pdf.getPage(1);
const viewport = page.getViewport({ scale: 2.0 });
const canvas = document.createElement('canvas');
const context = canvas.getContext('2d');
canvas.height = viewport.height;
canvas.width = viewport.width;
await page.render({ canvasContext: context, viewport }).promise;
return new Promise((resolve) => {
canvas.toBlob((convertedBlob) => {
resolve(new File([convertedBlob], file.name.replace(/\.pdf$/i, '.png'), { type: 'image/png' }));
}, 'image/png');
});
};
const handleQuoteOcr = async () => {
if (!form.quote_file) return;
isOcrLoading.value = true;
ocrError.value = null;
ocrSuccess.value = false;
try {
let fileToSend = form.quote_file;
const isPdf = /\.pdf$/i.test(form.quote_file.name);
if (selectedModel.value === 'ollama' && isPdf) {
fileToSend = await convertPdfBlobToImageFile(form.quote_file);
}
const formData = new FormData();
formData.append('file', fileToSend);
formData.append('mode', selectedModel.value);
const response = await axios.post(route('attachments.ocr-upload'), formData, {
headers: {
'Content-Type': 'multipart/form-data'
}
});
if (response.data.success && response.data.data) {
const data = response.data.data;
if (data.supplier) form.supplier = data.supplier;
if (data.quote_number) form.quote_number = data.quote_number;
let totalHt = data.total_ht;
if ((totalHt === undefined || totalHt === null || parseFloat(totalHt) === 0) && data.items && data.items.length > 0) {
totalHt = data.items.reduce((sum, item) => sum + (parseFloat(item.quantite) || 0) * (parseFloat(item.prix_unitaire) || 0), 0);
}
if (totalHt !== undefined && totalHt !== null) {
form.amount_ht = String(parseFloat(totalHt).toFixed(2));
}
if (data.title && !form.label) {
form.label = data.title;
}
if (data.items && data.items.length > 0) {
let itemsStr = "\n\n--- Détails du devis extraits par OCR ---\n";
data.items.forEach(item => {
itemsStr += `- [${item.type}] ${item.designation} : ${item.quantite} x ${item.prix_unitaire} € HT\n`;
});
form.notes = (form.notes + itemsStr).trim();
}
ocrSuccess.value = true;
} else {
ocrError.value = response.data.message || "Une erreur est survenue lors de l'analyse.";
}
} catch (e) {
ocrError.value = e.response?.data?.message || "Erreur lors de l'analyse du fichier.";
console.error(e);
} finally {
isOcrLoading.value = false;
}
};
const orderTypes = [
'Matériel réseau / serveur',
'Licences logicielles',
@@ -296,6 +406,30 @@ const demandeurs = ['Jérémy', 'Sylvain', 'Kévin'];
@input="form.quote_file = $event.target.files[0]"
class="block w-full text-xs text-slate-500 file:mr-4 file:py-2 file:px-4 file:rounded-md file:border-0 file:text-xs file:font-semibold file:bg-sky-50 file:text-sky-700 hover:file:bg-sky-100 dark:file:bg-slate-800 dark:file:text-slate-300"
/>
<div v-if="form.quote_file" class="mt-2 flex flex-col gap-2">
<!-- Sélecteur de modèle -->
<div v-if="ollamaAvailable" class="flex items-center gap-2 bg-slate-50 dark:bg-slate-950 border border-slate-200 dark:border-slate-800 rounded-lg px-2.5 py-1.5 shadow-sm">
<span class="text-xxs text-slate-500 dark:text-slate-400">Modèle OCR :</span>
<select v-model="selectedModel" class="text-xxs font-semibold border-0 p-0 pr-6 focus:ring-0 bg-transparent text-slate-700 dark:text-slate-300">
<option value="gemini">Gemini API</option>
<option value="ollama">GLM-OCR (Ollama)</option>
</select>
</div>
<button
type="button"
@click="handleQuoteOcr"
:disabled="isOcrLoading"
class="inline-flex items-center justify-center gap-1.5 px-3 py-1.5 text-xs font-semibold text-white bg-gradient-to-r from-violet-600 to-indigo-600 hover:from-violet-500 hover:to-indigo-500 rounded-lg shadow-sm focus:outline-none transition-all disabled:opacity-50"
>
<svg v-if="isOcrLoading" class="animate-spin h-3.5 w-3.5 text-white" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<span>✨ {{ isOcrLoading ? 'Analyse...' : 'Analyser le devis (OCR)' }}</span>
</button>
<p v-if="ocrError" class="text-xxs font-semibold text-rose-600 mt-1">{{ ocrError }}</p>
<p v-if="ocrSuccess" class="text-xxs font-semibold text-emerald-600 mt-1">✓ Devis analysé avec succès !</p>
</div>
<div v-if="getExistingFile('quote')" class="text-xs mt-1 text-slate-500">
Fichier existant :
<a :href="getExistingFile('quote').url" target="_blank" class="text-sky-600 dark:text-sky-400 hover:underline inline-flex items-center">
+11
View File
@@ -67,6 +67,14 @@ const getTimelineColor = (status) => {
default: return 'bg-gray-400';
}
};
// Calculer le pourcentage du sous-devis par rapport au devis parent (commande)
const getPercentage = (sdTotal) => {
const parentTotal = parseFloat(props.order.data.amount_ttc);
if (!parentTotal) return '0%';
const pct = (parseFloat(sdTotal) / parentTotal) * 100;
return `${pct.toFixed(1)} %`;
};
</script>
<template>
@@ -378,6 +386,9 @@ const getTimelineColor = (status) => {
<span class="text-sm font-bold text-slate-900 dark:text-slate-100">
{{ new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(sd.total_ttc) }}
</span>
<span class="text-xs font-semibold text-slate-500 bg-slate-150/80 dark:bg-slate-800 dark:text-slate-400 px-1.5 py-0.5 rounded">
{{ getPercentage(sd.total_ttc) }}
</span>
<Link :href="route('devis.show', sd.id)" class="text-xs font-semibold text-sky-600 dark:text-sky-400 hover:underline">
Voir
</Link>
+1 -1
View File
@@ -172,7 +172,7 @@ const formatCurrency = (value) => {
<!-- Prix unitaire -->
<div class="sm:col-span-2">
<label class="block text-xxs font-bold text-slate-400 uppercase tracking-wide mb-1">P.U. HT ()</label>
<input v-model="item.prix_unitaire" type="number" min="0" step="0.01" class="w-full text-xs rounded-lg border-slate-300 shadow-sm focus:border-sky-500 focus:ring-sky-500 dark:bg-slate-900 dark:border-slate-800 dark:text-slate-200" required>
<input v-model="item.prix_unitaire" type="number" step="0.01" class="w-full text-xs rounded-lg border-slate-300 shadow-sm focus:border-sky-500 focus:ring-sky-500 dark:bg-slate-900 dark:border-slate-800 dark:text-slate-200" required>
</div>
</div>
<button type="button" @click="removeItem(index)" class="mt-5 p-1.5 text-rose-500 hover:bg-rose-50 dark:hover:bg-rose-950/40 rounded-md border border-transparent hover:border-rose-200 dark:hover:border-rose-900/60">
+138 -2
View File
@@ -1,12 +1,106 @@
<script setup>
import { ref, computed } from 'vue';
import { Head, Link, useForm } from '@inertiajs/vue3';
import { Head, Link, useForm, usePage } from '@inertiajs/vue3';
const page = usePage();
const ollamaAvailable = computed(() => page.props.ollamaAvailable);
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout.vue';
import axios from 'axios';
const props = defineProps({
commande: Object,
});
const isOcrLoading = ref(false);
const ocrError = ref(null);
const selectedModel = ref('gemini');
const loadPdfJs = () => {
return new Promise((resolve, reject) => {
if (window.pdfjsLib) {
resolve(window.pdfjsLib);
return;
}
const script = document.createElement('script');
script.src = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.4.120/pdf.min.js';
script.onload = () => {
window.pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.4.120/pdf.worker.min.js';
resolve(window.pdfjsLib);
};
script.onerror = reject;
document.head.appendChild(script);
});
};
const convertPdfBlobToImageFile = async (blob, fileName) => {
const pdfjsLib = await loadPdfJs();
const arrayBuffer = await blob.arrayBuffer();
const loadingTask = pdfjsLib.getDocument({ data: arrayBuffer });
const pdf = await loadingTask.promise;
const page = await pdf.getPage(1);
const viewport = page.getViewport({ scale: 2.0 });
const canvas = document.createElement('canvas');
const context = canvas.getContext('2d');
canvas.height = viewport.height;
canvas.width = viewport.width;
await page.render({ canvasContext: context, viewport }).promise;
return new Promise((resolve) => {
canvas.toBlob((convertedBlob) => {
resolve(new File([convertedBlob], fileName.replace(/\.pdf$/i, '.png'), { type: 'image/png' }));
}, 'image/png');
});
};
const runOcr = async (att) => {
isOcrLoading.value = true;
ocrError.value = null;
try {
let response;
const isPdf = /\.pdf$/i.test(att.file_name);
if (selectedModel.value === 'ollama' && isPdf) {
const pdfResponse = await axios.get(route('attachments.show', att.id), { responseType: 'blob' });
const convertedFile = await convertPdfBlobToImageFile(pdfResponse.data, att.file_name);
const formData = new FormData();
formData.append('file', convertedFile);
formData.append('mode', 'ollama');
response = await axios.post(route('attachments.ocr-upload'), formData, {
headers: { 'Content-Type': 'multipart/form-data' }
});
} else {
response = await axios.post(route('attachments.ocr', att.id), {
mode: selectedModel.value
});
}
if (response.data.success && response.data.data) {
const data = response.data.data;
if (data.title) form.title = data.title;
if (data.date_devis) form.date_devis = data.date_devis;
if (data.tva_rate !== undefined) form.tva_rate = data.tva_rate;
if (data.items && data.items.length > 0) {
form.items = data.items.map(item => ({
type: item.type || 'Matériel',
designation: item.designation || '',
quantite: item.quantite || 1,
prix_unitaire: item.prix_unitaire || 0
}));
}
} else {
ocrError.value = response.data.message || "Une erreur est survenue lors de l'analyse.";
}
} catch (e) {
ocrError.value = e.response?.data?.message || "Erreur de connexion avec le serveur ou la clé API est incorrecte.";
console.error(e);
} finally {
isOcrLoading.value = false;
}
};
const itemTypes = ['Matériel', 'Licence', 'Prestation', 'Infrastructure', 'Autre'];
const form = useForm({
@@ -84,6 +178,48 @@ const submit = () => {
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8">
<form @submit.prevent="submit" class="space-y-6">
<!-- Bannière OCR Gemini -->
<div v-if="commande.quote_attachments && commande.quote_attachments.length > 0" class="bg-gradient-to-r from-violet-50 to-indigo-50 dark:from-violet-950/20 dark:to-indigo-950/20 border border-violet-100 dark:border-violet-900/40 rounded-xl shadow-sm p-6 flex flex-col md:flex-row md:items-center justify-between gap-4">
<div class="flex items-start gap-3">
<span class="text-2xl mt-0.5"></span>
<div>
<h4 class="font-bold text-slate-800 dark:text-slate-200 text-sm">Remplissage automatique intelligent (OCR)</h4>
<p class="text-xs text-slate-500 dark:text-slate-400 mt-0.5">
Un devis fournisseur est joint à cette commande. Vous pouvez l'analyser pour remplir automatiquement ce formulaire.
</p>
<p v-if="ocrError" class="text-xs font-semibold text-rose-600 mt-1 flex items-center gap-1">
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/></svg>
{{ ocrError }}
</p>
</div>
</div>
<div class="flex flex-wrap items-center gap-3">
<!-- Sélecteur de modèle -->
<div v-if="ollamaAvailable" class="flex items-center gap-2 bg-white/80 dark:bg-slate-900/80 border border-violet-100 dark:border-violet-900/40 rounded-lg px-2.5 py-1.5 shadow-sm">
<span class="text-xs text-slate-500 dark:text-slate-400">Modèle OCR :</span>
<select v-model="selectedModel" class="text-xs font-semibold border-0 p-0 pr-6 focus:ring-0 bg-transparent text-slate-700 dark:text-slate-300">
<option value="gemini">Gemini API</option>
<option value="ollama">GLM-OCR (Ollama)</option>
</select>
</div>
<button
v-for="att in commande.quote_attachments"
:key="att.id"
type="button"
@click="runOcr(att)"
:disabled="isOcrLoading"
class="inline-flex items-center gap-2 px-4 py-2 text-xs font-semibold text-white bg-gradient-to-r from-violet-600 to-indigo-600 hover:from-violet-500 hover:to-indigo-500 rounded-lg shadow-sm focus:outline-none transition-all disabled:opacity-50"
>
<svg v-if="isOcrLoading" class="animate-spin h-3 w-3 text-white" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<span v-else>🔍</span>
{{ isOcrLoading ? 'Analyse en cours...' : 'Analyser : ' + att.file_name }}
</button>
</div>
</div>
<!-- Informations générales -->
<div class="bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-xl shadow-sm p-6 space-y-6">
<h3 class="text-md font-bold text-slate-800 dark:text-slate-200 border-b border-slate-100 dark:border-slate-850 pb-3">
@@ -190,7 +326,7 @@ const submit = () => {
</div>
<div class="sm:col-span-2">
<label class="block text-xxs font-bold text-slate-400 uppercase tracking-wide mb-1">P.U. HT (€)</label>
<input v-model="item.prix_unitaire" type="number" min="0" step="0.01" class="w-full text-xs rounded-lg border-slate-300 shadow-sm focus:border-sky-500 focus:ring-sky-500 dark:bg-slate-900 dark:border-slate-800 dark:text-slate-200" required>
<input v-model="item.prix_unitaire" type="number" step="0.01" class="w-full text-xs rounded-lg border-slate-300 shadow-sm focus:border-sky-500 focus:ring-sky-500 dark:bg-slate-900 dark:border-slate-800 dark:text-slate-200" required>
</div>
</div>
<button type="button" @click="removeItem(index)" class="mt-5 p-1.5 text-rose-500 hover:bg-rose-50 dark:hover:bg-rose-950/40 rounded-md border border-transparent hover:border-rose-200 dark:hover:border-rose-900/60">
+1 -1
View File
@@ -176,7 +176,7 @@ const formatCurrency = (value) => {
<!-- Prix unitaire -->
<div class="sm:col-span-2">
<label class="block text-xxs font-bold text-slate-400 uppercase tracking-wide mb-1">P.U. HT ()</label>
<input v-model="item.prix_unitaire" type="number" min="0" step="0.01" class="w-full text-xs rounded-lg border-slate-300 shadow-sm focus:border-sky-500 focus:ring-sky-500 dark:bg-slate-900 dark:border-slate-800 dark:text-slate-200" required>
<input v-model="item.prix_unitaire" type="number" step="0.01" class="w-full text-xs rounded-lg border-slate-300 shadow-sm focus:border-sky-500 focus:ring-sky-500 dark:bg-slate-900 dark:border-slate-800 dark:text-slate-200" required>
</div>
</div>
<button type="button" @click="removeItem(index)" class="mt-5 p-1.5 text-rose-500 hover:bg-rose-50 dark:hover:bg-rose-950/40 rounded-md border border-transparent hover:border-rose-200 dark:hover:border-rose-900/60">
+139
View File
@@ -0,0 +1,139 @@
<script setup>
import { Head, Link, useForm } from '@inertiajs/vue3';
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout.vue';
import InputLabel from '@/Components/InputLabel.vue';
import TextInput from '@/Components/TextInput.vue';
import InputError from '@/Components/InputError.vue';
import PrimaryButton from '@/Components/PrimaryButton.vue';
const props = defineProps({
user: Object,
isEdit: Boolean,
});
const form = useForm({
name: props.user?.name || '',
email: props.user?.email || '',
password: '',
role: props.user?.role || 'admin_reseau',
});
const submit = () => {
if (props.isEdit) {
form.put(route('users.update', props.user.id));
} else {
form.post(route('users.store'));
}
};
</script>
<template>
<Head :title="isEdit ? `Modifier l'Utilisateur ${user.name}` : 'Nouvel Utilisateur'" />
<AuthenticatedLayout>
<template #header>
<div class="flex items-center justify-between">
<h2 class="text-xl font-bold leading-tight text-slate-800 dark:text-slate-200">
{{ isEdit ? `Modifier l'Utilisateur` : 'Créer un Utilisateur' }}
</h2>
<Link
:href="route('users.index')"
class="inline-flex items-center px-4 py-2 text-sm font-semibold text-slate-700 bg-white border border-slate-300 rounded-lg hover:bg-slate-50 dark:bg-slate-900 dark:text-slate-300 dark:border-slate-850 dark:hover:bg-slate-800 transition-colors"
>
Retour
</Link>
</div>
</template>
<div class="py-6">
<div class="max-w-xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-xl shadow-sm overflow-hidden p-6">
<form @submit.prevent="submit" class="space-y-6">
<!-- Nom -->
<div>
<InputLabel for="name" value="Nom complet" class="font-bold" />
<TextInput
id="name"
type="text"
class="mt-1 block w-full"
v-model="form.name"
required
placeholder="Ex: Sylvain Martin"
/>
<InputError class="mt-2" :message="form.errors.name" />
</div>
<!-- Email -->
<div>
<InputLabel for="email" value="Adresse email" class="font-bold" />
<TextInput
id="email"
type="email"
class="mt-1 block w-full"
v-model="form.email"
required
placeholder="Ex: s.martin@beziers-mediterranee.fr"
/>
<InputError class="mt-2" :message="form.errors.email" />
</div>
<!-- Mot de passe -->
<div>
<InputLabel
for="password"
:value="isEdit ? 'Nouveau mot de passe (laisser vide pour conserver)' : 'Mot de passe'"
class="font-bold"
/>
<TextInput
id="password"
type="password"
class="mt-1 block w-full"
v-model="form.password"
:required="!isEdit"
placeholder="••••••••"
/>
<InputError class="mt-2" :message="form.errors.password" />
</div>
<!-- Rôle -->
<div>
<InputLabel for="role" value="Rôle d'accès" class="font-bold" />
<select
id="role"
v-model="form.role"
class="mt-1 block w-full text-sm rounded-lg border-slate-300 shadow-sm focus:border-sky-500 focus:ring-sky-500 dark:bg-slate-950 dark:border-slate-800 dark:text-slate-200"
required
>
<option value="admin_reseau">Admin Réseau (accès standard)</option>
<option value="chef_service">Chef de Service (accès administrateur)</option>
</select>
<InputError class="mt-2" :message="form.errors.role" />
</div>
<!-- Boutons -->
<div class="flex items-center justify-end gap-3 pt-6 border-t border-slate-100 dark:border-slate-850">
<Link
:href="route('users.index')"
class="inline-flex items-center px-4 py-2 border border-slate-300 text-sm font-semibold rounded-lg text-slate-700 bg-white hover:bg-slate-50 dark:bg-slate-900 dark:text-slate-300 dark:border-slate-850 dark:hover:bg-slate-800 transition-colors shadow-sm"
>
Annuler
</Link>
<PrimaryButton
type="submit"
:disabled="form.processing"
class="inline-flex items-center px-4 py-2 text-sm font-semibold text-white bg-sky-600 hover:bg-sky-500 rounded-lg shadow-sm focus:outline-none focus:ring-2 focus:ring-sky-500 transition-colors disabled:opacity-50"
>
<svg v-if="form.processing" class="animate-spin -ml-1 mr-3 h-4 w-4 text-white" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
{{ isEdit ? 'Enregistrer les modifications' : 'Créer l\'utilisateur' }}
</PrimaryButton>
</div>
</form>
</div>
</div>
</div>
</AuthenticatedLayout>
</template>
+118
View File
@@ -0,0 +1,118 @@
<script setup>
import { Head, Link, router } from '@inertiajs/vue3';
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout.vue';
const props = defineProps({
users: Array,
});
const deleteUser = (user) => {
if (confirm(`Êtes-vous sûr de vouloir supprimer l'utilisateur "${user.name}" ?`)) {
router.delete(route('users.destroy', user.id));
}
};
const formatDate = (dateString) => {
if (!dateString) return '-';
return new Date(dateString).toLocaleDateString('fr-FR', {
day: 'numeric',
month: 'long',
year: 'numeric',
});
};
</script>
<template>
<Head title="Gestion des Utilisateurs" />
<AuthenticatedLayout>
<template #header>
<div class="flex items-center justify-between">
<h2 class="text-xl font-bold leading-tight text-slate-800 dark:text-slate-200">
Gestion des Utilisateurs
</h2>
<Link
:href="route('users.create')"
class="inline-flex items-center px-4 py-2 text-sm font-semibold text-white bg-sky-600 hover:bg-sky-500 rounded-lg shadow-sm focus:outline-none transition-colors"
>
+ Nouvel Utilisateur
</Link>
</div>
</template>
<div class="py-6">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<!-- Session Flash Messages -->
<div v-if="$page.props.flash.success" class="mb-4 p-4 text-sm text-emerald-800 bg-emerald-50 dark:bg-emerald-950/20 dark:text-emerald-300 border border-emerald-100 dark:border-emerald-900/40 rounded-xl">
{{ $page.props.flash.success }}
</div>
<div v-if="$page.props.errors.error" class="mb-4 p-4 text-sm text-rose-800 bg-rose-50 dark:bg-rose-950/20 dark:text-rose-300 border border-rose-100 dark:border-rose-900/40 rounded-xl">
{{ $page.props.errors.error }}
</div>
<!-- Users list -->
<div class="bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-xl shadow-sm overflow-hidden">
<div class="overflow-x-auto">
<table class="w-full text-left border-collapse">
<thead>
<tr class="bg-slate-50 dark:bg-slate-950 border-b border-slate-100 dark:border-slate-850">
<th class="px-6 py-4 text-xs font-bold uppercase tracking-wider text-slate-500 dark:text-slate-400">Nom</th>
<th class="px-6 py-4 text-xs font-bold uppercase tracking-wider text-slate-500 dark:text-slate-400">Email</th>
<th class="px-6 py-4 text-xs font-bold uppercase tracking-wider text-slate-500 dark:text-slate-400">Rôle</th>
<th class="px-6 py-4 text-xs font-bold uppercase tracking-wider text-slate-500 dark:text-slate-400">Inscrit le</th>
<th class="px-6 py-4 text-xs font-bold uppercase tracking-wider text-slate-500 dark:text-slate-400 text-right">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100 dark:divide-slate-850">
<tr v-for="user in users" :key="user.id" class="hover:bg-slate-50/50 dark:hover:bg-slate-850/20 transition-colors">
<td class="px-6 py-4 text-sm font-semibold text-slate-800 dark:text-slate-200">
{{ user.name }}
<span v-if="user.id === $page.props.auth.user.id" class="ml-2 text-xxs font-bold text-sky-600 bg-sky-50 dark:bg-sky-950/40 px-2 py-0.5 rounded-full">
Moi
</span>
</td>
<td class="px-6 py-4 text-sm text-slate-600 dark:text-slate-300">
{{ user.email }}
</td>
<td class="px-6 py-4 text-sm">
<span
v-if="user.role === 'chef_service'"
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-purple-50 text-purple-700 dark:bg-purple-950/40 dark:text-purple-300 border border-purple-100 dark:border-purple-900/60"
>
Chef de Service
</span>
<span
v-else
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-50 text-blue-700 dark:bg-blue-950/40 dark:text-blue-300 border border-blue-100 dark:border-blue-900/60"
>
Admin Réseau
</span>
</td>
<td class="px-6 py-4 text-sm text-slate-500 dark:text-slate-400">
{{ formatDate(user.created_at) }}
</td>
<td class="px-6 py-4 text-sm text-right space-x-2">
<Link
:href="route('users.edit', user.id)"
class="inline-flex items-center text-xs font-semibold text-indigo-600 hover:text-indigo-700 dark:text-indigo-400 dark:hover:text-indigo-350"
>
Modifier
</Link>
<button
v-if="user.id !== $page.props.auth.user.id"
type="button"
@click="deleteUser(user)"
class="inline-flex items-center text-xs font-semibold text-rose-600 hover:text-rose-700 dark:text-rose-400 dark:hover:text-rose-350"
>
Supprimer
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</AuthenticatedLayout>
</template>