568 lines
14 KiB
Vue
568 lines
14 KiB
Vue
<template>
|
|
<form @submit.prevent="submitForm" class="space-y-6">
|
|
<!-- Row 1: N° Avoir, Date d'émission, Statut -->
|
|
<div class="row g-3 mb-4">
|
|
<div class="col-md-3">
|
|
<label class="form-label">N° Avoir</label>
|
|
<soft-input
|
|
v-model="formData.number"
|
|
type="text"
|
|
placeholder="Auto-généré"
|
|
:disabled="true"
|
|
/>
|
|
</div>
|
|
<div class="col-md-3">
|
|
<label class="form-label">Date d'émission</label>
|
|
<soft-input
|
|
v-model="formData.date"
|
|
type="date"
|
|
/>
|
|
</div>
|
|
<div class="col-md-3">
|
|
<label class="form-label">Statut</label>
|
|
<select v-model="formData.status" class="form-select">
|
|
<option value="brouillon">Brouillon</option>
|
|
<option value="emis">Émis</option>
|
|
<option value="applique">Appliqué</option>
|
|
<option value="annule">Annulé</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Row 2: Facture d'origine (with search), Client -->
|
|
<div class="row g-3 mb-4">
|
|
<div class="col-md-6 position-relative invoice-search-container">
|
|
<label class="form-label">Facture d'origine</label>
|
|
<div class="search-input-wrapper">
|
|
<i class="fas fa-search search-icon"></i>
|
|
<soft-input
|
|
v-model="invoiceSearchQuery"
|
|
type="text"
|
|
placeholder="Rechercher une facture..."
|
|
@input="handleInvoiceSearch"
|
|
@focus="showInvoiceResults = true"
|
|
class="search-input"
|
|
/>
|
|
</div>
|
|
|
|
<!-- Search Results Dropdown -->
|
|
<div
|
|
v-if="showInvoiceResults && (invoiceSearchResults.length > 0 || isSearchingInvoices)"
|
|
class="search-dropdown"
|
|
>
|
|
<div v-if="isSearchingInvoices" class="dropdown-loading">
|
|
<div class="spinner-border spinner-border-sm text-primary" role="status">
|
|
<span class="visually-hidden">Chargement...</span>
|
|
</div>
|
|
</div>
|
|
<div v-else-if="invoiceSearchResults.length === 0" class="dropdown-empty">
|
|
Aucune facture trouvée
|
|
</div>
|
|
<template v-else>
|
|
<button
|
|
v-for="invoice in invoiceSearchResults"
|
|
:key="invoice.id"
|
|
type="button"
|
|
class="dropdown-item"
|
|
@click="selectInvoice(invoice)"
|
|
>
|
|
<span class="item-name">{{ invoice.invoice_number }}</span>
|
|
<span class="item-details">
|
|
{{ invoice.clientName }} • {{ formatCurrency(invoice.amount) }}
|
|
</span>
|
|
</button>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
<div class="col-md-6">
|
|
<label class="form-label">Client</label>
|
|
<soft-input
|
|
v-model="formData.clientName"
|
|
type="text"
|
|
placeholder="Automatiquement rempli"
|
|
:disabled="true"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Row 3: Motif, Mode de remboursement -->
|
|
<div class="row g-3 mb-4">
|
|
<div class="col-md-6">
|
|
<label class="form-label">Motif</label>
|
|
<select v-model="formData.reason" class="form-select">
|
|
<option value="">-- Sélectionner un motif --</option>
|
|
<option value="erreur_facturation">Erreur de facturation</option>
|
|
<option value="retour_marchandise">Retour de marchandise</option>
|
|
<option value="geste_commercial">Geste commercial</option>
|
|
<option value="annulation_prestation">Annulation de prestation</option>
|
|
<option value="autre">Autre</option>
|
|
</select>
|
|
</div>
|
|
<div class="col-md-6">
|
|
<label class="form-label">Mode de remboursement</label>
|
|
<select v-model="formData.refundMethod" class="form-select">
|
|
<option value="deduction_facture">Déduction sur prochaine facture</option>
|
|
<option value="virement">Virement bancaire</option>
|
|
<option value="cheque">Chèque</option>
|
|
<option value="especes">Espèces</option>
|
|
<option value="non_rembourse">Non remboursé</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Détail du motif -->
|
|
<div class="mb-4">
|
|
<label class="form-label">Détail du motif</label>
|
|
<textarea
|
|
v-model="formData.reasonDetail"
|
|
class="form-control notes-textarea"
|
|
placeholder="Précisez le motif de l'avoir..."
|
|
rows="2"
|
|
></textarea>
|
|
</div>
|
|
|
|
<!-- Lignes de l'avoir -->
|
|
<div class="mb-4">
|
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
|
<label class="form-label mb-0">Lignes de l'avoir</label>
|
|
<soft-button
|
|
type="button"
|
|
color="primary"
|
|
size="sm"
|
|
@click="addLine"
|
|
>
|
|
<i class="fas fa-plus me-1"></i> Ajouter une ligne
|
|
</soft-button>
|
|
</div>
|
|
|
|
<div class="space-y-3">
|
|
<div
|
|
v-for="(line, index) in formData.lines"
|
|
:key="index"
|
|
class="row g-2 align-items-end bg-light p-2 rounded line-item"
|
|
>
|
|
<div class="col-md-5">
|
|
<soft-input
|
|
v-model="line.designation"
|
|
type="text"
|
|
placeholder="Désignation"
|
|
/>
|
|
</div>
|
|
<div class="col-md-2">
|
|
<soft-input
|
|
v-model.number="line.quantity"
|
|
type="number"
|
|
placeholder="Qté"
|
|
:min="1"
|
|
/>
|
|
</div>
|
|
<div class="col-md-2">
|
|
<soft-input
|
|
v-model.number="line.priceHt"
|
|
type="number"
|
|
placeholder="Prix HT"
|
|
step="0.01"
|
|
/>
|
|
</div>
|
|
<div class="col-md-2 text-end font-weight-bold">
|
|
{{ formatCurrency(line.quantity * line.priceHt) }}
|
|
</div>
|
|
<div class="col-md-1 text-end">
|
|
<button
|
|
type="button"
|
|
class="btn btn-sm btn-danger"
|
|
@click="removeLine(index)"
|
|
:disabled="formData.lines.length === 1"
|
|
>
|
|
<i class="fas fa-trash"></i>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Totaux -->
|
|
<div class="row">
|
|
<div class="col-12">
|
|
<div class="totals-section">
|
|
<div class="d-flex justify-content-end">
|
|
<div class="text-end">
|
|
<p class="mb-2">
|
|
<strong>Total HT :</strong>
|
|
<span>{{ formatCurrency(calculateTotalHt()) }}</span>
|
|
</p>
|
|
<p class="mb-2">
|
|
<strong>TVA :</strong>
|
|
<span>{{ formatCurrency(calculateTotalTva()) }}</span>
|
|
</p>
|
|
<h5 class="mb-0 text-info">
|
|
<strong>Total TTC : {{ formatCurrency(calculateTotalTtc()) }}</strong>
|
|
</h5>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Notes internes -->
|
|
<div class="mb-4">
|
|
<label class="form-label">Notes internes</label>
|
|
<textarea
|
|
v-model="formData.notes"
|
|
class="form-control notes-textarea"
|
|
placeholder="Notes..."
|
|
rows="2"
|
|
></textarea>
|
|
</div>
|
|
|
|
<!-- Boutons d'action -->
|
|
<div class="d-flex justify-content-end gap-3">
|
|
<soft-button
|
|
type="button"
|
|
color="secondary"
|
|
variant="outline"
|
|
@click="cancelForm"
|
|
>
|
|
Annuler
|
|
</soft-button>
|
|
<soft-button
|
|
type="submit"
|
|
color="success"
|
|
>
|
|
Créer l'avoir
|
|
</soft-button>
|
|
</div>
|
|
</form>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, defineEmits, onMounted, onUnmounted } from "vue";
|
|
import { useRouter } from "vue-router";
|
|
import SoftInput from "@/components/SoftInput.vue";
|
|
import SoftButton from "@/components/SoftButton.vue";
|
|
import { useAvoirStore } from "@/stores/avoirStore";
|
|
import { useNotificationStore } from "@/stores/notification";
|
|
|
|
const router = useRouter();
|
|
const avoirStore = useAvoirStore();
|
|
const notificationStore = useNotificationStore();
|
|
const emit = defineEmits(["submit"]);
|
|
|
|
// Invoice Search States
|
|
const invoiceSearchQuery = ref("");
|
|
const invoiceSearchResults = ref([]);
|
|
const isSearchingInvoices = ref(false);
|
|
const showInvoiceResults = ref(false);
|
|
let searchTimeout = null;
|
|
|
|
// Sample invoices data
|
|
const sampleInvoices = [
|
|
{
|
|
id: "1",
|
|
invoice_number: "F-2026-00001",
|
|
clientName: "Caroline Lepetit thanatopraxie",
|
|
amount: 168.0,
|
|
},
|
|
{
|
|
id: "2",
|
|
invoice_number: "FAC-202512-0002",
|
|
clientName: "Hygiène Funéraire 50",
|
|
amount: 168.0,
|
|
},
|
|
{
|
|
id: "3",
|
|
invoice_number: "FAC-202512-0001",
|
|
clientName: "Hygiène Funéraire 50",
|
|
amount: 168.0,
|
|
},
|
|
{
|
|
id: "4",
|
|
invoice_number: "FAC-202512-0006",
|
|
clientName: "Pompes Funèbres Martin",
|
|
amount: 216.0,
|
|
},
|
|
{
|
|
id: "5",
|
|
invoice_number: "FAC-202512-0008",
|
|
clientName: "Pompes Funèbres Martin",
|
|
amount: 312.0,
|
|
},
|
|
{
|
|
id: "6",
|
|
invoice_number: "FACT-2024-003",
|
|
clientName: "PF Premium",
|
|
amount: 144.0,
|
|
},
|
|
];
|
|
|
|
const handleInvoiceSearch = () => {
|
|
if (invoiceSearchQuery.value.length < 2) {
|
|
invoiceSearchResults.value = [];
|
|
if (searchTimeout) clearTimeout(searchTimeout);
|
|
isSearchingInvoices.value = false;
|
|
return;
|
|
}
|
|
|
|
if (searchTimeout) clearTimeout(searchTimeout);
|
|
|
|
searchTimeout = setTimeout(async () => {
|
|
if (invoiceSearchQuery.value.trim() === "") return;
|
|
|
|
isSearchingInvoices.value = true;
|
|
showInvoiceResults.value = true;
|
|
try {
|
|
// Simulate API search
|
|
await new Promise(resolve => setTimeout(resolve, 300));
|
|
const query = invoiceSearchQuery.value.toLowerCase();
|
|
invoiceSearchResults.value = sampleInvoices.filter(invoice =>
|
|
invoice.invoice_number.toLowerCase().includes(query) ||
|
|
invoice.clientName.toLowerCase().includes(query)
|
|
);
|
|
} catch (error) {
|
|
console.error("Error searching invoices:", error);
|
|
} finally {
|
|
isSearchingInvoices.value = false;
|
|
}
|
|
}, 300);
|
|
};
|
|
|
|
const selectInvoice = (invoice) => {
|
|
if (!invoice || !invoice.id) return;
|
|
if (searchTimeout) clearTimeout(searchTimeout);
|
|
|
|
formData.value.invoiceId = invoice.id;
|
|
formData.value.clientId = invoice.clientId;
|
|
formData.value.clientName = invoice.clientName;
|
|
|
|
// Optionally pre-fill a line based on invoice
|
|
formData.value.lines = [
|
|
{
|
|
designation: `Remboursement partiel de ${invoice.invoice_number}`,
|
|
quantity: 1,
|
|
priceHt: invoice.amount,
|
|
},
|
|
];
|
|
|
|
invoiceSearchQuery.value = invoice.invoice_number;
|
|
showInvoiceResults.value = false;
|
|
};
|
|
|
|
// Close dropdowns on click outside
|
|
const handleClickOutside = (event) => {
|
|
const invoiceContainer = document.querySelector('.invoice-search-container');
|
|
if (invoiceContainer && !invoiceContainer.contains(event.target)) {
|
|
showInvoiceResults.value = false;
|
|
}
|
|
};
|
|
|
|
onMounted(() => {
|
|
document.addEventListener('click', handleClickOutside);
|
|
});
|
|
|
|
onUnmounted(() => {
|
|
document.removeEventListener('click', handleClickOutside);
|
|
});
|
|
|
|
const formData = ref({
|
|
number: "AV-" + new Date().getFullYear() + "-" + String(Date.now()).slice(-5),
|
|
date: new Date().toISOString().split("T")[0],
|
|
status: "brouillon",
|
|
invoiceId: "",
|
|
clientId: "",
|
|
clientName: "",
|
|
reason: "erreur_facturation",
|
|
refundMethod: "deduction_facture",
|
|
reasonDetail: "",
|
|
lines: [
|
|
{
|
|
designation: "",
|
|
quantity: 1,
|
|
priceHt: 0,
|
|
},
|
|
],
|
|
notes: "",
|
|
});
|
|
|
|
const addLine = () => {
|
|
formData.value.lines.push({
|
|
designation: "",
|
|
quantity: 1,
|
|
priceHt: 0,
|
|
});
|
|
};
|
|
|
|
const removeLine = (index) => {
|
|
if (formData.value.lines.length > 1) {
|
|
formData.value.lines.splice(index, 1);
|
|
}
|
|
};
|
|
|
|
const calculateTotalHt = () => {
|
|
return formData.value.lines.reduce((sum, line) => {
|
|
return sum + line.quantity * line.priceHt;
|
|
}, 0);
|
|
};
|
|
|
|
const calculateTotalTva = () => {
|
|
return 0; // For now, no TVA
|
|
};
|
|
|
|
const calculateTotalTtc = () => {
|
|
return calculateTotalHt() + calculateTotalTva();
|
|
};
|
|
|
|
const formatCurrency = (value) => {
|
|
return new Intl.NumberFormat("fr-FR", {
|
|
style: "currency",
|
|
currency: "EUR",
|
|
}).format(value);
|
|
};
|
|
|
|
const submitForm = () => {
|
|
if (!formData.value.invoiceId) {
|
|
notificationStore.error("Erreur", "Veuillez sélectionner une facture d'origine");
|
|
return;
|
|
}
|
|
if (formData.value.lines.length === 0) {
|
|
notificationStore.error("Erreur", "Veuillez ajouter au moins une ligne");
|
|
return;
|
|
}
|
|
|
|
emit("submit", formData.value);
|
|
};
|
|
|
|
const cancelForm = () => {
|
|
router.push("/avoirs");
|
|
};
|
|
</script>
|
|
|
|
<style scoped>
|
|
.space-y-3 > * + * {
|
|
margin-top: 1rem;
|
|
}
|
|
|
|
.gap-3 {
|
|
gap: 1rem;
|
|
}
|
|
|
|
/* Search Input with Icon */
|
|
.search-input-wrapper {
|
|
position: relative;
|
|
display: flex;
|
|
align-items: center;
|
|
}
|
|
|
|
.search-icon {
|
|
position: absolute;
|
|
left: 12px;
|
|
color: #6c757d;
|
|
font-size: 0.9rem;
|
|
z-index: 10;
|
|
pointer-events: none;
|
|
}
|
|
|
|
.search-input :deep(input) {
|
|
padding-left: 2.5rem !important;
|
|
}
|
|
|
|
/* Search Dropdown */
|
|
.search-dropdown {
|
|
position: absolute;
|
|
top: 100%;
|
|
left: 0;
|
|
right: 0;
|
|
background: #fff;
|
|
border: 1px solid #dee2e6;
|
|
border-radius: 8px;
|
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
|
|
max-height: 250px;
|
|
overflow-y: auto;
|
|
z-index: 9999;
|
|
margin-top: 4px;
|
|
}
|
|
|
|
.dropdown-item {
|
|
display: flex;
|
|
flex-direction: column;
|
|
padding: 0.75rem 1rem;
|
|
border-bottom: 1px solid #f1f3f5;
|
|
background: none;
|
|
border-left: none;
|
|
border-right: none;
|
|
border-top: none;
|
|
width: 100%;
|
|
text-align: left;
|
|
cursor: pointer;
|
|
transition: background-color 0.15s;
|
|
}
|
|
|
|
.dropdown-item:last-child {
|
|
border-bottom: none;
|
|
}
|
|
|
|
.dropdown-item:hover {
|
|
background-color: #f8f9fa;
|
|
}
|
|
|
|
.item-name {
|
|
font-weight: 600;
|
|
color: #212529;
|
|
font-size: 0.9rem;
|
|
}
|
|
|
|
.item-details {
|
|
font-size: 0.75rem;
|
|
color: #6c757d;
|
|
margin-top: 2px;
|
|
}
|
|
|
|
.dropdown-loading {
|
|
padding: 1rem;
|
|
text-align: center;
|
|
}
|
|
|
|
.dropdown-empty {
|
|
padding: 1rem;
|
|
text-align: center;
|
|
color: #6c757d;
|
|
font-style: italic;
|
|
font-size: 0.875rem;
|
|
}
|
|
|
|
.line-item {
|
|
background: #f8f9fa;
|
|
border: 1px solid #e9ecef;
|
|
border-radius: 8px;
|
|
transition: box-shadow 0.2s, border-color 0.2s;
|
|
}
|
|
|
|
.line-item:hover {
|
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
|
border-color: #dee2e6;
|
|
}
|
|
|
|
.notes-textarea {
|
|
border: 1px solid #dee2e6;
|
|
border-radius: 8px;
|
|
padding: 0.75rem 1rem;
|
|
font-size: 0.875rem;
|
|
resize: vertical;
|
|
transition: border-color 0.2s, box-shadow 0.2s;
|
|
}
|
|
|
|
.notes-textarea:focus {
|
|
border-color: #86b7fe;
|
|
box-shadow: 0 0 0 0.2rem rgba(13, 110, 253, 0.15);
|
|
outline: none;
|
|
}
|
|
|
|
.totals-section {
|
|
background: #fff;
|
|
border-radius: 12px;
|
|
padding: 1.5rem;
|
|
margin-bottom: 1.5rem;
|
|
border: 1px solid #e9ecef;
|
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
|
|
}
|
|
</style>
|