225 lines
12 KiB
Vue
225 lines
12 KiB
Vue
<script setup>
|
|
import { computed } from 'vue'
|
|
|
|
const props = defineProps({
|
|
modelValue: { type: Array, default: () => [] },
|
|
categories: { type: Array, default: () => [] },
|
|
articles: { type: Array, default: () => [] },
|
|
readonly: { type: Boolean, default: false },
|
|
showReceived: { type: Boolean, default: false },
|
|
})
|
|
|
|
const emit = defineEmits(['update:modelValue'])
|
|
|
|
const lignes = computed({
|
|
get: () => props.modelValue,
|
|
set: (val) => emit('update:modelValue', val),
|
|
})
|
|
|
|
function addLigne() {
|
|
emit('update:modelValue', [
|
|
...props.modelValue,
|
|
{ designation: '', reference: '', quantite: 1, quantite_recue: 0, unite: 'unité', prix_unitaire_ht: null, taux_tva: 20, categorie_id: null, article_id: null, notes: '' }
|
|
])
|
|
}
|
|
|
|
function removeLigne(idx) {
|
|
const updated = [...props.modelValue]
|
|
updated.splice(idx, 1)
|
|
emit('update:modelValue', updated)
|
|
}
|
|
|
|
function updateLigne(idx, field, value) {
|
|
const updated = [...props.modelValue]
|
|
updated[idx] = { ...updated[idx], [field]: value }
|
|
emit('update:modelValue', updated)
|
|
}
|
|
|
|
function onArticleSelect(idx, articleId) {
|
|
const article = props.articles.find(a => a.id == articleId)
|
|
if (!article) return
|
|
const updated = [...props.modelValue]
|
|
updated[idx] = {
|
|
...updated[idx],
|
|
article_id: article.id,
|
|
designation: article.designation,
|
|
reference: article.reference ?? '',
|
|
prix_unitaire_ht: article.prix_unitaire_ht,
|
|
unite: article.unite,
|
|
categorie_id: article.categorie_id,
|
|
}
|
|
emit('update:modelValue', updated)
|
|
}
|
|
|
|
function montantHT(ligne) {
|
|
const q = parseFloat(ligne.quantite) || 0
|
|
const p = parseFloat(ligne.prix_unitaire_ht) || 0
|
|
return (q * p).toFixed(2)
|
|
}
|
|
|
|
function montantTTC(ligne) {
|
|
const ht = parseFloat(montantHT(ligne))
|
|
const tva = parseFloat(ligne.taux_tva) || 0
|
|
return (ht * (1 + tva / 100)).toFixed(2)
|
|
}
|
|
|
|
const totalHT = computed(() => props.modelValue.reduce((sum, l) => sum + parseFloat(montantHT(l)), 0).toFixed(2))
|
|
const totalTTC = computed(() => props.modelValue.reduce((sum, l) => sum + parseFloat(montantTTC(l)), 0).toFixed(2))
|
|
|
|
function formatCurrency(val) {
|
|
return new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(val)
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<div>
|
|
<div class="overflow-x-auto">
|
|
<table class="min-w-full divide-y divide-gray-200 text-sm">
|
|
<thead class="bg-gray-50">
|
|
<tr>
|
|
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">Désignation</th>
|
|
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">Catégorie</th>
|
|
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">Réf.</th>
|
|
<th class="px-3 py-2 text-right text-xs font-medium text-gray-500 uppercase w-20">Qté</th>
|
|
<th v-if="showReceived" class="px-3 py-2 text-right text-xs font-medium text-gray-500 uppercase w-20">Reçu</th>
|
|
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase w-20">Unité</th>
|
|
<th class="px-3 py-2 text-right text-xs font-medium text-gray-500 uppercase w-28">P.U. HT</th>
|
|
<th class="px-3 py-2 text-right text-xs font-medium text-gray-500 uppercase w-16">TVA %</th>
|
|
<th class="px-3 py-2 text-right text-xs font-medium text-gray-500 uppercase w-28">Total HT</th>
|
|
<th v-if="!readonly" class="px-3 py-2 w-10"></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody class="divide-y divide-gray-100 bg-white">
|
|
<tr v-for="(ligne, idx) in lignes" :key="idx">
|
|
<!-- Désignation -->
|
|
<td class="px-3 py-2">
|
|
<div v-if="!readonly" class="space-y-1">
|
|
<select v-if="articles.length"
|
|
class="block w-full rounded border border-gray-200 px-2 py-1 text-xs text-gray-600 focus:border-blue-500 focus:outline-none"
|
|
@change="onArticleSelect(idx, $event.target.value)">
|
|
<option value="">— Choisir un article —</option>
|
|
<option v-for="art in articles" :key="art.id" :value="art.id">{{ art.designation }}</option>
|
|
</select>
|
|
<input :value="ligne.designation" @input="updateLigne(idx, 'designation', $event.target.value)"
|
|
type="text" placeholder="Désignation *" required
|
|
class="block w-full rounded border border-gray-200 px-2 py-1.5 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500" />
|
|
</div>
|
|
<span v-else class="font-medium">{{ ligne.designation }}</span>
|
|
</td>
|
|
|
|
<!-- Catégorie -->
|
|
<td class="px-3 py-2">
|
|
<select v-if="!readonly"
|
|
:value="ligne.categorie_id"
|
|
@change="updateLigne(idx, 'categorie_id', $event.target.value || null)"
|
|
class="block w-full rounded border border-gray-200 px-2 py-1.5 text-sm focus:border-blue-500 focus:outline-none">
|
|
<option value="">—</option>
|
|
<option v-for="cat in categories" :key="cat.id" :value="cat.id">{{ cat.nom }}</option>
|
|
</select>
|
|
<span v-else class="text-gray-600">{{ categories.find(c => c.id == ligne.categorie_id)?.nom ?? '—' }}</span>
|
|
</td>
|
|
|
|
<!-- Référence -->
|
|
<td class="px-3 py-2">
|
|
<input v-if="!readonly" :value="ligne.reference"
|
|
@input="updateLigne(idx, 'reference', $event.target.value)"
|
|
type="text" placeholder="Réf."
|
|
class="block w-full rounded border border-gray-200 px-2 py-1.5 text-sm focus:border-blue-500 focus:outline-none" />
|
|
<span v-else class="text-gray-600">{{ ligne.reference || '—' }}</span>
|
|
</td>
|
|
|
|
<!-- Quantité -->
|
|
<td class="px-3 py-2">
|
|
<input v-if="!readonly" :value="ligne.quantite"
|
|
@input="updateLigne(idx, 'quantite', $event.target.value)"
|
|
type="number" min="0.001" step="0.001" required
|
|
class="block w-full rounded border border-gray-200 px-2 py-1.5 text-sm text-right focus:border-blue-500 focus:outline-none" />
|
|
<span v-else class="block text-right">{{ ligne.quantite }}</span>
|
|
</td>
|
|
|
|
<!-- Quantité reçue -->
|
|
<td v-if="showReceived" class="px-3 py-2">
|
|
<input v-if="!readonly" :value="ligne.quantite_recue"
|
|
@input="updateLigne(idx, 'quantite_recue', $event.target.value)"
|
|
type="number" min="0" :max="ligne.quantite" step="0.001"
|
|
class="block w-full rounded border border-gray-200 px-2 py-1.5 text-sm text-right focus:border-blue-500 focus:outline-none" />
|
|
<span v-else class="block text-right">{{ ligne.quantite_recue }}</span>
|
|
</td>
|
|
|
|
<!-- Unité -->
|
|
<td class="px-3 py-2">
|
|
<input v-if="!readonly" :value="ligne.unite"
|
|
@input="updateLigne(idx, 'unite', $event.target.value)"
|
|
type="text" placeholder="unité"
|
|
class="block w-full rounded border border-gray-200 px-2 py-1.5 text-sm focus:border-blue-500 focus:outline-none" />
|
|
<span v-else class="text-gray-600">{{ ligne.unite }}</span>
|
|
</td>
|
|
|
|
<!-- Prix HT -->
|
|
<td class="px-3 py-2">
|
|
<input v-if="!readonly" :value="ligne.prix_unitaire_ht"
|
|
@input="updateLigne(idx, 'prix_unitaire_ht', $event.target.value)"
|
|
type="number" min="0" step="0.01" placeholder="0.00"
|
|
class="block w-full rounded border border-gray-200 px-2 py-1.5 text-sm text-right focus:border-blue-500 focus:outline-none" />
|
|
<span v-else class="block text-right">{{ ligne.prix_unitaire_ht != null ? formatCurrency(ligne.prix_unitaire_ht) : '—' }}</span>
|
|
</td>
|
|
|
|
<!-- TVA -->
|
|
<td class="px-3 py-2">
|
|
<input v-if="!readonly" :value="ligne.taux_tva"
|
|
@input="updateLigne(idx, 'taux_tva', $event.target.value)"
|
|
type="number" min="0" max="100" step="0.1"
|
|
class="block w-full rounded border border-gray-200 px-2 py-1.5 text-sm text-right focus:border-blue-500 focus:outline-none" />
|
|
<span v-else class="block text-right">{{ ligne.taux_tva }}%</span>
|
|
</td>
|
|
|
|
<!-- Montant HT -->
|
|
<td class="px-3 py-2 text-right font-medium text-gray-900">
|
|
{{ ligne.prix_unitaire_ht != null ? formatCurrency(montantHT(ligne)) : '—' }}
|
|
</td>
|
|
|
|
<!-- Supprimer -->
|
|
<td v-if="!readonly" class="px-3 py-2 text-center">
|
|
<button type="button" @click="removeLigne(idx)"
|
|
class="text-gray-400 hover:text-red-500 transition-colors">
|
|
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
|
</svg>
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
|
|
<tr v-if="!lignes.length">
|
|
<td :colspan="readonly ? 8 : 9" class="px-3 py-6 text-center text-sm text-gray-400 italic">
|
|
Aucune ligne de commande.
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
|
|
<!-- Totaux -->
|
|
<tfoot v-if="lignes.length" class="bg-gray-50 font-medium">
|
|
<tr>
|
|
<td :colspan="readonly ? 6 : 7" class="px-3 py-2 text-right text-sm text-gray-600">Total HT</td>
|
|
<td colspan="2" class="px-3 py-2 text-right text-sm">{{ formatCurrency(totalHT) }}</td>
|
|
<td v-if="!readonly"></td>
|
|
</tr>
|
|
<tr>
|
|
<td :colspan="readonly ? 6 : 7" class="px-3 py-2 text-right text-sm text-gray-600">Total TTC</td>
|
|
<td colspan="2" class="px-3 py-2 text-right font-bold text-blue-700">{{ formatCurrency(totalTTC) }}</td>
|
|
<td v-if="!readonly"></td>
|
|
</tr>
|
|
</tfoot>
|
|
</table>
|
|
</div>
|
|
|
|
<button v-if="!readonly" type="button" @click="addLigne"
|
|
class="mt-3 flex items-center gap-2 text-sm font-medium text-blue-600 hover:text-blue-800 transition-colors">
|
|
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
|
</svg>
|
|
Ajouter une ligne
|
|
</button>
|
|
</div>
|
|
</template>
|