diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..6df8428 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,18 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_size = 4 +indent_style = space +insert_final_newline = true +trim_trailing_whitespace = true + +[*.md] +trim_trailing_whitespace = false + +[*.{yml,yaml}] +indent_size = 2 + +[{compose,docker-compose}.{yml,yaml}] +indent_size = 4 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..c0660ea --- /dev/null +++ b/.env.example @@ -0,0 +1,65 @@ +APP_NAME=Laravel +APP_ENV=local +APP_KEY= +APP_DEBUG=true +APP_URL=http://localhost + +APP_LOCALE=en +APP_FALLBACK_LOCALE=en +APP_FAKER_LOCALE=en_US + +APP_MAINTENANCE_DRIVER=file +# APP_MAINTENANCE_STORE=database + +# PHP_CLI_SERVER_WORKERS=4 + +BCRYPT_ROUNDS=12 + +LOG_CHANNEL=stack +LOG_STACK=single +LOG_DEPRECATIONS_CHANNEL=null +LOG_LEVEL=debug + +DB_CONNECTION=sqlite +# DB_HOST=127.0.0.1 +# DB_PORT=3306 +# DB_DATABASE=laravel +# DB_USERNAME=root +# DB_PASSWORD= + +SESSION_DRIVER=database +SESSION_LIFETIME=120 +SESSION_ENCRYPT=false +SESSION_PATH=/ +SESSION_DOMAIN=null + +BROADCAST_CONNECTION=log +FILESYSTEM_DISK=local +QUEUE_CONNECTION=database + +CACHE_STORE=database +# CACHE_PREFIX= + +MEMCACHED_HOST=127.0.0.1 + +REDIS_CLIENT=phpredis +REDIS_HOST=127.0.0.1 +REDIS_PASSWORD=null +REDIS_PORT=6379 + +MAIL_MAILER=log +MAIL_SCHEME=null +MAIL_HOST=127.0.0.1 +MAIL_PORT=2525 +MAIL_USERNAME=null +MAIL_PASSWORD=null +MAIL_FROM_ADDRESS="hello@example.com" +MAIL_FROM_NAME="${APP_NAME}" + +AWS_ACCESS_KEY_ID= +AWS_SECRET_ACCESS_KEY= +AWS_DEFAULT_REGION=us-east-1 +AWS_BUCKET= +AWS_USE_PATH_STYLE_ENDPOINT=false + +VITE_APP_NAME="${APP_NAME}" diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..fcb21d3 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,11 @@ +* text=auto eol=lf + +*.blade.php diff=html +*.css diff=css +*.html diff=html +*.md diff=markdown +*.php diff=php + +/.github export-ignore +CHANGELOG.md export-ignore +.styleci.yml export-ignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7be55e2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,27 @@ +*.log +.DS_Store +.env +.env.backup +.env.production +.phpactor.json +.phpunit.result.cache +/.codex +/.cursor/ +/.idea +/.nova +/.phpunit.cache +/.vscode +/.zed +/auth.json +/node_modules +/public/build +/public/fonts-manifest.dev.json +/public/hot +/public/storage +/storage/*.key +/storage/pail +/vendor +_ide_helper.php +Homestead.json +Homestead.yaml +Thumbs.db diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..495a6af --- /dev/null +++ b/.npmrc @@ -0,0 +1,2 @@ +ignore-scripts=true +audit=true diff --git a/README.md b/README.md index a571fb0..5ad1377 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,58 @@ -# OrderCheck +
+ + +## About Laravel + +Laravel is a web application framework with expressive, elegant syntax. We believe development must be an enjoyable and creative experience to be truly fulfilling. Laravel takes the pain out of development by easing common tasks used in many web projects, such as: + +- [Simple, fast routing engine](https://laravel.com/docs/routing). +- [Powerful dependency injection container](https://laravel.com/docs/container). +- Multiple back-ends for [session](https://laravel.com/docs/session) and [cache](https://laravel.com/docs/cache) storage. +- Expressive, intuitive [database ORM](https://laravel.com/docs/eloquent). +- Database agnostic [schema migrations](https://laravel.com/docs/migrations). +- [Robust background job processing](https://laravel.com/docs/queues). +- [Real-time event broadcasting](https://laravel.com/docs/broadcasting). + +Laravel is accessible, powerful, and provides tools required for large, robust applications. + +## Learning Laravel + +Laravel has the most extensive and thorough [documentation](https://laravel.com/docs) and video tutorial library of all modern web application frameworks, making it a breeze to get started with the framework. + +In addition, [Laracasts](https://laracasts.com) contains thousands of video tutorials on a range of topics including Laravel, modern PHP, unit testing, and JavaScript. Boost your skills by digging into our comprehensive video library. + +You can also watch bite-sized lessons with real-world projects on [Laravel Learn](https://laravel.com/learn), where you will be guided through building a Laravel application from scratch while learning PHP fundamentals. + +## Agentic Development + +Laravel's predictable structure and conventions make it ideal for AI coding agents like Claude Code, Cursor, and GitHub Copilot. Install [Laravel Boost](https://laravel.com/docs/ai) to supercharge your AI workflow: + +```bash +composer require laravel/boost --dev + +php artisan boost:install +``` + +Boost provides your agent 15+ tools and skills that help agents build Laravel applications while following best practices. + +## Contributing + +Thank you for considering contributing to the Laravel framework! The contribution guide can be found in the [Laravel documentation](https://laravel.com/docs/contributions). + +## Code of Conduct + +In order to ensure that the Laravel community is welcoming to all, please review and abide by the [Code of Conduct](https://laravel.com/docs/contributions#code-of-conduct). + +## Security Vulnerabilities + +If you discover a security vulnerability within Laravel, please send an e-mail to Taylor Otwell via [taylor@laravel.com](mailto:taylor@laravel.com). All security vulnerabilities will be promptly addressed. + +## License + +The Laravel framework is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT). diff --git a/app/Http/Controllers/AttachmentController.php b/app/Http/Controllers/AttachmentController.php new file mode 100644 index 0000000..11072fe --- /dev/null +++ b/app/Http/Controllers/AttachmentController.php @@ -0,0 +1,33 @@ +order; + + Gate::authorize('view', $order); + + // Si le fichier n'existe pas dans le stockage public + if (!Storage::disk('public')->exists($attachment->file_path)) { + abort(404, 'Fichier non trouvé.'); + } + + $path = Storage::disk('public')->path($attachment->file_path); + + // On renvoie le fichier pour affichage inline (utile pour les PDF/images) + return response()->file($path, [ + 'Content-Disposition' => 'inline; filename="' . basename($attachment->file_name) . '"' + ]); + } +} diff --git a/app/Http/Controllers/Auth/AuthenticatedSessionController.php b/app/Http/Controllers/Auth/AuthenticatedSessionController.php new file mode 100644 index 0000000..d44fe97 --- /dev/null +++ b/app/Http/Controllers/Auth/AuthenticatedSessionController.php @@ -0,0 +1,52 @@ + Route::has('password.request'), + 'status' => session('status'), + ]); + } + + /** + * Handle an incoming authentication request. + */ + public function store(LoginRequest $request): RedirectResponse + { + $request->authenticate(); + + $request->session()->regenerate(); + + return redirect()->intended(route('dashboard', absolute: false)); + } + + /** + * Destroy an authenticated session. + */ + public function destroy(Request $request): RedirectResponse + { + Auth::guard('web')->logout(); + + $request->session()->invalidate(); + + $request->session()->regenerateToken(); + + return redirect('/'); + } +} diff --git a/app/Http/Controllers/Auth/ConfirmablePasswordController.php b/app/Http/Controllers/Auth/ConfirmablePasswordController.php new file mode 100644 index 0000000..d2b1f14 --- /dev/null +++ b/app/Http/Controllers/Auth/ConfirmablePasswordController.php @@ -0,0 +1,41 @@ +validate([ + 'email' => $request->user()->email, + 'password' => $request->password, + ])) { + throw ValidationException::withMessages([ + 'password' => __('auth.password'), + ]); + } + + $request->session()->put('auth.password_confirmed_at', time()); + + return redirect()->intended(route('dashboard', absolute: false)); + } +} diff --git a/app/Http/Controllers/Auth/EmailVerificationNotificationController.php b/app/Http/Controllers/Auth/EmailVerificationNotificationController.php new file mode 100644 index 0000000..f64fa9b --- /dev/null +++ b/app/Http/Controllers/Auth/EmailVerificationNotificationController.php @@ -0,0 +1,24 @@ +user()->hasVerifiedEmail()) { + return redirect()->intended(route('dashboard', absolute: false)); + } + + $request->user()->sendEmailVerificationNotification(); + + return back()->with('status', 'verification-link-sent'); + } +} diff --git a/app/Http/Controllers/Auth/EmailVerificationPromptController.php b/app/Http/Controllers/Auth/EmailVerificationPromptController.php new file mode 100644 index 0000000..b42e0d5 --- /dev/null +++ b/app/Http/Controllers/Auth/EmailVerificationPromptController.php @@ -0,0 +1,22 @@ +user()->hasVerifiedEmail() + ? redirect()->intended(route('dashboard', absolute: false)) + : Inertia::render('Auth/VerifyEmail', ['status' => session('status')]); + } +} diff --git a/app/Http/Controllers/Auth/NewPasswordController.php b/app/Http/Controllers/Auth/NewPasswordController.php new file mode 100644 index 0000000..740cc51 --- /dev/null +++ b/app/Http/Controllers/Auth/NewPasswordController.php @@ -0,0 +1,69 @@ + $request->email, + 'token' => $request->route('token'), + ]); + } + + /** + * Handle an incoming new password request. + * + * @throws ValidationException + */ + public function store(Request $request): RedirectResponse + { + $request->validate([ + 'token' => 'required', + 'email' => 'required|email', + 'password' => ['required', 'confirmed', Rules\Password::defaults()], + ]); + + // Here we will attempt to reset the user's password. If it is successful we + // will update the password on an actual user model and persist it to the + // database. Otherwise we will parse the error and return the response. + $status = Password::reset( + $request->only('email', 'password', 'password_confirmation', 'token'), + function ($user) use ($request) { + $user->forceFill([ + 'password' => Hash::make($request->password), + 'remember_token' => Str::random(60), + ])->save(); + + event(new PasswordReset($user)); + } + ); + + // If the password was successfully reset, we will redirect the user back to + // the application's home authenticated view. If there is an error we can + // redirect them back to where they came from with their error message. + if ($status == Password::PASSWORD_RESET) { + return redirect()->route('login')->with('status', __($status)); + } + + throw ValidationException::withMessages([ + 'email' => [trans($status)], + ]); + } +} diff --git a/app/Http/Controllers/Auth/PasswordController.php b/app/Http/Controllers/Auth/PasswordController.php new file mode 100644 index 0000000..57a82b5 --- /dev/null +++ b/app/Http/Controllers/Auth/PasswordController.php @@ -0,0 +1,29 @@ +validate([ + 'current_password' => ['required', 'current_password'], + 'password' => ['required', Password::defaults(), 'confirmed'], + ]); + + $request->user()->update([ + 'password' => Hash::make($validated['password']), + ]); + + return back(); + } +} diff --git a/app/Http/Controllers/Auth/PasswordResetLinkController.php b/app/Http/Controllers/Auth/PasswordResetLinkController.php new file mode 100644 index 0000000..c8b2b6f --- /dev/null +++ b/app/Http/Controllers/Auth/PasswordResetLinkController.php @@ -0,0 +1,51 @@ + session('status'), + ]); + } + + /** + * Handle an incoming password reset link request. + * + * @throws ValidationException + */ + public function store(Request $request): RedirectResponse + { + $request->validate([ + 'email' => 'required|email', + ]); + + // We will send the password reset link to this user. Once we have attempted + // to send the link, we will examine the response then see the message we + // need to show to the user. Finally, we'll send out a proper response. + $status = Password::sendResetLink( + $request->only('email') + ); + + if ($status == Password::RESET_LINK_SENT) { + return back()->with('status', __($status)); + } + + throw ValidationException::withMessages([ + 'email' => [trans($status)], + ]); + } +} diff --git a/app/Http/Controllers/Auth/RegisteredUserController.php b/app/Http/Controllers/Auth/RegisteredUserController.php new file mode 100644 index 0000000..3887f1c --- /dev/null +++ b/app/Http/Controllers/Auth/RegisteredUserController.php @@ -0,0 +1,52 @@ +validate([ + 'name' => 'required|string|max:255', + 'email' => 'required|string|lowercase|email|max:255|unique:'.User::class, + 'password' => ['required', 'confirmed', Rules\Password::defaults()], + ]); + + $user = User::create([ + 'name' => $request->name, + 'email' => $request->email, + 'password' => Hash::make($request->password), + ]); + + event(new Registered($user)); + + Auth::login($user); + + return redirect(route('dashboard', absolute: false)); + } +} diff --git a/app/Http/Controllers/Auth/VerifyEmailController.php b/app/Http/Controllers/Auth/VerifyEmailController.php new file mode 100644 index 0000000..784765e --- /dev/null +++ b/app/Http/Controllers/Auth/VerifyEmailController.php @@ -0,0 +1,27 @@ +user()->hasVerifiedEmail()) { + return redirect()->intended(route('dashboard', absolute: false).'?verified=1'); + } + + if ($request->user()->markEmailAsVerified()) { + event(new Verified($request->user())); + } + + return redirect()->intended(route('dashboard', absolute: false).'?verified=1'); + } +} diff --git a/app/Http/Controllers/Controller.php b/app/Http/Controllers/Controller.php new file mode 100644 index 0000000..8677cd5 --- /dev/null +++ b/app/Http/Controllers/Controller.php @@ -0,0 +1,8 @@ +with('order')->orderBy('created_at', 'desc'); + + // Moteur de recherche multicritère + if ($request->filled('search')) { + $query->search($request->input('search')); + } + + // Filtre par statut + if ($request->filled('status')) { + $query->byStatus($request->input('status')); + } + + // Filtre par type/catégorie + if ($request->filled('type')) { + $query->byType($request->input('type')); + } + + // Export CSV si demandé + if ($request->has('export')) { + $hardwares = $query->get(); + $headers = [ + "Content-type" => "text/csv; charset=UTF-8", + "Content-Disposition" => "attachment; filename=inventaire_materiels_" . now()->format('Y-m-d_H-i') . ".csv", + "Pragma" => "no-cache", + "Cache-Control" => "must-revalidate, post-check=0, pre-check=0", + "Expires" => "0" + ]; + + $callback = function () use ($hardwares) { + $file = fopen('php://output', 'w'); + // Ajouter le BOM UTF-8 pour Excel + fprintf($file, chr(0xEF).chr(0xBB).chr(0xBF)); + + // En-têtes CSV + fputcsv($file, [ + 'Nom', 'Catégorie', 'Marque', 'Modèle', 'Numéro de série', + 'Statut', 'Emplacement', 'Adresse IP', 'Date d\'achat', + 'Mise en service', 'Fin de garantie', 'Garantie active', 'Commande liée' + ], ';'); + + foreach ($hardwares as $hw) { + fputcsv($file, [ + $hw->name, + match ($hw->type) { + 'serveur' => 'Serveur', + 'switch' => 'Switch', + 'routeur' => 'Routeur', + 'onduleur' => 'Onduleur', + 'stockage' => 'Stockage (NAS/SAN)', + 'pare-feu' => 'Pare-feu', + 'poste_travail' => 'Poste de travail', + 'autre' => 'Autre', + default => $hw->type + }, + $hw->brand, + $hw->model, + $hw->serial_number, + match ($hw->status) { + 'en_stock' => 'En stock', + 'en_service' => 'En service', + 'en_panne' => 'En panne', + 'au_rebut' => 'Au rebut', + default => $hw->status + }, + $hw->location, + $hw->ip_address, + $hw->purchase_date?->format('d/m/Y'), + $hw->commissioning_date?->format('d/m/Y'), + $hw->warranty_expiration_date?->format('d/m/Y'), + $hw->is_under_warranty ? 'Oui' : 'Non', + $hw->order?->number + ], ';'); + } + + fclose($file); + }; + + return response()->stream($callback, 200, $headers); + } + + // Pagination classique + $hardwares = $query->paginate(10)->withQueryString(); + + // Calcul des KPIs + $counts = [ + 'total' => Hardware::count(), + 'en_service' => Hardware::where('status', 'en_service')->count(), + 'en_stock' => Hardware::where('status', 'en_stock')->count(), + 'en_panne' => Hardware::where('status', 'en_panne')->count(), + 'au_rebut' => Hardware::where('status', 'au_rebut')->count(), + 'under_warranty' => Hardware::get()->filter->is_under_warranty->count(), + ]; + + return Inertia::render('Materiels/Index', [ + 'hardwares' => HardwareResource::collection($hardwares), + 'metrics' => $counts, + 'filters' => $request->only(['search', 'status', 'type']), + ]); + } + + /** + * Formulaire de création d'un matériel. + */ + public function create() + { + Gate::authorize('create', Hardware::class); + + // Liste des commandes pour pouvoir lier l'équipement (id et numéro de commande) + $orders = Order::orderBy('created_at', 'desc')->get(['id', 'number', 'label']); + + return Inertia::render('Materiels/Form', [ + 'isEdit' => false, + 'orders' => $orders, + ]); + } + + /** + * Enregistre un nouveau matériel en base de données. + */ + public function store(StoreHardwareRequest $request) + { + Gate::authorize('create', Hardware::class); + + $hardware = Hardware::create($request->validated()); + + return redirect()->route('materiels.show', $hardware->id) + ->with('success', 'L\'équipement matériel a été enregistré avec succès dans l\'inventaire.'); + } + + /** + * Affiche les détails d'un équipement. + */ + public function show(Hardware $materiel) + { + Gate::authorize('view', $materiel); + + $materiel->load('order'); + + return Inertia::render('Materiels/Show', [ + 'hardware' => new HardwareResource($materiel), + ]); + } + + /** + * Formulaire d'édition d'un équipement. + */ + public function edit(Hardware $materiel) + { + Gate::authorize('update', $materiel); + + $materiel->load('order'); + $orders = Order::orderBy('created_at', 'desc')->get(['id', 'number', 'label']); + + return Inertia::render('Materiels/Form', [ + 'hardware' => new HardwareResource($materiel), + 'isEdit' => true, + 'orders' => $orders, + ]); + } + + /** + * Met à jour les informations d'un équipement. + */ + public function update(UpdateHardwareRequest $request, Hardware $materiel) + { + Gate::authorize('update', $materiel); + + $materiel->update($request->validated()); + + return redirect()->route('materiels.show', $materiel->id) + ->with('success', 'Les informations de l\'équipement ont été mises à jour.'); + } + + /** + * Supprime un équipement de la base de données. + */ + public function destroy(Hardware $materiel) + { + Gate::authorize('delete', $materiel); + + $materiel->delete(); + + return redirect()->route('materiels.index') + ->with('success', 'L\'équipement a été retiré définitivement de l\'inventaire.'); + } +} diff --git a/app/Http/Controllers/OrderController.php b/app/Http/Controllers/OrderController.php new file mode 100644 index 0000000..38b9ed7 --- /dev/null +++ b/app/Http/Controllers/OrderController.php @@ -0,0 +1,316 @@ +orderBy('created_at', 'desc'); + + // Recherche full-text sur libellé / fournisseur / numéro + if ($request->filled('search')) { + $search = $request->input('search'); + $query->where(function ($q) use ($search) { + $q->where('number', 'like', "%{$search}%") + ->orWhere('label', 'like', "%{$search}%") + ->orWhere('supplier', 'like', "%{$search}%"); + }); + } + + // Filtre par statut + if ($request->filled('status')) { + $query->byStatus($request->input('status')); + } + + // Filtre par demandeur + if ($request->filled('requested_by')) { + $query->byDemandeur($request->input('requested_by')); + } + + // Filtre par type + if ($request->filled('type')) { + $query->where('type', $request->input('type')); + } + + // Filtre par période (date souhaitée de livraison) + if ($request->filled('date_start')) { + $query->whereDate('delivery_deadline', '>=', $request->input('date_start')); + } + if ($request->filled('date_end')) { + $query->whereDate('delivery_deadline', '<=', $request->input('date_end')); + } + + // Export CSV si demandé + if ($request->has('export')) { + $orders = $query->get(); + $headers = [ + "Content-type" => "text/csv; charset=UTF-8", + "Content-Disposition" => "attachment; filename=commandes_" . now()->format('Y-m-d_H-i') . ".csv", + "Pragma" => "no-cache", + "Cache-Control" => "must-revalidate, post-check=0, pre-check=0", + "Expires" => "0" + ]; + + $callback = function () use ($orders) { + $file = fopen('php://output', 'w'); + // Ajouter le BOM UTF-8 pour Excel + fprintf($file, chr(0xEF).chr(0xBB).chr(0xBF)); + + // En-têtes CSV en français + fputcsv($file, [ + 'Numéro', 'Libellé / Réf Article', 'Type', 'Fournisseur', 'N° Devis', + 'Montant HT (€)', 'Montant TTC (€)', 'Demandeur', 'Prescripteur', + 'Date livraison souhaitée', 'Statut', 'Date création' + ], ';'); + + foreach ($orders as $order) { + fputcsv($file, [ + $order->number, + $order->label, + $order->type, + $order->supplier, + $order->quote_number, + number_format($order->amount_ht, 2, ',', ''), + number_format($order->amount_ttc, 2, ',', ''), + $order->requested_by, + $order->prescriber, + $order->delivery_deadline?->format('d/m/Y'), + match ($order->status) { + 'draft' => 'Brouillon', + 'validated' => 'Validée', + 'ordered' => 'Commandée', + 'delivered' => 'Livrée', + 'closed' => 'Clôturée', + default => $order->status + }, + $order->created_at?->format('d/m/Y H:i') + ], ';'); + } + + fclose($file); + }; + + return response()->stream($callback, 200, $headers); + } + + // Pagination classique + $orders = $query->paginate(10)->withQueryString(); + + return Inertia::render('Commandes/Index', [ + 'orders' => OrderResource::collection($orders), + 'filters' => $request->only(['search', 'status', 'requested_by', 'type', 'date_start', 'date_end']), + ]); + } + + /** + * Formulaire de création. + */ + public function create() + { + Gate::authorize('create', Order::class); + + return Inertia::render('Commandes/Form', [ + 'isEdit' => false, + ]); + } + + /** + * Enregistre une nouvelle commande en base de données. + */ + public function store(StoreOrderRequest $request, OrderService $orderService) + { + return DB::transaction(function () use ($request, $orderService) { + $validated = $request->validated(); + + // Calcul automatique de la TVA 20% (sauf si exonéré) + $excludeVat = (bool) ($validated['exclude_vat'] ?? false); + $validated['amount_ttc'] = $excludeVat ? $validated['amount_ht'] : $validated['amount_ht'] * 1.20; + + // Génération unique et sécurisée du numéro CMD + $validated['number'] = $orderService->generateOrderNumber(); + $validated['status'] = 'draft'; // Statut initial + + $order = Order::create($validated); + + // Gestion de l'historique initial + OrderStatusLog::create([ + 'order_id' => $order->id, + 'user_id' => $request->user()->id, + 'old_status' => null, + 'new_status' => 'draft', + 'changed_at' => now(), + ]); + + // Gestion de l'upload des fichiers + $fileTypes = [ + 'quote_file' => 'quote', + 'delivery_note_file' => 'delivery_note', + 'invoice_file' => 'invoice', + ]; + + foreach ($fileTypes as $inputName => $type) { + if ($request->hasFile($inputName)) { + $file = $request->file($inputName); + // Stockage dans storage/app/public/commandes/{id}/ + $path = $file->storeAs("commandes/{$order->id}", $file->getClientOriginalName(), 'public'); + + Attachment::create([ + 'order_id' => $order->id, + 'file_path' => $path, + 'file_name' => $file->getClientOriginalName(), + 'file_type' => $type, + ]); + } + } + + return redirect()->route('commandes.show', $order->id) + ->with('success', 'La demande de commande a été créée avec succès au statut Brouillon.'); + }); + } + + /** + * Affiche les détails d'une commande. + */ + public function show(Order $order) + { + Gate::authorize('view', $order); + + $order->load(['attachments', 'statusLogs.user']); + + return Inertia::render('Commandes/Show', [ + 'order' => new OrderResource($order), + ]); + } + + /** + * Formulaire d'édition. + */ + public function edit(Order $order) + { + Gate::authorize('update', $order); + + $order->load('attachments'); + + return Inertia::render('Commandes/Form', [ + 'order' => new OrderResource($order), + 'isEdit' => true, + ]); + } + + /** + * Met à jour les informations d'une commande. + */ + public function update(UpdateOrderRequest $request, Order $order) + { + return DB::transaction(function () use ($request, $order) { + $validated = $request->validated(); + + // Recalcul de la TVA (sauf si exonéré) + $excludeVat = (bool) ($validated['exclude_vat'] ?? false); + $validated['amount_ttc'] = $excludeVat ? $validated['amount_ht'] : $validated['amount_ht'] * 1.20; + + $order->update($validated); + + // Gestion de l'upload de nouvelles pièces jointes (ou mise à jour) + $fileTypes = [ + 'quote_file' => 'quote', + 'delivery_note_file' => 'delivery_note', + 'invoice_file' => 'invoice', + ]; + + foreach ($fileTypes as $inputName => $type) { + if ($request->hasFile($inputName)) { + $file = $request->file($inputName); + + // On supprime l'ancienne pièce jointe de ce type si elle existe + $existingAttachment = $order->attachments()->where('file_type', $type)->first(); + if ($existingAttachment) { + Storage::disk('public')->delete($existingAttachment->file_path); + $existingAttachment->delete(); + } + + // Stockage du nouveau fichier + $path = $file->storeAs("commandes/{$order->id}", $file->getClientOriginalName(), 'public'); + + Attachment::create([ + 'order_id' => $order->id, + 'file_path' => $path, + 'file_name' => $file->getClientOriginalName(), + 'file_type' => $type, + ]); + } + } + + return redirect()->route('commandes.show', $order->id) + ->with('success', 'La commande a été mise à jour.'); + }); + } + + /** + * Supprime une commande de la base de données. + */ + public function destroy(Order $order) + { + Gate::authorize('delete', $order); + + return DB::transaction(function () use ($order) { + // Suppression physique du répertoire contenant les pièces jointes + Storage::disk('public')->deleteDirectory("commandes/{$order->id}"); + + $order->delete(); + + return redirect()->route('commandes.index') + ->with('success', 'La commande et toutes ses pièces jointes ont été supprimées définitivement.'); + }); + } + + /** + * Gère les changements de statut (transitions). + */ + public function transition(Request $request, Order $order, OrderService $orderService) + { + $request->validate([ + 'new_status' => ['required', 'string', 'in:validated,ordered,delivered,closed'], + ]); + + $newStatus = $request->input('new_status'); + + // Autorisation de la transition selon le rôle et le statut cible + Gate::authorize('transition', [$order, $newStatus]); + + try { + $orderService->transitionStatus($order, $newStatus, $request->user()); + + $statusLabel = match ($newStatus) { + 'validated' => 'Validée', + 'ordered' => 'Commandée', + 'delivered' => 'Livrée', + 'closed' => 'Clôturée', + default => $newStatus + }; + + return redirect()->back()->with('success', "Le statut de la commande a été mis à jour avec succès : {$statusLabel}."); + } catch (\InvalidArgumentException $e) { + return redirect()->back()->withErrors(['error' => $e->getMessage()]); + } + } +} diff --git a/app/Http/Controllers/ProfileController.php b/app/Http/Controllers/ProfileController.php new file mode 100644 index 0000000..873b4f7 --- /dev/null +++ b/app/Http/Controllers/ProfileController.php @@ -0,0 +1,63 @@ + $request->user() instanceof MustVerifyEmail, + 'status' => session('status'), + ]); + } + + /** + * Update the user's profile information. + */ + public function update(ProfileUpdateRequest $request): RedirectResponse + { + $request->user()->fill($request->validated()); + + if ($request->user()->isDirty('email')) { + $request->user()->email_verified_at = null; + } + + $request->user()->save(); + + return Redirect::route('profile.edit'); + } + + /** + * Delete the user's account. + */ + public function destroy(Request $request): RedirectResponse + { + $request->validate([ + 'password' => ['required', 'current_password'], + ]); + + $user = $request->user(); + + Auth::logout(); + + $user->delete(); + + $request->session()->invalidate(); + $request->session()->regenerateToken(); + + return Redirect::to('/'); + } +} diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php new file mode 100644 index 0000000..685deb4 --- /dev/null +++ b/app/Http/Middleware/HandleInertiaRequests.php @@ -0,0 +1,43 @@ + + */ + public function share(Request $request): array + { + return [ + ...parent::share($request), + 'auth' => [ + 'user' => $request->user(), + ], + 'flash' => [ + 'success' => fn () => $request->session()->get('success'), + 'error' => fn () => $request->session()->get('error'), + ], + ]; + } +} diff --git a/app/Http/Requests/Auth/LoginRequest.php b/app/Http/Requests/Auth/LoginRequest.php new file mode 100644 index 0000000..711e0a1 --- /dev/null +++ b/app/Http/Requests/Auth/LoginRequest.php @@ -0,0 +1,86 @@ +|string> + */ + public function rules(): array + { + return [ + 'email' => ['required', 'string', 'email'], + 'password' => ['required', 'string'], + ]; + } + + /** + * Attempt to authenticate the request's credentials. + * + * @throws ValidationException + */ + public function authenticate(): void + { + $this->ensureIsNotRateLimited(); + + if (! Auth::attempt($this->only('email', 'password'), $this->boolean('remember'))) { + RateLimiter::hit($this->throttleKey()); + + throw ValidationException::withMessages([ + 'email' => trans('auth.failed'), + ]); + } + + RateLimiter::clear($this->throttleKey()); + } + + /** + * Ensure the login request is not rate limited. + * + * @throws ValidationException + */ + public function ensureIsNotRateLimited(): void + { + if (! RateLimiter::tooManyAttempts($this->throttleKey(), 5)) { + return; + } + + event(new Lockout($this)); + + $seconds = RateLimiter::availableIn($this->throttleKey()); + + throw ValidationException::withMessages([ + 'email' => trans('auth.throttle', [ + 'seconds' => $seconds, + 'minutes' => ceil($seconds / 60), + ]), + ]); + } + + /** + * Get the rate limiting throttle key for the request. + */ + public function throttleKey(): string + { + return Str::transliterate(Str::lower($this->string('email')).'|'.$this->ip()); + } +} diff --git a/app/Http/Requests/ProfileUpdateRequest.php b/app/Http/Requests/ProfileUpdateRequest.php new file mode 100644 index 0000000..e2202dd --- /dev/null +++ b/app/Http/Requests/ProfileUpdateRequest.php @@ -0,0 +1,31 @@ +|string> + */ + public function rules(): array + { + return [ + 'name' => ['required', 'string', 'max:255'], + 'email' => [ + 'required', + 'string', + 'lowercase', + 'email', + 'max:255', + Rule::unique(User::class)->ignore($this->user()->id), + ], + ]; + } +} diff --git a/app/Http/Requests/StoreHardwareRequest.php b/app/Http/Requests/StoreHardwareRequest.php new file mode 100644 index 0000000..4de5b1d --- /dev/null +++ b/app/Http/Requests/StoreHardwareRequest.php @@ -0,0 +1,63 @@ + ['required', 'string', 'max:255'], + 'type' => ['required', 'string', 'in:serveur,switch,routeur,onduleur,stockage,pare-feu,poste_travail,autre'], + 'brand' => ['required', 'string', 'max:255'], + 'model' => ['required', 'string', 'max:255'], + 'serial_number' => ['required', 'string', 'max:255', 'unique:hardwares,serial_number'], + 'status' => ['required', 'string', 'in:en_stock,en_service,en_panne,au_rebut'], + 'purchase_date' => ['nullable', 'date'], + 'commissioning_date' => ['nullable', 'date', 'after_or_equal:purchase_date'], + 'warranty_expiration_date' => ['nullable', 'date', 'after_or_equal:purchase_date'], + 'location' => ['required', 'string', 'max:255'], + 'ip_address' => ['nullable', 'string', 'max:45'], // max length for IPv6 + 'order_id' => ['nullable', 'exists:orders,id'], + 'notes' => ['nullable', 'string'], + ]; + } + + /** + * Messages d'erreur personnalisés en français. + */ + public function messages(): array + { + return [ + 'name.required' => 'Le nom de l\'équipement est requis.', + 'type.required' => 'Le type d\'équipement est requis.', + 'type.in' => 'Le type d\'équipement sélectionné est invalide.', + 'brand.required' => 'La marque est requise.', + 'model.required' => 'Le modèle est requis.', + 'serial_number.required' => 'Le numéro de série est requis.', + 'serial_number.unique' => 'Ce numéro de série existe déjà dans la base.', + 'status.required' => 'Le statut est requis.', + 'status.in' => 'Le statut sélectionné est invalide.', + 'purchase_date.date' => 'La date d\'achat doit être une date valide.', + 'commissioning_date.date' => 'La date de mise en service doit être une date valide.', + 'commissioning_date.after_or_equal' => 'La date de mise en service doit être postérieure ou égale à la date d\'achat.', + 'warranty_expiration_date.date' => 'La date de fin de garantie doit être une date valide.', + 'warranty_expiration_date.after_or_equal' => 'La date de fin de garantie doit être postérieure ou égale à la date d\'achat.', + 'location.required' => 'L\'emplacement est requis.', + 'order_id.exists' => 'La commande sélectionnée est invalide.', + ]; + } +} diff --git a/app/Http/Requests/StoreOrderRequest.php b/app/Http/Requests/StoreOrderRequest.php new file mode 100644 index 0000000..aed1721 --- /dev/null +++ b/app/Http/Requests/StoreOrderRequest.php @@ -0,0 +1,39 @@ +user()->can('create', \App\Models\Order::class); + } + + /** + * Get the validation rules that apply to the request. + */ + public function rules(): array + { + return [ + 'label' => ['required', 'string', 'max:255'], + 'type' => ['required', 'string', 'in:Matériel réseau / serveur,Licences logicielles,Consommables / câblage,Prestations / services'], + 'supplier' => ['required', 'string', 'max:255'], + 'quote_number' => ['required', 'string', 'max:255'], + 'amount_ht' => ['required', 'numeric', 'min:0'], + 'exclude_vat' => ['nullable', 'boolean'], + 'requested_by' => ['required', 'string', 'in:Jérémy,Sylvain,Kévin'], + 'prescriber' => ['required', 'string', 'max:255'], + 'delivery_deadline' => ['required', 'date'], + 'notes' => ['nullable', 'string'], + 'quote_file' => ['nullable', 'file', 'mimes:pdf,png,jpg,jpeg,doc,docx,xls,xlsx', 'max:10240'], + 'delivery_note_file' => ['nullable', 'file', 'mimes:pdf,png,jpg,jpeg,doc,docx,xls,xlsx', 'max:10240'], + 'invoice_file' => ['nullable', 'file', 'mimes:pdf,png,jpg,jpeg,doc,docx,xls,xlsx', 'max:10240'], + ]; + } +} diff --git a/app/Http/Requests/UpdateHardwareRequest.php b/app/Http/Requests/UpdateHardwareRequest.php new file mode 100644 index 0000000..b8184ae --- /dev/null +++ b/app/Http/Requests/UpdateHardwareRequest.php @@ -0,0 +1,65 @@ +route('materiel')?->id ?? $this->route('materiel'); + + return [ + 'name' => ['required', 'string', 'max:255'], + 'type' => ['required', 'string', 'in:serveur,switch,routeur,onduleur,stockage,pare-feu,poste_travail,autre'], + 'brand' => ['required', 'string', 'max:255'], + 'model' => ['required', 'string', 'max:255'], + 'serial_number' => ['required', 'string', 'max:255', 'unique:hardwares,serial_number,' . $hardwareId], + 'status' => ['required', 'string', 'in:en_stock,en_service,en_panne,au_rebut'], + 'purchase_date' => ['nullable', 'date'], + 'commissioning_date' => ['nullable', 'date', 'after_or_equal:purchase_date'], + 'warranty_expiration_date' => ['nullable', 'date', 'after_or_equal:purchase_date'], + 'location' => ['required', 'string', 'max:255'], + 'ip_address' => ['nullable', 'string', 'max:45'], + 'order_id' => ['nullable', 'exists:orders,id'], + 'notes' => ['nullable', 'string'], + ]; + } + + /** + * Messages d'erreur personnalisés en français. + */ + public function messages(): array + { + return [ + 'name.required' => 'Le nom de l\'équipement est requis.', + 'type.required' => 'Le type d\'équipement est requis.', + 'type.in' => 'Le type d\'équipement sélectionné est invalide.', + 'brand.required' => 'La marque est requise.', + 'model.required' => 'Le modèle est requis.', + 'serial_number.required' => 'Le numéro de série est requis.', + 'serial_number.unique' => 'Ce numéro de série existe déjà dans la base.', + 'status.required' => 'Le statut est requis.', + 'status.in' => 'Le statut sélectionné est invalide.', + 'purchase_date.date' => 'La date d\'achat doit être une date valide.', + 'commissioning_date.date' => 'La date de mise en service doit être une date valide.', + 'commissioning_date.after_or_equal' => 'La date de mise en service doit être postérieure ou égale à la date d\'achat.', + 'warranty_expiration_date.date' => 'La date de fin de garantie doit être une date valide.', + 'warranty_expiration_date.after_or_equal' => 'La date de fin de garantie doit être postérieure ou égale à la date d\'achat.', + 'location.required' => 'L\'emplacement est requis.', + 'order_id.exists' => 'La commande sélectionnée est invalide.', + ]; + } +} diff --git a/app/Http/Requests/UpdateOrderRequest.php b/app/Http/Requests/UpdateOrderRequest.php new file mode 100644 index 0000000..77b4d23 --- /dev/null +++ b/app/Http/Requests/UpdateOrderRequest.php @@ -0,0 +1,40 @@ +route('commande'); + return $this->user()->can('update', $order); + } + + /** + * Get the validation rules that apply to the request. + */ + public function rules(): array + { + return [ + 'label' => ['required', 'string', 'max:255'], + 'type' => ['required', 'string', 'in:Matériel réseau / serveur,Licences logicielles,Consommables / câblage,Prestations / services'], + 'supplier' => ['required', 'string', 'max:255'], + 'quote_number' => ['required', 'string', 'max:255'], + 'amount_ht' => ['required', 'numeric', 'min:0'], + 'exclude_vat' => ['nullable', 'boolean'], + 'requested_by' => ['required', 'string', 'in:Jérémy,Sylvain,Kévin'], + 'prescriber' => ['required', 'string', 'max:255'], + 'delivery_deadline' => ['required', 'date'], + 'notes' => ['nullable', 'string'], + 'quote_file' => ['nullable', 'file', 'mimes:pdf,png,jpg,jpeg,doc,docx,xls,xlsx', 'max:10240'], + 'delivery_note_file' => ['nullable', 'file', 'mimes:pdf,png,jpg,jpeg,doc,docx,xls,xlsx', 'max:10240'], + 'invoice_file' => ['nullable', 'file', 'mimes:pdf,png,jpg,jpeg,doc,docx,xls,xlsx', 'max:10240'], + ]; + } +} diff --git a/app/Http/Resources/AttachmentResource.php b/app/Http/Resources/AttachmentResource.php new file mode 100644 index 0000000..8b3ffe8 --- /dev/null +++ b/app/Http/Resources/AttachmentResource.php @@ -0,0 +1,26 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'order_id' => $this->order_id, + 'file_name' => $this->file_name, + 'file_type' => $this->file_type, + 'url' => $this->url, + 'created_at' => $this->created_at?->format('d/m/Y H:i'), + ]; + } +} diff --git a/app/Http/Resources/HardwareResource.php b/app/Http/Resources/HardwareResource.php new file mode 100644 index 0000000..8998f13 --- /dev/null +++ b/app/Http/Resources/HardwareResource.php @@ -0,0 +1,57 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'name' => $this->name, + 'type' => $this->type, + 'brand' => $this->brand, + 'model' => $this->model, + 'serial_number' => $this->serial_number, + 'status' => $this->status, + + // Format brut pour les inputs date HTML (YYYY-MM-DD) + 'purchase_date' => $this->purchase_date?->format('Y-m-d'), + 'commissioning_date' => $this->commissioning_date?->format('Y-m-d'), + 'warranty_expiration_date' => $this->warranty_expiration_date?->format('Y-m-d'), + + // Format français pour l'affichage (DD/MM/YYYY) + 'purchase_date_formatted' => $this->purchase_date?->format('d/m/Y') ?? 'Non spécifiée', + 'commissioning_date_formatted' => $this->commissioning_date?->format('d/m/Y') ?? 'Non spécifiée', + 'warranty_expiration_date_formatted' => $this->warranty_expiration_date?->format('d/m/Y') ?? 'Non spécifiée', + + 'location' => $this->location, + 'ip_address' => $this->ip_address, + 'order_id' => $this->order_id, + + // Relation éventuelle avec la commande + 'order' => $this->relationLoaded('order') && $this->order ? [ + 'id' => $this->order->id, + 'number' => $this->order->number, + 'label' => $this->order->label, + ] : null, + + 'notes' => $this->notes, + + // Propriétés calculées + 'is_under_warranty' => $this->is_under_warranty, + 'warranty_status_label' => $this->warranty_status_label, + 'warranty_remaining_days' => $this->warranty_remaining_days, + + 'created_at' => $this->created_at?->format('d/m/Y H:i'), + ]; + } +} diff --git a/app/Http/Resources/OrderResource.php b/app/Http/Resources/OrderResource.php new file mode 100644 index 0000000..10f3942 --- /dev/null +++ b/app/Http/Resources/OrderResource.php @@ -0,0 +1,53 @@ + + */ + public function toArray(Request $request): array + { + $now = now()->startOfDay(); + $deadline = $this->delivery_deadline ? \Carbon\Carbon::parse($this->delivery_deadline)->startOfDay() : null; + $isOverdue = $deadline && $deadline->lt($now) && !in_array($this->status, ['delivered', 'closed']); + + return [ + 'id' => $this->id, + 'number' => $this->number, + 'label' => $this->label, + 'type' => $this->type, + 'supplier' => $this->supplier, + 'quote_number' => $this->quote_number, + 'amount_ht' => (float) $this->amount_ht, + 'amount_ttc' => (float) $this->amount_ttc, + 'exclude_vat' => (bool) $this->exclude_vat, + 'requested_by' => $this->requested_by, + 'prescriber' => $this->prescriber, + 'delivery_deadline' => $this->delivery_deadline?->format('Y-m-d'), + 'delivery_deadline_formatted' => $this->delivery_deadline?->format('d/m/Y'), + 'status' => $this->status, + 'notes' => $this->notes, + 'is_overdue' => $isOverdue, + 'created_at' => $this->created_at?->format('d/m/Y H:i'), + 'attachments' => AttachmentResource::collection($this->whenLoaded('attachments')), + 'status_logs' => OrderStatusLogResource::collection($this->whenLoaded('statusLogs')), + 'can' => [ + 'update' => $request->user()?->can('update', $this->resource), + 'delete' => $request->user()?->can('delete', $this->resource), + ], + 'can_transition_to' => [ + 'validated' => $this->status === 'draft' && ($request->user()?->can('transition', [$this->resource, 'validated']) ?? false), + 'ordered' => $this->status === 'validated' && ($request->user()?->can('transition', [$this->resource, 'ordered']) ?? false), + 'delivered' => $this->status === 'ordered' && ($request->user()?->can('transition', [$this->resource, 'delivered']) ?? false), + 'closed' => $this->status === 'delivered' && ($request->user()?->can('transition', [$this->resource, 'closed']) ?? false), + ], + ]; + } +} diff --git a/app/Http/Resources/OrderStatusLogResource.php b/app/Http/Resources/OrderStatusLogResource.php new file mode 100644 index 0000000..772f2c5 --- /dev/null +++ b/app/Http/Resources/OrderStatusLogResource.php @@ -0,0 +1,30 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'order_id' => $this->order_id, + 'user' => [ + 'id' => $this->user?->id, + 'name' => $this->user?->name, + 'role' => $this->user?->role, + ], + 'old_status' => $this->old_status, + 'new_status' => $this->new_status, + 'changed_at' => $this->changed_at?->format('d/m/Y H:i'), + ]; + } +} diff --git a/app/Models/Attachment.php b/app/Models/Attachment.php new file mode 100644 index 0000000..f841d7b --- /dev/null +++ b/app/Models/Attachment.php @@ -0,0 +1,35 @@ +belongsTo(Order::class); + } + + /** + * Accesseur pour obtenir l'URL de téléchargement sécurisée. + */ + public function getUrlAttribute() + { + return route('attachments.show', $this->id); + } +} diff --git a/app/Models/Hardware.php b/app/Models/Hardware.php new file mode 100644 index 0000000..f498c12 --- /dev/null +++ b/app/Models/Hardware.php @@ -0,0 +1,123 @@ + 'date', + 'commissioning_date' => 'date', + 'warranty_expiration_date' => 'date', + ]; + + protected $appends = [ + 'is_under_warranty', + 'warranty_status_label', + 'warranty_remaining_days', + ]; + + /** + * Relation avec la commande d'achat. + */ + public function order() + { + return $this->belongsTo(Order::class); + } + + /** + * Accesseur : l'équipement est-il sous garantie ? + */ + public function getIsUnderWarrantyAttribute(): bool + { + if (!$this->warranty_expiration_date) { + return false; + } + + return $this->warranty_expiration_date->isAfter(Carbon::today()); + } + + /** + * Accesseur : label en français du statut de garantie. + */ + public function getWarrantyStatusLabelAttribute(): string + { + if (!$this->warranty_expiration_date) { + return 'Non spécifiée'; + } + + if ($this->is_under_warranty) { + $days = $this->warranty_remaining_days; + return "Sous garantie ($days j. restants)"; + } + + return 'Garantie expirée'; + } + + /** + * Accesseur : nombre de jours de garantie restants. + */ + public function getWarrantyRemainingDaysAttribute(): ?int + { + if (!$this->warranty_expiration_date) { + return null; + } + + if ($this->warranty_expiration_date->isBefore(Carbon::today())) { + return 0; + } + + return (int) Carbon::today()->diffInDays($this->warranty_expiration_date); + } + + /** + * Scope : Moteur de recherche multicritère + */ + public function scopeSearch($query, $search) + { + return $query->where(function ($q) use ($search) { + $q->where('name', 'like', "%{$search}%") + ->orWhere('brand', 'like', "%{$search}%") + ->orWhere('model', 'like', "%{$search}%") + ->orWhere('serial_number', 'like', "%{$search}%") + ->orWhere('location', 'like', "%{$search}%") + ->orWhere('ip_address', 'like', "%{$search}%"); + }); + } + + /** + * Scope : Filtrer par type + */ + public function scopeByType($query, $type) + { + return $query->where('type', $type); + } + + /** + * Scope : Filtrer par statut + */ + public function scopeByStatus($query, $status) + { + return $query->where('status', $status); + } +} diff --git a/app/Models/Order.php b/app/Models/Order.php new file mode 100644 index 0000000..67a7acf --- /dev/null +++ b/app/Models/Order.php @@ -0,0 +1,84 @@ + 'date', + 'amount_ht' => 'decimal:2', + 'amount_ttc' => 'decimal:2', + 'exclude_vat' => 'boolean', + ]; + + /** + * Relation avec l'historique des changements de statut. + */ + public function statusLogs() + { + return $this->hasMany(OrderStatusLog::class)->orderBy('changed_at', 'desc'); + } + + /** + * Relation avec les pièces jointes. + */ + public function attachments() + { + return $this->hasMany(Attachment::class); + } + + /** + * Scope par statut(s). + */ + public function scopeByStatus($query, $status) + { + if (empty($status)) { + return $query; + } + if (is_array($status)) { + return $query->whereIn('status', $status); + } + return $query->where('status', $status); + } + + /** + * Scope par demandeur(s). + */ + public function scopeByDemandeur($query, $demandeur) + { + if (empty($demandeur)) { + return $query; + } + if (is_array($demandeur)) { + return $query->whereIn('requested_by', $demandeur); + } + return $query->where('requested_by', $demandeur); + } + + /** + * Scope pour les commandes en retard de livraison (date dépassée et non livrée/clôturée). + */ + public function scopeOverdue($query) + { + return $query->where('delivery_deadline', '<', now()->toDateString()) + ->whereNotIn('status', ['delivered', 'closed']); + } +} diff --git a/app/Models/OrderStatusLog.php b/app/Models/OrderStatusLog.php new file mode 100644 index 0000000..9c07e60 --- /dev/null +++ b/app/Models/OrderStatusLog.php @@ -0,0 +1,38 @@ + 'datetime', + ]; + + /** + * Relation avec la commande. + */ + public function order() + { + return $this->belongsTo(Order::class); + } + + /** + * Relation avec l'utilisateur qui a fait la transition. + */ + public function user() + { + return $this->belongsTo(User::class); + } +} diff --git a/app/Models/User.php b/app/Models/User.php new file mode 100644 index 0000000..c2d71e2 --- /dev/null +++ b/app/Models/User.php @@ -0,0 +1,48 @@ + */ + use HasFactory, Notifiable; + + /** + * Vérifie si l'utilisateur est le Chef de Service. + */ + public function isChefService(): bool + { + return $this->role === 'chef_service'; + } + + /** + * Vérifie si l'utilisateur est un Administrateur Réseau. + */ + public function isAdminReseau(): bool + { + return $this->role === 'admin_reseau'; + } + + /** + * Get the attributes that should be cast. + * + * @return array+ {{ message }} +
++ {{ title }} +
++ {{ subtitle }} +
+| Numéro | +Libellé / Article | +Type | +Fournisseur | +Demandeur / Service | +Date souhaitée | +Montant TTC | +Statut | +Actions | +
|---|---|---|---|---|---|---|---|---|
| + + {{ order.number }} + + | + + ++ {{ order.label }} + | + + ++ {{ order.type }} + | + + ++ {{ order.supplier }} + | + + +
+ {{ order.requested_by }}
+ {{ order.prescriber }}
+ |
+
+
+
+
+ {{ order.delivery_deadline_formatted }}
+
+
+ RETARD
+
+
+ |
+
+
+ + {{ new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(order.amount_ttc) }} + | + + +
+ |
+
+
+ + + Voir + + + Éditer + + | +
| + Aucune commande ne correspond aux critères de recherche. + | +||||||||
+ Affichage de la page + {{ orders.meta.current_page }} + sur + {{ orders.meta.last_page }} +
++ Faites avancer cette demande à l'étape suivante en fonction de votre rôle. +
++ Auteur : {{ log.user.name }} + ({{ log.user.role === 'chef_service' ? 'Chef de service' : 'Admin réseau' }}) +
++ Transition depuis : {{ getStatusLabel(log.old_status) }} +
++ Création initiale de la demande +
++ Commandes validées ou transmises au fournisseur en attente de réception physique ou logique. +
+| Numéro | +Libellé | +Fournisseur | +Demandeur / Service | +Date de livraison souhaitée | +Montant TTC | +Statut | +Action | +
|---|---|---|---|---|---|---|---|
| + + {{ order.number }} + + | + + ++ {{ order.label }} + | + + ++ {{ order.supplier }} + | + + +
+ {{ order.requested_by }}
+ {{ order.prescriber }}
+ |
+
+
+
+
+ {{ order.delivery_deadline_formatted }}
+
+
+ RETARD
+
+
+ |
+
+
+ + {{ formatCurrency(order.amount_ttc) }} + | + + +
+ |
+
+
+ + + Consulter + + | +
| + Aucune commande active en attente de livraison pour le moment. + | +|||||||
| Nom de l'équipement | +Marque / Modèle | +Numéro de série | +Emplacement / IP | +Garantie | +Statut | +Actions | +
|---|---|---|---|---|---|---|
|
+
+
+ {{ hw.name }}
+
+
+
+ {{ getTypeLabel(hw.type) }}
+
+ |
+
+ {{ hw.brand }}
+ {{ hw.model }}
+ |
+ + {{ hw.serial_number }} + | +
+ {{ hw.location }}
+ {{ hw.ip_address }}
+ Pas d'IP de gestion
+ |
+ + + {{ hw.warranty_status_label }} + + + Fin : {{ hw.warranty_expiration_date_formatted }} + + | ++ + {{ getStatusLabel(hw.status) }} + + | +
+
+
+
+
+
+
+
+
+ |
+
| + Aucun équipement de matériel trouvé dans l'inventaire. + | +||||||
+ Cet équipement fait partie de la commande suivante : +
++ 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. +
++ 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. +
+ ++ Ensure your account is using a long, random password to stay + secure. +
++ Update your account's profile information and email address. +
++ Laravel has wonderful documentation + covering every aspect of the + framework. Whether you are a + newcomer or have prior experience + with Laravel, we recommend reading + our documentation from beginning to + end. +
++ Laracasts offers thousands of video + tutorials on Laravel, PHP, and JavaScript + development. Check them out, see for + yourself, and massively level up your + development skills in the process. +
++ Laravel News is a community driven portal + and newsletter aggregating all of the latest + and most important news in the Laravel + ecosystem, including new package releases + and tutorials. +
++ Laravel's robust library of first-party + tools and libraries, such as + Forge, + Vapor, + Nova, + Envoyer, and + Herd + help you take your projects to the next + level. Pair them with powerful open source + libraries like + Cashier, + Dusk, + Echo, + Horizon, + Sanctum, + Telescope, and more. +
+