Compare commits
10
Commits
29875be25b
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d67716516c | ||
|
|
07b75bb233 | ||
|
|
0987587a72 | ||
|
|
6d730a4363 | ||
|
|
72c6140949 | ||
|
|
2795335083 | ||
|
|
61c3c979c4 | ||
|
|
b85c2ba5a1 | ||
|
|
648749a69a | ||
|
|
51ac1b8353 |
@@ -19,6 +19,7 @@
|
|||||||
/public/hot
|
/public/hot
|
||||||
/public/storage
|
/public/storage
|
||||||
/storage/*.key
|
/storage/*.key
|
||||||
|
/storage/app/*.json
|
||||||
/storage/pail
|
/storage/pail
|
||||||
/vendor
|
/vendor
|
||||||
_ide_helper.php
|
_ide_helper.php
|
||||||
|
|||||||
@@ -3,8 +3,13 @@
|
|||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
use App\Models\Attachment;
|
use App\Models\Attachment;
|
||||||
|
use App\Services\GeminiService;
|
||||||
|
use App\Services\OllamaService;
|
||||||
|
use App\Services\MistralService;
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
use Illuminate\Support\Facades\Gate;
|
use Illuminate\Support\Facades\Gate;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
class AttachmentController extends Controller
|
class AttachmentController extends Controller
|
||||||
{
|
{
|
||||||
@@ -30,4 +35,65 @@ class AttachmentController extends Controller
|
|||||||
'Content-Disposition' => 'inline; filename="' . basename($attachment->file_name) . '"'
|
'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, MistralService $mistralService): 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);
|
||||||
|
} elseif ($mode === 'mistral') {
|
||||||
|
$data = $mistralService->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, MistralService $mistralService): 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'));
|
||||||
|
} elseif ($mode === 'mistral') {
|
||||||
|
$data = $mistralService->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)
|
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', [
|
return Inertia::render('Devis/CreateFromCommande', [
|
||||||
'commande' => [
|
'commande' => [
|
||||||
'id' => $commande->id,
|
'id' => $commande->id,
|
||||||
@@ -85,6 +91,7 @@ class DevisController extends Controller
|
|||||||
'label' => $commande->label,
|
'label' => $commande->label,
|
||||||
'supplier' => $commande->supplier,
|
'supplier' => $commande->supplier,
|
||||||
'quote_number' => $commande->quote_number,
|
'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.');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -38,6 +38,26 @@ class HandleInertiaRequests extends Middleware
|
|||||||
'success' => fn () => $request->session()->get('success'),
|
'success' => fn () => $request->session()->get('success'),
|
||||||
'error' => fn () => $request->session()->get('error'),
|
'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;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
'translations' => function () {
|
||||||
|
$locale = app()->getLocale();
|
||||||
|
$file = lang_path("{$locale}.json");
|
||||||
|
if (file_exists($file)) {
|
||||||
|
return json_decode(file_get_contents($file), true);
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Providers;
|
namespace App\Providers;
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\URL;
|
||||||
use Illuminate\Support\Facades\Vite;
|
use Illuminate\Support\Facades\Vite;
|
||||||
use Illuminate\Support\ServiceProvider;
|
use Illuminate\Support\ServiceProvider;
|
||||||
|
|
||||||
@@ -23,5 +24,9 @@ class AppServiceProvider extends ServiceProvider
|
|||||||
Vite::prefetch(concurrency: 3);
|
Vite::prefetch(concurrency: 3);
|
||||||
\Illuminate\Support\Facades\Route::model('commande', \App\Models\Order::class);
|
\Illuminate\Support\Facades\Route::model('commande', \App\Models\Order::class);
|
||||||
\Illuminate\Support\Facades\Route::model('materiel', \App\Models\Hardware::class);
|
\Illuminate\Support\Facades\Route::model('materiel', \App\Models\Hardware::class);
|
||||||
|
|
||||||
|
if (str_starts_with(config('app.url'), 'https://')) {
|
||||||
|
URL::forceScheme('https');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,211 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Models\Attachment;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Google\Auth\Credentials\ServiceAccountCredentials;
|
||||||
|
|
||||||
|
class GeminiService
|
||||||
|
{
|
||||||
|
protected ?string $apiKey;
|
||||||
|
protected string $model;
|
||||||
|
protected string $region;
|
||||||
|
protected ?string $projectId;
|
||||||
|
protected ?string $credentialsPath;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->apiKey = config('services.gemini.key');
|
||||||
|
$this->model = config('services.gemini.model', 'gemini-1.5-flash');
|
||||||
|
$this->region = config('services.gemini.region', 'us-central1');
|
||||||
|
$this->projectId = config('services.gemini.project_id');
|
||||||
|
$this->credentialsPath = config('services.gemini.credentials_path');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
{
|
||||||
|
// 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.";
|
||||||
|
|
||||||
|
// Détection automatique de Vertex AI vs Gemini (Google AI Studio)
|
||||||
|
$isVertex = false;
|
||||||
|
$credentials = null;
|
||||||
|
|
||||||
|
$resolvedPath = $this->credentialsPath;
|
||||||
|
if ($resolvedPath && !str_starts_with($resolvedPath, '{')) {
|
||||||
|
if (!file_exists($resolvedPath) && file_exists(base_path($resolvedPath))) {
|
||||||
|
$resolvedPath = base_path($resolvedPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($resolvedPath && (file_exists($resolvedPath) || str_starts_with($resolvedPath, '{'))) {
|
||||||
|
$isVertex = true;
|
||||||
|
$credentials = $resolvedPath;
|
||||||
|
} elseif ($this->apiKey) {
|
||||||
|
$resolvedApiKey = $this->apiKey;
|
||||||
|
if (!str_starts_with($resolvedApiKey, '{')) {
|
||||||
|
if (!file_exists($resolvedApiKey) && file_exists(base_path($resolvedApiKey))) {
|
||||||
|
$resolvedApiKey = base_path($resolvedApiKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (str_starts_with($resolvedApiKey, '{') || (file_exists($resolvedApiKey) && str_ends_with($resolvedApiKey, '.json'))) {
|
||||||
|
$isVertex = true;
|
||||||
|
$credentials = $resolvedApiKey;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($isVertex) {
|
||||||
|
Log::info("Début de l'analyse OCR Vertex AI pour le fichier : {$fileName}");
|
||||||
|
|
||||||
|
// Extraction du Project ID à partir des identifiants si non fourni explicitement
|
||||||
|
$projectId = $this->projectId;
|
||||||
|
if (!$projectId && $credentials) {
|
||||||
|
try {
|
||||||
|
$jsonContent = file_exists($credentials) ? file_get_contents($credentials) : $credentials;
|
||||||
|
$decodedCreds = json_decode($jsonContent, true);
|
||||||
|
$projectId = $decodedCreds['project_id'] ?? null;
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Log::warning("Impossible d'extraire le project_id de la configuration : " . $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$projectId) {
|
||||||
|
throw new \Exception("Le Project ID Google Cloud est requis pour Vertex AI.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Génération du token OAuth2 avec le SDK
|
||||||
|
try {
|
||||||
|
$scopes = ['https://www.googleapis.com/auth/cloud-platform'];
|
||||||
|
$sa = new ServiceAccountCredentials($scopes, $credentials);
|
||||||
|
$token = $sa->fetchAuthToken();
|
||||||
|
$accessToken = $token['access_token'];
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Log::error("Erreur d'authentification Vertex AI : " . $e->getMessage());
|
||||||
|
throw new \Exception("Échec de l'authentification Vertex AI : " . $e->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vertex AI Endpoint
|
||||||
|
$url = "https://{$this->region}-aiplatform.googleapis.com/v1/projects/{$projectId}/locations/{$this->region}/publishers/google/models/{$this->model}:generateContent";
|
||||||
|
|
||||||
|
$headers = [
|
||||||
|
'Authorization' => 'Bearer ' . $accessToken,
|
||||||
|
'Content-Type' => 'application/json',
|
||||||
|
];
|
||||||
|
} else {
|
||||||
|
if (empty($this->apiKey)) {
|
||||||
|
throw new \Exception("La clé API Gemini n'est pas configurée dans l'environnement.");
|
||||||
|
}
|
||||||
|
|
||||||
|
Log::info("Début de l'analyse OCR Gemini (Google AI Studio) pour le fichier : {$fileName}");
|
||||||
|
$url = "https://generativelanguage.googleapis.com/v1beta/models/{$this->model}:generateContent?key={$this->apiKey}";
|
||||||
|
$headers = [
|
||||||
|
'Content-Type' => 'application/json',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$response = Http::withHeaders($headers)->post($url, [
|
||||||
|
'contents' => [
|
||||||
|
[
|
||||||
|
'role' => 'user',
|
||||||
|
'parts' => [
|
||||||
|
['text' => $prompt],
|
||||||
|
[
|
||||||
|
'inlineData' => [
|
||||||
|
'mimeType' => $mimeType,
|
||||||
|
'data' => $base64Data
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
],
|
||||||
|
'generationConfig' => [
|
||||||
|
'responseMimeType' => 'application/json',
|
||||||
|
]
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($response->failed()) {
|
||||||
|
Log::error("Échec de l'appel API : " . $response->body());
|
||||||
|
throw new \Exception("Erreur de communication avec l'API Google : " . $response->status());
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = $response->json();
|
||||||
|
$textResult = $data['candidates'][0]['content']['parts'][0]['text'] ?? null;
|
||||||
|
|
||||||
|
if (!$textResult) {
|
||||||
|
Log::error("Google API 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 l'API : " . $textResult);
|
||||||
|
throw new \Exception("Le format des données retournées par l'OCR est invalide.");
|
||||||
|
}
|
||||||
|
|
||||||
|
Log::info("OCR Google réussi pour le fichier : {$fileName}");
|
||||||
|
|
||||||
|
return $decoded;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Models\Attachment;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
|
class MistralService
|
||||||
|
{
|
||||||
|
protected ?string $apiKey;
|
||||||
|
protected string $model;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->apiKey = config('services.mistral.key');
|
||||||
|
$this->model = config('services.mistral.model', 'pixtral-12b-2409');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Analyse un document joint via l'API Mistral AI 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 Mistral AI.
|
||||||
|
*/
|
||||||
|
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 Mistral AI 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 Mistral AI n'est pas configurée dans l'environnement.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($mimeType === 'application/pdf') {
|
||||||
|
throw new \Exception("Le modèle Mistral AI 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
|
||||||
|
$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.
|
||||||
|
|
||||||
|
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://api.mistral.ai/v1/chat/completions";
|
||||||
|
|
||||||
|
Log::info("Début de l'analyse OCR Mistral AI pour le fichier : {$fileName}");
|
||||||
|
|
||||||
|
$response = Http::withHeaders([
|
||||||
|
'Authorization' => 'Bearer ' . $this->apiKey,
|
||||||
|
'Content-Type' => 'application/json',
|
||||||
|
])->post($url, [
|
||||||
|
'model' => $this->model,
|
||||||
|
'messages' => [
|
||||||
|
[
|
||||||
|
'role' => 'user',
|
||||||
|
'content' => [
|
||||||
|
[
|
||||||
|
'type' => 'text',
|
||||||
|
'text' => $prompt
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'type' => 'image_url',
|
||||||
|
'image_url' => "data:{$mimeType};base64,{$base64Data}"
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
],
|
||||||
|
'response_format' => [
|
||||||
|
'type' => 'json_object'
|
||||||
|
],
|
||||||
|
'temperature' => 0.0
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($response->failed()) {
|
||||||
|
Log::error("Échec de l'appel Mistral AI API : " . $response->body());
|
||||||
|
throw new \Exception("Erreur de communication avec l'API Mistral AI : " . $response->status());
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = $response->json();
|
||||||
|
$textResult = $data['choices'][0]['message']['content'] ?? null;
|
||||||
|
|
||||||
|
if (!$textResult) {
|
||||||
|
Log::error("Mistral AI 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 Mistral AI : " . $textResult);
|
||||||
|
throw new \Exception("Le format des données retournées par l'OCR Mistral est invalide.");
|
||||||
|
}
|
||||||
|
|
||||||
|
Log::info("OCR Mistral AI 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+8
-1
@@ -4,6 +4,7 @@ use Illuminate\Foundation\Application;
|
|||||||
use Illuminate\Foundation\Configuration\Exceptions;
|
use Illuminate\Foundation\Configuration\Exceptions;
|
||||||
use Illuminate\Foundation\Configuration\Middleware;
|
use Illuminate\Foundation\Configuration\Middleware;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Request as SymfonyRequest;
|
||||||
|
|
||||||
return Application::configure(basePath: dirname(__DIR__))
|
return Application::configure(basePath: dirname(__DIR__))
|
||||||
->withRouting(
|
->withRouting(
|
||||||
@@ -17,7 +18,13 @@ return Application::configure(basePath: dirname(__DIR__))
|
|||||||
\Illuminate\Http\Middleware\AddLinkHeadersForPreloadedAssets::class,
|
\Illuminate\Http\Middleware\AddLinkHeadersForPreloadedAssets::class,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
//
|
$middleware->trustProxies(
|
||||||
|
at: '*',
|
||||||
|
headers: SymfonyRequest::HEADER_X_FORWARDED_FOR
|
||||||
|
| SymfonyRequest::HEADER_X_FORWARDED_HOST
|
||||||
|
| SymfonyRequest::HEADER_X_FORWARDED_PORT
|
||||||
|
| SymfonyRequest::HEADER_X_FORWARDED_PROTO,
|
||||||
|
);
|
||||||
})
|
})
|
||||||
->withExceptions(function (Exceptions $exceptions): void {
|
->withExceptions(function (Exceptions $exceptions): void {
|
||||||
$exceptions->shouldRenderJsonWhen(
|
$exceptions->shouldRenderJsonWhen(
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
"require": {
|
"require": {
|
||||||
"php": "^8.3",
|
"php": "^8.3",
|
||||||
"barryvdh/laravel-dompdf": "^3.1",
|
"barryvdh/laravel-dompdf": "^3.1",
|
||||||
|
"google/cloud-ai-platform": "^1.60",
|
||||||
"inertiajs/inertia-laravel": "^2.0",
|
"inertiajs/inertia-laravel": "^2.0",
|
||||||
"laravel/framework": "^13.8",
|
"laravel/framework": "^13.8",
|
||||||
"laravel/sanctum": "^4.0",
|
"laravel/sanctum": "^4.0",
|
||||||
@@ -16,6 +17,9 @@
|
|||||||
},
|
},
|
||||||
"require-dev": {
|
"require-dev": {
|
||||||
"fakerphp/faker": "^1.23",
|
"fakerphp/faker": "^1.23",
|
||||||
|
"laravel-lang/common": "^6.8",
|
||||||
|
"laravel-lang/locales": "^2.11",
|
||||||
|
"laravel-lang/publisher": "^16.8",
|
||||||
"laravel/breeze": "^2.4",
|
"laravel/breeze": "^2.4",
|
||||||
"laravel/pail": "^1.2.5",
|
"laravel/pail": "^1.2.5",
|
||||||
"laravel/pao": "^1.0.6",
|
"laravel/pao": "^1.0.6",
|
||||||
|
|||||||
Generated
+2455
-1
File diff suppressed because it is too large
Load Diff
@@ -35,4 +35,23 @@ return [
|
|||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
|
||||||
|
'gemini' => [
|
||||||
|
'key' => env('GEMINI_API_KEY'),
|
||||||
|
'model' => env('GEMINI_MODEL', 'gemini-1.5-flash'),
|
||||||
|
'region' => env('VERTEX_REGION', 'us-central1'),
|
||||||
|
'project_id' => env('VERTEX_PROJECT_ID'),
|
||||||
|
'credentials_path' => env('VERTEX_CREDENTIALS_PATH'),
|
||||||
|
],
|
||||||
|
|
||||||
|
'ollama' => [
|
||||||
|
'url' => env('OLLAMA_URL', 'http://localhost:11434'),
|
||||||
|
'model' => env('OLLAMA_MODEL', 'glm-ocr'),
|
||||||
|
],
|
||||||
|
|
||||||
|
'mistral' => [
|
||||||
|
'key' => env('MISTRAL_API_KEY'),
|
||||||
|
'model' => env('MISTRAL_MODEL', 'pixtral-12b-2409'),
|
||||||
|
],
|
||||||
|
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
+264
@@ -0,0 +1,264 @@
|
|||||||
|
{
|
||||||
|
"(and :count more error)": "(et :count erreur en plus)",
|
||||||
|
"(and :count more errors)": "(et :count erreur en plus)|(et :count erreurs en plus)|(et :count erreurs en plus)",
|
||||||
|
"A decryption key is required.": "Une clé de déchiffrement est requise.",
|
||||||
|
"A new verification link has been sent to the email address you provided during registration.": "Un nouveau lien de vérification a été envoyé à l'adresse e-mail que vous avez indiquée lors de votre inscription.",
|
||||||
|
"A new verification link has been sent to your email address.": "Un nouveau lien de vérification a été envoyé à votre adresse e-mail.",
|
||||||
|
"A Timeout Occurred": "Temps d'attente dépassé",
|
||||||
|
"Accept": "Accepter",
|
||||||
|
"Accepted": "Accepté",
|
||||||
|
"Action": "Action",
|
||||||
|
"Actions": "Actions",
|
||||||
|
"Add": "Ajouter",
|
||||||
|
"Add :name": "Ajouter :name",
|
||||||
|
"Admin": "Administrateur",
|
||||||
|
"Agree": "Accepter",
|
||||||
|
"All rights reserved.": "Tous droits réservés.",
|
||||||
|
"Already registered?": "Déjà inscrit ?",
|
||||||
|
"Already Reported": "Déjà rapporté",
|
||||||
|
"Archive": "Archive",
|
||||||
|
"Are you sure you want to delete your account?": "Êtes-vous sûr de vouloir supprimer votre compte ?",
|
||||||
|
"Assign": "Attribuer",
|
||||||
|
"Associate": "Associé",
|
||||||
|
"Attach": "Attacher",
|
||||||
|
"Bad Gateway": "Passerelle invalide",
|
||||||
|
"Bad Request": "Requête erronée",
|
||||||
|
"Bandwidth Limit Exceeded": "Limite de bande passante dépassée",
|
||||||
|
"Browse": "Parcourir",
|
||||||
|
"Cancel": "Annuler",
|
||||||
|
"Choose": "Choisir",
|
||||||
|
"Choose :name": "Choisir :name",
|
||||||
|
"Choose File": "Choisir le fichier",
|
||||||
|
"Choose Image": "Choisir une image",
|
||||||
|
"Click here to re-send the verification email.": "Cliquez ici pour renvoyer l'e-mail de vérification.",
|
||||||
|
"Click to copy": "Cliquer pour copier",
|
||||||
|
"Client Closed Request": "Demande fermée par le client",
|
||||||
|
"Close": "Fermer",
|
||||||
|
"Collapse": "Réduire",
|
||||||
|
"Collapse All": "Réduire tout",
|
||||||
|
"Comment": "Commentaire",
|
||||||
|
"Confirm": "Confirmer",
|
||||||
|
"Confirm Password": "Confirmer le mot de passe",
|
||||||
|
"Conflict": "Conflit",
|
||||||
|
"Connect": "Connecter",
|
||||||
|
"Connection Closed Without Response": "Connexion fermée sans réponse",
|
||||||
|
"Connection Timed Out": "La connexion a expiré",
|
||||||
|
"Continue": "Continuer",
|
||||||
|
"Create": "Créer",
|
||||||
|
"Create :name": "Créer :name",
|
||||||
|
"Created": "Créé",
|
||||||
|
"Current Password": "Mot de passe actuel",
|
||||||
|
"Dashboard": "Tableau de bord",
|
||||||
|
"Delete": "Supprimer",
|
||||||
|
"Delete :name": "Supprimer :name",
|
||||||
|
"Delete Account": "Supprimer le compte",
|
||||||
|
"Detach": "Détacher",
|
||||||
|
"Details": "Détails",
|
||||||
|
"Disable": "Désactiver",
|
||||||
|
"Discard": "Jeter",
|
||||||
|
"Done": "Fait",
|
||||||
|
"Down": "Descendre",
|
||||||
|
"Duplicate": "Dupliquer",
|
||||||
|
"Duplicate :name": "Dupliquer :name",
|
||||||
|
"Edit": "Éditer",
|
||||||
|
"Edit :name": "Modifier :name",
|
||||||
|
"Email": "E-mail",
|
||||||
|
"email": "Le champ :attribute doit être une adresse e-mail valide.",
|
||||||
|
"Email Password Reset Link": "Lien de réinitialisation du mot de passe",
|
||||||
|
"Enable": "Activer",
|
||||||
|
"Encrypted environment file already exists.": "Le fichier d'environnement chiffré existe déjà.",
|
||||||
|
"Encrypted environment file not found.": "Fichier d'environnement chiffré introuvable.",
|
||||||
|
"Ensure your account is using a long, random password to stay secure.": "Assurez-vous d'utiliser un mot de passe long et aléatoire pour sécuriser votre compte.",
|
||||||
|
"Environment file already exists.": "Le fichier d'environnement existe déjà.",
|
||||||
|
"Environment file not found.": "Fichier d'environnement introuvable.",
|
||||||
|
"errors": "les erreurs",
|
||||||
|
"Expand": "Développer",
|
||||||
|
"Expand All": "Développer tout",
|
||||||
|
"Expectation Failed": "Comportement attendu insatisfaisant",
|
||||||
|
"Explanation": "Explication",
|
||||||
|
"Export": "Exporter",
|
||||||
|
"Export :name": "Exporter :name",
|
||||||
|
"Failed Dependency": "Dépendance échouée",
|
||||||
|
"File": "Déposer",
|
||||||
|
"Files": "Des dossiers",
|
||||||
|
"Forbidden": "Interdit",
|
||||||
|
"Forgot your password?": "Mot de passe oublié ?",
|
||||||
|
"Forgot your password? No problem. Just let us know your email address and we will email you a password reset link that will allow you to choose a new one.": "Mot de passe oublié ? Pas de soucis. Veuillez nous indiquer votre adresse e-mail et nous vous enverrons un lien de réinitialisation du mot de passe.",
|
||||||
|
"Found": "Trouvé",
|
||||||
|
"Gateway Timeout": "Temps d'attente de la passerelle dépassé",
|
||||||
|
"Go Home": "Aller à l'accueil",
|
||||||
|
"Go to page :page": "Aller à la page :page",
|
||||||
|
"Gone": "Disparu",
|
||||||
|
"Hello!": "Bonjour !",
|
||||||
|
"Hide": "Cacher",
|
||||||
|
"Hide :name": "Cacher :name",
|
||||||
|
"Home": "Accueil",
|
||||||
|
"HTTP Version Not Supported": "Version HTTP non prise en charge",
|
||||||
|
"I'm a teapot": "Je suis une théière",
|
||||||
|
"If you did not create an account, no further action is required.": "Si vous n'avez pas créé de compte, vous pouvez ignorer ce message.",
|
||||||
|
"If you did not request a password reset, no further action is required.": "Si vous n'avez pas demandé de réinitialisation de mot de passe, vous pouvez ignorer ce message.",
|
||||||
|
"If you're having trouble clicking the \":actionText\" button, copy and paste the URL below\ninto your web browser:": "Si vous avez des difficultés à cliquer sur le bouton \":actionText\", copiez et collez l'URL ci-dessous\ndans votre navigateur Web :",
|
||||||
|
"IM Used": "IM utilisé",
|
||||||
|
"Image": "Image",
|
||||||
|
"Impersonate": "Utiliser un autre compte",
|
||||||
|
"Impersonation": "Imitation",
|
||||||
|
"Import": "Importer",
|
||||||
|
"Import :name": "Importer :name",
|
||||||
|
"Insufficient Storage": "Espace insuffisant",
|
||||||
|
"Internal Server Error": "Erreur interne du serveur",
|
||||||
|
"Introduction": "Introduction",
|
||||||
|
"Invalid filename.": "Nom de fichier incorrect.",
|
||||||
|
"Invalid JSON was returned from the route.": "Un JSON non valide a été renvoyé par la route.",
|
||||||
|
"Invalid SSL Certificate": "Certificat SSL invalide",
|
||||||
|
"length": "longueur",
|
||||||
|
"Length Required": "Longueur requise",
|
||||||
|
"Like": "Aimer",
|
||||||
|
"Load": "Charger",
|
||||||
|
"Localize": "Localiser",
|
||||||
|
"Location": "Emplacement",
|
||||||
|
"Locked": "Verrouillé",
|
||||||
|
"Log In": "Se connecter",
|
||||||
|
"Log in": "Se connecter",
|
||||||
|
"Log Out": "Se déconnecter",
|
||||||
|
"Login": "Connexion",
|
||||||
|
"Logout": "Déconnexion",
|
||||||
|
"Loop Detected": "Boucle détectée",
|
||||||
|
"Maintenance Mode": "Mode de maintenance",
|
||||||
|
"Method Not Allowed": "Méthode non autorisée",
|
||||||
|
"Misdirected Request": "Demande mal dirigée",
|
||||||
|
"Moved Permanently": "Déplacé de façon permanente",
|
||||||
|
"Multi-Status": "Statut multiple",
|
||||||
|
"Multiple Choices": "Choix multiples",
|
||||||
|
"Name": "Nom",
|
||||||
|
"name": "nom",
|
||||||
|
"Network Authentication Required": "Authentification réseau requise",
|
||||||
|
"Network Connect Timeout Error": "Temps d'attente de la connexion réseau dépassé",
|
||||||
|
"Network Read Timeout Error": "Temps d'attente de la lecture réseau dépassé",
|
||||||
|
"New": "Nouveau",
|
||||||
|
"New :name": "Nouveau :name",
|
||||||
|
"New Password": "Nouveau mot de passe",
|
||||||
|
"No": "Non",
|
||||||
|
"No Content": "Pas de contenu",
|
||||||
|
"Non-Authoritative Information": "Informations non certifiées",
|
||||||
|
"Not Acceptable": "Pas acceptable",
|
||||||
|
"Not Extended": "Non prolongé",
|
||||||
|
"Not Found": "Non trouvé",
|
||||||
|
"Not Implemented": "Non implémenté",
|
||||||
|
"Not Modified": "Non modifié",
|
||||||
|
"of": "de",
|
||||||
|
"OK": "OK",
|
||||||
|
"Once your account is deleted, all of its resources and data will be permanently deleted. Before deleting your account, please download any data or information that you wish to retain.": "Une fois que votre compte est supprimé, toutes vos données sont supprimées définitivement. Avant de supprimer votre compte, veuillez télécharger vos données.",
|
||||||
|
"Once your account is deleted, all of its resources and data will be permanently deleted. Please enter your password to confirm you would like to permanently delete your account.": "Une fois que votre compte est supprimé, toutes les données associées seront supprimées définitivement. Pour confirmer que vous voulez supprimer définitivement votre compte, renseignez votre mot de passe.",
|
||||||
|
"Open": "Ouvrir",
|
||||||
|
"Open in a current window": "Ouvrir dans une fenêtre actuelle",
|
||||||
|
"Open in a new window": "Ouvrir dans une nouvelle fenêtre",
|
||||||
|
"Open in a parent frame": "Ouvrir dans un cadre parent",
|
||||||
|
"Open in the topmost frame": "Ouvrir dans le cadre le plus haut",
|
||||||
|
"Open on the website": "Ouvrir sur le site",
|
||||||
|
"Origin Is Unreachable": "L'origine est inaccessible",
|
||||||
|
"Page Expired": "Page expirée",
|
||||||
|
"Pagination Navigation": "Pagination",
|
||||||
|
"Partial Content": "Contenu partiel",
|
||||||
|
"Password": "Mot de passe",
|
||||||
|
"password": "Le mot de passe est incorrect",
|
||||||
|
"Payload Too Large": "Charge utile trop grande",
|
||||||
|
"Payment Required": "Paiement requis",
|
||||||
|
"Permanent Redirect": "Redirection permanente",
|
||||||
|
"Please click the button below to verify your email address.": "Veuillez cliquer sur le bouton ci-dessous pour vérifier votre adresse e-mail :",
|
||||||
|
"Precondition Failed": "La précondition a échoué",
|
||||||
|
"Precondition Required": "Condition préalable requise",
|
||||||
|
"Preview": "Aperçu",
|
||||||
|
"Price": "Prix",
|
||||||
|
"Processing": "En traitement",
|
||||||
|
"Profile": "Profil",
|
||||||
|
"Profile Information": "Informations du profil",
|
||||||
|
"Proxy Authentication Required": "Authentification proxy requise",
|
||||||
|
"Railgun Error": "Erreur de Railgun",
|
||||||
|
"Range Not Satisfiable": "Plage non satisfaisante",
|
||||||
|
"Record": "Enregistrer",
|
||||||
|
"Regards,": "Cordialement,",
|
||||||
|
"Register": "Inscription",
|
||||||
|
"Remember me": "Se souvenir de moi",
|
||||||
|
"Request Header Fields Too Large": "Champs d'en-tête de requête trop grands",
|
||||||
|
"Request Timeout": "Temps d'attente de la requête dépassé",
|
||||||
|
"Resend Verification Email": "Renvoyer l'e-mail de vérification",
|
||||||
|
"Reset Content": "Réinitialiser le contenu",
|
||||||
|
"Reset Password": "Réinitialisation du mot de passe",
|
||||||
|
"Reset your password": "Reset your password",
|
||||||
|
"Restore": "Restaurer",
|
||||||
|
"Restore :name": "Restaurer :name",
|
||||||
|
"results": "résultats",
|
||||||
|
"Retry With": "Réessayer avec",
|
||||||
|
"Save": "Sauvegarder",
|
||||||
|
"Save & Close": "Sauvegarder et fermer",
|
||||||
|
"Save & Return": "Sauvegarder et retourner",
|
||||||
|
"Save :name": "Sauvegarder :name",
|
||||||
|
"Saved.": "Sauvegardé.",
|
||||||
|
"Search": "Rechercher",
|
||||||
|
"Search :name": "Chercher :name",
|
||||||
|
"See Other": "Voir autre",
|
||||||
|
"Select": "Sélectionner",
|
||||||
|
"Select All": "Tout sélectionner",
|
||||||
|
"Send": "Envoyer",
|
||||||
|
"Server Error": "Erreur serveur",
|
||||||
|
"Service Unavailable": "Service indisponible",
|
||||||
|
"Session Has Expired": "La session a expiré",
|
||||||
|
"Settings": "Paramètres",
|
||||||
|
"Show": "Afficher",
|
||||||
|
"Show :name": "Afficher :name",
|
||||||
|
"Show All": "Afficher tout",
|
||||||
|
"Showing": "Montrant",
|
||||||
|
"Sign In": "Se connecter",
|
||||||
|
"Solve": "Résoudre",
|
||||||
|
"SSL Handshake Failed": "Échec de la prise de contact SSL",
|
||||||
|
"Start": "Commencer",
|
||||||
|
"Stop": "Arrêter",
|
||||||
|
"Submit": "Soumettre",
|
||||||
|
"Subscribe": "S'abonner",
|
||||||
|
"Switch": "Changer",
|
||||||
|
"Switch To Role": "Passer au rôle",
|
||||||
|
"Switching Protocols": "Protocoles de commutation",
|
||||||
|
"Tag": "Mot clé",
|
||||||
|
"Tags": "Mots clés",
|
||||||
|
"Temporary Redirect": "Redirection temporaire",
|
||||||
|
"Thanks for signing up! Before getting started, could you verify your email address by clicking on the link we just emailed to you? If you didn't receive the email, we will gladly send you another.": "Merci de vous être inscrit(e) ! Avant de commencer, veuillez vérifier votre adresse e-mail en cliquant sur le lien que nous venons de vous envoyer. Si vous n'avez pas reçu cet e-mail, nous vous en enverrons un nouveau avec plaisir.",
|
||||||
|
"The given data was invalid.": "La donnée renseignée est incorrecte.",
|
||||||
|
"The response is not a streamed response.": "La réponse n'est pas une réponse diffusée.",
|
||||||
|
"The response is not a view.": "La réponse n'est pas une vue.",
|
||||||
|
"This action is unauthorized.": "Cette action n'est pas autorisée.",
|
||||||
|
"This is a secure area of the application. Please confirm your password before continuing.": "Ceci est une zone sécurisée de l'application. Veuillez confirmer votre mot de passe avant de continuer.",
|
||||||
|
"This password reset link will expire in :count minutes.": "Ce lien de réinitialisation du mot de passe expirera dans :count minutes.",
|
||||||
|
"to": "à",
|
||||||
|
"Toggle navigation": "Afficher / masquer le menu de navigation",
|
||||||
|
"Too Early": "Trop tôt",
|
||||||
|
"Too Many Requests": "Trop de requêtes",
|
||||||
|
"Translate": "Traduire",
|
||||||
|
"Translate It": "Traduis le",
|
||||||
|
"Unauthorized": "Non autorisé",
|
||||||
|
"Unavailable For Legal Reasons": "Indisponible pour des raisons légales",
|
||||||
|
"Unknown Error": "Erreur inconnue",
|
||||||
|
"Unpack": "Déballer",
|
||||||
|
"Unprocessable Entity": "Entité non traitable",
|
||||||
|
"Unsubscribe": "Se désabonner",
|
||||||
|
"Unsupported Media Type": "Type de média non supporté",
|
||||||
|
"Up": "Monter",
|
||||||
|
"Update": "Mettre à jour",
|
||||||
|
"Update :name": "Mettre à jour :name",
|
||||||
|
"Update Password": "Mettre à jour le mot de passe",
|
||||||
|
"Update your account's profile information and email address.": "Modifier le profil associé à votre compte ainsi que votre adresse e-mail.",
|
||||||
|
"Upgrade Required": "Mise à niveau requise",
|
||||||
|
"URI Too Long": "URI trop long",
|
||||||
|
"Use Proxy": "Utiliser un proxy",
|
||||||
|
"User": "Utilisateur",
|
||||||
|
"Variant Also Negotiates": "La variante négocie également",
|
||||||
|
"Verify Email Address": "Vérifier l'adresse e-mail",
|
||||||
|
"Verify your email address": "Verify your email address",
|
||||||
|
"View": "Vue",
|
||||||
|
"View :name": "Voir :name",
|
||||||
|
"Web Server is Down": "Le serveur Web est en panne",
|
||||||
|
"Whoops!": "Oups !",
|
||||||
|
"Yes": "Oui",
|
||||||
|
"You are receiving this email because we received a password reset request for your account.": "Vous recevez cet e-mail car nous avons reçu une demande de réinitialisation de mot de passe pour votre compte.",
|
||||||
|
"You're logged in!": "Vous êtes connecté !",
|
||||||
|
"Your email address is unverified.": "Votre adresse e-mail n'est pas vérifiée."
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'accept' => 'Accepter',
|
||||||
|
'action' => 'Action',
|
||||||
|
'actions' => 'Actions',
|
||||||
|
'add' => 'Ajouter',
|
||||||
|
'admin' => 'Administrateur',
|
||||||
|
'agree' => 'Approuver',
|
||||||
|
'archive' => 'Archiver',
|
||||||
|
'assign' => 'Attribuer',
|
||||||
|
'associate' => 'Associer',
|
||||||
|
'attach' => 'Attacher',
|
||||||
|
'browse' => 'Parcourir',
|
||||||
|
'cancel' => 'Annuler',
|
||||||
|
'choose' => 'Choisir',
|
||||||
|
'choose_file' => 'Choisir le fichier',
|
||||||
|
'choose_image' => 'Choisir une image',
|
||||||
|
'click_to_copy' => 'Cliquer pour copier',
|
||||||
|
'close' => 'Fermer',
|
||||||
|
'collapse' => 'Réduire',
|
||||||
|
'collapse_all' => 'Réduire tout',
|
||||||
|
'comment' => 'Commentaire',
|
||||||
|
'confirm' => 'Confirmer',
|
||||||
|
'connect' => 'Connecter',
|
||||||
|
'create' => 'Créer',
|
||||||
|
'delete' => 'Supprimer',
|
||||||
|
'detach' => 'Détacher',
|
||||||
|
'details' => 'Détails',
|
||||||
|
'disable' => 'Désactiver',
|
||||||
|
'discard' => 'Jeter',
|
||||||
|
'done' => 'Fait',
|
||||||
|
'down' => 'Descendre',
|
||||||
|
'duplicate' => 'Dupliquer',
|
||||||
|
'edit' => 'Editer',
|
||||||
|
'enable' => 'Activer',
|
||||||
|
'expand' => 'Développer',
|
||||||
|
'expand_all' => 'Développer tout',
|
||||||
|
'explanation' => 'Explication',
|
||||||
|
'export' => 'Exporter',
|
||||||
|
'file' => 'Déposer',
|
||||||
|
'files' => 'Fichiers',
|
||||||
|
'go_home' => 'Aller à l\'accueil',
|
||||||
|
'hide' => 'Cacher',
|
||||||
|
'home' => 'Accueil',
|
||||||
|
'image' => 'Image',
|
||||||
|
'impersonate' => 'Imiter',
|
||||||
|
'impersonation' => 'Imitation',
|
||||||
|
'import' => 'Importer',
|
||||||
|
'introduction' => 'Introduction',
|
||||||
|
'like' => 'Aimer',
|
||||||
|
'load' => 'Charger',
|
||||||
|
'localize' => 'Localiser',
|
||||||
|
'log_in' => 'Se connecter',
|
||||||
|
'log_out' => 'Se déconnecter',
|
||||||
|
'named' => [
|
||||||
|
'add' => 'Ajouter :name',
|
||||||
|
'choose' => 'Choisir :name',
|
||||||
|
'create' => 'Créer :name',
|
||||||
|
'delete' => 'Supprimer :name',
|
||||||
|
'duplicate' => 'Dupliquer :name',
|
||||||
|
'edit' => 'Editer :name',
|
||||||
|
'export' => 'Exporter :name',
|
||||||
|
'hide' => 'Cacher :name',
|
||||||
|
'import' => 'Importer :name',
|
||||||
|
'new' => 'Nouveau :name',
|
||||||
|
'restore' => 'Restaurer :name',
|
||||||
|
'save' => 'Sauvegarder :name',
|
||||||
|
'search' => 'Chercher :name',
|
||||||
|
'show' => 'Afficher :name',
|
||||||
|
'update' => 'Mettre à jour :name',
|
||||||
|
'view' => 'Voir :name',
|
||||||
|
],
|
||||||
|
'new' => 'Nouveau',
|
||||||
|
'no' => 'Non',
|
||||||
|
'open' => 'Ouvrir',
|
||||||
|
'open_website' => 'Ouvrir sur le site',
|
||||||
|
'preview' => 'Aperçu',
|
||||||
|
'price' => 'Prix',
|
||||||
|
'record' => 'Enregistrer',
|
||||||
|
'restore' => 'Restaurer',
|
||||||
|
'save' => 'Sauvegarder',
|
||||||
|
'save_and_close' => 'Sauvegarder et fermer',
|
||||||
|
'save_and_return' => 'Sauvegarder et retourner',
|
||||||
|
'search' => 'Chercher',
|
||||||
|
'select' => 'Sélectionner',
|
||||||
|
'select_all' => 'Tout sélectionner',
|
||||||
|
'send' => 'Envoyer',
|
||||||
|
'settings' => 'Paramètres',
|
||||||
|
'show' => 'Montrer',
|
||||||
|
'show_all' => 'Afficher tout',
|
||||||
|
'sign_in' => 'Se connecter',
|
||||||
|
'solve' => 'Résoudre',
|
||||||
|
'start' => 'Commencer',
|
||||||
|
'stop' => 'Arrêter',
|
||||||
|
'submit' => 'Soumettre',
|
||||||
|
'subscribe' => 'S\'abonner',
|
||||||
|
'switch' => 'Changer',
|
||||||
|
'switch_to_role' => 'Passer au rôle',
|
||||||
|
'tag' => 'Mot clé',
|
||||||
|
'tags' => 'Mots clés',
|
||||||
|
'target_link' => [
|
||||||
|
'blank' => 'Ouvrir dans une nouvelle fenêtre',
|
||||||
|
'parent' => 'Ouvrir dans la fenêtre parente',
|
||||||
|
'self' => 'Ouvrir dans la fenêtre actuelle',
|
||||||
|
'top' => 'Ouvrir dans le cadre le plus haut',
|
||||||
|
],
|
||||||
|
'translate' => 'Traduire',
|
||||||
|
'translate_it' => 'Traduis le',
|
||||||
|
'unpack' => 'Déballer',
|
||||||
|
'unsubscribe' => 'Se désabonner',
|
||||||
|
'up' => 'Monter',
|
||||||
|
'update' => 'Mettre à jour',
|
||||||
|
'user' => 'Utilisateur',
|
||||||
|
'view' => 'Voir',
|
||||||
|
'yes' => 'Oui',
|
||||||
|
];
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'failed' => 'Ces identifiants ne correspondent pas à nos enregistrements.',
|
||||||
|
'password' => 'Le mot de passe est incorrect',
|
||||||
|
'throttle' => 'Tentatives de connexion trop nombreuses. Veuillez essayer de nouveau dans :seconds secondes.',
|
||||||
|
];
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'0' => 'Erreur inconnue',
|
||||||
|
'100' => 'Continuer',
|
||||||
|
'101' => 'Protocoles de commutation',
|
||||||
|
'102' => 'En traitement',
|
||||||
|
'200' => 'OK',
|
||||||
|
'201' => 'Créé',
|
||||||
|
'202' => 'Accepté',
|
||||||
|
'203' => 'Informations non certifiées',
|
||||||
|
'204' => 'Pas de contenu',
|
||||||
|
'205' => 'Réinitialiser le contenu',
|
||||||
|
'206' => 'Contenu partiel',
|
||||||
|
'207' => 'Statut multiple',
|
||||||
|
'208' => 'Déjà rapporté',
|
||||||
|
'226' => 'IM utilisé',
|
||||||
|
'300' => 'Choix multiples',
|
||||||
|
'301' => 'Déplacé de façon permanente',
|
||||||
|
'302' => 'A trouvé',
|
||||||
|
'303' => 'Voir autre',
|
||||||
|
'304' => 'Non modifié',
|
||||||
|
'305' => 'Utiliser un proxy',
|
||||||
|
'307' => 'Redirection temporaire',
|
||||||
|
'308' => 'Redirection permanente',
|
||||||
|
'400' => 'Requête invalide',
|
||||||
|
'401' => 'Non authentifié',
|
||||||
|
'402' => 'Paiement requis',
|
||||||
|
'403' => 'Interdit',
|
||||||
|
'404' => 'Page non trouvée',
|
||||||
|
'405' => 'Méthode non autorisée',
|
||||||
|
'406' => 'Non acceptable',
|
||||||
|
'407' => 'Authentification proxy requise',
|
||||||
|
'408' => 'Requête expirée',
|
||||||
|
'409' => 'Conflit',
|
||||||
|
'410' => 'Disparu',
|
||||||
|
'411' => 'Longueur requise',
|
||||||
|
'412' => 'La précondition a échoué',
|
||||||
|
'413' => 'Charge utile trop grande',
|
||||||
|
'414' => 'URI trop long',
|
||||||
|
'415' => 'Type de média non supporté',
|
||||||
|
'416' => 'Plage non satisfaisante',
|
||||||
|
'417' => 'Comportement attendu insatisfaisant',
|
||||||
|
'418' => 'Je suis une théière',
|
||||||
|
'419' => 'La session a expiré',
|
||||||
|
'421' => 'Demande mal dirigée',
|
||||||
|
'422' => 'Contenu non traitable',
|
||||||
|
'423' => 'Verrouillé',
|
||||||
|
'424' => 'Dépendance échouée',
|
||||||
|
'425' => 'Trop tôt',
|
||||||
|
'426' => 'Mise à niveau requise',
|
||||||
|
'428' => 'Condition préalable requise',
|
||||||
|
'429' => 'Trop de demandes',
|
||||||
|
'431' => 'Champs d\'en-tête de requête trop grands',
|
||||||
|
'444' => 'Connexion fermée sans réponse',
|
||||||
|
'449' => 'Réessayer avec',
|
||||||
|
'451' => 'Indisponible pour des raisons légales',
|
||||||
|
'499' => 'Demande fermée par le client',
|
||||||
|
'500' => 'Erreur interne du serveur',
|
||||||
|
'501' => 'Non implémenté',
|
||||||
|
'502' => 'Mauvaise passerelle',
|
||||||
|
'503' => 'Service non disponible',
|
||||||
|
'504' => 'Temps d\'attente de la passerelle dépassé',
|
||||||
|
'505' => 'Version HTTP non prise en charge',
|
||||||
|
'506' => 'La variante négocie également',
|
||||||
|
'507' => 'Espace insuffisant',
|
||||||
|
'508' => 'Boucle détectée',
|
||||||
|
'509' => 'Limite de bande passante dépassée',
|
||||||
|
'510' => 'Non prolongé',
|
||||||
|
'511' => 'Authentification réseau requise',
|
||||||
|
'520' => 'Erreur inconnue',
|
||||||
|
'521' => 'Le serveur Web est en panne',
|
||||||
|
'522' => 'La connexion a expiré',
|
||||||
|
'523' => 'L\'origine est inaccessible',
|
||||||
|
'524' => 'Un dépassement de délai s\'est produit',
|
||||||
|
'525' => 'Échec de la prise de contact SSL',
|
||||||
|
'526' => 'Certificat SSL invalide',
|
||||||
|
'527' => 'Erreur de Railgun',
|
||||||
|
'598' => 'Temps d\'attente de la lecture réseau dépassé',
|
||||||
|
'599' => 'Temps d\'attente de la connexion réseau dépassé',
|
||||||
|
'unknownError' => 'Erreur inconnue',
|
||||||
|
];
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'next' => 'Suivant »',
|
||||||
|
'previous' => '« Précédent',
|
||||||
|
];
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'reset' => 'Votre mot de passe a été réinitialisé !',
|
||||||
|
'sent' => 'Nous vous avons envoyé par email le lien de réinitialisation du mot de passe !',
|
||||||
|
'throttled' => 'Veuillez patienter avant de réessayer.',
|
||||||
|
'token' => 'Ce jeton de réinitialisation du mot de passe n\'est pas valide.',
|
||||||
|
'user' => 'Aucun utilisateur n\'a été trouvé avec cette adresse email.',
|
||||||
|
];
|
||||||
@@ -0,0 +1,288 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'accepted' => 'Le champ :attribute doit être accepté.',
|
||||||
|
'accepted_if' => 'Le champ :attribute doit être accepté quand :other a la valeur :value.',
|
||||||
|
'active_url' => 'Le champ :attribute n\'est pas une URL valide.',
|
||||||
|
'after' => 'Le champ :attribute doit être une date postérieure au :date.',
|
||||||
|
'after_or_equal' => 'Le champ :attribute doit être une date postérieure ou égale au :date.',
|
||||||
|
'alpha' => 'Le champ :attribute doit contenir uniquement des lettres.',
|
||||||
|
'alpha_dash' => 'Le champ :attribute doit contenir uniquement des lettres, des chiffres et des tirets.',
|
||||||
|
'alpha_num' => 'Le champ :attribute doit contenir uniquement des chiffres et des lettres.',
|
||||||
|
'any_of' => 'Le champ :attribute est invalide.',
|
||||||
|
'array' => 'Le champ :attribute doit être un tableau.',
|
||||||
|
'ascii' => 'Le champ :attribute ne doit contenir que des caractères alphanumériques et des symboles codés sur un octet.',
|
||||||
|
'before' => 'Le champ :attribute doit être une date antérieure au :date.',
|
||||||
|
'before_or_equal' => 'Le champ :attribute doit être une date antérieure ou égale au :date.',
|
||||||
|
'between' => [
|
||||||
|
'array' => 'Le tableau :attribute doit contenir entre :min et :max éléments.',
|
||||||
|
'file' => 'La taille du fichier de :attribute doit être comprise entre :min et :max kilo-octets.',
|
||||||
|
'numeric' => 'La valeur de :attribute doit être comprise entre :min et :max.',
|
||||||
|
'string' => 'Le texte :attribute doit contenir entre :min et :max caractères.',
|
||||||
|
],
|
||||||
|
'boolean' => 'Le champ :attribute doit être vrai ou faux.',
|
||||||
|
'can' => 'Le champ :attribute contient une valeur non autorisée.',
|
||||||
|
'confirmed' => 'Le champ de confirmation :attribute ne correspond pas.',
|
||||||
|
'contains' => 'Le champ :attribute manque une valeur requise.',
|
||||||
|
'current_password' => 'Le mot de passe est incorrect.',
|
||||||
|
'date' => 'Le champ :attribute n\'est pas une date valide.',
|
||||||
|
'date_equals' => 'Le champ :attribute doit être une date égale à :date.',
|
||||||
|
'date_format' => 'Le champ :attribute ne correspond pas au format :format.',
|
||||||
|
'decimal' => 'Le champ :attribute doit comporter :decimal décimales.',
|
||||||
|
'declined' => 'Le champ :attribute doit être décliné.',
|
||||||
|
'declined_if' => 'Le champ :attribute doit être décliné quand :other a la valeur :value.',
|
||||||
|
'different' => 'Les champs :attribute et :other doivent être différents.',
|
||||||
|
'digits' => 'Le champ :attribute doit contenir :digits chiffres.',
|
||||||
|
'digits_between' => 'Le champ :attribute doit contenir entre :min et :max chiffres.',
|
||||||
|
'dimensions' => 'La taille de l\'image :attribute n\'est pas conforme.',
|
||||||
|
'distinct' => 'Le champ :attribute a une valeur en double.',
|
||||||
|
'doesnt_contain' => 'Le champ :attribute ne doit contenir aucun des éléments suivants : :values.',
|
||||||
|
'doesnt_end_with' => 'Le champ :attribute ne doit pas finir avec une des valeurs suivantes : :values.',
|
||||||
|
'doesnt_start_with' => 'Le champ :attribute ne doit pas commencer avec une des valeurs suivantes : :values.',
|
||||||
|
'email' => 'Le champ :attribute doit être une adresse e-mail valide.',
|
||||||
|
'encoding' => 'The :attribute field must be encoded in :encoding.',
|
||||||
|
'ends_with' => 'Le champ :attribute doit se terminer par une des valeurs suivantes : :values',
|
||||||
|
'enum' => 'Le champ :attribute sélectionné est invalide.',
|
||||||
|
'exists' => 'Le champ :attribute sélectionné est invalide.',
|
||||||
|
'extensions' => 'Le champ :attribute doit avoir l\'une des extensions suivantes : :values.',
|
||||||
|
'file' => 'Le champ :attribute doit être un fichier.',
|
||||||
|
'filled' => 'Le champ :attribute doit avoir une valeur.',
|
||||||
|
'gt' => [
|
||||||
|
'array' => 'Le tableau :attribute doit contenir plus de :value éléments.',
|
||||||
|
'file' => 'La taille du fichier de :attribute doit être supérieure à :value kilo-octets.',
|
||||||
|
'numeric' => 'La valeur de :attribute doit être supérieure à :value.',
|
||||||
|
'string' => 'Le texte :attribute doit contenir plus de :value caractères.',
|
||||||
|
],
|
||||||
|
'gte' => [
|
||||||
|
'array' => 'Le tableau :attribute doit contenir au moins :value éléments.',
|
||||||
|
'file' => 'La taille du fichier de :attribute doit être supérieure ou égale à :value kilo-octets.',
|
||||||
|
'numeric' => 'La valeur de :attribute doit être supérieure ou égale à :value.',
|
||||||
|
'string' => 'Le texte :attribute doit contenir au moins :value caractères.',
|
||||||
|
],
|
||||||
|
'hex_color' => 'Le champ :attribute doit être une couleur hexadécimale valide.',
|
||||||
|
'image' => 'Le champ :attribute doit être une image.',
|
||||||
|
'in' => 'Le champ :attribute est invalide.',
|
||||||
|
'in_array' => 'Le champ :attribute n\'existe pas dans :other.',
|
||||||
|
'in_array_keys' => 'Le champ :attribute doit contenir au moins l\'une des clés suivantes : :values.',
|
||||||
|
'integer' => 'Le champ :attribute doit être un entier.',
|
||||||
|
'ip' => 'Le champ :attribute doit être une adresse IP valide.',
|
||||||
|
'ipv4' => 'Le champ :attribute doit être une adresse IPv4 valide.',
|
||||||
|
'ipv6' => 'Le champ :attribute doit être une adresse IPv6 valide.',
|
||||||
|
'json' => 'Le champ :attribute doit être un document JSON valide.',
|
||||||
|
'list' => 'Le champ :attribute doit être une liste.',
|
||||||
|
'lowercase' => 'Le champ :attribute doit être en minuscules.',
|
||||||
|
'lt' => [
|
||||||
|
'array' => 'Le tableau :attribute doit contenir moins de :value éléments.',
|
||||||
|
'file' => 'La taille du fichier de :attribute doit être inférieure à :value kilo-octets.',
|
||||||
|
'numeric' => 'La valeur de :attribute doit être inférieure à :value.',
|
||||||
|
'string' => 'Le texte :attribute doit contenir moins de :value caractères.',
|
||||||
|
],
|
||||||
|
'lte' => [
|
||||||
|
'array' => 'Le tableau :attribute doit contenir au plus :value éléments.',
|
||||||
|
'file' => 'La taille du fichier de :attribute doit être inférieure ou égale à :value kilo-octets.',
|
||||||
|
'numeric' => 'La valeur de :attribute doit être inférieure ou égale à :value.',
|
||||||
|
'string' => 'Le texte :attribute doit contenir au plus :value caractères.',
|
||||||
|
],
|
||||||
|
'mac_address' => 'Le champ :attribute doit être une adresse MAC valide.',
|
||||||
|
'max' => [
|
||||||
|
'array' => 'Le tableau :attribute ne peut pas contenir plus que :max éléments.',
|
||||||
|
'file' => 'La taille du fichier de :attribute ne peut pas dépasser :max kilo-octets.',
|
||||||
|
'numeric' => 'La valeur de :attribute ne peut pas être supérieure à :max.',
|
||||||
|
'string' => 'Le texte de :attribute ne peut pas contenir plus de :max caractères.',
|
||||||
|
],
|
||||||
|
'max_digits' => 'Le champ :attribute ne doit pas avoir plus de :max chiffres.',
|
||||||
|
'mimes' => 'Le champ :attribute doit être un fichier de type : :values.',
|
||||||
|
'mimetypes' => 'Le champ :attribute doit être un fichier de type : :values.',
|
||||||
|
'min' => [
|
||||||
|
'array' => 'Le tableau :attribute doit contenir au moins :min éléments.',
|
||||||
|
'file' => 'La taille du fichier de :attribute doit être supérieure ou égale à :min kilo-octets.',
|
||||||
|
'numeric' => 'La valeur de :attribute doit être supérieure ou égale à :min.',
|
||||||
|
'string' => 'Le texte de :attribute doit contenir au moins :min caractères.',
|
||||||
|
],
|
||||||
|
'min_digits' => 'Le champ :attribute doit avoir au moins :min chiffres.',
|
||||||
|
'missing' => 'Le champ :attribute doit être manquant.',
|
||||||
|
'missing_if' => 'Le champ :attribute doit être manquant quand :other a la valeur :value.',
|
||||||
|
'missing_unless' => 'Le champ :attribute doit être manquant sauf si :other a la valeur :value.',
|
||||||
|
'missing_with' => 'Le champ :attribute doit être manquant quand :values est présent.',
|
||||||
|
'missing_with_all' => 'Le champ :attribute doit être manquant quand :values sont présents.',
|
||||||
|
'multiple_of' => 'La valeur de :attribute doit être un multiple de :value',
|
||||||
|
'not_in' => 'Le champ :attribute sélectionné n\'est pas valide.',
|
||||||
|
'not_regex' => 'Le format du champ :attribute n\'est pas valide.',
|
||||||
|
'numeric' => 'Le champ :attribute doit contenir un nombre.',
|
||||||
|
'password' => [
|
||||||
|
'letters' => 'Le champ :attribute doit contenir au moins une lettre.',
|
||||||
|
'mixed' => 'Le champ :attribute doit contenir au moins une majuscule et une minuscule.',
|
||||||
|
'numbers' => 'Le champ :attribute doit contenir au moins un chiffre.',
|
||||||
|
'symbols' => 'Le champ :attribute doit contenir au moins un symbole.',
|
||||||
|
'uncompromised' => 'La valeur du champ :attribute est apparue dans une fuite de données. Veuillez choisir une valeur différente.',
|
||||||
|
],
|
||||||
|
'present' => 'Le champ :attribute doit être présent.',
|
||||||
|
'present_if' => 'Le champ :attribute doit être présent lorsque :other est :value.',
|
||||||
|
'present_unless' => 'Le champ :attribute doit être présent sauf si :other vaut :value.',
|
||||||
|
'present_with' => 'Le champ :attribute doit être présent lorsque :values est présent.',
|
||||||
|
'present_with_all' => 'Le champ :attribute doit être présent lorsque :values sont présents.',
|
||||||
|
'prohibited' => 'Le champ :attribute est interdit.',
|
||||||
|
'prohibited_if' => 'Le champ :attribute est interdit quand :other a la valeur :value.',
|
||||||
|
'prohibited_if_accepted' => 'Le champ :attribute est interdit quand :other a été accepté.',
|
||||||
|
'prohibited_if_declined' => 'Le champ :attribute est interdit quand :other a été refusé.',
|
||||||
|
'prohibited_unless' => 'Le champ :attribute est interdit à moins que :other est l\'une des valeurs :values.',
|
||||||
|
'prohibits' => 'Le champ :attribute interdit :other d\'être présent.',
|
||||||
|
'regex' => 'Le format du champ :attribute est invalide.',
|
||||||
|
'required' => 'Le champ :attribute est obligatoire.',
|
||||||
|
'required_array_keys' => 'Le champ :attribute doit contenir des entrées pour : :values.',
|
||||||
|
'required_if' => 'Le champ :attribute est obligatoire quand la valeur de :other est :value.',
|
||||||
|
'required_if_accepted' => 'Le champ :attribute est obligatoire quand le champ :other a été accepté.',
|
||||||
|
'required_if_declined' => 'Le champ :attribute est obligatoire quand le champ :other a été refusé.',
|
||||||
|
'required_unless' => 'Le champ :attribute est obligatoire sauf si :other est :values.',
|
||||||
|
'required_with' => 'Le champ :attribute est obligatoire quand :values est présent.',
|
||||||
|
'required_with_all' => 'Le champ :attribute est obligatoire quand :values sont présents.',
|
||||||
|
'required_without' => 'Le champ :attribute est obligatoire quand :values n\'est pas présent.',
|
||||||
|
'required_without_all' => 'Le champ :attribute est requis quand aucun de :values n\'est présent.',
|
||||||
|
'same' => 'Les champs :attribute et :other doivent être identiques.',
|
||||||
|
'size' => [
|
||||||
|
'array' => 'Le tableau :attribute doit contenir :size éléments.',
|
||||||
|
'file' => 'La taille du fichier de :attribute doit être de :size kilo-octets.',
|
||||||
|
'numeric' => 'La valeur de :attribute doit être :size.',
|
||||||
|
'string' => 'Le texte de :attribute doit contenir :size caractères.',
|
||||||
|
],
|
||||||
|
'starts_with' => 'Le champ :attribute doit commencer avec une des valeurs suivantes : :values',
|
||||||
|
'string' => 'Le champ :attribute doit être une chaîne de caractères.',
|
||||||
|
'timezone' => 'Le champ :attribute doit être un fuseau horaire valide.',
|
||||||
|
'ulid' => 'Le champ :attribute doit être un ULID valide.',
|
||||||
|
'unique' => 'La valeur du champ :attribute est déjà utilisée.',
|
||||||
|
'uploaded' => 'Le fichier du champ :attribute n\'a pu être téléversé.',
|
||||||
|
'uppercase' => 'Le champ :attribute doit être en majuscules.',
|
||||||
|
'url' => 'Le format de l\'URL de :attribute n\'est pas valide.',
|
||||||
|
'uuid' => 'Le champ :attribute doit être un UUID valide',
|
||||||
|
'attributes' => [
|
||||||
|
'address' => 'adresse',
|
||||||
|
'affiliate_url' => 'URL d\'affiliation',
|
||||||
|
'age' => 'âge',
|
||||||
|
'amount' => 'montant',
|
||||||
|
'announcement' => 'annonce',
|
||||||
|
'area' => 'zone',
|
||||||
|
'audience_prize' => 'prix du public',
|
||||||
|
'audience_winner' => 'gagnant du public',
|
||||||
|
'available' => 'disponible',
|
||||||
|
'birthday' => 'anniversaire',
|
||||||
|
'body' => 'corps',
|
||||||
|
'city' => 'ville',
|
||||||
|
'color' => 'color',
|
||||||
|
'company' => 'entreprise',
|
||||||
|
'compilation' => 'compilation',
|
||||||
|
'concept' => 'concept',
|
||||||
|
'conditions' => 'conditions',
|
||||||
|
'content' => 'contenu',
|
||||||
|
'contest' => 'contest',
|
||||||
|
'country' => 'pays',
|
||||||
|
'cover' => 'couverture',
|
||||||
|
'created_at' => 'date de création',
|
||||||
|
'creator' => 'créateur',
|
||||||
|
'currency' => 'devise',
|
||||||
|
'current_password' => 'mot de passe actuel',
|
||||||
|
'customer' => 'client',
|
||||||
|
'date' => 'date',
|
||||||
|
'date_of_birth' => 'date de naissance',
|
||||||
|
'dates' => 'rendez-vous',
|
||||||
|
'day' => 'jour',
|
||||||
|
'deleted_at' => 'date de suppression',
|
||||||
|
'description' => 'description',
|
||||||
|
'display_type' => 'type d\'affichage',
|
||||||
|
'district' => 'quartier',
|
||||||
|
'duration' => 'durée',
|
||||||
|
'email' => 'adresse e-mail',
|
||||||
|
'excerpt' => 'extrait',
|
||||||
|
'filter' => 'filtre',
|
||||||
|
'finished_at' => 'date de fin',
|
||||||
|
'first_name' => 'prénom',
|
||||||
|
'gender' => 'genre',
|
||||||
|
'grand_prize' => 'grand prix',
|
||||||
|
'group' => 'groupe',
|
||||||
|
'hour' => 'heure',
|
||||||
|
'image' => 'image',
|
||||||
|
'image_desktop' => 'image de bureau',
|
||||||
|
'image_main' => 'image principale',
|
||||||
|
'image_mobile' => 'image mobile',
|
||||||
|
'images' => 'images',
|
||||||
|
'is_audience_winner' => 'est le gagnant du public',
|
||||||
|
'is_hidden' => 'est caché',
|
||||||
|
'is_subscribed' => 'est abonné',
|
||||||
|
'is_visible' => 'est visible',
|
||||||
|
'is_winner' => 'est gagnant',
|
||||||
|
'items' => 'articles',
|
||||||
|
'key' => 'clé',
|
||||||
|
'last_name' => 'nom de famille',
|
||||||
|
'lesson' => 'leçon',
|
||||||
|
'line_address_1' => 'ligne d\'adresse 1',
|
||||||
|
'line_address_2' => 'ligne d\'adresse 2',
|
||||||
|
'login' => 'identifiant',
|
||||||
|
'message' => 'message',
|
||||||
|
'middle_name' => 'deuxième prénom',
|
||||||
|
'minute' => 'minute',
|
||||||
|
'mobile' => 'portable',
|
||||||
|
'month' => 'mois',
|
||||||
|
'name' => 'nom',
|
||||||
|
'national_code' => 'code national',
|
||||||
|
'number' => 'numéro',
|
||||||
|
'password' => 'mot de passe',
|
||||||
|
'password_confirmation' => 'confirmation du mot de passe',
|
||||||
|
'phone' => 'téléphone',
|
||||||
|
'photo' => 'photo',
|
||||||
|
'portfolio' => 'portefeuille',
|
||||||
|
'postal_code' => 'code postal',
|
||||||
|
'preview' => 'aperçu',
|
||||||
|
'price' => 'prix',
|
||||||
|
'product_id' => 'identifiant du produit',
|
||||||
|
'product_uid' => 'UID du produit',
|
||||||
|
'product_uuid' => 'UUID du produit',
|
||||||
|
'promo_code' => 'code promo',
|
||||||
|
'province' => 'région',
|
||||||
|
'quantity' => 'quantité',
|
||||||
|
'reason' => 'raison',
|
||||||
|
'recaptcha_response_field' => 'champ de réponse reCAPTCHA',
|
||||||
|
'referee' => 'arbitre',
|
||||||
|
'referees' => 'arbitres',
|
||||||
|
'region' => 'region',
|
||||||
|
'reject_reason' => 'motif de rejet',
|
||||||
|
'remember' => 'se souvenir',
|
||||||
|
'restored_at' => 'date de restauration',
|
||||||
|
'result_text_under_image' => 'texte de résultat sous l\'image',
|
||||||
|
'role' => 'rôle',
|
||||||
|
'rule' => 'règle',
|
||||||
|
'rules' => 'règles',
|
||||||
|
'second' => 'seconde',
|
||||||
|
'sex' => 'sexe',
|
||||||
|
'shipment' => 'expédition',
|
||||||
|
'short_text' => 'texte court',
|
||||||
|
'size' => 'taille',
|
||||||
|
'skills' => 'compétences',
|
||||||
|
'slug' => 'slug',
|
||||||
|
'specialization' => 'spécialisation',
|
||||||
|
'started_at' => 'date de début',
|
||||||
|
'state' => 'état',
|
||||||
|
'status' => 'statut',
|
||||||
|
'street' => 'rue',
|
||||||
|
'student' => 'étudiant',
|
||||||
|
'subject' => 'sujet',
|
||||||
|
'tag' => 'mot clé',
|
||||||
|
'tags' => 'mots clés',
|
||||||
|
'teacher' => 'professeur',
|
||||||
|
'terms' => 'conditions',
|
||||||
|
'test_description' => 'description du test',
|
||||||
|
'test_locale' => 'localisation du test',
|
||||||
|
'test_name' => 'nom du test',
|
||||||
|
'text' => 'texte',
|
||||||
|
'time' => 'heure',
|
||||||
|
'title' => 'titre',
|
||||||
|
'type' => 'type',
|
||||||
|
'updated_at' => 'date de mise à jour',
|
||||||
|
'user' => 'utilisateur',
|
||||||
|
'username' => 'nom d\'utilisateur',
|
||||||
|
'value' => 'valeur',
|
||||||
|
'winner' => 'gagnant',
|
||||||
|
'work' => 'travail',
|
||||||
|
'year' => 'année',
|
||||||
|
],
|
||||||
|
];
|
||||||
Generated
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "OrderCheck",
|
"name": "SuivisCommande",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref } from 'vue';
|
import { ref } from 'vue';
|
||||||
import ApplicationLogo from '@/Components/ApplicationLogo.vue';
|
|
||||||
import Dropdown from '@/Components/Dropdown.vue';
|
import Dropdown from '@/Components/Dropdown.vue';
|
||||||
import DropdownLink from '@/Components/DropdownLink.vue';
|
import DropdownLink from '@/Components/DropdownLink.vue';
|
||||||
import NavLink from '@/Components/NavLink.vue';
|
import NavLink from '@/Components/NavLink.vue';
|
||||||
@@ -20,12 +19,10 @@ const showingNavigationDropdown = ref(false);
|
|||||||
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||||
<div class="flex h-16 justify-between">
|
<div class="flex h-16 justify-between">
|
||||||
<div class="flex">
|
<div class="flex">
|
||||||
<!-- Logo -->
|
<!-- App name -->
|
||||||
<div class="flex shrink-0 items-center">
|
<div class="flex shrink-0 items-center">
|
||||||
<Link :href="route('dashboard')">
|
<Link :href="route('dashboard')" class="text-base font-extrabold tracking-tight text-slate-800 dark:text-slate-100">
|
||||||
<ApplicationLogo
|
{{ $page.props.appName ?? 'OrderCheck' }}
|
||||||
class="block h-9 w-auto fill-current text-gray-800 dark:text-gray-200"
|
|
||||||
/>
|
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -45,6 +42,12 @@ const showingNavigationDropdown = ref(false);
|
|||||||
>
|
>
|
||||||
Commandes
|
Commandes
|
||||||
</NavLink>
|
</NavLink>
|
||||||
|
<NavLink
|
||||||
|
:href="route('devis.index')"
|
||||||
|
:active="route().current('devis.*')"
|
||||||
|
>
|
||||||
|
Devis
|
||||||
|
</NavLink>
|
||||||
<NavLink
|
<NavLink
|
||||||
:href="route('materiels.index')"
|
:href="route('materiels.index')"
|
||||||
:active="route().current('materiels.*')"
|
:active="route().current('materiels.*')"
|
||||||
@@ -55,13 +58,14 @@ const showingNavigationDropdown = ref(false);
|
|||||||
:href="route('besoins-informatiques.index')"
|
:href="route('besoins-informatiques.index')"
|
||||||
:active="route().current('besoins-informatiques.*')"
|
:active="route().current('besoins-informatiques.*')"
|
||||||
>
|
>
|
||||||
Besoins Informatiques
|
Fiches Actions
|
||||||
</NavLink>
|
</NavLink>
|
||||||
<NavLink
|
<NavLink
|
||||||
:href="route('devis.index')"
|
v-if="$page.props.auth.user.role === 'chef_service'"
|
||||||
:active="route().current('devis.*')"
|
:href="route('users.index')"
|
||||||
|
:active="route().current('users.*')"
|
||||||
>
|
>
|
||||||
Gestion des Devis
|
Utilisateurs
|
||||||
</NavLink>
|
</NavLink>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -98,14 +102,14 @@ const showingNavigationDropdown = ref(false);
|
|||||||
<DropdownLink
|
<DropdownLink
|
||||||
:href="route('profile.edit')"
|
:href="route('profile.edit')"
|
||||||
>
|
>
|
||||||
Profile
|
{{ __('Profile') }}
|
||||||
</DropdownLink>
|
</DropdownLink>
|
||||||
<DropdownLink
|
<DropdownLink
|
||||||
:href="route('logout')"
|
:href="route('logout')"
|
||||||
method="post"
|
method="post"
|
||||||
as="button"
|
as="button"
|
||||||
>
|
>
|
||||||
Log Out
|
{{ __('Logout') }}
|
||||||
</DropdownLink>
|
</DropdownLink>
|
||||||
</template>
|
</template>
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
@@ -194,6 +198,13 @@ const showingNavigationDropdown = ref(false);
|
|||||||
>
|
>
|
||||||
Gestion des Devis
|
Gestion des Devis
|
||||||
</ResponsiveNavLink>
|
</ResponsiveNavLink>
|
||||||
|
<ResponsiveNavLink
|
||||||
|
v-if="$page.props.auth.user.role === 'chef_service'"
|
||||||
|
:href="route('users.index')"
|
||||||
|
:active="route().current('users.*')"
|
||||||
|
>
|
||||||
|
Utilisateurs
|
||||||
|
</ResponsiveNavLink>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Responsive Settings Options -->
|
<!-- Responsive Settings Options -->
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ const submit = () => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<GuestLayout>
|
<GuestLayout>
|
||||||
<Head title="Log in" />
|
<Head :title="__('Log in')" />
|
||||||
|
|
||||||
<div v-if="status" class="mb-4 text-sm font-medium text-green-600">
|
<div v-if="status" class="mb-4 text-sm font-medium text-green-600">
|
||||||
{{ status }}
|
{{ status }}
|
||||||
@@ -39,7 +39,7 @@ const submit = () => {
|
|||||||
|
|
||||||
<form @submit.prevent="submit">
|
<form @submit.prevent="submit">
|
||||||
<div>
|
<div>
|
||||||
<InputLabel for="email" value="Email" />
|
<InputLabel for="email" :value="__('Email')" />
|
||||||
|
|
||||||
<TextInput
|
<TextInput
|
||||||
id="email"
|
id="email"
|
||||||
@@ -55,7 +55,7 @@ const submit = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-4">
|
<div class="mt-4">
|
||||||
<InputLabel for="password" value="Password" />
|
<InputLabel for="password" :value="__('Password')" />
|
||||||
|
|
||||||
<TextInput
|
<TextInput
|
||||||
id="password"
|
id="password"
|
||||||
@@ -73,7 +73,7 @@ const submit = () => {
|
|||||||
<label class="flex items-center">
|
<label class="flex items-center">
|
||||||
<Checkbox name="remember" v-model:checked="form.remember" />
|
<Checkbox name="remember" v-model:checked="form.remember" />
|
||||||
<span class="ms-2 text-sm text-gray-600 dark:text-gray-400"
|
<span class="ms-2 text-sm text-gray-600 dark:text-gray-400"
|
||||||
>Remember me</span
|
>{{ __('Remember me') }}</span
|
||||||
>
|
>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
@@ -84,7 +84,7 @@ const submit = () => {
|
|||||||
:href="route('password.request')"
|
:href="route('password.request')"
|
||||||
class="rounded-md text-sm text-gray-600 underline hover:text-gray-900 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 dark:text-gray-400 dark:hover:text-gray-100 dark:focus:ring-offset-gray-800"
|
class="rounded-md text-sm text-gray-600 underline hover:text-gray-900 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 dark:text-gray-400 dark:hover:text-gray-100 dark:focus:ring-offset-gray-800"
|
||||||
>
|
>
|
||||||
Forgot your password?
|
{{ __('Forgot your password?') }}
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<PrimaryButton
|
<PrimaryButton
|
||||||
@@ -92,7 +92,7 @@ const submit = () => {
|
|||||||
:class="{ 'opacity-25': form.processing }"
|
:class="{ 'opacity-25': form.processing }"
|
||||||
:disabled="form.processing"
|
:disabled="form.processing"
|
||||||
>
|
>
|
||||||
Log in
|
{{ __('Log in') }}
|
||||||
</PrimaryButton>
|
</PrimaryButton>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed } from 'vue';
|
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 AuthenticatedLayout from '@/Layouts/AuthenticatedLayout.vue';
|
||||||
import InputLabel from '@/Components/InputLabel.vue';
|
import InputLabel from '@/Components/InputLabel.vue';
|
||||||
import TextInput from '@/Components/TextInput.vue';
|
import TextInput from '@/Components/TextInput.vue';
|
||||||
import InputError from '@/Components/InputError.vue';
|
import InputError from '@/Components/InputError.vue';
|
||||||
import PrimaryButton from '@/Components/PrimaryButton.vue';
|
import PrimaryButton from '@/Components/PrimaryButton.vue';
|
||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
order: {
|
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' || selectedModel.value === 'mistral') && 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 = [
|
const orderTypes = [
|
||||||
'Matériel réseau / serveur',
|
'Matériel réseau / serveur',
|
||||||
'Licences logicielles',
|
'Licences logicielles',
|
||||||
@@ -296,6 +406,31 @@ const demandeurs = ['Jérémy', 'Sylvain', 'Kévin'];
|
|||||||
@input="form.quote_file = $event.target.files[0]"
|
@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"
|
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 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="mistral">Mistral AI</option>
|
||||||
|
<option v-if="ollamaAvailable" 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">
|
<div v-if="getExistingFile('quote')" class="text-xs mt-1 text-slate-500">
|
||||||
Fichier existant :
|
Fichier existant :
|
||||||
<a :href="getExistingFile('quote').url" target="_blank" class="text-sky-600 dark:text-sky-400 hover:underline inline-flex items-center">
|
<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';
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -378,6 +386,9 @@ const getTimelineColor = (status) => {
|
|||||||
<span class="text-sm font-bold text-slate-900 dark:text-slate-100">
|
<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) }}
|
{{ new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(sd.total_ttc) }}
|
||||||
</span>
|
</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">
|
<Link :href="route('devis.show', sd.id)" class="text-xs font-semibold text-sky-600 dark:text-sky-400 hover:underline">
|
||||||
Voir →
|
Voir →
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -172,7 +172,7 @@ const formatCurrency = (value) => {
|
|||||||
<!-- Prix unitaire -->
|
<!-- Prix unitaire -->
|
||||||
<div class="sm:col-span-2">
|
<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>
|
<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>
|
||||||
</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">
|
<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>
|
<script setup>
|
||||||
import { ref, computed } from 'vue';
|
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 AuthenticatedLayout from '@/Layouts/AuthenticatedLayout.vue';
|
||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
commande: Object,
|
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' || selectedModel.value === 'mistral') && 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', selectedModel.value);
|
||||||
|
|
||||||
|
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 itemTypes = ['Matériel', 'Licence', 'Prestation', 'Infrastructure', 'Autre'];
|
||||||
|
|
||||||
const form = useForm({
|
const form = useForm({
|
||||||
@@ -84,6 +178,49 @@ const submit = () => {
|
|||||||
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8">
|
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
<form @submit.prevent="submit" class="space-y-6">
|
<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 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="mistral">Mistral AI</option>
|
||||||
|
<option v-if="ollamaAvailable" 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 -->
|
<!-- 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">
|
<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">
|
<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 +327,7 @@ const submit = () => {
|
|||||||
</div>
|
</div>
|
||||||
<div class="sm:col-span-2">
|
<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>
|
<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>
|
||||||
</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">
|
<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 -->
|
<!-- Prix unitaire -->
|
||||||
<div class="sm:col-span-2">
|
<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>
|
<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>
|
||||||
</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">
|
<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">
|
||||||
|
|||||||
@@ -16,14 +16,14 @@ defineProps({
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Head title="Profile" />
|
<Head :title="__('Profile')" />
|
||||||
|
|
||||||
<AuthenticatedLayout>
|
<AuthenticatedLayout>
|
||||||
<template #header>
|
<template #header>
|
||||||
<h2
|
<h2
|
||||||
class="text-xl font-semibold leading-tight text-gray-800 dark:text-gray-200"
|
class="text-xl font-semibold leading-tight text-gray-800 dark:text-gray-200"
|
||||||
>
|
>
|
||||||
Profile
|
{{ __('Profile') }}
|
||||||
</h2>
|
</h2>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -42,36 +42,32 @@ const closeModal = () => {
|
|||||||
<section class="space-y-6">
|
<section class="space-y-6">
|
||||||
<header>
|
<header>
|
||||||
<h2 class="text-lg font-medium text-gray-900 dark:text-gray-100">
|
<h2 class="text-lg font-medium text-gray-900 dark:text-gray-100">
|
||||||
Delete Account
|
{{ __('Delete Account') }}
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<p class="mt-1 text-sm text-gray-600 dark:text-gray-400">
|
<p class="mt-1 text-sm text-gray-600 dark:text-gray-400">
|
||||||
Once your account is deleted, all of its resources and data will
|
{{ __("Once your account is deleted, all of its resources and data will be permanently deleted. Before deleting your account, please download any data or information that you wish to retain.") }}
|
||||||
be permanently deleted. Before deleting your account, please
|
|
||||||
download any data or information that you wish to retain.
|
|
||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<DangerButton @click="confirmUserDeletion">Delete Account</DangerButton>
|
<DangerButton @click="confirmUserDeletion">{{ __('Delete Account') }}</DangerButton>
|
||||||
|
|
||||||
<Modal :show="confirmingUserDeletion" @close="closeModal">
|
<Modal :show="confirmingUserDeletion" @close="closeModal">
|
||||||
<div class="p-6">
|
<div class="p-6">
|
||||||
<h2
|
<h2
|
||||||
class="text-lg font-medium text-gray-900 dark:text-gray-100"
|
class="text-lg font-medium text-gray-900 dark:text-gray-100"
|
||||||
>
|
>
|
||||||
Are you sure you want to delete your account?
|
{{ __('Are you sure you want to delete your account?') }}
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<p class="mt-1 text-sm text-gray-600 dark:text-gray-400">
|
<p class="mt-1 text-sm text-gray-600 dark:text-gray-400">
|
||||||
Once your account is deleted, all of its resources and data
|
{{ __("Once your account is deleted, all of its resources and data will be permanently deleted. Please enter your password to confirm you would like to permanently delete your account.") }}
|
||||||
will be permanently deleted. Please enter your password to
|
|
||||||
confirm you would like to permanently delete your account.
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="mt-6">
|
<div class="mt-6">
|
||||||
<InputLabel
|
<InputLabel
|
||||||
for="password"
|
for="password"
|
||||||
value="Password"
|
:value="__('Password')"
|
||||||
class="sr-only"
|
class="sr-only"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -81,7 +77,7 @@ const closeModal = () => {
|
|||||||
v-model="form.password"
|
v-model="form.password"
|
||||||
type="password"
|
type="password"
|
||||||
class="mt-1 block w-3/4"
|
class="mt-1 block w-3/4"
|
||||||
placeholder="Password"
|
:placeholder="__('Password')"
|
||||||
@keyup.enter="deleteUser"
|
@keyup.enter="deleteUser"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -90,7 +86,7 @@ const closeModal = () => {
|
|||||||
|
|
||||||
<div class="mt-6 flex justify-end">
|
<div class="mt-6 flex justify-end">
|
||||||
<SecondaryButton @click="closeModal">
|
<SecondaryButton @click="closeModal">
|
||||||
Cancel
|
{{ __('Cancel') }}
|
||||||
</SecondaryButton>
|
</SecondaryButton>
|
||||||
|
|
||||||
<DangerButton
|
<DangerButton
|
||||||
@@ -99,7 +95,7 @@ const closeModal = () => {
|
|||||||
:disabled="form.processing"
|
:disabled="form.processing"
|
||||||
@click="deleteUser"
|
@click="deleteUser"
|
||||||
>
|
>
|
||||||
Delete Account
|
{{ __('Delete Account') }}
|
||||||
</DangerButton>
|
</DangerButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -37,18 +37,17 @@ const updatePassword = () => {
|
|||||||
<section>
|
<section>
|
||||||
<header>
|
<header>
|
||||||
<h2 class="text-lg font-medium text-gray-900 dark:text-gray-100">
|
<h2 class="text-lg font-medium text-gray-900 dark:text-gray-100">
|
||||||
Update Password
|
{{ __('Update Password') }}
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<p class="mt-1 text-sm text-gray-600 dark:text-gray-400">
|
<p class="mt-1 text-sm text-gray-600 dark:text-gray-400">
|
||||||
Ensure your account is using a long, random password to stay
|
{{ __('Ensure your account is using a long, random password to stay secure.') }}
|
||||||
secure.
|
|
||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<form @submit.prevent="updatePassword" class="mt-6 space-y-6">
|
<form @submit.prevent="updatePassword" class="mt-6 space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<InputLabel for="current_password" value="Current Password" />
|
<InputLabel for="current_password" :value="__('Current Password')" />
|
||||||
|
|
||||||
<TextInput
|
<TextInput
|
||||||
id="current_password"
|
id="current_password"
|
||||||
@@ -66,7 +65,7 @@ const updatePassword = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<InputLabel for="password" value="New Password" />
|
<InputLabel for="password" :value="__('New Password')" />
|
||||||
|
|
||||||
<TextInput
|
<TextInput
|
||||||
id="password"
|
id="password"
|
||||||
@@ -83,7 +82,7 @@ const updatePassword = () => {
|
|||||||
<div>
|
<div>
|
||||||
<InputLabel
|
<InputLabel
|
||||||
for="password_confirmation"
|
for="password_confirmation"
|
||||||
value="Confirm Password"
|
:value="__('Confirm Password')"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<TextInput
|
<TextInput
|
||||||
@@ -101,7 +100,7 @@ const updatePassword = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4">
|
||||||
<PrimaryButton :disabled="form.processing">Save</PrimaryButton>
|
<PrimaryButton :disabled="form.processing">{{ __('Save') }}</PrimaryButton>
|
||||||
|
|
||||||
<Transition
|
<Transition
|
||||||
enter-active-class="transition ease-in-out"
|
enter-active-class="transition ease-in-out"
|
||||||
@@ -113,7 +112,7 @@ const updatePassword = () => {
|
|||||||
v-if="form.recentlySuccessful"
|
v-if="form.recentlySuccessful"
|
||||||
class="text-sm text-gray-600 dark:text-gray-400"
|
class="text-sm text-gray-600 dark:text-gray-400"
|
||||||
>
|
>
|
||||||
Saved.
|
{{ __('Saved.') }}
|
||||||
</p>
|
</p>
|
||||||
</Transition>
|
</Transition>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -26,11 +26,11 @@ const form = useForm({
|
|||||||
<section>
|
<section>
|
||||||
<header>
|
<header>
|
||||||
<h2 class="text-lg font-medium text-gray-900 dark:text-gray-100">
|
<h2 class="text-lg font-medium text-gray-900 dark:text-gray-100">
|
||||||
Profile Information
|
{{ __('Profile Information') }}
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<p class="mt-1 text-sm text-gray-600 dark:text-gray-400">
|
<p class="mt-1 text-sm text-gray-600 dark:text-gray-400">
|
||||||
Update your account's profile information and email address.
|
{{ __("Update your account's profile information and email address.") }}
|
||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -39,7 +39,7 @@ const form = useForm({
|
|||||||
class="mt-6 space-y-6"
|
class="mt-6 space-y-6"
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<InputLabel for="name" value="Name" />
|
<InputLabel for="name" :value="__('Name')" />
|
||||||
|
|
||||||
<TextInput
|
<TextInput
|
||||||
id="name"
|
id="name"
|
||||||
@@ -55,7 +55,7 @@ const form = useForm({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<InputLabel for="email" value="Email" />
|
<InputLabel for="email" :value="__('Email')" />
|
||||||
|
|
||||||
<TextInput
|
<TextInput
|
||||||
id="email"
|
id="email"
|
||||||
@@ -71,14 +71,14 @@ const form = useForm({
|
|||||||
|
|
||||||
<div v-if="mustVerifyEmail && user.email_verified_at === null">
|
<div v-if="mustVerifyEmail && user.email_verified_at === null">
|
||||||
<p class="mt-2 text-sm text-gray-800 dark:text-gray-200">
|
<p class="mt-2 text-sm text-gray-800 dark:text-gray-200">
|
||||||
Your email address is unverified.
|
{{ __('Your email address is unverified.') }}
|
||||||
<Link
|
<Link
|
||||||
:href="route('verification.send')"
|
:href="route('verification.send')"
|
||||||
method="post"
|
method="post"
|
||||||
as="button"
|
as="button"
|
||||||
class="rounded-md text-sm text-gray-600 underline hover:text-gray-900 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 dark:text-gray-400 dark:hover:text-gray-100 dark:focus:ring-offset-gray-800"
|
class="rounded-md text-sm text-gray-600 underline hover:text-gray-900 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 dark:text-gray-400 dark:hover:text-gray-100 dark:focus:ring-offset-gray-800"
|
||||||
>
|
>
|
||||||
Click here to re-send the verification email.
|
{{ __('Click here to re-send the verification email.') }}
|
||||||
</Link>
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@@ -86,12 +86,12 @@ const form = useForm({
|
|||||||
v-show="status === 'verification-link-sent'"
|
v-show="status === 'verification-link-sent'"
|
||||||
class="mt-2 text-sm font-medium text-green-600 dark:text-green-400"
|
class="mt-2 text-sm font-medium text-green-600 dark:text-green-400"
|
||||||
>
|
>
|
||||||
A new verification link has been sent to your email address.
|
{{ __('A new verification link has been sent to your email address.') }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4">
|
||||||
<PrimaryButton :disabled="form.processing">Save</PrimaryButton>
|
<PrimaryButton :disabled="form.processing">{{ __('Save') }}</PrimaryButton>
|
||||||
|
|
||||||
<Transition
|
<Transition
|
||||||
enter-active-class="transition ease-in-out"
|
enter-active-class="transition ease-in-out"
|
||||||
@@ -103,7 +103,7 @@ const form = useForm({
|
|||||||
v-if="form.recentlySuccessful"
|
v-if="form.recentlySuccessful"
|
||||||
class="text-sm text-gray-600 dark:text-gray-400"
|
class="text-sm text-gray-600 dark:text-gray-400"
|
||||||
>
|
>
|
||||||
Saved.
|
{{ __('Saved.') }}
|
||||||
</p>
|
</p>
|
||||||
</Transition>
|
</Transition>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -19,6 +19,17 @@ createInertiaApp({
|
|||||||
return createApp({ render: () => h(App, props) })
|
return createApp({ render: () => h(App, props) })
|
||||||
.use(plugin)
|
.use(plugin)
|
||||||
.use(ZiggyVue)
|
.use(ZiggyVue)
|
||||||
|
.mixin({
|
||||||
|
methods: {
|
||||||
|
__(key, replace = {}) {
|
||||||
|
let translation = this.$page.props.translations?.[key] || key;
|
||||||
|
Object.keys(replace).forEach(r => {
|
||||||
|
translation = translation.replace(`:${r}`, replace[r]);
|
||||||
|
});
|
||||||
|
return translation;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
.mount(el);
|
.mount(el);
|
||||||
},
|
},
|
||||||
progress: {
|
progress: {
|
||||||
|
|||||||
@@ -208,9 +208,9 @@
|
|||||||
<div class="item-designation">{{ $item['designation'] ?? '' }}</div>
|
<div class="item-designation">{{ $item['designation'] ?? '' }}</div>
|
||||||
<div class="item-type">{{ $item['type'] ?? '' }}</div>
|
<div class="item-type">{{ $item['type'] ?? '' }}</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="col-price">{{ number_format($item['prix_unitaire'] ?? 0, 2, ',', "\u{202F}") }} €</td>
|
<td class="col-price">{{ number_format($item['prix_unitaire'] ?? 0, 2, ',', "\xc2\xa0") }} €</td>
|
||||||
<td class="col-qty">{{ $item['quantite'] ?? 0 }}</td>
|
<td class="col-qty">{{ $item['quantite'] ?? 0 }}</td>
|
||||||
<td class="col-total">{{ number_format(($item['quantite'] ?? 0) * ($item['prix_unitaire'] ?? 0), 2, ',', "\u{202F}") }} €</td>
|
<td class="col-total">{{ number_format(($item['quantite'] ?? 0) * ($item['prix_unitaire'] ?? 0), 2, ',', "\xc2\xa0") }} €</td>
|
||||||
</tr>
|
</tr>
|
||||||
@endforeach
|
@endforeach
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -227,15 +227,15 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
<td style="border: none; padding: 4px 0; font-weight: bold; font-size: 8.5pt; color: #0f172a; text-align: left;">TOTAL HT :</td>
|
<td style="border: none; padding: 4px 0; font-weight: bold; font-size: 8.5pt; color: #0f172a; text-align: left;">TOTAL HT :</td>
|
||||||
<td style="border: none; padding: 4px 0; font-weight: bold; font-size: 8.5pt; color: #0f172a; text-align: right;">{{ number_format($devis->total_ht, 2, ',', "\u{202F}") }} €</td>
|
<td style="border: none; padding: 4px 0; font-weight: bold; font-size: 8.5pt; color: #0f172a; text-align: right;">{{ number_format($devis->total_ht, 2, ',', "\xc2\xa0") }} €</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td style="border: none; padding: 4px 0; font-weight: bold; font-size: 8.5pt; color: #0f172a; text-align: left;">TVA ({{ $devis->tva_rate }}%) :</td>
|
<td style="border: none; padding: 4px 0; font-weight: bold; font-size: 8.5pt; color: #0f172a; text-align: left;">TVA ({{ $devis->tva_rate }}%) :</td>
|
||||||
<td style="border: none; padding: 4px 0; font-weight: bold; font-size: 8.5pt; color: #0f172a; text-align: right;">{{ number_format($devis->total_ttc - $devis->total_ht, 2, ',', "\u{202F}") }} €</td>
|
<td style="border: none; padding: 4px 0; font-weight: bold; font-size: 8.5pt; color: #0f172a; text-align: right;">{{ number_format($devis->total_ttc - $devis->total_ht, 2, ',', "\xc2\xa0") }} €</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td style="border: none; border-top: 2px solid #0f172a; padding: 9px 0 4px 0; font-weight: bold; font-size: 10.5pt; color: #0f172a; text-align: left;">TOTAL TTC :</td>
|
<td style="border: none; border-top: 2px solid #0f172a; padding: 9px 0 4px 0; font-weight: bold; font-size: 10.5pt; color: #0f172a; text-align: left;">TOTAL TTC :</td>
|
||||||
<td style="border: none; border-top: 2px solid #0f172a; padding: 9px 0 4px 0; font-weight: 800; font-size: 10.5pt; color: #0284c7; text-align: right;">{{ number_format($devis->total_ttc, 2, ',', "\u{202F}") }} €</td>
|
<td style="border: none; border-top: 2px solid #0f172a; padding: 9px 0 4px 0; font-weight: 800; font-size: 10.5pt; color: #0284c7; text-align: right;">{{ number_format($devis->total_ttc, 2, ',', "\xc2\xa0") }} €</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ use App\Http\Controllers\AttachmentController;
|
|||||||
use App\Http\Controllers\HardwareController;
|
use App\Http\Controllers\HardwareController;
|
||||||
use App\Http\Controllers\BesoinInformatiqueController;
|
use App\Http\Controllers\BesoinInformatiqueController;
|
||||||
use App\Http\Controllers\DevisController;
|
use App\Http\Controllers\DevisController;
|
||||||
|
use App\Http\Controllers\UserController;
|
||||||
|
|
||||||
Route::get('/', function () {
|
Route::get('/', function () {
|
||||||
return redirect()->route('login');
|
return redirect()->route('login');
|
||||||
@@ -71,6 +72,8 @@ Route::middleware('auth')->group(function () {
|
|||||||
|
|
||||||
// Pièces jointes
|
// Pièces jointes
|
||||||
Route::get('/attachments/{attachment}', [AttachmentController::class, 'show'])->name('attachments.show');
|
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
|
// Gestion de matériel d'infrastructure
|
||||||
Route::resource('materiels', HardwareController::class);
|
Route::resource('materiels', HardwareController::class);
|
||||||
@@ -89,6 +92,9 @@ Route::middleware('auth')->group(function () {
|
|||||||
Route::resource('devis', DevisController::class)->parameters([
|
Route::resource('devis', DevisController::class)->parameters([
|
||||||
'devis' => 'devi',
|
'devis' => 'devi',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Gestion des utilisateurs
|
||||||
|
Route::resource('users', UserController::class);
|
||||||
});
|
});
|
||||||
|
|
||||||
require __DIR__.'/auth.php';
|
require __DIR__.'/auth.php';
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Unit;
|
||||||
|
|
||||||
|
use Tests\TestCase;
|
||||||
|
use App\Services\GeminiService;
|
||||||
|
use Illuminate\Support\Facades\Config;
|
||||||
|
use ReflectionMethod;
|
||||||
|
|
||||||
|
class GeminiServiceTest extends TestCase
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Test dynamic detection of Vertex AI vs AI Studio drivers.
|
||||||
|
*/
|
||||||
|
public function test_driver_detection()
|
||||||
|
{
|
||||||
|
// 1. Test standard AI Studio API Key
|
||||||
|
Config::set('services.gemini.key', 'AIzaSyFakeKey');
|
||||||
|
Config::set('services.gemini.credentials_path', null);
|
||||||
|
|
||||||
|
$service = new GeminiService();
|
||||||
|
$method = new ReflectionMethod(GeminiService::class, 'analyzeContent');
|
||||||
|
$method->setAccessible(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$method->invoke($service, 'content', 'image/png', 'test.png');
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
// Exception is expected since the key is fake, but it should try Gemini
|
||||||
|
$this->assertTrue(
|
||||||
|
str_contains($e->getMessage(), 'Gemini') ||
|
||||||
|
str_contains($e->getMessage(), 'communication avec l\'API') ||
|
||||||
|
str_contains($e->getMessage(), 'La clé API Gemini')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Test Vertex AI JSON Credentials string
|
||||||
|
$fakeCreds = json_encode([
|
||||||
|
'type' => 'service_account',
|
||||||
|
'project_id' => 'my-vertex-project',
|
||||||
|
]);
|
||||||
|
Config::set('services.gemini.key', $fakeCreds);
|
||||||
|
|
||||||
|
$serviceVertex = new GeminiService();
|
||||||
|
try {
|
||||||
|
$method->invoke($serviceVertex, 'content', 'image/png', 'test.png');
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
// Exception expected during auth token exchange, but it must be a Vertex error
|
||||||
|
$this->assertStringContainsString('Vertex', $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test MistralService features.
|
||||||
|
*/
|
||||||
|
public function test_mistral_service()
|
||||||
|
{
|
||||||
|
Config::set('services.mistral.key', 'fake-mistral-key');
|
||||||
|
|
||||||
|
$mistral = new \App\Services\MistralService();
|
||||||
|
$method = new ReflectionMethod(\App\Services\MistralService::class, 'analyzeContent');
|
||||||
|
$method->setAccessible(true);
|
||||||
|
|
||||||
|
// PDF should be rejected
|
||||||
|
$this->expectException(\Exception::class);
|
||||||
|
$this->expectExceptionMessage('ne prend en charge que les images');
|
||||||
|
$method->invoke($mistral, 'content', 'application/pdf', 'test.pdf');
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user