diff --git a/app/Http/Controllers/DevisController.php b/app/Http/Controllers/DevisController.php index a93db83..336cc79 100644 --- a/app/Http/Controllers/DevisController.php +++ b/app/Http/Controllers/DevisController.php @@ -12,9 +12,9 @@ class DevisController extends Controller { public function index() { - $devis = Devis::latest()->get(); + $devis = Devis::with(['sousDevis', 'parent'])->latest()->get(); return Inertia::render('Devis/Index', [ - 'devis' => $devis + 'devis' => $devis, ]); } @@ -68,12 +68,97 @@ class DevisController extends Controller 'adresse' => "Béziers, France", ]); + $devi->load(['sousDevis', 'parent']); + return Inertia::render('Devis/Show', [ 'devis' => $devi, - 'settings' => $settings + 'settings' => $settings, ]); } + public function createFromCommande(\App\Models\Order $commande) + { + return Inertia::render('Devis/CreateFromCommande', [ + 'commande' => [ + 'id' => $commande->id, + 'number' => $commande->number, + 'label' => $commande->label, + 'supplier' => $commande->supplier, + 'quote_number' => $commande->quote_number, + ], + ]); + } + + public function storeFromCommande(Request $request, \App\Models\Order $commande) + { + $validated = $request->validate([ + 'title' => 'required|string|max:255', + 'destinataire_type' => 'required|string|in:commune,service_interne', + 'destinataire_nom' => 'required|string|max:255', + 'referent' => 'nullable|string|max:255', + 'date_devis' => 'required|date', + 'status' => 'required|string|in:draft,sent,accepted,rejected', + 'items' => 'required|array|min:1', + 'tva_rate' => 'nullable|numeric|min:0|max:100', + 'notes' => 'nullable|string', + ]); + + $totalHt = 0; + foreach ($validated['items'] as $item) { + $totalHt += floatval($item['quantite'] ?? 0) * floatval($item['prix_unitaire'] ?? 0); + } + + $tvaRate = floatval($validated['tva_rate'] ?? 20); + + $validated['total_ht'] = $totalHt; + $validated['total_ttc'] = $totalHt * (1 + ($tvaRate / 100)); + $validated['commande_id'] = $commande->id; + $validated['commande_number'] = $commande->number; + + Devis::create($validated); + + return redirect()->route('commandes.show', $commande->id) + ->with('success', 'Sous-devis créé avec succès.'); + } + + public function createSousDevis(Devis $devi) + { + return Inertia::render('Devis/CreateSousDevis', [ + 'parentDevis' => $devi, + ]); + } + + public function storeSousDevis(Request $request, Devis $devi) + { + $validated = $request->validate([ + 'title' => 'required|string|max:255', + 'destinataire_type' => 'required|string|in:commune,service_interne', + 'destinataire_nom' => 'required|string|max:255', + 'referent' => 'nullable|string|max:255', + 'date_devis' => 'required|date', + 'status' => 'required|string|in:draft,sent,accepted,rejected', + 'items' => 'required|array|min:1', + 'tva_rate' => 'nullable|numeric|min:0|max:100', + 'notes' => 'nullable|string', + ]); + + $totalHt = 0; + foreach ($validated['items'] as $item) { + $totalHt += floatval($item['quantite'] ?? 0) * floatval($item['prix_unitaire'] ?? 0); + } + + $tvaRate = floatval($validated['tva_rate'] ?? 20); + + $validated['total_ht'] = $totalHt; + $validated['total_ttc'] = $totalHt * (1 + ($tvaRate / 100)); + $validated['parent_devis_id'] = $devi->id; + + Devis::create($validated); + + return redirect()->route('devis.show', $devi->id) + ->with('success', 'Sous-devis créé avec succès.'); + } + public function edit(Devis $devi) { return Inertia::render('Devis/Edit', [ diff --git a/app/Http/Controllers/OrderController.php b/app/Http/Controllers/OrderController.php index 38b9ed7..cf494f3 100644 --- a/app/Http/Controllers/OrderController.php +++ b/app/Http/Controllers/OrderController.php @@ -194,10 +194,18 @@ class OrderController extends Controller { Gate::authorize('view', $order); - $order->load(['attachments', 'statusLogs.user']); + $order->load(['attachments', 'statusLogs.user', 'sousDevis']); return Inertia::render('Commandes/Show', [ - 'order' => new OrderResource($order), + 'order' => new OrderResource($order), + 'sousDevis' => $order->sousDevis->map(fn($d) => [ + 'id' => $d->id, + 'number' => $d->number, + 'title' => $d->title, + 'destinataire_nom' => $d->destinataire_nom, + 'total_ttc' => (float) $d->total_ttc, + 'status' => $d->status, + ]), ]); } diff --git a/app/Models/Devis.php b/app/Models/Devis.php index 7cf546f..4d1da04 100644 --- a/app/Models/Devis.php +++ b/app/Models/Devis.php @@ -3,6 +3,8 @@ namespace App\Models; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\HasMany; class Devis extends Model { @@ -16,6 +18,21 @@ class Devis extends Model 'tva_rate' => 'decimal:2', ]; + public function parent(): BelongsTo + { + return $this->belongsTo(Devis::class, 'parent_devis_id'); + } + + public function sousDevis(): HasMany + { + return $this->hasMany(Devis::class, 'parent_devis_id'); + } + + public function commande(): BelongsTo + { + return $this->belongsTo(\App\Models\Order::class, 'commande_id'); + } + protected static function booted() { static::creating(function ($devis) { diff --git a/app/Models/Order.php b/app/Models/Order.php index 67a7acf..23c4b80 100644 --- a/app/Models/Order.php +++ b/app/Models/Order.php @@ -45,6 +45,11 @@ class Order extends Model return $this->hasMany(Attachment::class); } + public function sousDevis() + { + return $this->hasMany(\App\Models\Devis::class, 'commande_id')->latest(); + } + /** * Scope par statut(s). */ diff --git a/database/migrations/2026_06_19_080000_add_parent_devis_id_to_devis_table.php b/database/migrations/2026_06_19_080000_add_parent_devis_id_to_devis_table.php new file mode 100644 index 0000000..508af34 --- /dev/null +++ b/database/migrations/2026_06_19_080000_add_parent_devis_id_to_devis_table.php @@ -0,0 +1,27 @@ +foreignId('parent_devis_id') + ->nullable() + ->after('id') + ->constrained('devis') + ->nullOnDelete(); + }); + } + + public function down(): void + { + Schema::table('devis', function (Blueprint $table) { + $table->dropForeignIdFor(\App\Models\Devis::class, 'parent_devis_id'); + $table->dropColumn('parent_devis_id'); + }); + } +}; diff --git a/database/migrations/2026_06_19_090000_add_commande_id_to_devis_table.php b/database/migrations/2026_06_19_090000_add_commande_id_to_devis_table.php new file mode 100644 index 0000000..f9e8f75 --- /dev/null +++ b/database/migrations/2026_06_19_090000_add_commande_id_to_devis_table.php @@ -0,0 +1,28 @@ +foreignId('commande_id') + ->nullable() + ->after('parent_devis_id') + ->constrained('orders') + ->nullOnDelete(); + $table->string('commande_number')->nullable()->after('commande_id'); + }); + } + + public function down(): void + { + Schema::table('devis', function (Blueprint $table) { + $table->dropForeignIdFor(\App\Models\Order::class, 'commande_id'); + $table->dropColumn(['commande_id', 'commande_number']); + }); + } +}; diff --git a/resources/js/Pages/Commandes/Show.vue b/resources/js/Pages/Commandes/Show.vue index 9cc1d91..37667a9 100644 --- a/resources/js/Pages/Commandes/Show.vue +++ b/resources/js/Pages/Commandes/Show.vue @@ -5,8 +5,29 @@ import StatusBadge from '@/Components/StatusBadge.vue'; const props = defineProps({ order: Object, + sousDevis: { type: Array, default: () => [] }, }); +const getDevisStatusLabel = (status) => { + switch (status) { + case 'draft': return 'Brouillon'; + case 'sent': return 'Envoyé'; + case 'accepted': return 'Accepté'; + case 'rejected': return 'Refusé'; + default: return status; + } +}; + +const getDevisStatusClasses = (status) => { + switch (status) { + case 'draft': return 'bg-slate-100 text-slate-700 border-slate-200 dark:bg-slate-900/60 dark:text-slate-400 dark:border-slate-800'; + case 'sent': return 'bg-blue-50 text-blue-700 border-blue-200 dark:bg-blue-950/40 dark:text-blue-300 dark:border-blue-900/60'; + case 'accepted': return 'bg-emerald-50 text-emerald-700 border-emerald-200 dark:bg-emerald-950/40 dark:text-emerald-300 dark:border-emerald-900/60'; + case 'rejected': return 'bg-rose-50 text-rose-700 border-rose-200 dark:bg-rose-950/40 dark:text-rose-300 dark:border-rose-900/60'; + default: return 'bg-slate-100 text-slate-700 border-slate-200'; + } +}; + // Lancer la transition de statut const transitionTo = (status) => { router.post(route('commandes.transition', { commande: props.order.data.id }), { @@ -75,6 +96,16 @@ const getTimelineColor = (status) => { Retour à la liste + + + + + Créer un sous-devis + + { - + + +
+
+

+ Sous-devis de ventilation + + {{ sousDevis.length }} + +

+ + + Nouveau sous-devis + +
+
+
+
+
+ + {{ sd.number }} + + {{ sd.title }} + → {{ sd.destinataire_nom }} +
+
+ + {{ getDevisStatusLabel(sd.status) }} + + + {{ new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(sd.total_ttc) }} + + + Voir → + +
+
+
+
+ Aucun sous-devis généré pour cette commande. + + Créer le premier sous-devis → + +
+
+
+
@@ -325,11 +408,9 @@ const getTimelineColor = (status) => { :key="log.id" class="relative" > -
-
@@ -340,7 +421,7 @@ const getTimelineColor = (status) => {

- Auteur : {{ log.user.name }} + Auteur : {{ log.user.name }} ({{ log.user.role === 'chef_service' ? 'Chef de service' : 'Admin réseau' }})

@@ -356,8 +437,9 @@ const getTimelineColor = (status) => { Aucun journal de statut disponible.

- - + + + diff --git a/resources/js/Pages/Devis/CreateFromCommande.vue b/resources/js/Pages/Devis/CreateFromCommande.vue new file mode 100644 index 0000000..f98aeec --- /dev/null +++ b/resources/js/Pages/Devis/CreateFromCommande.vue @@ -0,0 +1,248 @@ + + + diff --git a/resources/js/Pages/Devis/CreateSousDevis.vue b/resources/js/Pages/Devis/CreateSousDevis.vue new file mode 100644 index 0000000..bb8a5ff --- /dev/null +++ b/resources/js/Pages/Devis/CreateSousDevis.vue @@ -0,0 +1,272 @@ + + + diff --git a/resources/js/Pages/Devis/Index.vue b/resources/js/Pages/Devis/Index.vue index efd4076..0a5f214 100644 --- a/resources/js/Pages/Devis/Index.vue +++ b/resources/js/Pages/Devis/Index.vue @@ -1,11 +1,14 @@