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
@@ -3,8 +3,12 @@
namespace App\Http\Controllers;
use App\Models\Attachment;
use App\Services\GeminiService;
use App\Services\OllamaService;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\Gate;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class AttachmentController extends Controller
{
@@ -30,4 +34,61 @@ class AttachmentController extends Controller
'Content-Disposition' => 'inline; filename="' . basename($attachment->file_name) . '"'
]);
}
/**
* Effectue une OCRisation de la pièce jointe à l'aide de l'API Gemini.
*/
public function ocr(Request $request, Attachment $attachment, GeminiService $geminiService, OllamaService $ollamaService): JsonResponse
{
// On vérifie si l'utilisateur a le droit de voir la commande liée à cette pièce jointe
$order = $attachment->order;
Gate::authorize('view', $order);
try {
$mode = $request->input('mode', 'gemini');
if ($mode === 'ollama') {
$data = $ollamaService->analyzeQuote($attachment);
} else {
$data = $geminiService->analyzeQuote($attachment);
}
return response()->json([
'success' => true,
'data' => $data
]);
} catch (\Exception $e) {
return response()->json([
'success' => false,
'message' => $e->getMessage()
], 500);
}
}
/**
* Effectue une OCRisation directe sur un fichier téléchargé temporairement.
*/
public function ocrUpload(Request $request, GeminiService $geminiService, OllamaService $ollamaService): JsonResponse
{
$request->validate([
'file' => 'required|file|mimes:pdf,png,jpg,jpeg,webp|max:10240',
'mode' => 'nullable|string'
]);
try {
$mode = $request->input('mode', 'gemini');
if ($mode === 'ollama') {
$data = $ollamaService->analyzeUploadedFile($request->file('file'));
} else {
$data = $geminiService->analyzeUploadedFile($request->file('file'));
}
return response()->json([
'success' => true,
'data' => $data
]);
} catch (\Exception $e) {
return response()->json([
'success' => false,
'message' => $e->getMessage()
], 500);
}
}
}
+7
View File
@@ -78,6 +78,12 @@ class DevisController extends Controller
public function createFromCommande(\App\Models\Order $commande)
{
$commande->load('attachments');
$quoteAttachments = $commande->attachments->where('file_type', 'quote')->map(fn($a) => [
'id' => $a->id,
'file_name' => $a->file_name,
])->values()->all();
return Inertia::render('Devis/CreateFromCommande', [
'commande' => [
'id' => $commande->id,
@@ -85,6 +91,7 @@ class DevisController extends Controller
'label' => $commande->label,
'supplier' => $commande->supplier,
'quote_number' => $commande->quote_number,
'quote_attachments' => $quoteAttachments,
],
]);
}
+137
View File
@@ -0,0 +1,137 @@
<?php
namespace App\Http\Controllers;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Rule;
use Inertia\Inertia;
class UserController extends Controller
{
/**
* Vérifie les droits d'administration de l'utilisateur.
*/
protected function authorizeAdmin(Request $request)
{
if ($request->user()->role !== 'chef_service') {
abort(403, 'Action non autorisée. Réservé aux chefs de service.');
}
}
/**
* Liste des utilisateurs.
*/
public function index(Request $request)
{
$this->authorizeAdmin($request);
$users = User::latest()->get();
return Inertia::render('Users/Index', [
'users' => $users,
]);
}
/**
* Formulaire de création.
*/
public function create(Request $request)
{
$this->authorizeAdmin($request);
return Inertia::render('Users/Form', [
'isEdit' => false,
'user' => null,
]);
}
/**
* Enregistrer un nouvel utilisateur.
*/
public function store(Request $request)
{
$this->authorizeAdmin($request);
$validated = $request->validate([
'name' => 'required|string|max:255',
'email' => 'required|string|email|max:255|unique:users',
'password' => 'required|string|min:8',
'role' => 'required|string|in:chef_service,admin_reseau',
]);
User::create([
'name' => $validated['name'],
'email' => $validated['email'],
'password' => Hash::make($validated['password']),
'role' => $validated['role'],
]);
return redirect()->route('users.index')->with('success', 'Utilisateur créé avec succès.');
}
/**
* Formulaire d'édition.
*/
public function edit(Request $request, User $user)
{
$this->authorizeAdmin($request);
return Inertia::render('Users/Form', [
'isEdit' => true,
'user' => $user,
]);
}
/**
* Mettre à jour un utilisateur.
*/
public function update(Request $request, User $user)
{
$this->authorizeAdmin($request);
$validated = $request->validate([
'name' => 'required|string|max:255',
'email' => [
'required',
'string',
'email',
'max:255',
Rule::unique('users')->ignore($user->id),
],
'password' => 'nullable|string|min:8',
'role' => 'required|string|in:chef_service,admin_reseau',
]);
$data = [
'name' => $validated['name'],
'email' => $validated['email'],
'role' => $validated['role'],
];
if (!empty($validated['password'])) {
$data['password'] = Hash::make($validated['password']);
}
$user->update($data);
return redirect()->route('users.index')->with('success', 'Utilisateur mis à jour avec succès.');
}
/**
* Supprimer un utilisateur.
*/
public function destroy(Request $request, User $user)
{
$this->authorizeAdmin($request);
if ($request->user()->id === $user->id) {
return redirect()->back()->withErrors(['error' => 'Vous ne pouvez pas vous supprimer vous-même !']);
}
$user->delete();
return redirect()->route('users.index')->with('success', 'Utilisateur supprimé avec succès.');
}
}
@@ -39,6 +39,17 @@ class HandleInertiaRequests extends Middleware
'error' => fn () => $request->session()->get('error'),
],
'appName' => config('app.name'),
'ollamaAvailable' => cache()->remember('ollama_available', 15, function () {
try {
$url = rtrim(config('services.ollama.url', 'http://127.0.0.1:11434'), '/');
// Remplacement de localhost par 127.0.0.1 pour contourner les lenteurs de résolution DNS sur Windows
$url = str_replace('localhost', '127.0.0.1', $url);
$response = \Illuminate\Support\Facades\Http::timeout(2.0)->get($url);
return $response->successful();
} catch (\Exception $e) {
return false;
}
}),
];
}
}
+137
View File
@@ -0,0 +1,137 @@
<?php
namespace App\Services;
use App\Models\Attachment;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\Log;
class GeminiService
{
protected ?string $apiKey;
protected string $model;
public function __construct()
{
$this->apiKey = config('services.gemini.key');
$this->model = config('services.gemini.model', 'gemini-1.5-flash');
}
/**
* Analyse un document joint via l'API Gemini et extrait les détails structurés du devis.
*/
public function analyzeQuote(Attachment $attachment): array
{
$disk = Storage::disk('public');
if (!$disk->exists($attachment->file_path)) {
throw new \Exception("Le fichier joint n'existe pas sur le disque.");
}
$path = $disk->path($attachment->file_path);
$fileContent = file_get_contents($path);
$mimeType = $disk->mimeType($attachment->file_path);
return $this->analyzeContent($fileContent, $mimeType, $attachment->file_name);
}
/**
* Analyse directement un fichier téléchargé temporairement via l'API Gemini.
*/
public function analyzeUploadedFile(\Illuminate\Http\UploadedFile $file): array
{
$fileContent = file_get_contents($file->getRealPath());
$mimeType = $file->getMimeType();
return $this->analyzeContent($fileContent, $mimeType, $file->getClientOriginalName());
}
/**
* Exécute l'appel à l'API Gemini pour extraire les données du devis.
*/
protected function analyzeContent(string $fileContent, string $mimeType, string $fileName): array
{
if (empty($this->apiKey)) {
throw new \Exception("La clé API Gemini n'est pas configurée dans l'environnement.");
}
// Encodage Base64 pour l'API Gemini
$base64Data = base64_encode($fileContent);
// Prompt strict demandant un format JSON conforme aux attentes du formulaire
$prompt = "Tu es un assistant administratif spécialisé dans l'analyse de devis informatiques et de prestations.
Analyse le document joint (devis) et extrais de façon très précise les informations suivantes au format JSON uniquement.
Structure JSON attendue :
{
\"title\": \"Un titre descriptif pour le devis (ex: Achat matériel informatique ou Prestation d'installation)\",
\"date_devis\": \"La date d'émission du devis au format YYYY-MM-DD (si non trouvée, renvoie null)\",
\"supplier\": \"Le nom de l'entreprise émettrice / fournisseur\",
\"quote_number\": \"Le numéro de devis du fournisseur\",
\"tva_rate\": 20.0, // le taux de TVA standard constaté dans le document (valeur numérique, ex: 20 ou 5.5)
\"total_ht\": 1250.00, // Le montant total HT du devis (nombre décimal)
\"items\": [
{
\"type\": \"Matériel|Licence|Prestation|Infrastructure|Autre\", // Choisis la catégorie la plus adaptée parmi ces 5 valeurs exactes
\"designation\": \"La description de l'article ou de la prestation\",
\"quantite\": 1, // La quantité (entier ou décimal)
\"prix_unitaire\": 120.50 // Le prix unitaire HT (nombre décimal)
}
]
}
Règles importantes :
1. Extrais TOUTES les lignes d'articles réelles présentes dans le devis. Ne résume pas ou ne groupe pas les lignes si elles sont détaillées.
2. Pour chaque article, détermine la catégorie la plus adaptée ('Matériel', 'Licence', 'Prestation', 'Infrastructure', 'Autre').
3. Ne réponds QUE par le JSON brut, valide, sans aucun texte explicatif avant ou après, et sans bloc de code markdown.
4. Assure-toi que les montants et quantités sont des nombres.";
$url = "https://generativelanguage.googleapis.com/v1beta/models/{$this->model}:generateContent?key={$this->apiKey}";
Log::info("Début de l'analyse OCR Gemini pour le fichier : {$fileName}");
$response = Http::withHeaders([
'Content-Type' => 'application/json',
])->post($url, [
'contents' => [
[
'parts' => [
['text' => $prompt],
[
'inlineData' => [
'mimeType' => $mimeType,
'data' => $base64Data
]
]
]
]
],
'generationConfig' => [
'responseMimeType' => 'application/json',
]
]);
if ($response->failed()) {
Log::error("Échec de l'appel Gemini API : " . $response->body());
throw new \Exception("Erreur de communication avec l'API Gemini : " . $response->status());
}
$data = $response->json();
$textResult = $data['candidates'][0]['content']['parts'][0]['text'] ?? null;
if (!$textResult) {
Log::error("Gemini n'a renvoyé aucun contenu exploitable.");
throw new \Exception("L'analyse du devis n'a retourné aucun résultat.");
}
$decoded = json_decode($textResult, true);
if (json_last_error() !== JSON_ERROR_NONE) {
Log::error("Impossible de parser le JSON retourné par Gemini : " . $textResult);
throw new \Exception("Le format des données retournées par l'OCR est invalide.");
}
Log::info("OCR Gemini réussi pour le fichier : {$fileName}");
return $decoded;
}
}
+145
View File
@@ -0,0 +1,145 @@
<?php
namespace App\Services;
use App\Models\Attachment;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\Log;
class OllamaService
{
protected string $url;
protected string $model;
public function __construct()
{
$this->url = rtrim(config('services.ollama.url', 'http://localhost:11434'), '/');
$this->model = config('services.ollama.model', 'glm-ocr');
}
/**
* Analyse un document joint via l'API Ollama et extrait les détails structurés du devis.
*/
public function analyzeQuote(Attachment $attachment): array
{
$disk = Storage::disk('public');
if (!$disk->exists($attachment->file_path)) {
throw new \Exception("Le fichier joint n'existe pas sur le disque.");
}
$path = $disk->path($attachment->file_path);
$fileContent = file_get_contents($path);
$mimeType = $disk->mimeType($attachment->file_path);
return $this->analyzeContent($fileContent, $mimeType, $attachment->file_name);
}
/**
* Analyse directement un fichier téléchargé temporairement via l'API Ollama.
*/
public function analyzeUploadedFile(\Illuminate\Http\UploadedFile $file): array
{
$fileContent = file_get_contents($file->getRealPath());
$mimeType = $file->getMimeType();
return $this->analyzeContent($fileContent, $mimeType, $file->getClientOriginalName());
}
/**
* Exécute l'appel à l'API Ollama pour extraire les données du devis.
*/
protected function analyzeContent(string $fileContent, string $mimeType, string $fileName): array
{
if ($mimeType === 'application/pdf') {
throw new \Exception("Le modèle local (Ollama/GLM-OCR) ne prend en charge que les images (PNG, JPG, JPEG, WEBP). Pour analyser un fichier PDF, veuillez utiliser Gemini ou convertir votre document PDF en image.");
}
// Encodage Base64
$base64Data = base64_encode($fileContent);
// Prompt strict demandant un format JSON conforme aux attentes du formulaire sans inclure de placeholders dans la structure JSON
$prompt = "Tu es un assistant administratif spécialisé dans l'analyse de devis.
Analyse le document joint pour en extraire très précisément les informations requises et retourne-les au format JSON uniquement.
Instructions d'extraction :
- title : Donne un titre descriptif et court basé sur l'objet du devis (ex: 'Achat matériel' ou 'Prestation installation').
- date_devis : Extrais la date d'émission au format YYYY-MM-DD (renvoie null si absente).
- supplier : Extrais le nom de l'entreprise émettrice / fournisseur.
- quote_number : Extrais le numéro du devis.
- tva_rate : Détermine le taux de TVA standard constaté (nombre décimal, ex: 20.0).
- total_ht : Extrais le montant total HT du devis (nombre décimal).
- items : Liste de TOUTES les lignes d'articles réelles détaillées. Pour chaque article :
* type : Choisis une valeur parmi ces 5 valeurs exactes uniquement : 'Matériel', 'Licence', 'Prestation', 'Infrastructure', 'Autre'.
* designation : La description / libellé de la ligne d'article.
* quantite : La quantité (nombre).
* prix_unitaire : Le prix unitaire HT (nombre).
Retourne UNIQUEMENT le JSON brut suivant cette structure exacte, sans commentaires, sans texte explicatif, et sans bloc markdown :
{
\"title\": \"\",
\"date_devis\": \"\",
\"supplier\": \"\",
\"quote_number\": \"\",
\"tva_rate\": 20.0,
\"total_ht\": 0.0,
\"items\": [
{
\"type\": \"\",
\"designation\": \"\",
\"quantite\": 1,
\"prix_unitaire\": 0.0
}
]
}";
$url = "{$this->url}/api/generate";
Log::info("Début de l'analyse OCR Ollama (GLM-OCR) pour le fichier : {$fileName}");
// Appel API Ollama
$response = Http::withHeaders([
'Content-Type' => 'application/json',
])->timeout(120)->post($url, [
'model' => $this->model,
'prompt' => $prompt,
'images' => [$base64Data],
'stream' => false,
'format' => 'json',
'options' => [
'temperature' => 0.0,
]
]);
if ($response->failed()) {
Log::error("Échec de l'appel Ollama API : " . $response->body());
throw new \Exception("Erreur de communication avec Ollama : " . $response->status());
}
$data = $response->json();
$textResult = $data['response'] ?? null;
if (!$textResult) {
Log::error("Ollama n'a renvoyé aucun contenu exploitable.");
throw new \Exception("L'analyse du devis n'a retourné aucun résultat.");
}
// Nettoyer si du markdown entoure le JSON (parfois Ollama insiste malgré format: json)
$textResult = trim($textResult);
if (str_starts_with($textResult, '```')) {
$textResult = preg_replace('/^```(?:json)?\s*/i', '', $textResult);
$textResult = preg_replace('/```$/', '', $textResult);
$textResult = trim($textResult);
}
$decoded = json_decode($textResult, true);
if (json_last_error() !== JSON_ERROR_NONE) {
Log::error("Impossible de parser le JSON retourné par Ollama : " . $textResult);
throw new \Exception("Le format des données retournées par l'OCR Ollama est invalide.");
}
Log::info("OCR Ollama réussi pour le fichier : {$fileName}");
return $decoded;
}
}
+11
View File
@@ -35,4 +35,15 @@ return [
],
],
'gemini' => [
'key' => env('GEMINI_API_KEY'),
'model' => env('GEMINI_MODEL', 'gemini-1.5-flash'),
],
'ollama' => [
'url' => env('OLLAMA_URL', 'http://localhost:11434'),
'model' => env('OLLAMA_MODEL', 'glm-ocr'),
],
];
+1 -1
View File
@@ -1,5 +1,5 @@
{
"name": "OrderCheck",
"name": "SuivisCommande",
"lockfileVersion": 3,
"requires": true,
"packages": {
@@ -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>
+6
View File
@@ -10,6 +10,7 @@ use App\Http\Controllers\AttachmentController;
use App\Http\Controllers\HardwareController;
use App\Http\Controllers\BesoinInformatiqueController;
use App\Http\Controllers\DevisController;
use App\Http\Controllers\UserController;
Route::get('/', function () {
return redirect()->route('login');
@@ -71,6 +72,8 @@ Route::middleware('auth')->group(function () {
// Pièces jointes
Route::get('/attachments/{attachment}', [AttachmentController::class, 'show'])->name('attachments.show');
Route::post('/attachments/{attachment}/ocr', [AttachmentController::class, 'ocr'])->name('attachments.ocr');
Route::post('/attachments/ocr-upload', [AttachmentController::class, 'ocrUpload'])->name('attachments.ocr-upload');
// Gestion de matériel d'infrastructure
Route::resource('materiels', HardwareController::class);
@@ -89,6 +92,9 @@ Route::middleware('auth')->group(function () {
Route::resource('devis', DevisController::class)->parameters([
'devis' => 'devi',
]);
// Gestion des utilisateurs
Route::resource('users', UserController::class);
});
require __DIR__.'/auth.php';