feat: add sous-devis generation from commandes

From a commande, users can now create one or more sub-quotes
(sous-devis) to ventilate purchases across communes for approval.

- Add parent_devis_id and commande_id FKs on devis table
- Order model: sousDevis() hasMany relation
- Devis model: commande() and parent() relations
- DevisController: createFromCommande / storeFromCommande methods
- OrderController: load and pass sous_devis on show
- Commandes/Show: "Créer un sous-devis" button + ventilation panel
- Devis/CreateFromCommande: form pre-loaded with commande context
- Devis/Index: badge "Sous-devis · CMD-XXXX" on linked quotes
- Devis/Show: back-link to originating commande

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
mrKamoo
2026-06-19 15:14:45 +02:00
co-authored by Claude Sonnet 4.6
parent 7312c7640b
commit 8d51f4de3e
12 changed files with 874 additions and 15 deletions
+88 -6
View File
@@ -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
</Link>
<Link
:href="route('commandes.sous-devis.create', { commande: order.data.id })"
class="inline-flex items-center px-4 py-2 text-sm font-semibold text-white bg-violet-600 rounded-lg hover:bg-violet-500 transition-colors shadow-sm"
>
<svg class="w-4 h-4 mr-1.5" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25M9 16.5v.75m3-3v3M15 12v5.25m-4.5-15H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z" />
</svg>
Créer un sous-devis
</Link>
<Link
v-if="order.data.can.update"
:href="route('commandes.edit', { commande: order.data.id })"
@@ -309,7 +340,59 @@ const getTimelineColor = (status) => {
</div>
</div>
</div>
</div>
<!-- Sous-devis liés -->
<div class="bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-xl shadow-sm overflow-hidden">
<div class="border-b border-slate-100 dark:border-slate-850 px-6 py-4 flex items-center justify-between">
<h3 class="text-sm font-bold text-slate-800 dark:text-slate-200 uppercase tracking-wider">
Sous-devis de ventilation
<span v-if="sousDevis.length > 0" class="ml-2 inline-flex items-center px-2 py-0.5 rounded-full text-xs font-bold bg-violet-100 text-violet-700 dark:bg-violet-950/40 dark:text-violet-300">
{{ sousDevis.length }}
</span>
</h3>
<Link
:href="route('commandes.sous-devis.create', { commande: order.data.id })"
class="text-xs font-semibold text-violet-600 dark:text-violet-400 hover:underline"
>
+ Nouveau sous-devis
</Link>
</div>
<div class="p-6">
<div v-if="sousDevis.length > 0" class="space-y-3">
<div
v-for="sd in sousDevis"
:key="sd.id"
class="flex items-center justify-between px-4 py-3 rounded-xl border border-slate-200 dark:border-slate-800 bg-slate-50 dark:bg-slate-950/40 hover:border-violet-200 dark:hover:border-violet-900/60 transition-colors"
>
<div class="flex items-center gap-3 min-w-0">
<Link :href="route('devis.show', sd.id)" class="font-mono text-xs font-bold text-violet-600 dark:text-violet-400 hover:underline shrink-0">
{{ sd.number }}
</Link>
<span class="text-sm font-medium text-slate-700 dark:text-slate-300 truncate">{{ sd.title }}</span>
<span class="text-xs text-slate-500 dark:text-slate-400 shrink-0"> {{ sd.destinataire_nom }}</span>
</div>
<div class="flex items-center gap-3 shrink-0 ml-4">
<span :class="`inline-flex items-center px-2 py-0.5 rounded text-xs font-semibold border ${getDevisStatusClasses(sd.status)}`">
{{ getDevisStatusLabel(sd.status) }}
</span>
<span class="text-sm font-bold text-slate-900 dark:text-slate-100">
{{ new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(sd.total_ttc) }}
</span>
<Link :href="route('devis.show', sd.id)" class="text-xs font-semibold text-sky-600 dark:text-sky-400 hover:underline">
Voir
</Link>
</div>
</div>
</div>
<div v-else class="text-center py-6 text-sm text-slate-500 dark:text-slate-400">
Aucun sous-devis généré pour cette commande.
<Link :href="route('commandes.sous-devis.create', { commande: order.data.id })" class="block mt-2 text-violet-600 dark:text-violet-400 font-semibold hover:underline">
Créer le premier sous-devis
</Link>
</div>
</div>
</div>
</div><!-- fin lg:col-span-2 -->
<!-- Journal d'historique (Col 3) -->
<div class="bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-xl shadow-sm overflow-hidden h-fit">
@@ -325,11 +408,9 @@ const getTimelineColor = (status) => {
:key="log.id"
class="relative"
>
<!-- Point coloré -->
<div
:class="`absolute -left-6.5 top-1.5 h-3.5 w-3.5 rounded-full border-2 border-white dark:border-slate-900 ${getTimelineColor(log.new_status)} shadow-sm`"
></div>
<div class="space-y-1">
<div class="flex items-center justify-between gap-2">
<span class="text-sm font-bold text-slate-800 dark:text-slate-200">
@@ -340,7 +421,7 @@ const getTimelineColor = (status) => {
</span>
</div>
<p class="text-xs text-slate-500 dark:text-slate-400">
Auteur : <span class="font-semibold text-slate-600 dark:text-slate-300">{{ log.user.name }}</span>
Auteur : <span class="font-semibold text-slate-600 dark:text-slate-300">{{ log.user.name }}</span>
<span class="text-slate-400 text-xxs font-normal">({{ log.user.role === 'chef_service' ? 'Chef de service' : 'Admin réseau' }})</span>
</p>
<p v-if="log.old_status" class="text-xxs text-slate-400">
@@ -356,8 +437,9 @@ const getTimelineColor = (status) => {
Aucun journal de statut disponible.
</div>
</div>
</div>
</div>
</div><!-- fin col 3 -->
</div><!-- fin grid -->
</div>
</div>
</AuthenticatedLayout>