Files
OrderCheck/resources/js/Pages/Devis/CreateSousDevis.vue
T
mrKamooandClaude Sonnet 4.6 8d51f4de3e 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>
2026-06-19 15:14:45 +02:00

273 lines
16 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup>
import { ref, computed } from 'vue';
import { Head, Link, useForm } from '@inertiajs/vue3';
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout.vue';
const props = defineProps({
parentDevis: Object,
});
// Each parent item has a `selected` toggle + editable quantite
const parentItems = ref(
(props.parentDevis.items ?? []).map(item => ({
...item,
selected: true,
quantite: item.quantite,
}))
);
const form = useForm({
title: '',
destinataire_type: 'commune',
destinataire_nom: '',
referent: '',
date_devis: new Date().toISOString().split('T')[0],
status: 'draft',
tva_rate: props.parentDevis.tva_rate ?? 20,
notes: '',
items: [],
});
const selectedItems = computed(() =>
parentItems.value.filter(i => i.selected)
);
const totalHt = computed(() =>
selectedItems.value.reduce((sum, i) =>
sum + (parseFloat(i.quantite) || 0) * (parseFloat(i.prix_unitaire) || 0), 0)
);
const tvaAmount = computed(() =>
totalHt.value * ((parseFloat(form.tva_rate) || 0) / 100)
);
const totalTtc = computed(() => totalHt.value + tvaAmount.value);
const formatCurrency = (v) =>
new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(v);
const submit = () => {
form.items = selectedItems.value.map(({ selected, ...item }) => item);
form.post(route('devis.sous-devis.store', props.parentDevis.id));
};
</script>
<template>
<Head title="Créer un Sous-Devis" />
<AuthenticatedLayout>
<template #header>
<div class="flex items-center justify-between">
<div>
<h2 class="text-xl font-bold leading-tight text-slate-800 dark:text-slate-200">
Nouveau Sous-Devis
</h2>
<p class="text-sm text-slate-500 dark:text-slate-400 mt-0.5">
Basé sur
<Link :href="route('devis.show', parentDevis.id)" class="text-sky-600 dark:text-sky-400 font-semibold hover:underline">
{{ parentDevis.number }}
</Link>
{{ parentDevis.title }}
</p>
</div>
<Link
:href="route('devis.show', parentDevis.id)"
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-5xl mx-auto px-4 sm:px-6 lg:px-8">
<form @submit.prevent="submit" class="space-y-6">
<!-- Informations générales -->
<div class="bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-xl shadow-sm p-6 space-y-6">
<h3 class="text-md font-bold text-slate-800 dark:text-slate-200 border-b border-slate-100 dark:border-slate-850 pb-3">
Informations Générales
</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="md:col-span-2">
<label class="block text-sm font-bold text-slate-750 dark:text-slate-300 mb-1">Objet du sous-devis :</label>
<input
v-model="form.title"
type="text"
class="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"
placeholder="Ex: Sous-devis Mairie de Corneilhan postes de travail"
required
>
<p v-if="form.errors.title" class="text-xs text-rose-600 mt-1">{{ form.errors.title }}</p>
</div>
<div>
<label class="block text-sm font-bold text-slate-755 dark:text-slate-300 mb-1">Type de destinataire :</label>
<select v-model="form.destinataire_type" class="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">
<option value="commune">Commune (Collectivité extérieure)</option>
<option value="service_interne">Service Interne (Direction / Service)</option>
</select>
</div>
<div>
<label class="block text-sm font-bold text-slate-755 dark:text-slate-300 mb-1">Nom du destinataire :</label>
<input
v-model="form.destinataire_nom"
type="text"
class="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"
placeholder="Ex: Mairie de Corneilhan"
required
>
<p v-if="form.errors.destinataire_nom" class="text-xs text-rose-600 mt-1">{{ form.errors.destinataire_nom }}</p>
</div>
<div>
<label class="block text-sm font-bold text-slate-755 dark:text-slate-300 mb-1">Référent :</label>
<input
v-model="form.referent"
type="text"
class="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"
placeholder="Ex: Jean Dupont"
>
</div>
<div>
<label class="block text-sm font-bold text-slate-755 dark:text-slate-300 mb-1">Date du devis :</label>
<input v-model="form.date_devis" type="date" class="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>
</div>
<div>
<label class="block text-sm font-bold text-slate-755 dark:text-slate-300 mb-1">Statut :</label>
<select v-model="form.status" class="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">
<option value="draft">Brouillon</option>
<option value="sent">Envoyé</option>
<option value="accepted">Accepté</option>
<option value="rejected">Refusé</option>
</select>
</div>
<div>
<label class="block text-sm font-bold text-slate-755 dark:text-slate-300 mb-1">Taux TVA (%) :</label>
<input v-model="form.tva_rate" type="number" min="0" max="100" step="0.1" class="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">
</div>
</div>
</div>
<!-- Sélection des lignes du devis parent -->
<div class="bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-xl shadow-sm p-6 space-y-4">
<div class="border-b border-slate-100 dark:border-slate-850 pb-3">
<h3 class="text-md font-bold text-slate-800 dark:text-slate-200">
Sélection des lignes du devis fournisseur
</h3>
<p class="text-xs text-slate-500 dark:text-slate-400 mt-1">
Cochez les éléments à inclure. Les quantités sont modifiables (max : quantité du devis fournisseur).
</p>
</div>
<div v-if="parentItems.length === 0" class="text-sm text-slate-400 py-4 text-center">
Le devis parent ne contient aucune ligne.
</div>
<div class="space-y-3">
<div
v-for="(item, index) in parentItems"
:key="index"
:class="[
'flex items-center gap-4 p-4 rounded-xl border transition-colors',
item.selected
? 'bg-sky-50 dark:bg-sky-950/20 border-sky-200 dark:border-sky-900/60'
: 'bg-slate-50 dark:bg-slate-950/50 border-slate-100 dark:border-slate-850 opacity-50'
]"
>
<!-- Checkbox -->
<input
v-model="item.selected"
type="checkbox"
class="w-4 h-4 text-sky-600 rounded border-slate-300 focus:ring-sky-500 dark:border-slate-700 dark:bg-slate-800 shrink-0"
>
<!-- Désignation + type -->
<div class="flex-1 min-w-0">
<p class="text-sm font-semibold text-slate-900 dark:text-slate-100 truncate">{{ item.designation }}</p>
<p class="text-xxs font-bold uppercase tracking-wider text-slate-400">{{ item.type }}</p>
</div>
<!-- Prix unitaire (lecture seule) -->
<div class="text-right shrink-0">
<p class="text-xs text-slate-500 dark:text-slate-400">P.U. HT</p>
<p class="text-sm font-bold text-slate-800 dark:text-slate-200">{{ formatCurrency(item.prix_unitaire) }}</p>
</div>
<!-- Quantité modifiable -->
<div class="shrink-0">
<label class="block text-xxs font-bold text-slate-400 uppercase tracking-wide mb-1">Qté</label>
<input
v-model="item.quantite"
type="number"
min="1"
:max="item.quantite"
step="1"
:disabled="!item.selected"
class="w-20 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 disabled:opacity-40"
>
</div>
<!-- Sous-total -->
<div class="text-right shrink-0 w-24">
<p class="text-xs text-slate-500 dark:text-slate-400">Sous-total</p>
<p class="text-sm font-black text-slate-900 dark:text-slate-100">
{{ formatCurrency((parseFloat(item.quantite) || 0) * (parseFloat(item.prix_unitaire) || 0)) }}
</p>
</div>
</div>
</div>
<p v-if="form.errors.items" class="text-xs text-rose-600 mt-1">{{ form.errors.items }}</p>
<!-- Récapitulatif -->
<div class="bg-slate-50 dark:bg-slate-950 p-4 rounded-xl border border-slate-200 dark:border-slate-800 flex flex-col items-end space-y-2 text-sm">
<div class="flex justify-between w-64">
<span class="text-slate-500">Total HT :</span>
<span class="font-bold text-slate-850 dark:text-slate-200">{{ formatCurrency(totalHt) }}</span>
</div>
<div class="flex justify-between w-64 border-b border-slate-200 dark:border-slate-800 pb-2">
<span class="text-slate-500">TVA ({{ form.tva_rate }}%) :</span>
<span class="font-semibold text-slate-850 dark:text-slate-200">{{ formatCurrency(tvaAmount) }}</span>
</div>
<div class="flex justify-between w-64 text-base font-extrabold text-sky-600 dark:text-sky-400 pt-1">
<span>Total TTC :</span>
<span>{{ formatCurrency(totalTtc) }}</span>
</div>
</div>
</div>
<!-- Notes -->
<div class="bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-xl shadow-sm p-6">
<label class="block text-sm font-bold text-slate-755 dark:text-slate-300 mb-1">Notes / Conditions :</label>
<textarea v-model="form.notes" rows="3" class="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" placeholder="Ex: Devis valable 30 jours."></textarea>
</div>
<!-- Actions -->
<div class="flex items-center justify-end gap-3 pt-6 border-t border-slate-200 dark:border-slate-800">
<Link
:href="route('devis.show', parentDevis.id)"
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>
<button
type="submit"
:disabled="form.processing || selectedItems.length === 0"
class="inline-flex items-center px-4 py-2 text-sm font-semibold text-white bg-sky-600 hover:bg-sky-500 dark:bg-sky-500 dark:hover:bg-sky-400 rounded-lg shadow-sm focus:outline-none focus:ring-2 focus:ring-sky-500 transition-colors disabled:opacity-50"
>
Créer le Sous-Devis
</button>
</div>
</form>
</div>
</div>
</AuthenticatedLayout>
</template>