Compare commits
2
Commits
7312c7640b
...
29875be25b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
29875be25b | ||
|
|
8d51f4de3e |
@@ -1,65 +0,0 @@
|
||||
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}"
|
||||
@@ -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', [
|
||||
|
||||
@@ -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,
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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).
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('devis', function (Blueprint $table) {
|
||||
$table->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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('devis', function (Blueprint $table) {
|
||||
$table->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']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { Head, Link, useForm } from '@inertiajs/vue3';
|
||||
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout.vue';
|
||||
|
||||
const props = defineProps({
|
||||
commande: Object,
|
||||
});
|
||||
|
||||
const itemTypes = ['Matériel', 'Licence', 'Prestation', 'Infrastructure', 'Autre'];
|
||||
|
||||
const form = useForm({
|
||||
title: '',
|
||||
destinataire_type: 'commune',
|
||||
destinataire_nom: '',
|
||||
referent: '',
|
||||
date_devis: new Date().toISOString().split('T')[0],
|
||||
status: 'draft',
|
||||
items: [
|
||||
{ type: 'Matériel', designation: '', quantite: 1, prix_unitaire: 0 }
|
||||
],
|
||||
tva_rate: 20,
|
||||
notes: '',
|
||||
});
|
||||
|
||||
const addItem = () => {
|
||||
form.items.push({ type: 'Matériel', designation: '', quantite: 1, prix_unitaire: 0 });
|
||||
};
|
||||
|
||||
const removeItem = (index) => {
|
||||
form.items.splice(index, 1);
|
||||
if (form.items.length === 0) addItem();
|
||||
};
|
||||
|
||||
const totalHt = computed(() =>
|
||||
form.items.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.post(route('commandes.sous-devis.store', props.commande.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">
|
||||
Commande
|
||||
<Link :href="route('commandes.show', commande.id)" class="text-sky-600 dark:text-sky-400 font-semibold hover:underline">
|
||||
{{ commande.number }}
|
||||
</Link>
|
||||
— {{ commande.label }}
|
||||
<span v-if="commande.supplier" class="text-slate-400"> · {{ commande.supplier }}</span>
|
||||
<span v-if="commande.quote_number" class="font-mono text-xs text-slate-400"> (Réf. devis fournisseur : {{ commande.quote_number }})</span>
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
:href="route('commandes.show', commande.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 à la commande
|
||||
</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: Ventilation postes de travail – Mairie de Corneilhan"
|
||||
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 / Contact :</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>
|
||||
|
||||
<!-- Lignes à ventiler -->
|
||||
<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="flex items-center justify-between border-b border-slate-100 dark:border-slate-850 pb-3">
|
||||
<div>
|
||||
<h3 class="text-md font-bold text-slate-800 dark:text-slate-200">
|
||||
Lignes à ventiler
|
||||
</h3>
|
||||
<p class="text-xs text-slate-500 dark:text-slate-400 mt-0.5">
|
||||
Saisissez les éléments du devis fournisseur ({{ commande.quote_number || commande.number }}) à refacturer à cette collectivité.
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" @click="addItem" class="text-xs font-semibold text-sky-600 hover:text-sky-700 bg-sky-50 dark:bg-sky-950/40 px-3 py-1.5 rounded-lg border border-sky-100 dark:border-sky-900/60 transition-colors">
|
||||
+ Ajouter une ligne
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div v-for="(item, index) in form.items" :key="index" class="flex items-start gap-3 bg-slate-50 dark:bg-slate-950/50 p-4 rounded-xl border border-slate-100 dark:border-slate-850">
|
||||
<div class="flex-1 grid grid-cols-1 sm:grid-cols-12 gap-3">
|
||||
<div class="sm:col-span-3">
|
||||
<label class="block text-xxs font-bold text-slate-400 uppercase tracking-wide mb-1">Catégorie</label>
|
||||
<select v-model="item.type" 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">
|
||||
<option v-for="t in itemTypes" :key="t" :value="t">{{ t }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="sm:col-span-5">
|
||||
<label class="block text-xxs font-bold text-slate-400 uppercase tracking-wide mb-1">Description</label>
|
||||
<input v-model="item.designation" type="text" placeholder="Désignation" 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 class="sm:col-span-2">
|
||||
<label class="block text-xxs font-bold text-slate-400 uppercase tracking-wide mb-1">Quantité</label>
|
||||
<input v-model="item.quantite" type="number" min="1" step="1" 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 class="sm:col-span-2">
|
||||
<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>
|
||||
</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">
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="form.errors.items" class="text-xs text-rose-600">{{ 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('commandes.show', commande.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"
|
||||
class="inline-flex items-center px-4 py-2 text-sm font-semibold text-white bg-violet-600 hover:bg-violet-500 rounded-lg shadow-sm focus:outline-none focus:ring-2 focus:ring-violet-500 transition-colors disabled:opacity-50"
|
||||
>
|
||||
Créer le Sous-Devis
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</AuthenticatedLayout>
|
||||
</template>
|
||||
@@ -0,0 +1,272 @@
|
||||
<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>
|
||||
@@ -1,11 +1,14 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { Head, Link } from '@inertiajs/vue3';
|
||||
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout.vue';
|
||||
|
||||
defineProps({
|
||||
const props = defineProps({
|
||||
devis: Array,
|
||||
});
|
||||
|
||||
const orderedDevis = computed(() => props.devis);
|
||||
|
||||
const getStatusClasses = (status) => {
|
||||
switch (status) {
|
||||
case 'draft':
|
||||
@@ -96,15 +99,23 @@ const formatCurrency = (value) => {
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-100 dark:divide-slate-850 text-sm">
|
||||
<tr
|
||||
v-for="d in devis"
|
||||
v-for="d in orderedDevis"
|
||||
:key="d.id"
|
||||
class="hover:bg-slate-50/50 dark:hover:bg-slate-950/20 transition-colors"
|
||||
:class="[
|
||||
'hover:bg-slate-50/50 dark:hover:bg-slate-950/20 transition-colors',
|
||||
!d._isParent ? 'bg-violet-50/30 dark:bg-violet-950/10' : ''
|
||||
]"
|
||||
>
|
||||
<!-- Numéro -->
|
||||
<td class="px-6 py-4 font-mono text-xs font-bold text-sky-600 dark:text-sky-400">
|
||||
<Link :href="route('devis.show', d.id)" class="hover:underline">
|
||||
{{ d.number }}
|
||||
</Link>
|
||||
<div v-if="d.commande_id" class="mt-0.5">
|
||||
<span class="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xxs font-bold bg-violet-100 text-violet-700 dark:bg-violet-950/40 dark:text-violet-300 border border-violet-200 dark:border-violet-900/60">
|
||||
Sous-devis · {{ d.commande_number }}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<!-- Objet -->
|
||||
<td class="px-6 py-4 font-medium text-slate-800 dark:text-slate-200">
|
||||
@@ -169,7 +180,7 @@ const formatCurrency = (value) => {
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="devis.length === 0">
|
||||
<tr v-if="orderedDevis.length === 0">
|
||||
<td colspan="7" class="px-6 py-12 text-center">
|
||||
<div class="flex flex-col items-center justify-center text-slate-500 dark:text-slate-400">
|
||||
<svg class="w-12 h-12 mb-4 text-slate-300 dark:text-slate-600" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
|
||||
@@ -68,6 +68,16 @@ const printDevis = () => {
|
||||
>
|
||||
Modifier
|
||||
</Link>
|
||||
<Link
|
||||
v-if="!devis.parent_devis_id"
|
||||
:href="route('devis.sous-devis.create', devis.id)"
|
||||
class="inline-flex items-center px-4 py-2 text-sm font-semibold text-white bg-violet-600 hover:bg-violet-500 rounded-lg 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>
|
||||
<a
|
||||
:href="route('devis.pdf', devis.id)"
|
||||
class="inline-flex items-center px-4 py-2 text-sm font-semibold text-white bg-sky-600 hover:bg-sky-550 rounded-lg transition-colors shadow-sm"
|
||||
@@ -224,6 +234,68 @@ const printDevis = () => {
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Badge : lien vers la commande d'origine si sous-devis -->
|
||||
<div v-if="devis.commande_id" class="pt-6 border-t border-slate-200 dark:border-slate-800">
|
||||
<div class="flex items-center gap-3 text-sm">
|
||||
<span class="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-bold bg-violet-100 text-violet-800 dark:bg-violet-950/40 dark:text-violet-300 border border-violet-200 dark:border-violet-900/60">
|
||||
Sous-devis
|
||||
</span>
|
||||
<span class="text-slate-500 dark:text-slate-400">Issu de la commande :</span>
|
||||
<Link :href="route('commandes.show', devis.commande_id)" class="font-mono text-xs font-bold text-sky-600 dark:text-sky-400 hover:underline">
|
||||
{{ devis.commande_number }}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Badge : lien vers le devis parent si sous-devis (legacy) -->
|
||||
<div v-if="devis.parent_devis_id && devis.parent && !devis.commande_id" class="pt-6 border-t border-slate-200 dark:border-slate-800">
|
||||
<div class="flex items-center gap-3 text-sm">
|
||||
<span class="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-bold bg-violet-100 text-violet-800 dark:bg-violet-950/40 dark:text-violet-300 border border-violet-200 dark:border-violet-900/60">
|
||||
Sous-devis
|
||||
</span>
|
||||
<span class="text-slate-500 dark:text-slate-400">Issu du devis fournisseur :</span>
|
||||
<Link :href="route('devis.show', devis.parent.id)" class="font-mono text-xs font-bold text-sky-600 dark:text-sky-400 hover:underline">
|
||||
{{ devis.parent.number }}
|
||||
</Link>
|
||||
<span class="text-slate-400 dark:text-slate-600">—</span>
|
||||
<span class="text-slate-600 dark:text-slate-400">{{ devis.parent.title }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Section sous-devis enfants (si devis parent) -->
|
||||
<div v-if="!devis.parent_devis_id && devis.sous_devis && devis.sous_devis.length > 0" class="pt-6 border-t border-slate-200 dark:border-slate-800 space-y-4 no-print">
|
||||
<h3 class="text-sm font-black uppercase tracking-wider text-slate-500 dark:text-slate-400">
|
||||
Sous-devis générés ({{ devis.sous_devis.length }})
|
||||
</h3>
|
||||
<div class="space-y-2">
|
||||
<div
|
||||
v-for="sd in devis.sous_devis"
|
||||
: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"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="font-mono text-xs font-bold text-violet-600 dark:text-violet-400">{{ sd.number }}</span>
|
||||
<span class="text-sm text-slate-700 dark:text-slate-300">{{ sd.title }}</span>
|
||||
<span class="text-xs text-slate-500 dark:text-slate-400">→ {{ sd.destinataire_nom }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<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>
|
||||
<Link
|
||||
:href="route('devis.sous-devis.create', devis.id)"
|
||||
class="inline-flex items-center text-xs font-semibold text-violet-600 dark:text-violet-400 hover:underline"
|
||||
>
|
||||
+ Ajouter un sous-devis
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -65,6 +65,8 @@ Route::middleware('auth')->group(function () {
|
||||
|
||||
// Suivi des commandes
|
||||
Route::post('/commandes/{commande}/transition', [OrderController::class, 'transition'])->name('commandes.transition');
|
||||
Route::get('/commandes/{commande}/sous-devis/create', [DevisController::class, 'createFromCommande'])->name('commandes.sous-devis.create');
|
||||
Route::post('/commandes/{commande}/sous-devis', [DevisController::class, 'storeFromCommande'])->name('commandes.sous-devis.store');
|
||||
Route::resource('commandes', OrderController::class);
|
||||
|
||||
// Pièces jointes
|
||||
@@ -82,6 +84,8 @@ Route::middleware('auth')->group(function () {
|
||||
Route::get('/devis/settings', [DevisController::class, 'settings'])->name('devis.settings');
|
||||
Route::put('/devis/settings', [DevisController::class, 'updateSettings'])->name('devis.settings.update');
|
||||
Route::get('/devis/{devi}/pdf', [DevisController::class, 'downloadPdf'])->name('devis.pdf');
|
||||
Route::get('/devis/{devi}/sous-devis/create', [DevisController::class, 'createSousDevis'])->name('devis.sous-devis.create');
|
||||
Route::post('/devis/{devi}/sous-devis', [DevisController::class, 'storeSousDevis'])->name('devis.sous-devis.store');
|
||||
Route::resource('devis', DevisController::class)->parameters([
|
||||
'devis' => 'devi',
|
||||
]);
|
||||
|
||||
Reference in New Issue
Block a user