2026-03-13 16:13:49 +03:00

325 lines
9.0 KiB
Vue

<template>
<!-- Loading -->
<div v-if="loading" class="detail-state">
<div class="spinner-ring"></div>
<p>Chargement du devis…</p>
</div>
<!-- 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 -->
<quote-detail-template v-else-if="quote">
<template #header>
<div class="d-flex justify-content-between align-items-start flex-wrap gap-3">
<div>
<h6 class="mb-1">Quote Details</h6>
<p class="text-sm mb-0">
Quote no.
<b>{{ quote.reference || "—" }}</b>
from
<b>{{ formatDate(quote.quote_date) }}</b>
</p>
<p class="text-sm mb-0">
Valid until:
<b>{{ formatDate(quote.valid_until) }}</b>
</p>
</div>
<div class="d-flex align-items-center flex-wrap gap-2">
<soft-badge :color="statusBadgeColor(quote.status)" variant="gradient">
<i :class="statusIcon(quote.status) + ' me-1'"></i>
{{ getStatusLabel(quote.status) }}
</soft-badge>
<soft-button color="secondary" variant="gradient" class="mb-0">
Export PDF
</soft-button>
</div>
</div>
</template>
<template #product>
<div class="d-flex">
<div class="qd-visual me-3">
<i class="fas fa-file-signature"></i>
</div>
<div>
<h6 class="text-lg mb-0 mt-1">{{ quote.client?.name || "Client inconnu" }}</h6>
<p class="text-sm mb-3">{{ quote.lines?.length || 0 }} ligne(s) dans ce devis.</p>
<soft-badge :color="statusBadgeColor(quote.status)" variant="gradient" size="sm">
{{ getStatusLabel(quote.status) }}
</soft-badge>
</div>
</div>
</template>
<template #cta>
<div class="d-flex justify-content-end">
<div class="qd-status-select-wrap">
<label class="form-label text-xs mb-1">Change status</label>
<select
class="form-select qd-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">
Change quote status directly from this section.
</p>
</template>
<template #timeline>
<h6 class="mb-3">Track quote</h6>
<quote-timeline :history="quote.history" />
</template>
<template #payment>
<h6 class="mb-3">Quote lines</h6>
<quote-lines-table :lines="quote.lines" />
<h6 class="mb-3 mt-4">Billing Information</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">{{ quote.client?.name || "Client inconnu" }}</h6>
<span class="mb-2 text-xs">
Email Address:
<span class="text-dark ms-2 font-weight-bold">{{ quote.client?.email || "—" }}</span>
</span>
<span class="mb-2 text-xs">
Phone:
<span class="text-dark ms-2 font-weight-bold">{{ quote.client?.phone || "—" }}</span>
</span>
<span class="text-xs">
Quote Reference:
<span class="text-dark ms-2 font-weight-bold">{{ quote.reference || "—" }}</span>
</span>
</div>
</li>
</ul>
</template>
<template #summary>
<h6 class="mb-3">Quote Summary</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(quote.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(quote.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(quote.total_ttc) }}</span>
</div>
</template>
</quote-detail-template>
</template>
<script setup>
import { ref, onMounted, defineProps } from "vue";
import { useQuoteStore } from "@/stores/quoteStore";
import { useNotificationStore } from "@/stores/notification";
import QuoteDetailTemplate from "@/components/templates/Quote/QuoteDetailTemplate.vue";
import QuoteTimeline from "@/components/molecules/Quote/QuoteTimeline.vue";
import QuoteLinesTable from "@/components/molecules/Quote/QuoteLinesTable.vue";
import SoftButton from "@/components/SoftButton.vue";
import SoftBadge from "@/components/SoftBadge.vue";
const props = defineProps({
quoteId: { type: [String, Number], required: true },
});
const quoteStore = useQuoteStore();
const notificationStore = useNotificationStore();
const quote = ref(null);
const loading = ref(true);
const updating = ref(false);
const error = ref(null);
const selectedStatus = ref("brouillon");
const load = async () => {
loading.value = true;
error.value = null;
try {
quote.value = await quoteStore.fetchQuote(props.quoteId);
selectedStatus.value = quote.value?.status || "brouillon";
} catch (e) {
error.value = "Impossible de charger le devis.";
console.error(e);
} finally {
loading.value = false;
}
};
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 = ["brouillon", "envoye", "accepte", "refuse", "expire", "annule"];
const statusLabels = {
brouillon: "Brouillon",
envoye: "Envoyé",
accepte: "Accepté",
refuse: "Refusé",
expire: "Expiré",
annule: "Annulé",
};
const statusIcons = {
brouillon: "fas fa-pencil-alt",
envoye: "fas fa-paper-plane",
accepte: "fas fa-check-circle",
refuse: "fas fa-times-circle",
expire: "fas fa-clock",
annule: "fas fa-ban",
};
const statusBadgeColor = (status) => {
const map = {
brouillon: "warning",
envoye: "info",
accepte: "success",
refuse: "danger",
expire: "secondary",
annule: "dark",
};
return map[status] || "secondary";
};
const getStatusLabel = (s) => statusLabels[s] || s;
const statusIcon = (s) => statusIcons[s] || "fas fa-circle";
const onStatusSelect = (event) => {
const newStatus = event.target.value;
selectedStatus.value = newStatus;
if (!quote.value?.id || newStatus === quote.value.status) return;
changeStatus(quote.value.id, newStatus);
};
/* ── Status Update ── */
const changeStatus = (id, newStatus) => {
if (!id || updating.value) return;
updating.value = true;
quoteStore
.updateQuote({ id, status: newStatus })
.then((updated) => {
if (`${props.quoteId}` !== `${id}`) return;
quote.value = updated;
selectedStatus.value = updated?.status || newStatus;
notificationStore.success(
"Statut mis à jour",
`Le devis est maintenant "${getStatusLabel(newStatus)}"`,
3000
);
})
.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; }
.qd-visual {
width: 64px;
height: 64px;
border-radius: 12px;
background: linear-gradient(135deg, #5e72e4, #825ee4);
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-size: 1.3rem;
flex-shrink: 0;
}
.qd-status-select-wrap {
min-width: 220px;
}
.qd-status-select {
font-size: 0.85rem;
}
</style>