feat: implement user management system and integrate AI services with Ollama and Gemini
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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'),
|
||||
],
|
||||
|
||||
];
|
||||
|
||||
|
||||
Generated
+1
-1
@@ -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 -->
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user