Feat: redesign facture comme dans le facutre

This commit is contained in:
nyavokevin 2026-03-16 17:13:10 +03:00
parent 8171a20d41
commit 8ee7d8f8e9
4 changed files with 388 additions and 147 deletions

View File

@ -1,103 +1,129 @@
<template>
<div v-if="loading" class="text-center py-5">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Loading...</span>
</div>
<!-- Loading -->
<div v-if="loading" class="detail-state">
<div class="spinner-ring"></div>
<p>Chargement de la facture</p>
</div>
<div v-else-if="error" class="text-center py-5 text-danger">
{{ error }}
<!-- Error -->
<div v-else-if="error" class="detail-state detail-state--error">
<i class="fas fa-exclamation-triangle"></i>
<p>{{ error }}</p>
<button class="btn-retry" @click="reload">
<i class="fas fa-redo me-2"></i>Réessayer
</button>
</div>
<!-- Content -->
<invoice-detail-template v-else-if="invoice">
<template #header>
<invoice-header
:invoice-number="invoice.invoice_number"
:date="invoice.invoice_date"
/>
<div class="d-flex justify-content-between align-items-start flex-wrap gap-3">
<div>
<h6 class="mb-1">Détails Facture</h6>
<p class="text-sm mb-0">
Facture n°
<b>{{ invoice.invoice_number || "—" }}</b>
du
<b>{{ formatDate(invoice.invoice_date) }}</b>
</p>
<p class="text-sm mb-0">
Échéance :
<b>{{ formatDate(invoice.due_date) }}</b>
</p>
</div>
<div class="d-flex align-items-center flex-wrap gap-2">
<soft-badge :color="statusBadgeColor(invoice.status)" variant="gradient">
<i :class="statusIcon(invoice.status) + ' me-1'"></i>
{{ getStatusLabel(invoice.status) }}
</soft-badge>
<soft-button color="secondary" variant="gradient" class="mb-0">
Export PDF
</soft-button>
</div>
</div>
</template>
<template #lines>
<invoice-lines-table :lines="invoice.lines" />
<template #product>
<div class="d-flex">
<div class="inv-visual me-3">
<i class="fas fa-file-invoice-dollar"></i>
</div>
<div>
<h6 class="text-lg mb-0 mt-1">{{ invoice.client?.name || "Client inconnu" }}</h6>
<p class="text-sm mb-3">{{ invoice.lines?.length || 0 }} ligne(s) dans cette facture.</p>
<soft-badge :color="statusBadgeColor(invoice.status)" variant="gradient" size="sm">
{{ getStatusLabel(invoice.status) }}
</soft-badge>
</div>
</div>
</template>
<template #cta>
<div class="d-flex justify-content-end">
<div class="inv-status-select-wrap">
<label class="form-label text-xs mb-1">Changer le statut</label>
<select
class="form-select inv-status-select"
:value="selectedStatus"
:disabled="updating"
@change="onStatusSelect"
>
<option v-for="s in availableStatuses" :key="s" :value="s">
{{ getStatusLabel(s) }}
</option>
</select>
</div>
</div>
<p class="text-sm mt-2 mb-0 text-end">
Modifier le statut de la facture directement depuis cette section.
</p>
</template>
<template #timeline>
<div>
<h6 class="mb-3 text-sm">Historique</h6>
<div v-if="invoice.history && invoice.history.length > 0">
<div
v-for="(entry, index) in invoice.history"
:key="index"
class="mb-2"
>
<span class="text-xs text-secondary">
{{ formatDate(entry.changed_at) }}
</span>
<p class="text-xs mb-0">{{ entry.comment }}</p>
</div>
</div>
<p v-else class="text-xs text-secondary">Aucun historique</p>
</div>
<h6 class="mb-3">Suivi facture</h6>
<invoice-timeline :history="invoice.history" />
</template>
<template #billing>
<div>
<h6 class="mb-3 text-sm">Informations Client</h6>
<p class="text-sm mb-1">
<strong>{{
invoice.client ? invoice.client.name : "Client inconnu"
}}</strong>
</p>
<p class="text-xs text-secondary mb-1">
{{ invoice.client ? invoice.client.email : "" }}
</p>
<p class="text-xs text-secondary mb-0">
{{ invoice.client ? invoice.client.phone : "" }}
</p>
</div>
<template #payment>
<h6 class="mb-3">Lignes de facture</h6>
<invoice-lines-table :lines="invoice.lines" />
<h6 class="mb-3 mt-4">Informations Client</h6>
<ul class="list-group">
<li class="list-group-item border-0 d-flex p-4 mb-2 bg-gray-100 border-radius-lg">
<div class="d-flex flex-column">
<h6 class="mb-3 text-sm">{{ invoice.client?.name || "Client inconnu" }}</h6>
<span class="mb-2 text-xs">
Adresse email :
<span class="text-dark ms-2 font-weight-bold">{{ invoice.client?.email || "—" }}</span>
</span>
<span class="mb-2 text-xs">
Téléphone :
<span class="text-dark ms-2 font-weight-bold">{{ invoice.client?.phone || "—" }}</span>
</span>
<span class="text-xs">
Référence facture :
<span class="text-dark ms-2 font-weight-bold">{{ invoice.invoice_number || "—" }}</span>
</span>
</div>
</li>
</ul>
</template>
<template #summary>
<invoice-summary
:ht="invoice.total_ht"
:tva="invoice.total_tva"
:ttc="invoice.total_ttc"
/>
</template>
<template #actions>
<div class="d-flex justify-content-end">
<div class="position-relative d-inline-block me-2">
<soft-button
color="secondary"
variant="gradient"
@click="dropdownOpen = !dropdownOpen"
>
{{ getStatusLabel(invoice.status) }}
<i class="fas fa-chevron-down ms-2"></i>
</soft-button>
<ul
v-if="dropdownOpen"
class="dropdown-menu show position-absolute"
style="top: 100%; left: 0; z-index: 1000"
>
<li v-for="status in availableStatuses" :key="status">
<a
class="dropdown-item"
:class="{ active: status === invoice.status }"
href="javascript:;"
@click="
changeStatus(status);
dropdownOpen = false;
"
>
{{ getStatusLabel(status) }}
</a>
</li>
</ul>
</div>
<soft-button color="info" variant="outline">
<i class="fas fa-file-pdf me-1"></i> Télécharger PDF
</soft-button>
<h6 class="mb-3">Résumé Facture</h6>
<div class="d-flex justify-content-between">
<span class="mb-2 text-sm">Total HT :</span>
<span class="text-dark font-weight-bold ms-2">{{ formatCurrency(invoice.total_ht) }}</span>
</div>
<div class="d-flex justify-content-between">
<span class="mb-2 text-sm">TVA :</span>
<span class="text-dark ms-2 font-weight-bold">{{ formatCurrency(invoice.total_tva) }}</span>
</div>
<div class="d-flex justify-content-between mt-4">
<span class="mb-2 text-lg">Total TTC :</span>
<span class="text-dark text-lg ms-2 font-weight-bold">{{ formatCurrency(invoice.total_ttc) }}</span>
</div>
</template>
</invoice-detail-template>
@ -105,14 +131,13 @@
<script setup>
import { ref, onMounted, defineProps } from "vue";
import { useRouter } from "vue-router";
import { useInvoiceStore } from "@/stores/invoiceStore";
import { useNotificationStore } from "@/stores/notification";
import InvoiceDetailTemplate from "@/components/templates/Invoice/InvoiceDetailTemplate.vue";
import InvoiceHeader from "@/components/molecules/Invoice/InvoiceHeader.vue";
import InvoiceTimeline from "@/components/molecules/Invoice/InvoiceTimeline.vue";
import InvoiceLinesTable from "@/components/molecules/Invoice/InvoiceLinesTable.vue";
import InvoiceSummary from "@/components/molecules/Invoice/InvoiceSummary.vue";
import SoftButton from "@/components/SoftButton.vue";
import SoftBadge from "@/components/SoftBadge.vue";
const props = defineProps({
invoiceId: {
@ -121,30 +146,49 @@ const props = defineProps({
},
});
const router = useRouter();
const invoiceStore = useInvoiceStore();
const notificationStore = useNotificationStore();
const invoice = ref(null);
const loading = ref(true);
const updating = ref(false);
const error = ref(null);
const dropdownOpen = ref(false);
const selectedStatus = ref("brouillon");
onMounted(async () => {
const load = async () => {
loading.value = true;
error.value = null;
try {
const fetchedInvoice = await invoiceStore.fetchInvoice(props.invoiceId);
invoice.value = fetchedInvoice;
invoice.value = await invoiceStore.fetchInvoice(props.invoiceId);
selectedStatus.value = invoice.value?.status || "brouillon";
} catch (e) {
error.value = "Impossible de charger la facture.";
console.error(e);
} finally {
loading.value = false;
}
});
};
const formatDate = (dateString) => {
if (!dateString) return "-";
return new Date(dateString).toLocaleDateString("fr-FR");
const reload = () => load();
onMounted(load);
/* ── Helpers ── */
const formatDate = (d) =>
d
? new Date(d).toLocaleDateString("fr-FR", {
day: "2-digit",
month: "short",
year: "numeric",
})
: "—";
const formatCurrency = (value) => {
const amount = Number(value || 0);
return new Intl.NumberFormat("fr-FR", {
style: "currency",
currency: "EUR",
minimumFractionDigits: 2,
}).format(amount);
};
const availableStatuses = [
@ -158,51 +202,139 @@ const availableStatuses = [
"avoir",
];
const getStatusLabel = (status) => {
const labels = {
brouillon: "Brouillon",
emise: "Émise",
envoyee: "Envoyée",
partiellement_payee: "Part. Payée",
payee: "Payée",
echue: "Échue",
annulee: "Annulée",
avoir: "Avoir",
};
return labels[status] || status;
const statusLabels = {
brouillon: "Brouillon",
emise: "Émise",
envoyee: "Envoyée",
partiellement_payee: "Part. Payée",
payee: "Payée",
echue: "Échue",
annulee: "Annulée",
avoir: "Avoir",
};
/* eslint-disable require-atomic-updates */
const changeStatus = async (newStatus) => {
if (!invoice.value?.id) return;
const statusIcons = {
brouillon: "fas fa-pencil-alt",
emise: "fas fa-file-export",
envoyee: "fas fa-paper-plane",
partiellement_payee: "fas fa-coins",
payee: "fas fa-check-circle",
echue: "fas fa-clock",
annulee: "fas fa-ban",
avoir: "fas fa-undo",
};
const currentInvoiceId = invoice.value.id;
const statusBadgeColor = (status) => {
const map = {
brouillon: "warning",
emise: "info",
envoyee: "info",
partiellement_payee: "warning",
payee: "success",
echue: "danger",
annulee: "dark",
avoir: "secondary",
};
return map[status] || "secondary";
};
try {
loading.value = true;
const updated = await invoiceStore.updateInvoice({
id: currentInvoiceId,
status: newStatus,
});
const getStatusLabel = (s) => statusLabels[s] || s;
const statusIcon = (s) => statusIcons[s] || "fas fa-circle";
if (invoice.value?.id === currentInvoiceId) {
const onStatusSelect = (event) => {
const newStatus = event.target.value;
selectedStatus.value = newStatus;
if (!invoice.value?.id || newStatus === invoice.value.status) return;
changeStatus(invoice.value.id, newStatus);
};
/* ── Status Update ── */
const changeStatus = (id, newStatus) => {
if (!id || updating.value) return;
updating.value = true;
invoiceStore
.updateInvoice({ id, status: newStatus })
.then((updated) => {
if (`${props.invoiceId}` !== `${id}`) return;
invoice.value = updated;
selectedStatus.value = updated?.status || newStatus;
notificationStore.success(
"Statut mis à jour",
`La facture est maintenant "${getStatusLabel(newStatus)}"`,
3000
);
}
} catch (e) {
console.error("Failed to update status", e);
notificationStore.error(
"Erreur",
"Impossible de mettre à jour le statut",
3000
);
} finally {
loading.value = false;
}
})
.catch((e) => {
console.error(e);
notificationStore.error("Erreur", "Impossible de mettre à jour le statut", 3000);
})
.finally(() => {
updating.value = false;
});
};
</script>
<style scoped>
/* ── States ── */
.detail-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 60vh;
color: #8898aa;
font-size: .9rem;
gap: .6rem;
}
.detail-state--error i {
font-size: 2.5rem;
color: #f5365c;
}
.spinner-ring {
width: 42px;
height: 42px;
border: 3px solid #e9ecef;
border-top-color: #5e72e4;
border-radius: 50%;
animation: spin .8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
.btn-retry {
margin-top: .25rem;
padding: .4rem 1.1rem;
border-radius: 8px;
border: 1.5px solid #f5365c;
background: none;
color: #f5365c;
font-size: .8rem;
font-weight: 700;
cursor: pointer;
transition: background .15s;
}
.btn-retry:hover { background: #fff0f3; }
.inv-visual {
width: 64px;
height: 64px;
border-radius: 12px;
background: linear-gradient(135deg, #2dce89, #11cdef);
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-size: 1.3rem;
flex-shrink: 0;
}
.inv-status-select-wrap {
min-width: 220px;
}
.inv-status-select {
font-size: 0.85rem;
}
</style>

View File

@ -0,0 +1,111 @@
<template>
<div>
<h6 class="mb-3">Suivi de la Facture</h6>
<div class="timeline-scrollable">
<div class="timeline timeline-one-side">
<div
v-for="(item, index) in history"
:key="index"
class="timeline-block mb-3"
>
<span class="timeline-step">
<i :class="getStatusIcon(item.new_status)"></i>
</span>
<div class="timeline-content">
<h6 class="text-dark text-sm font-weight-bold mb-0">
{{ getStatusLabel(item.new_status) }}
</h6>
<p class="text-secondary font-weight-bold text-xs mt-1 mb-0">
{{ formatDate(item.changed_at) }}
<span v-if="item.changed_by">par {{ item.changed_by }}</span>
</p>
<p v-if="item.comment" class="text-sm mt-2 mb-0">
{{ item.comment }}
</p>
</div>
</div>
<div v-if="history.length === 0" class="text-sm text-secondary">
Aucun historique disponible.
</div>
</div>
</div>
</div>
</template>
<script setup>
import { defineProps } from "vue";
const props = defineProps({
history: {
type: Array,
default: () => [],
},
});
const formatDate = (dateString) => {
if (!dateString) return "-";
const date = new Date(dateString);
const options = {
day: "numeric",
month: "short",
hour: "2-digit",
minute: "2-digit",
};
return date.toLocaleDateString("fr-FR", options);
};
const getStatusIcon = (status) => {
const map = {
brouillon: "ni ni-bell-55 text-secondary",
emise: "ni ni-send text-info",
envoyee: "ni ni-email-83 text-info",
partiellement_payee: "ni ni-money-coins text-warning",
payee: "ni ni-check-bold text-success text-gradient",
echue: "ni ni-time-alarm text-danger",
annulee: "ni ni-fat-remove text-danger text-gradient",
avoir: "ni ni-archive-2 text-dark",
};
return map[status] || "ni ni-bell-55 text-secondary";
};
const getStatusLabel = (status) => {
const labels = {
brouillon: "Facture créée (Brouillon)",
emise: "Facture émise",
envoyee: "Facture envoyée",
partiellement_payee: "Partiellement payée",
payee: "Facture payée",
echue: "Facture échue",
annulee: "Facture annulée",
avoir: "Avoir généré",
};
return labels[status] || status;
};
</script>
<style scoped>
.timeline-scrollable {
max-height: 300px;
overflow-y: auto;
overflow-x: hidden;
padding-right: 5px;
}
.timeline-scrollable::-webkit-scrollbar {
width: 6px;
}
.timeline-scrollable::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 10px;
}
.timeline-scrollable::-webkit-scrollbar-thumb {
background: #888;
border-radius: 10px;
}
.timeline-scrollable::-webkit-scrollbar-thumb:hover {
background: #555;
}
</style>

View File

@ -1,42 +1,41 @@
<template>
<div class="container-fluid py-4">
<div class="row">
<div class="col-lg-8 mx-auto">
<div class="col-lg-10 mx-auto">
<div class="card mb-4">
<slot name="header"></slot>
<div class="card-header p-3 pb-0">
<slot name="header"></slot>
</div>
<div class="card-body p-3 pt-0">
<hr class="horizontal dark mt-0 mb-4" />
<!-- Product Lines Section -->
<div class="row">
<div class="col-12">
<slot name="lines"></slot>
<div class="col-lg-6 col-md-6 col-12">
<slot name="product"></slot>
</div>
<div class="col-lg-6 col-md-6 col-12 my-auto text-end">
<slot name="cta"></slot>
</div>
</div>
<hr class="horizontal dark mt-4 mb-4" />
<div class="row">
<!-- Tracking/Timeline Section -->
<div class="row g-3">
<div class="col-lg-3 col-md-6 col-12">
<slot name="timeline"></slot>
</div>
<!-- Billing Info Section -->
<div class="col-lg-5 col-md-6 col-12">
<slot name="billing"></slot>
<slot name="payment"></slot>
</div>
<!-- Summary Section -->
<div class="col-lg-3 col-12 ms-auto">
<div class="col-lg-4 col-12 ms-auto">
<slot name="summary"></slot>
</div>
</div>
</div>
<div class="card-footer p-3">
<slot name="actions"></slot>
</div>
</div>
</div>
</div>

View File

@ -128,7 +128,6 @@ watch(
// Load data on component mount
onMounted(() => {
console.log("test");
fetchIntervention();
});
</script>