325 lines
9.0 KiB
Vue
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>
|