Feat: redesing form on new
This commit is contained in:
parent
ebd171e9de
commit
dd6fc4665c
@ -82,7 +82,10 @@ class InterventionRepository implements InterventionRepositoryInterface
|
||||
'practitioners',
|
||||
'attachments',
|
||||
'notifications',
|
||||
'quote'
|
||||
'quote',
|
||||
'quote.client',
|
||||
'quote.lines',
|
||||
'quote.history'
|
||||
])->findOrFail($id);
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="container-fluid py-4">
|
||||
<div class="container-fluid py-4 new-commande-page">
|
||||
<div class="row">
|
||||
<div class="col-lg-8 mx-auto">
|
||||
<div class="col-12 col-xl-11 mx-auto">
|
||||
<div class="card">
|
||||
<div class="card-header pb-0 p-3">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
@ -17,7 +17,11 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body p-3">
|
||||
<new-commande-form @submit="handleSubmit" />
|
||||
<new-commande-form
|
||||
@submit="handleSubmit"
|
||||
@created="handleCreated"
|
||||
@cancel="goBack"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -37,6 +41,16 @@ const goBack = () => {
|
||||
};
|
||||
|
||||
const handleSubmit = (formData) => {
|
||||
return formData;
|
||||
};
|
||||
|
||||
const handleCreated = () => {
|
||||
router.push("/fournisseurs/commandes");
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.new-commande-page {
|
||||
background: #f8f9fe;
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -269,26 +269,55 @@
|
||||
<div v-if="activeTab === 'quote'" class="tab-pane fade show active">
|
||||
<div class="card">
|
||||
<div class="card-header pb-0">
|
||||
<div class="d-flex align-items-center">
|
||||
<div
|
||||
class="d-flex align-items-center justify-content-between flex-wrap gap-2"
|
||||
>
|
||||
<h6 class="mb-0">Détails du devis</h6>
|
||||
<router-link
|
||||
v-if="intervention.quote?.id"
|
||||
:to="`/ventes/devis/${intervention.quote.id}`"
|
||||
class="btn btn-sm btn-outline-success"
|
||||
>
|
||||
Ouvrir le devis
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div v-if="intervention.quote" class="row mt-4">
|
||||
<div class="col-md-6 mb-3">
|
||||
<InfoCard title="Informations" icon="fas fa-file-invoice text-info">
|
||||
<InfoCard
|
||||
title="Informations"
|
||||
icon="fas fa-file-invoice text-info"
|
||||
>
|
||||
<ul class="list-group list-group-flush">
|
||||
<li class="list-group-item mx-0 px-0">
|
||||
<strong class="text-dark">Référence:</strong>
|
||||
<span class="ms-2">{{ intervention.quote.reference || '-' }}</span>
|
||||
<span class="ms-2">{{
|
||||
intervention.quote.reference || "-"
|
||||
}}</span>
|
||||
</li>
|
||||
<li class="list-group-item mx-0 px-0">
|
||||
<strong class="text-dark">Date:</strong>
|
||||
<span class="ms-2">{{ intervention.quote.quote_date || '-' }}</span>
|
||||
<span class="ms-2">{{
|
||||
intervention.quote.quote_date || "-"
|
||||
}}</span>
|
||||
</li>
|
||||
<li class="list-group-item mx-0 px-0">
|
||||
<strong class="text-dark">Validité:</strong>
|
||||
<span class="ms-2">{{
|
||||
intervention.quote.valid_until || "-"
|
||||
}}</span>
|
||||
</li>
|
||||
<li class="list-group-item mx-0 px-0">
|
||||
<strong class="text-dark">Statut:</strong>
|
||||
<span class="ms-2 badge badge-sm bg-gradient-success">{{ intervention.quote.status || '-' }}</span>
|
||||
<span
|
||||
class="ms-2 badge badge-sm"
|
||||
:class="`bg-gradient-${getQuoteStatusColor(
|
||||
intervention.quote.status
|
||||
)}`"
|
||||
>
|
||||
{{ getQuoteStatusLabel(intervention.quote.status) }}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</InfoCard>
|
||||
@ -296,25 +325,76 @@
|
||||
<div class="col-md-6 mb-3">
|
||||
<InfoCard title="Montants" icon="fas fa-euro-sign text-success">
|
||||
<ul class="list-group list-group-flush">
|
||||
<li class="list-group-item mx-0 px-0 d-flex justify-content-between">
|
||||
<li
|
||||
class="list-group-item mx-0 px-0 d-flex justify-content-between"
|
||||
>
|
||||
<strong class="text-dark">Total HT:</strong>
|
||||
<span>{{ intervention.quote.total_ht || '0.00' }} €</span>
|
||||
<span>{{
|
||||
formatCurrency(intervention.quote.total_ht)
|
||||
}}</span>
|
||||
</li>
|
||||
<li class="list-group-item mx-0 px-0 d-flex justify-content-between">
|
||||
<li
|
||||
class="list-group-item mx-0 px-0 d-flex justify-content-between"
|
||||
>
|
||||
<strong class="text-dark">Total TVA:</strong>
|
||||
<span>{{ intervention.quote.total_tva || '0.00' }} €</span>
|
||||
<span>{{
|
||||
formatCurrency(intervention.quote.total_tva)
|
||||
}}</span>
|
||||
</li>
|
||||
<li class="list-group-item mx-0 px-0 d-flex justify-content-between border-0">
|
||||
<li
|
||||
class="list-group-item mx-0 px-0 d-flex justify-content-between border-0"
|
||||
>
|
||||
<strong class="text-dark">Total TTC:</strong>
|
||||
<span class="fw-bold">{{ intervention.quote.total_ttc || '0.00' }} €</span>
|
||||
<span class="fw-bold">{{
|
||||
formatCurrency(intervention.quote.total_ttc)
|
||||
}}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</InfoCard>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<InfoCard title="Lignes du devis" icon="fas fa-list text-dark">
|
||||
<div
|
||||
v-if="intervention.quote.lines?.length"
|
||||
class="table-responsive"
|
||||
>
|
||||
<table class="table table-sm align-middle mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Description</th>
|
||||
<th class="text-center">Qté</th>
|
||||
<th class="text-end">Prix unitaire</th>
|
||||
<th class="text-end">Total HT</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="line in intervention.quote.lines"
|
||||
:key="line.id"
|
||||
>
|
||||
<td>{{ line.description || "-" }}</td>
|
||||
<td class="text-center">{{ line.units_qty || 0 }}</td>
|
||||
<td class="text-end">
|
||||
{{ formatCurrency(line.unit_price) }}
|
||||
</td>
|
||||
<td class="text-end">
|
||||
{{ formatCurrency(line.total_ht) }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p v-else class="text-sm text-muted mb-0">
|
||||
Aucune ligne de devis disponible.
|
||||
</p>
|
||||
</InfoCard>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="text-center py-5">
|
||||
<div class="avatar avatar-xl mb-3">
|
||||
<div class="avatar-title bg-gradient-secondary text-white h5 mb-0">
|
||||
<div
|
||||
class="avatar-title bg-gradient-secondary text-white h5 mb-0"
|
||||
>
|
||||
<i class="fas fa-file-invoice"></i>
|
||||
</div>
|
||||
</div>
|
||||
@ -364,12 +444,45 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { RouterLink } from "vue-router";
|
||||
import InfoCard from "@/components/atoms/client/InfoCard.vue";
|
||||
import InterventionDetails from "@/components/molecules/Interventions/interventionDetails.vue";
|
||||
import DocumentManagement from "@/components/molecules/Interventions/DocumentManagement.vue";
|
||||
import { useDocumentAttachmentStore } from "@/stores/documentAttachmentStore";
|
||||
import { defineProps, defineEmits, computed, watch, onMounted } from "vue";
|
||||
|
||||
const getQuoteStatusLabel = (status) => {
|
||||
const statusLabels = {
|
||||
brouillon: "Brouillon",
|
||||
envoye: "Envoyé",
|
||||
accepte: "Accepté",
|
||||
refuse: "Refusé",
|
||||
expire: "Expiré",
|
||||
};
|
||||
|
||||
return statusLabels[status] || status || "Inconnu";
|
||||
};
|
||||
|
||||
const getQuoteStatusColor = (status) => {
|
||||
const statusColors = {
|
||||
brouillon: "secondary",
|
||||
envoye: "info",
|
||||
accepte: "success",
|
||||
refuse: "danger",
|
||||
expire: "warning",
|
||||
};
|
||||
|
||||
return statusColors[status] || "secondary";
|
||||
};
|
||||
|
||||
const formatCurrency = (value) => {
|
||||
return new Intl.NumberFormat("fr-FR", {
|
||||
style: "currency",
|
||||
currency: "EUR",
|
||||
minimumFractionDigits: 2,
|
||||
}).format(Number(value || 0));
|
||||
};
|
||||
|
||||
const props = defineProps({
|
||||
activeTab: {
|
||||
type: String,
|
||||
|
||||
@ -17,7 +17,9 @@
|
||||
<!-- Content -->
|
||||
<invoice-detail-template v-else-if="invoice">
|
||||
<template #header>
|
||||
<div class="d-flex justify-content-between align-items-start flex-wrap gap-3">
|
||||
<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">
|
||||
@ -33,7 +35,10 @@
|
||||
</div>
|
||||
|
||||
<div class="d-flex align-items-center flex-wrap gap-2">
|
||||
<soft-badge :color="statusBadgeColor(invoice.status)" variant="gradient">
|
||||
<soft-badge
|
||||
:color="statusBadgeColor(invoice.status)"
|
||||
variant="gradient"
|
||||
>
|
||||
<i :class="statusIcon(invoice.status) + ' me-1'"></i>
|
||||
{{ getStatusLabel(invoice.status) }}
|
||||
</soft-badge>
|
||||
@ -50,9 +55,17 @@
|
||||
<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">
|
||||
<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>
|
||||
@ -91,20 +104,30 @@
|
||||
|
||||
<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">
|
||||
<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>
|
||||
<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 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 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 class="text-dark ms-2 font-weight-bold">{{
|
||||
invoice.invoice_number || "—"
|
||||
}}</span>
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
@ -115,15 +138,21 @@
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<span class="text-dark text-lg ms-2 font-weight-bold">{{
|
||||
formatCurrency(invoice.total_ttc)
|
||||
}}</span>
|
||||
</div>
|
||||
</template>
|
||||
</invoice-detail-template>
|
||||
@ -266,7 +295,11 @@ const changeStatus = (id, newStatus) => {
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
notificationStore.error("Erreur", "Impossible de mettre à jour le statut", 3000);
|
||||
notificationStore.error(
|
||||
"Erreur",
|
||||
"Impossible de mettre à jour le statut",
|
||||
3000
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
updating.value = false;
|
||||
@ -283,8 +316,8 @@ const changeStatus = (id, newStatus) => {
|
||||
justify-content: center;
|
||||
min-height: 60vh;
|
||||
color: #8898aa;
|
||||
font-size: .9rem;
|
||||
gap: .6rem;
|
||||
font-size: 0.9rem;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.detail-state--error i {
|
||||
@ -298,24 +331,30 @@ const changeStatus = (id, newStatus) => {
|
||||
border: 3px solid #e9ecef;
|
||||
border-top-color: #5e72e4;
|
||||
border-radius: 50%;
|
||||
animation: spin .8s linear infinite;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.btn-retry {
|
||||
margin-top: .25rem;
|
||||
padding: .4rem 1.1rem;
|
||||
margin-top: 0.25rem;
|
||||
padding: 0.4rem 1.1rem;
|
||||
border-radius: 8px;
|
||||
border: 1.5px solid #f5365c;
|
||||
background: none;
|
||||
color: #f5365c;
|
||||
font-size: .8rem;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: background .15s;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.btn-retry:hover {
|
||||
background: #fff0f3;
|
||||
}
|
||||
.btn-retry:hover { background: #fff0f3; }
|
||||
|
||||
.inv-visual {
|
||||
width: 64px;
|
||||
|
||||
@ -2,10 +2,20 @@
|
||||
<create-quote-template>
|
||||
<!-- ── Actions ── -->
|
||||
<template #actions>
|
||||
<soft-button color="secondary" variant="outline" class="me-2" @click="cancel">
|
||||
<soft-button
|
||||
color="secondary"
|
||||
variant="outline"
|
||||
class="me-2"
|
||||
@click="cancel"
|
||||
>
|
||||
<i class="fas fa-times me-1"></i> Annuler
|
||||
</soft-button>
|
||||
<soft-button color="primary" variant="gradient" :disabled="loading" @click="saveQuote">
|
||||
<soft-button
|
||||
color="primary"
|
||||
variant="gradient"
|
||||
:disabled="loading"
|
||||
@click="saveQuote"
|
||||
>
|
||||
<i class="fas fa-save me-1"></i>
|
||||
{{ loading ? "Enregistrement..." : "Enregistrer le devis" }}
|
||||
</soft-button>
|
||||
@ -14,7 +24,9 @@
|
||||
<!-- ── Client Selection ── -->
|
||||
<template #client-selection>
|
||||
<div class="field-group">
|
||||
<label class="field-label">Client <span class="text-danger">*</span></label>
|
||||
<label class="field-label"
|
||||
>Client <span class="text-danger">*</span></label
|
||||
>
|
||||
<select v-model="form.client_id" class="form-select field-select">
|
||||
<option value="" disabled>— Sélectionner un client —</option>
|
||||
<option v-for="client in clients" :key="client.id" :value="client.id">
|
||||
@ -62,11 +74,7 @@
|
||||
|
||||
<!-- Lines -->
|
||||
<transition-group name="line-fade" tag="div">
|
||||
<div
|
||||
v-for="(line, index) in form.lines"
|
||||
:key="index"
|
||||
class="line-row"
|
||||
>
|
||||
<div v-for="(line, index) in form.lines" :key="index" class="line-row">
|
||||
<product-line-item
|
||||
v-model="form.lines[index]"
|
||||
@remove="removeLine(index)"
|
||||
@ -82,7 +90,13 @@
|
||||
|
||||
<!-- Add Line -->
|
||||
<div class="lines-footer">
|
||||
<soft-button type="button" color="info" variant="outline" class="add-line-btn" @click="addLine">
|
||||
<soft-button
|
||||
type="button"
|
||||
color="info"
|
||||
variant="outline"
|
||||
class="add-line-btn"
|
||||
@click="addLine"
|
||||
>
|
||||
<i class="fas fa-plus-circle me-2"></i>Ajouter une ligne
|
||||
</soft-button>
|
||||
</div>
|
||||
@ -101,7 +115,9 @@
|
||||
</div>
|
||||
<div class="totals-row totals-row--final">
|
||||
<span class="totals-label">Total TTC</span>
|
||||
<span class="totals-value totals-value--final">{{ formatCurrency(totals.ttc) }}</span>
|
||||
<span class="totals-value totals-value--final">{{
|
||||
formatCurrency(totals.ttc)
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@ -128,9 +144,24 @@ const loading = ref(false);
|
||||
const attempted = ref(false);
|
||||
|
||||
const statuses = [
|
||||
{ value: "brouillon", label: "Brouillon", icon: "fas fa-pencil-alt", color: "warning" },
|
||||
{ value: "envoye", label: "Envoyé", icon: "fas fa-paper-plane", color: "info" },
|
||||
{ value: "accepte", label: "Accepté", icon: "fas fa-check", color: "success" },
|
||||
{
|
||||
value: "brouillon",
|
||||
label: "Brouillon",
|
||||
icon: "fas fa-pencil-alt",
|
||||
color: "warning",
|
||||
},
|
||||
{
|
||||
value: "envoye",
|
||||
label: "Envoyé",
|
||||
icon: "fas fa-paper-plane",
|
||||
color: "info",
|
||||
},
|
||||
{
|
||||
value: "accepte",
|
||||
label: "Accepté",
|
||||
icon: "fas fa-check",
|
||||
color: "success",
|
||||
},
|
||||
];
|
||||
|
||||
const defaultLine = () => ({
|
||||
@ -155,7 +186,8 @@ const totals = computed(() => {
|
||||
let ht = 0;
|
||||
let tva = 0;
|
||||
form.value.lines.forEach((line) => {
|
||||
const afterDiscount = line.quantity * line.unit_price * (1 - (line.discount_pct || 0) / 100);
|
||||
const afterDiscount =
|
||||
line.quantity * line.unit_price * (1 - (line.discount_pct || 0) / 100);
|
||||
ht += afterDiscount;
|
||||
tva += afterDiscount * (line.tva / 100);
|
||||
});
|
||||
@ -166,7 +198,9 @@ const addLine = () => form.value.lines.push(defaultLine());
|
||||
const removeLine = (index) => form.value.lines.splice(index, 1);
|
||||
|
||||
const formatCurrency = (value) =>
|
||||
new Intl.NumberFormat("fr-FR", { style: "currency", currency: "EUR" }).format(value);
|
||||
new Intl.NumberFormat("fr-FR", { style: "currency", currency: "EUR" }).format(
|
||||
value
|
||||
);
|
||||
|
||||
const saveQuote = async () => {
|
||||
attempted.value = true;
|
||||
@ -186,7 +220,10 @@ const saveQuote = async () => {
|
||||
lines: form.value.lines.map((line) => ({
|
||||
...line,
|
||||
discount_pct: line.discount_pct || 0,
|
||||
total_ht: line.quantity * line.unit_price * (1 - (line.discount_pct || 0) / 100),
|
||||
total_ht:
|
||||
line.quantity *
|
||||
line.unit_price *
|
||||
(1 - (line.discount_pct || 0) / 100),
|
||||
description: line.product_name || "Produit sans nom",
|
||||
})),
|
||||
});
|
||||
@ -245,13 +282,32 @@ onMounted(() => clientStore.fetchClients());
|
||||
color: #adb5bd;
|
||||
}
|
||||
|
||||
.line-col--product { flex: 3; }
|
||||
.line-col--qty { flex: 1; text-align: center; }
|
||||
.line-col--price { flex: 1.5; text-align: right; }
|
||||
.line-col--tva { flex: 1; text-align: center; }
|
||||
.line-col--discount{ flex: 1; text-align: center; }
|
||||
.line-col--total { flex: 1.5; text-align: right; }
|
||||
.line-col--action { width: 36px; }
|
||||
.line-col--product {
|
||||
flex: 3;
|
||||
}
|
||||
.line-col--qty {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
}
|
||||
.line-col--price {
|
||||
flex: 1.5;
|
||||
text-align: right;
|
||||
}
|
||||
.line-col--tva {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
}
|
||||
.line-col--discount {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
}
|
||||
.line-col--total {
|
||||
flex: 1.5;
|
||||
text-align: right;
|
||||
}
|
||||
.line-col--action {
|
||||
width: 36px;
|
||||
}
|
||||
|
||||
/* ── Line Row ── */
|
||||
.line-row {
|
||||
|
||||
@ -17,7 +17,9 @@
|
||||
<!-- 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
|
||||
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">
|
||||
@ -33,7 +35,10 @@
|
||||
</div>
|
||||
|
||||
<div class="d-flex align-items-center flex-wrap gap-2">
|
||||
<soft-badge :color="statusBadgeColor(quote.status)" variant="gradient">
|
||||
<soft-badge
|
||||
:color="statusBadgeColor(quote.status)"
|
||||
variant="gradient"
|
||||
>
|
||||
<i :class="statusIcon(quote.status) + ' me-1'"></i>
|
||||
{{ getStatusLabel(quote.status) }}
|
||||
</soft-badge>
|
||||
@ -50,9 +55,17 @@
|
||||
<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">
|
||||
<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>
|
||||
@ -91,20 +104,30 @@
|
||||
|
||||
<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">
|
||||
<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>
|
||||
<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 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 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 class="text-dark ms-2 font-weight-bold">{{
|
||||
quote.reference || "—"
|
||||
}}</span>
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
@ -115,15 +138,21 @@
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<span class="text-dark text-lg ms-2 font-weight-bold">{{
|
||||
formatCurrency(quote.total_ttc)
|
||||
}}</span>
|
||||
</div>
|
||||
</template>
|
||||
</quote-detail-template>
|
||||
@ -188,7 +217,14 @@ const formatCurrency = (value) => {
|
||||
}).format(amount);
|
||||
};
|
||||
|
||||
const availableStatuses = ["brouillon", "envoye", "accepte", "refuse", "expire", "annule"];
|
||||
const availableStatuses = [
|
||||
"brouillon",
|
||||
"envoye",
|
||||
"accepte",
|
||||
"refuse",
|
||||
"expire",
|
||||
"annule",
|
||||
];
|
||||
|
||||
const statusLabels = {
|
||||
brouillon: "Brouillon",
|
||||
@ -248,7 +284,11 @@ const changeStatus = (id, newStatus) => {
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
notificationStore.error("Erreur", "Impossible de mettre à jour le statut", 3000);
|
||||
notificationStore.error(
|
||||
"Erreur",
|
||||
"Impossible de mettre à jour le statut",
|
||||
3000
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
updating.value = false;
|
||||
@ -265,8 +305,8 @@ const changeStatus = (id, newStatus) => {
|
||||
justify-content: center;
|
||||
min-height: 60vh;
|
||||
color: #8898aa;
|
||||
font-size: .9rem;
|
||||
gap: .6rem;
|
||||
font-size: 0.9rem;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.detail-state--error i {
|
||||
@ -280,24 +320,30 @@ const changeStatus = (id, newStatus) => {
|
||||
border: 3px solid #e9ecef;
|
||||
border-top-color: #5e72e4;
|
||||
border-radius: 50%;
|
||||
animation: spin .8s linear infinite;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.btn-retry {
|
||||
margin-top: .25rem;
|
||||
padding: .4rem 1.1rem;
|
||||
margin-top: 0.25rem;
|
||||
padding: 0.4rem 1.1rem;
|
||||
border-radius: 8px;
|
||||
border: 1.5px solid #f5365c;
|
||||
background: none;
|
||||
color: #f5365c;
|
||||
font-size: .8rem;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: background .15s;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.btn-retry:hover {
|
||||
background: #fff0f3;
|
||||
}
|
||||
.btn-retry:hover { background: #fff0f3; }
|
||||
|
||||
.qd-visual {
|
||||
width: 64px;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,48 @@
|
||||
<template>
|
||||
<div class="field-row">
|
||||
<span class="field-row__label">{{ label }}</span>
|
||||
<div class="field-row__value" :class="valueClass">
|
||||
<slot>{{ value || fallback }}</slot>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { defineProps } from "vue";
|
||||
|
||||
defineProps({
|
||||
label: { type: String, required: true },
|
||||
value: { type: [String, Number], default: "" },
|
||||
fallback: { type: String, default: "—" },
|
||||
valueClass: { type: String, default: "" },
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.field-row {
|
||||
display: grid;
|
||||
grid-template-columns: 160px 1fr;
|
||||
align-items: start;
|
||||
gap: 0.5rem;
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid #f3f4f6;
|
||||
}
|
||||
.field-row:last-child { border-bottom: none; }
|
||||
|
||||
.field-row__label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.03em;
|
||||
color: #9ca3af;
|
||||
text-transform: uppercase;
|
||||
padding-top: 1px;
|
||||
user-select: none;
|
||||
}
|
||||
.field-row__value {
|
||||
font-size: 14px;
|
||||
color: #111827;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.field-row__value.expired { color: #dc2626; font-weight: 500; }
|
||||
.field-row__value.expiring-soon { color: #d97706; font-weight: 500; }
|
||||
</style>
|
||||
92
thanasoft-front/src/components/atoms/Product/FieldInput.vue
Normal file
92
thanasoft-front/src/components/atoms/Product/FieldInput.vue
Normal file
@ -0,0 +1,92 @@
|
||||
<template>
|
||||
<div class="field-input-group">
|
||||
<label class="field-input-group__label">
|
||||
{{ label }}<span v-if="required" class="field-input-group__req">*</span>
|
||||
</label>
|
||||
<textarea
|
||||
v-if="type === 'textarea'"
|
||||
:value="modelValue"
|
||||
class="field-input-group__ctrl"
|
||||
:class="{ 'is-invalid': error }"
|
||||
:placeholder="placeholder"
|
||||
:rows="rows"
|
||||
@input="$emit('update:modelValue', $event.target.value)"
|
||||
/>
|
||||
<select
|
||||
v-else-if="type === 'select'"
|
||||
:value="modelValue"
|
||||
class="field-input-group__ctrl"
|
||||
:class="{ 'is-invalid': error }"
|
||||
@change="$emit('update:modelValue', $event.target.value)"
|
||||
>
|
||||
<slot />
|
||||
</select>
|
||||
<input
|
||||
v-else
|
||||
:value="modelValue"
|
||||
:type="type"
|
||||
class="field-input-group__ctrl"
|
||||
:class="{ 'is-invalid': error }"
|
||||
:placeholder="placeholder"
|
||||
:min="min"
|
||||
:step="step"
|
||||
@input="$emit('update:modelValue', $event.target.value)"
|
||||
/>
|
||||
<span v-if="error" class="field-input-group__error">{{ error }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { defineProps, defineEmits } from "vue";
|
||||
|
||||
defineProps({
|
||||
modelValue: { type: [String, Number], default: "" },
|
||||
label: { type: String, required: true },
|
||||
type: { type: String, default: "text" },
|
||||
placeholder:{ type: String, default: "" },
|
||||
required: { type: Boolean, default: false },
|
||||
error: { type: String, default: "" },
|
||||
rows: { type: Number, default: 3 },
|
||||
min: { type: [String, Number], default: undefined },
|
||||
step: { type: [String, Number], default: undefined },
|
||||
});
|
||||
defineEmits(["update:modelValue"]);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.field-input-group { display: flex; flex-direction: column; gap: 5px; }
|
||||
|
||||
.field-input-group__label {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: #6b7280;
|
||||
}
|
||||
.field-input-group__req { color: #dc2626; margin-left: 2px; }
|
||||
|
||||
.field-input-group__ctrl {
|
||||
font-size: 14px;
|
||||
color: #111827;
|
||||
background: #fff;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 6px;
|
||||
padding: 8px 11px;
|
||||
width: 100%;
|
||||
font-family: inherit;
|
||||
outline: none;
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
appearance: none;
|
||||
}
|
||||
.field-input-group__ctrl:focus {
|
||||
border-color: #5e72e4;
|
||||
box-shadow: 0 0 0 3px rgba(94, 114, 228, 0.18);
|
||||
}
|
||||
.field-input-group__ctrl.is-invalid { border-color: #dc2626; }
|
||||
.field-input-group__ctrl.is-invalid:focus { box-shadow: 0 0 0 3px rgba(220,38,38,0.08); }
|
||||
|
||||
.field-input-group__error {
|
||||
font-size: 12px;
|
||||
color: #dc2626;
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,56 @@
|
||||
<template>
|
||||
<span class="product-badge" :class="`product-badge--${variant}`">
|
||||
<span class="product-badge__dot"></span>
|
||||
<slot />
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { defineProps } from "vue";
|
||||
|
||||
defineProps({
|
||||
variant: {
|
||||
type: String,
|
||||
default: "neutral",
|
||||
validator: (v) =>
|
||||
["success", "warning", "danger", "info", "neutral"].includes(v),
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.product-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
padding: 4px 9px;
|
||||
border-radius: 4px;
|
||||
white-space: nowrap;
|
||||
font-family: inherit;
|
||||
}
|
||||
.product-badge__dot {
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.product-badge--success { background: rgba(45, 206, 137, 0.16); color: #2dce89; }
|
||||
.product-badge--success .product-badge__dot { background: #2dce89; }
|
||||
|
||||
.product-badge--warning { background: rgba(251, 99, 64, 0.16); color: #fb6340; }
|
||||
.product-badge--warning .product-badge__dot { background: #fb6340; }
|
||||
|
||||
.product-badge--danger { background: rgba(245, 54, 92, 0.16); color: #f5365c; }
|
||||
.product-badge--danger .product-badge__dot { background: #f5365c; }
|
||||
|
||||
.product-badge--info { background: rgba(17, 205, 239, 0.16); color: #11cdef; }
|
||||
.product-badge--info .product-badge__dot { background: #11cdef; }
|
||||
|
||||
.product-badge--neutral { background: rgba(131, 146, 171, 0.16); color: #8392ab; }
|
||||
.product-badge--neutral .product-badge__dot { background: #8392ab; }
|
||||
</style>
|
||||
@ -0,0 +1,63 @@
|
||||
<template>
|
||||
<div class="stat-card">
|
||||
<span class="stat-card__label">{{ label }}</span>
|
||||
<div class="stat-card__value" :class="valueClass">
|
||||
<span class="stat-card__number">{{ value }}</span>
|
||||
<span v-if="unit" class="stat-card__unit">{{ unit }}</span>
|
||||
</div>
|
||||
<span v-if="sub" class="stat-card__sub">{{ sub }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { defineProps } from "vue";
|
||||
|
||||
defineProps({
|
||||
label: { type: String, required: true },
|
||||
value: { type: [String, Number], required: true },
|
||||
unit: { type: String, default: "" },
|
||||
sub: { type: String, default: "" },
|
||||
valueClass: { type: String, default: "" },
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.stat-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
padding: 1rem 1.125rem;
|
||||
background: #f9fafb;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.stat-card__label {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
color: #9ca3af;
|
||||
}
|
||||
.stat-card__value {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 5px;
|
||||
}
|
||||
.stat-card__number {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: #111827;
|
||||
line-height: 1.1;
|
||||
}
|
||||
.stat-card__number.low { color: #dc2626; }
|
||||
.stat-card__number.ok { color: #059669; }
|
||||
.stat-card__unit {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #6b7280;
|
||||
}
|
||||
.stat-card__sub {
|
||||
font-size: 12px;
|
||||
color: #9ca3af;
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
@ -24,7 +24,10 @@
|
||||
selectedItem.email || "Pas d'email"
|
||||
}})
|
||||
</div>
|
||||
<div v-if="fieldErrors.client_id" class="invalid-feedback small-error">
|
||||
<div
|
||||
v-if="fieldErrors.client_id"
|
||||
class="invalid-feedback small-error"
|
||||
>
|
||||
{{ fieldErrors.client_id }}
|
||||
</div>
|
||||
</div>
|
||||
@ -47,7 +50,10 @@
|
||||
Sélectionné: {{ selectedDeceased.last_name }}
|
||||
{{ selectedDeceased.first_name || "" }}
|
||||
</div>
|
||||
<div v-if="fieldErrors.deceased_id" class="invalid-feedback small-error">
|
||||
<div
|
||||
v-if="fieldErrors.deceased_id"
|
||||
class="invalid-feedback small-error"
|
||||
>
|
||||
{{ fieldErrors.deceased_id }}
|
||||
</div>
|
||||
</div>
|
||||
@ -88,7 +94,10 @@
|
||||
:class="{ 'is-invalid': fieldErrors.scheduled_at }"
|
||||
type="date"
|
||||
/>
|
||||
<div v-if="fieldErrors.scheduled_at" class="invalid-feedback small-error">
|
||||
<div
|
||||
v-if="fieldErrors.scheduled_at"
|
||||
class="invalid-feedback small-error"
|
||||
>
|
||||
{{ fieldErrors.scheduled_at }}
|
||||
</div>
|
||||
</div>
|
||||
@ -100,7 +109,10 @@
|
||||
:class="{ 'is-invalid': fieldErrors.scheduled_at }"
|
||||
type="time"
|
||||
/>
|
||||
<div v-if="fieldErrors.scheduled_at" class="invalid-feedback small-error">
|
||||
<div
|
||||
v-if="fieldErrors.scheduled_at"
|
||||
class="invalid-feedback small-error"
|
||||
>
|
||||
{{ fieldErrors.scheduled_at }}
|
||||
</div>
|
||||
</div>
|
||||
@ -118,7 +130,10 @@
|
||||
min="1"
|
||||
placeholder="ex. 90"
|
||||
/>
|
||||
<div v-if="fieldErrors.duration_min" class="invalid-feedback small-error">
|
||||
<div
|
||||
v-if="fieldErrors.duration_min"
|
||||
class="invalid-feedback small-error"
|
||||
>
|
||||
{{ fieldErrors.duration_min }}
|
||||
</div>
|
||||
</div>
|
||||
@ -153,7 +168,10 @@
|
||||
type="text"
|
||||
placeholder="Nom du donneur d'ordre"
|
||||
/>
|
||||
<div v-if="fieldErrors.order_giver" class="invalid-feedback small-error">
|
||||
<div
|
||||
v-if="fieldErrors.order_giver"
|
||||
class="invalid-feedback small-error"
|
||||
>
|
||||
{{ fieldErrors.order_giver }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -40,11 +40,7 @@ const props = defineProps({
|
||||
|
||||
// Use provided data if available, otherwise fall back to default
|
||||
const interventions = computed(() => {
|
||||
return props.interventionData.length > 0
|
||||
? props.interventionData
|
||||
: [
|
||||
|
||||
];
|
||||
return props.interventionData.length > 0 ? props.interventionData : [];
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@ -1,27 +1,14 @@
|
||||
<template>
|
||||
<div class="d-grid gap-2">
|
||||
<soft-button
|
||||
color="info"
|
||||
@click="$emit('select-type', 'intervention')"
|
||||
>
|
||||
|
||||
<soft-button color="info" @click="$emit('select-type', 'intervention')">
|
||||
Créer une intervention
|
||||
</soft-button>
|
||||
|
||||
<soft-button
|
||||
color="warning"
|
||||
|
||||
@click="$emit('select-type', 'leave')"
|
||||
>
|
||||
|
||||
<soft-button color="warning" @click="$emit('select-type', 'leave')">
|
||||
Demande de congé employé
|
||||
</soft-button>
|
||||
|
||||
<soft-button
|
||||
color="success"
|
||||
@click="$emit('select-type', 'event')"
|
||||
>
|
||||
|
||||
<soft-button color="success" @click="$emit('select-type', 'event')">
|
||||
Créer un événement
|
||||
</soft-button>
|
||||
</div>
|
||||
|
||||
@ -1,10 +1,9 @@
|
||||
<template>
|
||||
<div class="planning-kanban-root">
|
||||
|
||||
<!-- Toolbar -->
|
||||
<div class="kanban-toolbar">
|
||||
<div class="toolbar-left">
|
||||
<div class="toolbar-stat" v-for="col in columnsConfig" :key="col.id">
|
||||
<div v-for="col in columnsConfig" :key="col.id" class="toolbar-stat">
|
||||
<span class="stat-dot" :style="{ background: col.color }"></span>
|
||||
<span class="stat-label">{{ col.title }}</span>
|
||||
<span class="stat-count">{{ countByStatus(col.status) }}</span>
|
||||
@ -13,7 +12,9 @@
|
||||
<div class="toolbar-right">
|
||||
<span class="total-badge">
|
||||
<i class="fas fa-list-ul me-1"></i>
|
||||
{{ interventions.length }} intervention{{ interventions.length > 1 ? 's' : '' }}
|
||||
{{ interventions.length }} intervention{{
|
||||
interventions.length > 1 ? "s" : ""
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@ -21,7 +22,6 @@
|
||||
<!-- Kanban Board -->
|
||||
<div class="kanban-scroll-area">
|
||||
<div class="kanban-columns">
|
||||
|
||||
<div
|
||||
v-for="col in columnsConfig"
|
||||
:key="col.id"
|
||||
@ -74,7 +74,7 @@
|
||||
<div class="card-top">
|
||||
<span class="type-badge">
|
||||
<i :class="getTypeIcon(item.type)" class="type-icon"></i>
|
||||
{{ item.type || 'Soin' }}
|
||||
{{ item.type || "Soin" }}
|
||||
</span>
|
||||
<span class="card-time">
|
||||
<i class="far fa-clock time-icon"></i>
|
||||
@ -84,13 +84,13 @@
|
||||
|
||||
<!-- Deceased name -->
|
||||
<div class="card-deceased">
|
||||
{{ item.deceased || 'Non spécifié' }}
|
||||
{{ item.deceased || "Non spécifié" }}
|
||||
</div>
|
||||
|
||||
<!-- Client -->
|
||||
<div class="card-client">
|
||||
<i class="fas fa-user card-client-icon"></i>
|
||||
<span>{{ item.client || '–' }}</span>
|
||||
<span>{{ item.client || "–" }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Bottom row: date + collaborator -->
|
||||
@ -107,7 +107,11 @@
|
||||
|
||||
<!-- Hover action bar -->
|
||||
<div class="card-hover-bar">
|
||||
<button class="hover-action" @click.stop="emit('edit', item)" title="Modifier">
|
||||
<button
|
||||
class="hover-action"
|
||||
title="Modifier"
|
||||
@click.stop="emit('edit', item)"
|
||||
>
|
||||
<i class="fas fa-pen"></i>
|
||||
</button>
|
||||
<div class="hover-divider"></div>
|
||||
@ -117,13 +121,15 @@
|
||||
</transition-group>
|
||||
|
||||
<!-- Empty state -->
|
||||
<div v-if="itemsByStatus(col.status).length === 0" class="empty-column">
|
||||
<div
|
||||
v-if="itemsByStatus(col.status).length === 0"
|
||||
class="empty-column"
|
||||
>
|
||||
<i class="fas fa-inbox empty-icon"></i>
|
||||
<span>Aucune intervention</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -135,8 +141,8 @@ import { ref, defineProps, defineEmits } from "vue";
|
||||
const props = defineProps({
|
||||
interventions: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
default: () => [],
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(["edit", "update-status"]);
|
||||
@ -146,40 +152,85 @@ const draggingItem = ref(null);
|
||||
const dragOverCol = ref(null);
|
||||
|
||||
const columnsConfig = [
|
||||
{ id: 'todo', title: 'À planifier', status: 'En attente', color: '#f59e0b', icon: 'fas fa-hourglass-half' },
|
||||
{ id: 'planned', title: 'Confirmé', status: 'Confirmé', color: '#3b82f6', icon: 'fas fa-calendar-check' },
|
||||
{ id: 'in-progress', title: 'En cours', status: 'En cours', color: '#8b5cf6', icon: 'fas fa-bolt' },
|
||||
{ id: 'done', title: 'Terminé', status: 'Terminé', color: '#10b981', icon: 'fas fa-check-circle' },
|
||||
{
|
||||
id: "todo",
|
||||
title: "À planifier",
|
||||
status: "En attente",
|
||||
color: "#f59e0b",
|
||||
icon: "fas fa-hourglass-half",
|
||||
},
|
||||
{
|
||||
id: "planned",
|
||||
title: "Confirmé",
|
||||
status: "Confirmé",
|
||||
color: "#3b82f6",
|
||||
icon: "fas fa-calendar-check",
|
||||
},
|
||||
{
|
||||
id: "in-progress",
|
||||
title: "En cours",
|
||||
status: "En cours",
|
||||
color: "#8b5cf6",
|
||||
icon: "fas fa-bolt",
|
||||
},
|
||||
{
|
||||
id: "done",
|
||||
title: "Terminé",
|
||||
status: "Terminé",
|
||||
color: "#10b981",
|
||||
icon: "fas fa-check-circle",
|
||||
},
|
||||
];
|
||||
|
||||
const typeColors = {
|
||||
'Soin': '#3b82f6',
|
||||
'Transport': '#10b981',
|
||||
'Mise en bière': '#f59e0b',
|
||||
'Cérémonie': '#8b5cf6',
|
||||
Soin: "#3b82f6",
|
||||
Transport: "#10b981",
|
||||
"Mise en bière": "#f59e0b",
|
||||
Cérémonie: "#8b5cf6",
|
||||
};
|
||||
|
||||
const typeIcons = {
|
||||
'Soin': 'fas fa-heartbeat',
|
||||
'Transport': 'fas fa-car',
|
||||
'Mise en bière': 'fas fa-box',
|
||||
'Cérémonie': 'fas fa-dove',
|
||||
Soin: "fas fa-heartbeat",
|
||||
Transport: "fas fa-car",
|
||||
"Mise en bière": "fas fa-box",
|
||||
Cérémonie: "fas fa-dove",
|
||||
};
|
||||
|
||||
const getTypeColor = (type) => typeColors[type] || '#6b7280';
|
||||
const getTypeIcon = (type) => typeIcons[type] || 'fas fa-briefcase-medical';
|
||||
const getTypeColor = (type) => typeColors[type] || "#6b7280";
|
||||
const getTypeIcon = (type) => typeIcons[type] || "fas fa-briefcase-medical";
|
||||
|
||||
const formatTime = (d) => {
|
||||
try { return new Date(d).toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' }); }
|
||||
catch { return '--:--'; }
|
||||
try {
|
||||
return new Date(d).toLocaleTimeString("fr-FR", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
} catch {
|
||||
return "--:--";
|
||||
}
|
||||
};
|
||||
const formatDate = (d) => {
|
||||
try { return new Date(d).toLocaleDateString('fr-FR', { day: 'numeric', month: 'short' }); }
|
||||
catch { return '–'; }
|
||||
try {
|
||||
return new Date(d).toLocaleDateString("fr-FR", {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
});
|
||||
} catch {
|
||||
return "–";
|
||||
}
|
||||
};
|
||||
const getInitials = (n) => n ? n.split(' ').map(s => s[0]).join('').substring(0, 2).toUpperCase() : '?';
|
||||
const getInitials = (n) =>
|
||||
n
|
||||
? n
|
||||
.split(" ")
|
||||
.map((s) => s[0])
|
||||
.join("")
|
||||
.substring(0, 2)
|
||||
.toUpperCase()
|
||||
: "?";
|
||||
|
||||
const itemsByStatus = (status) => props.interventions.filter(i => i.status === status);
|
||||
const itemsByStatus = (status) =>
|
||||
props.interventions.filter((i) => i.status === status);
|
||||
const countByStatus = (status) => itemsByStatus(status).length;
|
||||
const progressWidth = (status) => {
|
||||
if (!props.interventions.length) return 0;
|
||||
@ -190,39 +241,43 @@ const progressWidth = (status) => {
|
||||
const onDragStart = (e, item) => {
|
||||
draggingId.value = item.id.toString();
|
||||
draggingItem.value = item;
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
e.dataTransfer.setData('text/plain', item.id.toString());
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
e.dataTransfer.setData("text/plain", item.id.toString());
|
||||
};
|
||||
|
||||
const onDragEnd = () => {
|
||||
draggingId.value = null;
|
||||
draggingItem.value = null;
|
||||
dragOverCol.value = null;
|
||||
document.querySelectorAll('.cards-zone').forEach(z => z.classList.remove('drag-over'));
|
||||
document
|
||||
.querySelectorAll(".cards-zone")
|
||||
.forEach((z) => z.classList.remove("drag-over"));
|
||||
};
|
||||
|
||||
const onDragOver = (e, colId) => {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
e.dataTransfer.dropEffect = "move";
|
||||
if (dragOverCol.value !== colId) {
|
||||
document.querySelectorAll('.cards-zone').forEach(z => z.classList.remove('drag-over'));
|
||||
document
|
||||
.querySelectorAll(".cards-zone")
|
||||
.forEach((z) => z.classList.remove("drag-over"));
|
||||
dragOverCol.value = colId;
|
||||
e.currentTarget.classList.add('drag-over');
|
||||
e.currentTarget.classList.add("drag-over");
|
||||
}
|
||||
};
|
||||
|
||||
const onDragLeave = (e) => {
|
||||
if (!e.currentTarget.contains(e.relatedTarget)) {
|
||||
e.currentTarget.classList.remove('drag-over');
|
||||
e.currentTarget.classList.remove("drag-over");
|
||||
}
|
||||
};
|
||||
|
||||
const onDrop = (e, col) => {
|
||||
e.preventDefault();
|
||||
e.currentTarget.classList.remove('drag-over');
|
||||
const id = e.dataTransfer.getData('text/plain');
|
||||
e.currentTarget.classList.remove("drag-over");
|
||||
const id = e.dataTransfer.getData("text/plain");
|
||||
if (id && draggingItem.value) {
|
||||
emit('update-status', { id, status: col.status });
|
||||
emit("update-status", { id, status: col.status });
|
||||
}
|
||||
draggingId.value = null;
|
||||
draggingItem.value = null;
|
||||
@ -237,7 +292,6 @@ const onDrop = (e, col) => {
|
||||
flex-direction: column;
|
||||
height: calc(100vh - 180px);
|
||||
min-height: 500px;
|
||||
|
||||
}
|
||||
|
||||
/* ─── Toolbar ─── */
|
||||
@ -304,10 +358,20 @@ const onDrop = (e, col) => {
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
.kanban-scroll-area::-webkit-scrollbar { height: 5px; }
|
||||
.kanban-scroll-area::-webkit-scrollbar-track { background: #f1f5f9; border-radius: 10px; }
|
||||
.kanban-scroll-area::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 10px; }
|
||||
.kanban-scroll-area::-webkit-scrollbar-thumb:hover { background: #94a3b8; }
|
||||
.kanban-scroll-area::-webkit-scrollbar {
|
||||
height: 5px;
|
||||
}
|
||||
.kanban-scroll-area::-webkit-scrollbar-track {
|
||||
background: #f1f5f9;
|
||||
border-radius: 10px;
|
||||
}
|
||||
.kanban-scroll-area::-webkit-scrollbar-thumb {
|
||||
background: #cbd5e1;
|
||||
border-radius: 10px;
|
||||
}
|
||||
.kanban-scroll-area::-webkit-scrollbar-thumb:hover {
|
||||
background: #94a3b8;
|
||||
}
|
||||
|
||||
/* ─── Columns ─── */
|
||||
.kanban-columns {
|
||||
@ -407,9 +471,16 @@ const onDrop = (e, col) => {
|
||||
min-height: 100px;
|
||||
}
|
||||
|
||||
.cards-zone::-webkit-scrollbar { width: 4px; }
|
||||
.cards-zone::-webkit-scrollbar-track { background: transparent; }
|
||||
.cards-zone::-webkit-scrollbar-thumb { background: #e2e8f0; border-radius: 4px; }
|
||||
.cards-zone::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
.cards-zone::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
.cards-zone::-webkit-scrollbar-thumb {
|
||||
background: #e2e8f0;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.cards-zone.drag-over {
|
||||
background: color-mix(in srgb, #3b82f6 6%, #f8fafc);
|
||||
@ -427,12 +498,12 @@ const onDrop = (e, col) => {
|
||||
overflow: hidden;
|
||||
cursor: grab;
|
||||
transition: transform 0.18s ease, box-shadow 0.18s ease, opacity 0.18s;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.06);
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.kanban-card:hover {
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0 8px 20px rgba(0,0,0,0.1);
|
||||
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.1);
|
||||
border-color: #dde3ea;
|
||||
}
|
||||
|
||||
@ -650,4 +721,4 @@ const onDrop = (e, col) => {
|
||||
.card-list-move {
|
||||
transition: transform 0.28s ease;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
<template>
|
||||
<div class="calendar-root">
|
||||
|
||||
<!-- Custom Header / Navigation -->
|
||||
<div class="calendar-header">
|
||||
<div class="header-left">
|
||||
@ -9,18 +8,26 @@
|
||||
<span class="week-range">{{ weekRangeLabel }}</span>
|
||||
</div>
|
||||
<div class="legend-row">
|
||||
<span v-for="(color, type) in typeColors" :key="type" class="legend-item">
|
||||
<span
|
||||
v-for="(color, type) in typeColors"
|
||||
:key="type"
|
||||
class="legend-item"
|
||||
>
|
||||
<span class="legend-dot" :style="{ background: color }"></span>
|
||||
{{ type }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<button class="nav-btn" @click="navigate(-1)" title="Semaine précédente">
|
||||
<button
|
||||
class="nav-btn"
|
||||
title="Semaine précédente"
|
||||
@click="navigate(-1)"
|
||||
>
|
||||
<i class="fas fa-chevron-left"></i>
|
||||
</button>
|
||||
<button class="today-btn" @click="goToday">Aujourd'hui</button>
|
||||
<button class="nav-btn" @click="navigate(1)" title="Semaine suivante">
|
||||
<button class="nav-btn" title="Semaine suivante" @click="navigate(1)">
|
||||
<i class="fas fa-chevron-right"></i>
|
||||
</button>
|
||||
</div>
|
||||
@ -39,23 +46,48 @@
|
||||
:style="popoverStyle"
|
||||
@click.stop
|
||||
>
|
||||
<div class="popover-strip" :style="{ background: activePopover.color }"></div>
|
||||
<div
|
||||
class="popover-strip"
|
||||
:style="{ background: activePopover.color }"
|
||||
></div>
|
||||
<div class="popover-body">
|
||||
<div class="popover-top">
|
||||
<span class="popover-type" :style="{ color: activePopover.color, background: activePopover.color + '18' }">
|
||||
<span
|
||||
class="popover-type"
|
||||
:style="{
|
||||
color: activePopover.color,
|
||||
background: activePopover.color + '18',
|
||||
}"
|
||||
>
|
||||
<i :class="getTypeIcon(activePopover.type)" class="me-1"></i>
|
||||
{{ activePopover.type }}
|
||||
</span>
|
||||
<button class="popover-close" @click="closePopover"><i class="fas fa-times"></i></button>
|
||||
<button class="popover-close" @click="closePopover">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="popover-deceased">
|
||||
{{ activePopover.deceased || "Non spécifié" }}
|
||||
</div>
|
||||
<div class="popover-deceased">{{ activePopover.deceased || 'Non spécifié' }}</div>
|
||||
<div class="popover-meta">
|
||||
<span><i class="fas fa-user me-1"></i>{{ activePopover.client || '–' }}</span>
|
||||
<span><i class="far fa-clock me-1"></i>{{ activePopover.timeLabel }}</span>
|
||||
<span
|
||||
><i class="fas fa-user me-1"></i
|
||||
>{{ activePopover.client || "–" }}</span
|
||||
>
|
||||
<span
|
||||
><i class="far fa-clock me-1"></i
|
||||
>{{ activePopover.timeLabel }}</span
|
||||
>
|
||||
</div>
|
||||
<div class="popover-status">
|
||||
<span class="status-pill" :style="{ background: activePopover.color + '20', color: activePopover.color }">
|
||||
{{ activePopover.status || 'Planifié' }}
|
||||
<span
|
||||
class="status-pill"
|
||||
:style="{
|
||||
background: activePopover.color + '20',
|
||||
color: activePopover.color,
|
||||
}"
|
||||
>
|
||||
{{ activePopover.status || "Planifié" }}
|
||||
</span>
|
||||
</div>
|
||||
<button class="popover-edit-btn" @click="editFromPopover">
|
||||
@ -64,13 +96,24 @@
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
<div v-if="activePopover" class="popover-backdrop" @click="closePopover"></div>
|
||||
|
||||
<div
|
||||
v-if="activePopover"
|
||||
class="popover-backdrop"
|
||||
@click="closePopover"
|
||||
></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted, watch, computed, defineProps, defineEmits } from "vue";
|
||||
import {
|
||||
ref,
|
||||
onMounted,
|
||||
onUnmounted,
|
||||
watch,
|
||||
computed,
|
||||
defineProps,
|
||||
defineEmits,
|
||||
} from "vue";
|
||||
import { Calendar } from "@fullcalendar/core";
|
||||
import timeGridPlugin from "@fullcalendar/timegrid";
|
||||
import interactionPlugin from "@fullcalendar/interaction";
|
||||
@ -91,28 +134,29 @@ const popoverStyle = ref({});
|
||||
const currentDate = ref(props.startDate);
|
||||
|
||||
const typeColors = {
|
||||
'Soin': '#3b82f6',
|
||||
'Transport': '#10b981',
|
||||
'Mise en bière': '#f59e0b',
|
||||
'Cérémonie': '#8b5cf6',
|
||||
Soin: "#3b82f6",
|
||||
Transport: "#10b981",
|
||||
"Mise en bière": "#f59e0b",
|
||||
Cérémonie: "#8b5cf6",
|
||||
};
|
||||
|
||||
const typeIcons = {
|
||||
'Soin': 'fas fa-heartbeat',
|
||||
'Transport': 'fas fa-car',
|
||||
'Mise en bière': 'fas fa-box',
|
||||
'Cérémonie': 'fas fa-dove',
|
||||
Soin: "fas fa-heartbeat",
|
||||
Transport: "fas fa-car",
|
||||
"Mise en bière": "fas fa-box",
|
||||
Cérémonie: "fas fa-dove",
|
||||
};
|
||||
|
||||
const getTypeIcon = (type) => typeIcons[type] || 'fas fa-briefcase-medical';
|
||||
const getTypeIcon = (type) => typeIcons[type] || "fas fa-briefcase-medical";
|
||||
|
||||
const parseInterventionDate = (value) => {
|
||||
if (!value) return null;
|
||||
if (value instanceof Date) return Number.isNaN(value.getTime()) ? null : value;
|
||||
if (value instanceof Date)
|
||||
return Number.isNaN(value.getTime()) ? null : value;
|
||||
|
||||
if (typeof value === 'string') {
|
||||
if (typeof value === "string") {
|
||||
// Support backend format "YYYY-MM-DD HH:mm:ss" (without timezone)
|
||||
const normalized = value.includes(' ') ? value.replace(' ', 'T') : value;
|
||||
const normalized = value.includes(" ") ? value.replace(" ", "T") : value;
|
||||
const parsed = new Date(normalized);
|
||||
if (!Number.isNaN(parsed.getTime())) return parsed;
|
||||
}
|
||||
@ -122,41 +166,54 @@ const parseInterventionDate = (value) => {
|
||||
};
|
||||
|
||||
const weekRangeLabel = computed(() => {
|
||||
if (!calendar) return '';
|
||||
if (!calendar) return "";
|
||||
try {
|
||||
const view = calendar.view;
|
||||
const start = view.currentStart;
|
||||
const end = new Date(view.currentEnd);
|
||||
end.setDate(end.getDate() - 1);
|
||||
const fmt = (d) => d.toLocaleDateString('fr-FR', { day: 'numeric', month: 'short' });
|
||||
const fmt = (d) =>
|
||||
d.toLocaleDateString("fr-FR", { day: "numeric", month: "short" });
|
||||
return `${fmt(start)} – ${fmt(end)} ${start.getFullYear()}`;
|
||||
} catch { return ''; }
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
});
|
||||
|
||||
const mapEvents = (interventions) => interventions.map((i) => {
|
||||
const color = typeColors[i.type] || '#6b7280';
|
||||
const start = parseInterventionDate(i.date);
|
||||
if (!start) return null;
|
||||
const mapEvents = (interventions) =>
|
||||
interventions
|
||||
.map((i) => {
|
||||
const color = typeColors[i.type] || "#6b7280";
|
||||
const start = parseInterventionDate(i.date);
|
||||
if (!start) return null;
|
||||
|
||||
const parsedEnd = parseInterventionDate(i.end);
|
||||
const end = parsedEnd || new Date(start.getTime() + 60 * 60 * 1000);
|
||||
const parsedEnd = parseInterventionDate(i.end);
|
||||
const end = parsedEnd || new Date(start.getTime() + 60 * 60 * 1000);
|
||||
|
||||
return {
|
||||
id: String(i.id),
|
||||
title: i.deceased || i.type || 'Intervention',
|
||||
start, end,
|
||||
backgroundColor: color + 'dd',
|
||||
borderColor: color,
|
||||
textColor: '#fff',
|
||||
extendedProps: { originalData: i, color },
|
||||
};
|
||||
}).filter(Boolean);
|
||||
return {
|
||||
id: String(i.id),
|
||||
title: i.deceased || i.type || "Intervention",
|
||||
start,
|
||||
end,
|
||||
backgroundColor: color + "dd",
|
||||
borderColor: color,
|
||||
textColor: "#fff",
|
||||
extendedProps: { originalData: i, color },
|
||||
};
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
const showPopover = (jsEvent, originalData, color) => {
|
||||
const rect = jsEvent.target.closest('.fc-event')?.getBoundingClientRect() || jsEvent.target.getBoundingClientRect();
|
||||
const rect =
|
||||
jsEvent.target.closest(".fc-event")?.getBoundingClientRect() ||
|
||||
jsEvent.target.getBoundingClientRect();
|
||||
const rootRect = calendarEl.value.getBoundingClientRect();
|
||||
|
||||
const fmt = (d) => new Date(d).toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' });
|
||||
const fmt = (d) =>
|
||||
new Date(d).toLocaleTimeString("fr-FR", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
|
||||
activePopover.value = {
|
||||
...originalData,
|
||||
@ -177,14 +234,16 @@ const showPopover = (jsEvent, originalData, color) => {
|
||||
if (top + 200 > rootRect.height) top = rootRect.height - 210;
|
||||
if (top < 0) top = 8;
|
||||
|
||||
popoverStyle.value = { left: left + 'px', top: top + 'px' };
|
||||
popoverStyle.value = { left: left + "px", top: top + "px" };
|
||||
};
|
||||
|
||||
const closePopover = () => { activePopover.value = null; };
|
||||
const closePopover = () => {
|
||||
activePopover.value = null;
|
||||
};
|
||||
|
||||
const editFromPopover = () => {
|
||||
if (activePopover.value) {
|
||||
emit('edit', activePopover.value);
|
||||
emit("edit", activePopover.value);
|
||||
closePopover();
|
||||
}
|
||||
};
|
||||
@ -208,19 +267,19 @@ const initCalendar = () => {
|
||||
|
||||
calendar = new Calendar(calendarEl.value, {
|
||||
plugins: [timeGridPlugin, interactionPlugin],
|
||||
initialView: 'timeGridWeek',
|
||||
locale: frLocale,
|
||||
headerToolbar: false,
|
||||
initialDate: props.startDate,
|
||||
allDaySlot: false,
|
||||
slotMinTime: '00:00:00',
|
||||
slotMaxTime: '24:00:00',
|
||||
height: 'auto',
|
||||
initialView: "timeGridWeek",
|
||||
locale: frLocale,
|
||||
headerToolbar: false,
|
||||
initialDate: props.startDate,
|
||||
allDaySlot: false,
|
||||
slotMinTime: "00:00:00",
|
||||
slotMaxTime: "24:00:00",
|
||||
height: "auto",
|
||||
expandRows: true,
|
||||
stickyHeaderDates: true,
|
||||
nowIndicator: true,
|
||||
slotDuration: '00:30:00',
|
||||
dayHeaderFormat: { weekday: 'short', day: 'numeric', month: 'short' },
|
||||
slotDuration: "00:30:00",
|
||||
dayHeaderFormat: { weekday: "short", day: "numeric", month: "short" },
|
||||
events: mapEvents(props.interventions),
|
||||
|
||||
eventClick: (info) => {
|
||||
@ -232,14 +291,18 @@ const initCalendar = () => {
|
||||
|
||||
dateClick: (info) => {
|
||||
closePopover();
|
||||
emit('cell-click', { date: info.date });
|
||||
emit("cell-click", { date: info.date });
|
||||
},
|
||||
|
||||
eventContent: (arg) => {
|
||||
const data = arg.event.extendedProps.originalData;
|
||||
const color = arg.event.extendedProps.color;
|
||||
const icon = typeIcons[data?.type] || 'fas fa-briefcase-medical';
|
||||
const fmt = (d) => new Date(d).toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' });
|
||||
const icon = typeIcons[data?.type] || "fas fa-briefcase-medical";
|
||||
const fmt = (d) =>
|
||||
new Date(d).toLocaleTimeString("fr-FR", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
return {
|
||||
html: `
|
||||
<div class="fc-event-custom">
|
||||
@ -248,9 +311,9 @@ const initCalendar = () => {
|
||||
<span class="fce-time">${fmt(arg.event.start)}</span>
|
||||
</div>
|
||||
<div class="fce-title">${arg.event.title}</div>
|
||||
${data?.client ? `<div class="fce-sub">${data.client}</div>` : ''}
|
||||
${data?.client ? `<div class="fce-sub">${data.client}</div>` : ""}
|
||||
</div>
|
||||
`
|
||||
`,
|
||||
};
|
||||
},
|
||||
|
||||
@ -264,15 +327,29 @@ const initCalendar = () => {
|
||||
};
|
||||
|
||||
onMounted(() => initCalendar());
|
||||
onUnmounted(() => { if (calendar) calendar.destroy(); });
|
||||
onUnmounted(() => {
|
||||
if (calendar) calendar.destroy();
|
||||
});
|
||||
|
||||
watch(() => props.startDate, (d) => { if (calendar) { calendar.gotoDate(d); currentDate.value = d; } });
|
||||
watch(() => props.interventions, (v) => {
|
||||
if (calendar) {
|
||||
calendar.removeAllEvents();
|
||||
calendar.addEventSource(mapEvents(v));
|
||||
watch(
|
||||
() => props.startDate,
|
||||
(d) => {
|
||||
if (calendar) {
|
||||
calendar.gotoDate(d);
|
||||
currentDate.value = d;
|
||||
}
|
||||
}
|
||||
}, { deep: true });
|
||||
);
|
||||
watch(
|
||||
() => props.interventions,
|
||||
(v) => {
|
||||
if (calendar) {
|
||||
calendar.removeAllEvents();
|
||||
calendar.addEventSource(mapEvents(v));
|
||||
}
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
|
||||
// Force label recompute when currentDate changes
|
||||
watch(currentDate, () => {}); // just triggers computed re-eval
|
||||
@ -287,7 +364,7 @@ watch(currentDate, () => {}); // just triggers computed re-eval
|
||||
background: #fff;
|
||||
border-radius: 16px;
|
||||
border: 1px solid #e5e7eb;
|
||||
box-shadow: 0 4px 20px rgba(0,0,0,0.06);
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.06);
|
||||
overflow: hidden;
|
||||
min-height: 620px;
|
||||
}
|
||||
@ -407,7 +484,7 @@ watch(currentDate, () => {}); // just triggers computed re-eval
|
||||
|
||||
/* ─── FullCalendar Overrides ─── */
|
||||
:deep(.fc) {
|
||||
font-family: 'Segoe UI', system-ui, sans-serif;
|
||||
font-family: "Segoe UI", system-ui, sans-serif;
|
||||
}
|
||||
|
||||
:deep(.fc-scrollgrid) {
|
||||
@ -486,12 +563,12 @@ watch(currentDate, () => {}); // just triggers computed re-eval
|
||||
transition: transform 0.15s, box-shadow 0.15s !important;
|
||||
padding: 0 !important;
|
||||
margin: 1px 2px !important;
|
||||
box-shadow: 0 1px 4px rgba(0,0,0,0.1) !important;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1) !important;
|
||||
}
|
||||
|
||||
:deep(.fc-event:hover) {
|
||||
transform: scale(1.02);
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.15) !important;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15) !important;
|
||||
z-index: 10 !important;
|
||||
}
|
||||
|
||||
@ -546,7 +623,7 @@ watch(currentDate, () => {}); // just triggers computed re-eval
|
||||
width: 260px;
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 16px 40px rgba(0,0,0,0.14), 0 4px 12px rgba(0,0,0,0.08);
|
||||
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.14), 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||
z-index: 200;
|
||||
overflow: hidden;
|
||||
border: 1px solid #e5e7eb;
|
||||
@ -595,7 +672,9 @@ watch(currentDate, () => {}); // just triggers computed re-eval
|
||||
border-radius: 5px;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
.popover-close:hover { color: #374151; }
|
||||
.popover-close:hover {
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.popover-deceased {
|
||||
font-size: 0.9rem;
|
||||
@ -644,7 +723,9 @@ watch(currentDate, () => {}); // just triggers computed re-eval
|
||||
transition: background 0.15s;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.popover-edit-btn:hover { background: #2d4a6e; }
|
||||
.popover-edit-btn:hover {
|
||||
background: #2d4a6e;
|
||||
}
|
||||
|
||||
/* ─── Popover animation ─── */
|
||||
.popover-fade-enter-active,
|
||||
|
||||
@ -0,0 +1,138 @@
|
||||
<template>
|
||||
<div class="info-section">
|
||||
<!-- Read mode -->
|
||||
<template v-if="!editMode">
|
||||
<field-display label="Nom du produit" :value="product.nom" />
|
||||
<field-display label="Référence" :value="product.reference" fallback="Sans référence" />
|
||||
<field-display label="Catégorie" :value="categoryName" />
|
||||
<field-display label="Fabricant" :value="product.fabricant" fallback="Non renseigné" />
|
||||
<field-display label="Numéro de lot" :value="product.numero_lot" fallback="Non renseigné" />
|
||||
<field-display label="Unité" :value="product.unite" />
|
||||
<field-display
|
||||
label="Date d'expiration"
|
||||
:value="formattedExpiration"
|
||||
:value-class="expirationClass"
|
||||
/>
|
||||
<field-display label="Description">
|
||||
<span style="white-space: pre-line">{{ product.description || "Aucune description" }}</span>
|
||||
</field-display>
|
||||
</template>
|
||||
|
||||
<!-- Edit mode -->
|
||||
<template v-else>
|
||||
<div class="info-section__grid">
|
||||
<field-input
|
||||
label="Nom du produit"
|
||||
:model-value="form.nom"
|
||||
required
|
||||
:error="errors.nom?.[0]"
|
||||
placeholder="Nom du produit"
|
||||
@update:model-value="$emit('update:form', { ...form, nom: $event })"
|
||||
/>
|
||||
<field-input
|
||||
label="Référence"
|
||||
:model-value="form.reference"
|
||||
required
|
||||
:error="errors.reference?.[0]"
|
||||
placeholder="Référence produit"
|
||||
@update:model-value="$emit('update:form', { ...form, reference: $event })"
|
||||
/>
|
||||
<field-input
|
||||
label="Catégorie"
|
||||
type="select"
|
||||
:model-value="form.categorie_id"
|
||||
required
|
||||
:error="errors.categorie_id?.[0]"
|
||||
@update:model-value="$emit('update:form', { ...form, categorie_id: $event })"
|
||||
>
|
||||
<option value="">Sélectionner une catégorie</option>
|
||||
<option v-for="cat in categories" :key="cat.id" :value="cat.id.toString()">
|
||||
{{ cat.name }}
|
||||
</option>
|
||||
</field-input>
|
||||
<field-input
|
||||
label="Fabricant"
|
||||
:model-value="form.fabricant"
|
||||
:error="errors.fabricant?.[0]"
|
||||
placeholder="Fabricant"
|
||||
@update:model-value="$emit('update:form', { ...form, fabricant: $event })"
|
||||
/>
|
||||
<field-input
|
||||
label="Numéro de lot"
|
||||
:model-value="form.numero_lot"
|
||||
:error="errors.numero_lot?.[0]"
|
||||
placeholder="Numéro de lot"
|
||||
@update:model-value="$emit('update:form', { ...form, numero_lot: $event })"
|
||||
/>
|
||||
<field-input
|
||||
label="Date d'expiration"
|
||||
type="date"
|
||||
:model-value="form.date_expiration"
|
||||
:error="errors.date_expiration?.[0]"
|
||||
@update:model-value="$emit('update:form', { ...form, date_expiration: $event })"
|
||||
/>
|
||||
<field-input
|
||||
label="Unité"
|
||||
:model-value="form.unite"
|
||||
required
|
||||
:error="errors.unite?.[0]"
|
||||
placeholder="ex: boîte, flacon"
|
||||
@update:model-value="$emit('update:form', { ...form, unite: $event })"
|
||||
/>
|
||||
<field-input
|
||||
label="Description"
|
||||
type="textarea"
|
||||
:model-value="form.description"
|
||||
placeholder="Description du produit..."
|
||||
style="grid-column: 1 / -1"
|
||||
@update:model-value="$emit('update:form', { ...form, description: $event })"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, defineProps, defineEmits } from "vue";
|
||||
import FieldDisplay from "@/components/atoms/Product/FieldDisplay.vue";
|
||||
import FieldInput from "@/components/atoms/Product/FieldInput.vue";
|
||||
|
||||
const props = defineProps({
|
||||
product: { type: Object, required: true },
|
||||
form: { type: Object, required: true },
|
||||
categories: { type: Array, default: () => [] },
|
||||
errors: { type: Object, default: () => ({}) },
|
||||
editMode: { type: Boolean, default: false },
|
||||
categoryName:{ type: String, default: "" },
|
||||
});
|
||||
defineEmits(["update:form"]);
|
||||
|
||||
const formattedExpiration = computed(() => {
|
||||
if (!props.product?.date_expiration) return "Non renseignée";
|
||||
return new Date(props.product.date_expiration).toLocaleDateString("fr-FR", {
|
||||
day: "numeric", month: "long", year: "numeric",
|
||||
});
|
||||
});
|
||||
|
||||
const expirationClass = computed(() => {
|
||||
if (!props.product?.date_expiration) return "";
|
||||
const diff = Math.ceil(
|
||||
(new Date(props.product.date_expiration) - new Date()) / 86400000
|
||||
);
|
||||
if (diff < 0) return "expired";
|
||||
if (diff <= 30) return "expiring-soon";
|
||||
return "";
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.info-section { display: flex; flex-direction: column; }
|
||||
.info-section__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.info-section__grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,131 @@
|
||||
<template>
|
||||
<div>
|
||||
<div v-if="!movements.length" class="movements-empty">
|
||||
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1"><path d="M7 16l-4-4 4-4M17 8l4 4-4 4M14 4l-4 16"/></svg>
|
||||
<p>Aucun mouvement de stock enregistré</p>
|
||||
</div>
|
||||
<div v-else class="movements-table-wrap">
|
||||
<table class="movements-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>Type</th>
|
||||
<th>Quantité</th>
|
||||
<th>Référence</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="m in movements" :key="m.id">
|
||||
<td class="movements-table__date">{{ formatDate(m.date || m.created_at) }}</td>
|
||||
<td>
|
||||
<span class="movements-table__type" :class="typeClass(m.type)">
|
||||
{{ m.type || "—" }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="movements-table__qty" :class="qtyClass(m)">
|
||||
{{ formatQty(m) }}
|
||||
</td>
|
||||
<td class="movements-table__ref">{{ m.reference || m.reason || "—" }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { defineProps } from "vue";
|
||||
|
||||
defineProps({
|
||||
movements: { type: Array, default: () => [] },
|
||||
});
|
||||
|
||||
const formatDate = (d) => {
|
||||
if (!d) return "—";
|
||||
return new Date(d).toLocaleDateString("fr-FR", {
|
||||
day: "2-digit", month: "short", year: "numeric",
|
||||
});
|
||||
};
|
||||
|
||||
const formatQty = (m) => {
|
||||
const q = m.quantite ?? m.quantity;
|
||||
if (q === undefined || q === null) return "—";
|
||||
const prefix = q > 0 ? "+" : "";
|
||||
return `${prefix}${q}`;
|
||||
};
|
||||
|
||||
const typeClass = (type) => {
|
||||
if (!type) return "";
|
||||
const t = type.toLowerCase();
|
||||
if (t.includes("entree") || t.includes("entrée") || t.includes("achat")) return "type-in";
|
||||
if (t.includes("sortie") || t.includes("utilisation")) return "type-out";
|
||||
return "type-neutral";
|
||||
};
|
||||
|
||||
const qtyClass = (m) => {
|
||||
const q = m.quantite ?? m.quantity ?? 0;
|
||||
return q > 0 ? "qty-positive" : q < 0 ? "qty-negative" : "";
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.movements-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 3rem 1rem;
|
||||
color: #9ca3af;
|
||||
text-align: center;
|
||||
}
|
||||
.movements-empty p { font-size: 14px; margin: 0; }
|
||||
|
||||
.movements-table-wrap { overflow-x: auto; }
|
||||
|
||||
.movements-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
}
|
||||
.movements-table thead tr {
|
||||
border-bottom: 2px solid #f3f4f6;
|
||||
}
|
||||
.movements-table th {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: #9ca3af;
|
||||
padding: 0 0.75rem 0.625rem;
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.movements-table td {
|
||||
padding: 10px 0.75rem;
|
||||
color: #374151;
|
||||
border-bottom: 1px solid #f9fafb;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.movements-table tr:last-child td { border-bottom: none; }
|
||||
.movements-table tr:hover td { background: #f9fafb; }
|
||||
|
||||
.movements-table__date { color: #6b7280; font-variant-numeric: tabular-nums; }
|
||||
.movements-table__ref { color: #9ca3af; font-size: 12px; }
|
||||
|
||||
.movements-table__type {
|
||||
display: inline-block;
|
||||
padding: 3px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.type-in { background: #ecfdf5; color: #065f46; }
|
||||
.type-out { background: #fef2f2; color: #991b1b; }
|
||||
.type-neutral { background: #f3f4f6; color: #374151; }
|
||||
|
||||
.movements-table__qty { font-weight: 600; font-variant-numeric: tabular-nums; }
|
||||
.qty-positive { color: #059669; }
|
||||
.qty-negative { color: #dc2626; }
|
||||
</style>
|
||||
@ -0,0 +1,135 @@
|
||||
<template>
|
||||
<aside class="product-sidebar">
|
||||
<div class="product-sidebar__img-wrap">
|
||||
<product-image
|
||||
:image-url="imageUrl"
|
||||
:alt-text="`Image de ${name}`"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="product-sidebar__meta">
|
||||
<h6 class="product-sidebar__name">{{ name }}</h6>
|
||||
<p class="product-sidebar__ref">{{ reference || "Sans référence" }}</p>
|
||||
<div class="product-sidebar__badges">
|
||||
<product-badge :variant="isLowStock ? 'warning' : 'success'">
|
||||
{{ isLowStock ? "Stock faible" : "Stock OK" }}
|
||||
</product-badge>
|
||||
<product-badge v-if="isExpired" variant="danger">Expiré</product-badge>
|
||||
<product-badge v-else-if="isExpiringSoon" variant="info">Expire bientôt</product-badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav class="product-sidebar__nav">
|
||||
<button
|
||||
v-for="tab in tabs"
|
||||
:key="tab.id"
|
||||
class="product-sidebar__nav-item"
|
||||
:class="{ 'is-active': modelValue === tab.id }"
|
||||
@click="$emit('update:modelValue', tab.id)"
|
||||
>
|
||||
<component :is="tab.icon" class="nav-icon" />
|
||||
{{ tab.label }}
|
||||
</button>
|
||||
</nav>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { defineProps, defineEmits } from "vue";
|
||||
import ProductImage from "@/components/atoms/Product/ProductImage.vue";
|
||||
import ProductBadge from "@/components/atoms/Product/ProductBadge.vue";
|
||||
|
||||
defineProps({
|
||||
modelValue: { type: String, default: "details" },
|
||||
name: { type: String, default: "" },
|
||||
reference: { type: String, default: "" },
|
||||
imageUrl: { type: String, default: "" },
|
||||
isLowStock: { type: Boolean, default: false },
|
||||
isExpired: { type: Boolean, default: false },
|
||||
isExpiringSoon:{ type: Boolean, default: false },
|
||||
tabs: { type: Array, default: () => [] },
|
||||
});
|
||||
defineEmits(["update:modelValue"]);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.product-sidebar {
|
||||
position: sticky;
|
||||
top: 1.25rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
background: #fff;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.product-sidebar__img-wrap {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
border: 1px solid #e5e7eb;
|
||||
margin: 1.25rem auto 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.product-sidebar__meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 0.75rem 1rem 1rem;
|
||||
border-bottom: 1px solid #f3f4f6;
|
||||
}
|
||||
.product-sidebar__name {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: #111827;
|
||||
text-align: center;
|
||||
margin: 0;
|
||||
}
|
||||
.product-sidebar__ref {
|
||||
font-size: 12px;
|
||||
color: #9ca3af;
|
||||
margin: 0;
|
||||
font-family: 'SF Mono', 'Fira Code', monospace;
|
||||
}
|
||||
.product-sidebar__badges {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 5px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.product-sidebar__nav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0.5rem;
|
||||
gap: 2px;
|
||||
}
|
||||
.product-sidebar__nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
padding: 9px 12px;
|
||||
border-radius: 7px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #6b7280;
|
||||
font-family: inherit;
|
||||
text-align: left;
|
||||
transition: background 0.12s, color 0.12s;
|
||||
}
|
||||
.product-sidebar__nav-item:hover { background: #f9fafb; color: #111827; }
|
||||
.product-sidebar__nav-item.is-active {
|
||||
background: #111827;
|
||||
color: #fff;
|
||||
}
|
||||
.nav-icon { width: 15px; height: 15px; flex-shrink: 0; }
|
||||
</style>
|
||||
@ -0,0 +1,172 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="stock-stats">
|
||||
<product-stat-card
|
||||
label="Stock actuel"
|
||||
:value="product.stock_actuel ?? 0"
|
||||
:unit="product.unite"
|
||||
:value-class="product.is_low_stock ? 'low' : 'ok'"
|
||||
/>
|
||||
<product-stat-card
|
||||
label="Stock minimum"
|
||||
:value="product.stock_minimum ?? 0"
|
||||
:unit="product.unite"
|
||||
/>
|
||||
<product-stat-card
|
||||
label="Prix unitaire"
|
||||
:value="formattedPrice"
|
||||
:sub="product.unite ? `par ${product.unite}` : ''"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<template v-if="editMode">
|
||||
<div class="stock-grid mt-4">
|
||||
<field-input
|
||||
label="Stock actuel"
|
||||
type="number"
|
||||
:model-value="form.stock_actuel"
|
||||
required
|
||||
:error="errors.stock_actuel?.[0]"
|
||||
min="0"
|
||||
step="1"
|
||||
placeholder="0"
|
||||
@update:model-value="$emit('update:form', { ...form, stock_actuel: $event })"
|
||||
/>
|
||||
<field-input
|
||||
label="Stock minimum"
|
||||
type="number"
|
||||
:model-value="form.stock_minimum"
|
||||
required
|
||||
:error="errors.stock_minimum?.[0]"
|
||||
min="0"
|
||||
step="1"
|
||||
placeholder="0"
|
||||
@update:model-value="$emit('update:form', { ...form, stock_minimum: $event })"
|
||||
/>
|
||||
<field-input
|
||||
label="Prix unitaire (€)"
|
||||
type="number"
|
||||
:model-value="form.prix_unitaire"
|
||||
required
|
||||
:error="errors.prix_unitaire?.[0]"
|
||||
min="0"
|
||||
step="0.01"
|
||||
placeholder="0.00"
|
||||
@update:model-value="$emit('update:form', { ...form, prix_unitaire: $event })"
|
||||
/>
|
||||
<field-input
|
||||
label="Conditionnement"
|
||||
:model-value="form.conditionnement_nom"
|
||||
:error="errors.conditionnement_nom?.[0]"
|
||||
placeholder="ex: Carton 12 unités"
|
||||
@update:model-value="$emit('update:form', { ...form, conditionnement_nom: $event })"
|
||||
/>
|
||||
<field-input
|
||||
label="Qté par conditionnement"
|
||||
type="number"
|
||||
:model-value="form.conditionnement_quantite"
|
||||
:error="errors.conditionnement_quantite?.[0]"
|
||||
min="0"
|
||||
step="1"
|
||||
placeholder="0"
|
||||
@update:model-value="$emit('update:form', { ...form, conditionnement_quantite: $event })"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="hasConditioning">
|
||||
<div class="cond-row mt-4">
|
||||
<span class="cond-label">Conditionnement</span>
|
||||
<span class="cond-value">
|
||||
{{ product.conditionnement?.nom }}
|
||||
<span v-if="product.conditionnement?.quantite">
|
||||
· {{ product.conditionnement.quantite }} {{ product.conditionnement.unite }}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="stock-indicators mt-4">
|
||||
<soft-badge
|
||||
:color="product.is_low_stock ? 'warning' : 'success'"
|
||||
variant="gradient"
|
||||
size="sm"
|
||||
>
|
||||
{{ product.is_low_stock ? "Stock faible" : "Stock OK" }}
|
||||
</soft-badge>
|
||||
<soft-badge v-if="isExpired" color="danger" variant="gradient" size="sm"
|
||||
>Expiré</soft-badge
|
||||
>
|
||||
<soft-badge
|
||||
v-else-if="isExpiringSoon"
|
||||
color="info"
|
||||
variant="gradient"
|
||||
size="sm"
|
||||
>Expire bientôt</soft-badge
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, defineProps, defineEmits } from "vue";
|
||||
import ProductStatCard from "@/components/atoms/Product/ProductStatCard.vue";
|
||||
import FieldInput from "@/components/atoms/Product/FieldInput.vue";
|
||||
import SoftBadge from "@/components/SoftBadge.vue";
|
||||
|
||||
const props = defineProps({
|
||||
product: { type: Object, required: true },
|
||||
form: { type: Object, required: true },
|
||||
errors: { type: Object, default: () => ({}) },
|
||||
editMode: { type: Boolean, default: false },
|
||||
isExpired: { type: Boolean, default: false },
|
||||
isExpiringSoon:{ type: Boolean, default: false },
|
||||
});
|
||||
defineEmits(["update:form"]);
|
||||
|
||||
const formattedPrice = computed(() => {
|
||||
const p = props.product.prix_unitaire;
|
||||
if (!p && p !== 0) return "—";
|
||||
return new Intl.NumberFormat("fr-FR", { style: "currency", currency: "EUR" }).format(p);
|
||||
});
|
||||
|
||||
const hasConditioning = computed(() =>
|
||||
props.product.conditionnement?.nom || props.product.conditionnement?.quantite
|
||||
);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.stock-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.stock-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
.stock-indicators { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.cond-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 10px 12px;
|
||||
background: #f9fafb;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 7px;
|
||||
}
|
||||
.cond-label {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: #9ca3af;
|
||||
min-width: 120px;
|
||||
}
|
||||
.cond-value { font-size: 14px; color: #111827; }
|
||||
@media (max-width: 640px) {
|
||||
.stock-stats { grid-template-columns: 1fr 1fr; }
|
||||
.stock-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,104 @@
|
||||
<template>
|
||||
<div>
|
||||
<div v-if="supplier" class="supplier-card">
|
||||
<div class="supplier-card__avatar">
|
||||
{{ initials }}
|
||||
</div>
|
||||
<div class="supplier-card__body">
|
||||
<p class="supplier-card__name">{{ supplier.name || supplier.nom }}</p>
|
||||
<p v-if="supplier.email" class="supplier-card__email">{{ supplier.email }}</p>
|
||||
<p v-if="supplier.phone" class="supplier-card__phone">{{ supplier.phone }}</p>
|
||||
</div>
|
||||
<button class="supplier-card__cta" @click="$emit('view', supplier)">
|
||||
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<path d="M3 13L13 3M13 3H7M13 3v6"/>
|
||||
</svg>
|
||||
Voir le fournisseur
|
||||
</button>
|
||||
</div>
|
||||
<div v-else class="supplier-empty">
|
||||
<svg width="20" height="20" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1"><rect x="2" y="4" width="12" height="9" rx="1.5"/><path d="M5 4V3a1 1 0 011-1h4a1 1 0 011 1v1"/></svg>
|
||||
Aucun fournisseur associé
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, defineProps, defineEmits } from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
supplier: { type: Object, default: null },
|
||||
});
|
||||
defineEmits(["view"]);
|
||||
|
||||
const initials = computed(() => {
|
||||
const name = props.supplier?.name || props.supplier?.nom || "";
|
||||
return name.split(" ").map(w => w[0] || "").join("").slice(0, 2).toUpperCase();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.supplier-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 1rem 1.125rem;
|
||||
background: #f9fafb;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 10px;
|
||||
}
|
||||
.supplier-card__avatar {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 10px;
|
||||
background: #111827;
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.supplier-card__body { flex: 1; min-width: 0; }
|
||||
.supplier-card__name {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #111827;
|
||||
margin: 0;
|
||||
}
|
||||
.supplier-card__email,
|
||||
.supplier-card__phone {
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
margin: 2px 0 0;
|
||||
}
|
||||
.supplier-card__cta {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #111827;
|
||||
background: #fff;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 6px;
|
||||
padding: 7px 12px;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
white-space: nowrap;
|
||||
transition: background 0.12s, border-color 0.12s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.supplier-card__cta:hover { background: #f3f4f6; border-color: #9ca3af; }
|
||||
|
||||
.supplier-empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
color: #9ca3af;
|
||||
padding: 1rem 0;
|
||||
font-style: italic;
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
@ -4,13 +4,24 @@
|
||||
<p class="mb-0 text-sm">Associez un contact à un client existant</p>
|
||||
|
||||
<transition name="banner-fade">
|
||||
<div v-if="props.success" class="alert alert-success text-white mt-3 mb-0 py-2" role="alert">
|
||||
<span class="text-sm"><strong>Contact créé avec succès !</strong> Il est maintenant lié au client.</span>
|
||||
<div
|
||||
v-if="props.success"
|
||||
class="alert alert-success text-white mt-3 mb-0 py-2"
|
||||
role="alert"
|
||||
>
|
||||
<span class="text-sm"
|
||||
><strong>Contact créé avec succès !</strong> Il est maintenant lié au
|
||||
client.</span
|
||||
>
|
||||
</div>
|
||||
</transition>
|
||||
|
||||
<transition name="banner-fade">
|
||||
<div v-if="fieldErrors.general" class="alert alert-danger text-white mt-3 mb-0 py-2" role="alert">
|
||||
<div
|
||||
v-if="fieldErrors.general"
|
||||
class="alert alert-danger text-white mt-3 mb-0 py-2"
|
||||
role="alert"
|
||||
>
|
||||
<span class="text-sm">{{ fieldErrors.general }}</span>
|
||||
</div>
|
||||
</transition>
|
||||
@ -18,10 +29,15 @@
|
||||
<div class="multisteps-form__content">
|
||||
<div class="row mt-3">
|
||||
<div class="col-12">
|
||||
<label class="form-label">Client associé <span class="text-danger">*</span></label>
|
||||
<label class="form-label"
|
||||
>Client associé <span class="text-danger">*</span></label
|
||||
>
|
||||
|
||||
<div v-if="!selectedClient" class="position-relative">
|
||||
<div class="search-wrap" :class="{ 'has-error': fieldErrors.client_id }">
|
||||
<div
|
||||
class="search-wrap"
|
||||
:class="{ 'has-error': fieldErrors.client_id }"
|
||||
>
|
||||
<i class="fas fa-search search-pfx"></i>
|
||||
<input
|
||||
:value="searchQuery"
|
||||
@ -32,12 +48,21 @@
|
||||
@blur="onInputBlur"
|
||||
/>
|
||||
<span v-if="searchQuery" class="search-loader">
|
||||
<i class="fas fa-circle-notch fa-spin text-secondary text-xs"></i>
|
||||
<i
|
||||
class="fas fa-circle-notch fa-spin text-secondary text-xs"
|
||||
></i>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<transition name="dropdown-pop">
|
||||
<div v-if="showDropdown && props.searchResults && props.searchResults.length > 0" class="search-dropdown">
|
||||
<div
|
||||
v-if="
|
||||
showDropdown &&
|
||||
props.searchResults &&
|
||||
props.searchResults.length > 0
|
||||
"
|
||||
class="search-dropdown"
|
||||
>
|
||||
<button
|
||||
v-for="client in props.searchResults"
|
||||
:key="client.id"
|
||||
@ -48,11 +73,21 @@
|
||||
<span class="dr-avatar">{{ (client.name || "?")[0] }}</span>
|
||||
<span class="dr-info">
|
||||
<span class="dr-name">{{ client.name }}</span>
|
||||
<span v-if="client.email" class="dr-meta">{{ client.email }}</span>
|
||||
<span v-if="client.email" class="dr-meta">{{
|
||||
client.email
|
||||
}}</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<div v-else-if="showDropdown && searchQuery && props.searchResults && props.searchResults.length === 0" class="search-dropdown py-3 text-center text-sm text-secondary">
|
||||
<div
|
||||
v-else-if="
|
||||
showDropdown &&
|
||||
searchQuery &&
|
||||
props.searchResults &&
|
||||
props.searchResults.length === 0
|
||||
"
|
||||
class="search-dropdown py-3 text-center text-sm text-secondary"
|
||||
>
|
||||
Aucun résultat pour <strong>« {{ searchQuery }} »</strong>
|
||||
</div>
|
||||
</transition>
|
||||
@ -63,15 +98,27 @@
|
||||
<span class="sc-info">
|
||||
<span class="sc-label">Client sélectionné</span>
|
||||
<span class="sc-name">{{ selectedClient.name }}</span>
|
||||
<span v-if="selectedClient.email" class="sc-meta">{{ selectedClient.email }}</span>
|
||||
<span v-if="selectedClient.email" class="sc-meta">{{
|
||||
selectedClient.email
|
||||
}}</span>
|
||||
</span>
|
||||
<soft-button type="button" color="secondary" variant="outline" size="sm" @click="clearSelection">
|
||||
<soft-button
|
||||
type="button"
|
||||
color="secondary"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@click="clearSelection"
|
||||
>
|
||||
Changer
|
||||
</soft-button>
|
||||
</div>
|
||||
|
||||
<div v-if="fieldErrors.client_id" class="invalid-feedback d-block">{{ fieldErrors.client_id }}</div>
|
||||
<p class="text-xs text-secondary mb-0 mt-1">Le contact sera rattaché à ce client</p>
|
||||
<div v-if="fieldErrors.client_id" class="invalid-feedback d-block">
|
||||
{{ fieldErrors.client_id }}
|
||||
</div>
|
||||
<p class="text-xs text-secondary mb-0 mt-1">
|
||||
Le contact sera rattaché à ce client
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -85,7 +132,9 @@
|
||||
type="text"
|
||||
placeholder="Jean"
|
||||
/>
|
||||
<div v-if="fieldErrors.first_name" class="invalid-feedback d-block">{{ fieldErrors.first_name }}</div>
|
||||
<div v-if="fieldErrors.first_name" class="invalid-feedback d-block">
|
||||
{{ fieldErrors.first_name }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-sm-6 mt-3 mt-sm-0">
|
||||
<label class="form-label">Nom</label>
|
||||
@ -96,7 +145,9 @@
|
||||
type="text"
|
||||
placeholder="Dupont"
|
||||
/>
|
||||
<div v-if="fieldErrors.last_name" class="invalid-feedback d-block">{{ fieldErrors.last_name }}</div>
|
||||
<div v-if="fieldErrors.last_name" class="invalid-feedback d-block">
|
||||
{{ fieldErrors.last_name }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -110,7 +161,9 @@
|
||||
type="text"
|
||||
placeholder="ex. Directeur Commercial, Responsable RH..."
|
||||
/>
|
||||
<div v-if="fieldErrors.role" class="invalid-feedback d-block">{{ fieldErrors.role }}</div>
|
||||
<div v-if="fieldErrors.role" class="invalid-feedback d-block">
|
||||
{{ fieldErrors.role }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -124,7 +177,9 @@
|
||||
type="email"
|
||||
placeholder="jean.dupont@entreprise.com"
|
||||
/>
|
||||
<div v-if="fieldErrors.email" class="invalid-feedback d-block">{{ fieldErrors.email }}</div>
|
||||
<div v-if="fieldErrors.email" class="invalid-feedback d-block">
|
||||
{{ fieldErrors.email }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -138,7 +193,9 @@
|
||||
type="text"
|
||||
placeholder="+33 1 23 45 67 89"
|
||||
/>
|
||||
<div v-if="fieldErrors.phone" class="invalid-feedback d-block">{{ fieldErrors.phone }}</div>
|
||||
<div v-if="fieldErrors.phone" class="invalid-feedback d-block">
|
||||
{{ fieldErrors.phone }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-sm-6 mt-3 mt-sm-0">
|
||||
<label class="form-label">Mobile</label>
|
||||
@ -149,7 +206,9 @@
|
||||
type="text"
|
||||
placeholder="+33 6 12 34 56 78"
|
||||
/>
|
||||
<div v-if="fieldErrors.mobile" class="invalid-feedback d-block">{{ fieldErrors.mobile }}</div>
|
||||
<div v-if="fieldErrors.mobile" class="invalid-feedback d-block">
|
||||
{{ fieldErrors.mobile }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -164,18 +223,32 @@
|
||||
maxlength="1000"
|
||||
@input="form.notes = $event.target.value"
|
||||
></textarea>
|
||||
<div class="text-end text-xs text-secondary mt-1">{{ (form.notes || "").length }}/1000</div>
|
||||
<div class="text-end text-xs text-secondary mt-1">
|
||||
{{ (form.notes || "").length }}/1000
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<transition name="banner-fade">
|
||||
<div v-if="showValidationWarning && form.client_id" class="alert alert-warning text-dark mt-3 py-2 mb-0" role="alert">
|
||||
<span class="text-sm">Renseignez au moins un champ parmi : prénom, nom, email ou téléphone.</span>
|
||||
<div
|
||||
v-if="showValidationWarning && form.client_id"
|
||||
class="alert alert-warning text-dark mt-3 py-2 mb-0"
|
||||
role="alert"
|
||||
>
|
||||
<span class="text-sm"
|
||||
>Renseignez au moins un champ parmi : prénom, nom, email ou
|
||||
téléphone.</span
|
||||
>
|
||||
</div>
|
||||
</transition>
|
||||
|
||||
<div class="d-flex mt-4 justify-content-between gap-2 flex-wrap">
|
||||
<soft-button type="button" color="secondary" variant="outline" @click="resetForm">
|
||||
<soft-button
|
||||
type="button"
|
||||
color="secondary"
|
||||
variant="outline"
|
||||
@click="resetForm"
|
||||
>
|
||||
Réinitialiser
|
||||
</soft-button>
|
||||
<soft-button
|
||||
@ -185,8 +258,14 @@
|
||||
:disabled="props.loading || !isFormValid"
|
||||
@click="submitForm"
|
||||
>
|
||||
<span v-if="props.loading" class="spinner-border spinner-border-sm me-2" role="status"></span>
|
||||
<span>{{ props.loading ? "Création en cours…" : "Créer le contact" }}</span>
|
||||
<span
|
||||
v-if="props.loading"
|
||||
class="spinner-border spinner-border-sm me-2"
|
||||
role="status"
|
||||
></span>
|
||||
<span>{{
|
||||
props.loading ? "Création en cours…" : "Créer le contact"
|
||||
}}</span>
|
||||
</soft-button>
|
||||
</div>
|
||||
</div>
|
||||
@ -214,26 +293,50 @@ const showDropdown = ref(false);
|
||||
const searchTimeout = ref(null);
|
||||
|
||||
const form = ref({
|
||||
client_id: null, first_name: "", last_name: "",
|
||||
email: "", phone: "", mobile: "", role: "", notes: "",
|
||||
client_id: null,
|
||||
first_name: "",
|
||||
last_name: "",
|
||||
email: "",
|
||||
phone: "",
|
||||
mobile: "",
|
||||
role: "",
|
||||
notes: "",
|
||||
});
|
||||
|
||||
const showValidationWarning = computed(() =>
|
||||
!form.value.first_name && !form.value.last_name &&
|
||||
!form.value.email && !form.value.phone && !form.value.mobile
|
||||
const showValidationWarning = computed(
|
||||
() =>
|
||||
!form.value.first_name &&
|
||||
!form.value.last_name &&
|
||||
!form.value.email &&
|
||||
!form.value.phone &&
|
||||
!form.value.mobile
|
||||
);
|
||||
|
||||
const isFormValid = computed(() =>
|
||||
!!form.value.client_id && !showValidationWarning.value
|
||||
const isFormValid = computed(
|
||||
() => !!form.value.client_id && !showValidationWarning.value
|
||||
);
|
||||
|
||||
watch(() => props.validationErrors, (v) => { fieldErrors.value = { ...v }; }, { deep: true });
|
||||
watch(() => props.success, (v) => { if (v) resetForm(); });
|
||||
watch(
|
||||
() => props.validationErrors,
|
||||
(v) => {
|
||||
fieldErrors.value = { ...v };
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
watch(
|
||||
() => props.success,
|
||||
(v) => {
|
||||
if (v) resetForm();
|
||||
}
|
||||
);
|
||||
|
||||
const handleSearchInput = (value) => {
|
||||
searchQuery.value = value;
|
||||
if (searchTimeout.value) clearTimeout(searchTimeout.value);
|
||||
if (!value.trim()) { showDropdown.value = false; return; }
|
||||
if (!value.trim()) {
|
||||
showDropdown.value = false;
|
||||
return;
|
||||
}
|
||||
showDropdown.value = true;
|
||||
searchTimeout.value = setTimeout(() => emit("searchClient", value), 300);
|
||||
};
|
||||
@ -255,22 +358,31 @@ const clearSelection = () => {
|
||||
emit("clientSelected", null);
|
||||
};
|
||||
|
||||
const onInputBlur = () => setTimeout(() => { showDropdown.value = false; }, 200);
|
||||
const onInputBlur = () =>
|
||||
setTimeout(() => {
|
||||
showDropdown.value = false;
|
||||
}, 200);
|
||||
|
||||
const isValidEmail = (e) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(e);
|
||||
|
||||
const validateForm = () => {
|
||||
const errs = {};
|
||||
if (!form.value.client_id) errs.client_id = "Le client est obligatoire.";
|
||||
if (form.value.email && !isValidEmail(form.value.email)) errs.email = "Adresse email invalide.";
|
||||
if (showValidationWarning.value) errs.general = "Au moins un champ (prénom, nom, email ou téléphone) doit être renseigné.";
|
||||
if (form.value.email && !isValidEmail(form.value.email))
|
||||
errs.email = "Adresse email invalide.";
|
||||
if (showValidationWarning.value)
|
||||
errs.general =
|
||||
"Au moins un champ (prénom, nom, email ou téléphone) doit être renseigné.";
|
||||
return errs;
|
||||
};
|
||||
|
||||
const submitForm = () => {
|
||||
fieldErrors.value = {};
|
||||
const localErrors = validateForm();
|
||||
if (Object.keys(localErrors).length > 0) { fieldErrors.value = localErrors; return; }
|
||||
if (Object.keys(localErrors).length > 0) {
|
||||
fieldErrors.value = localErrors;
|
||||
return;
|
||||
}
|
||||
const cleaned = Object.fromEntries(
|
||||
Object.entries(form.value).map(([k, v]) => [k, v === "" ? null : v])
|
||||
);
|
||||
@ -278,7 +390,16 @@ const submitForm = () => {
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
form.value = { client_id: null, first_name: "", last_name: "", email: "", phone: "", mobile: "", role: "", notes: "" };
|
||||
form.value = {
|
||||
client_id: null,
|
||||
first_name: "",
|
||||
last_name: "",
|
||||
email: "",
|
||||
phone: "",
|
||||
mobile: "",
|
||||
role: "",
|
||||
notes: "",
|
||||
};
|
||||
selectedClient.value = null;
|
||||
searchQuery.value = "";
|
||||
showDropdown.value = false;
|
||||
@ -299,9 +420,11 @@ const resetForm = () => {
|
||||
}
|
||||
.search-wrap:focus-within {
|
||||
border-color: #8392ab;
|
||||
box-shadow: 0 0 0 2px rgba(131,146,171,0.25);
|
||||
box-shadow: 0 0 0 2px rgba(131, 146, 171, 0.25);
|
||||
}
|
||||
.search-wrap.has-error {
|
||||
border-color: #fd5c70;
|
||||
}
|
||||
.search-wrap.has-error { border-color: #fd5c70; }
|
||||
|
||||
.search-pfx {
|
||||
padding: 0 0.75rem;
|
||||
@ -317,9 +440,14 @@ const resetForm = () => {
|
||||
min-width: 0;
|
||||
outline: none;
|
||||
}
|
||||
.search-input:focus { box-shadow: none; }
|
||||
.search-input:focus {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.search-loader { padding: 0 0.75rem; flex-shrink: 0; }
|
||||
.search-loader {
|
||||
padding: 0 0.75rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.search-dropdown {
|
||||
position: absolute;
|
||||
@ -329,15 +457,20 @@ const resetForm = () => {
|
||||
background: #fff;
|
||||
border: 1px solid #e9ecef;
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: 0 12px 32px rgba(0,0,0,0.12);
|
||||
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.12);
|
||||
z-index: 1050;
|
||||
overflow: hidden;
|
||||
max-height: 260px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.search-dropdown::-webkit-scrollbar { width: 4px; }
|
||||
.search-dropdown::-webkit-scrollbar-thumb { background: #e2e8f0; border-radius: 4px; }
|
||||
.search-dropdown::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
.search-dropdown::-webkit-scrollbar-thumb {
|
||||
background: #e2e8f0;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.dropdown-row {
|
||||
width: 100%;
|
||||
@ -352,8 +485,12 @@ const resetForm = () => {
|
||||
border-bottom: 1px solid #f3f4f6;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.dropdown-row:last-child { border-bottom: none; }
|
||||
.dropdown-row:hover { background: #f5f8ff; }
|
||||
.dropdown-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.dropdown-row:hover {
|
||||
background: #f5f8ff;
|
||||
}
|
||||
|
||||
.dr-avatar {
|
||||
width: 2rem;
|
||||
@ -452,12 +589,28 @@ const resetForm = () => {
|
||||
|
||||
/* ─── Animations ─── */
|
||||
.banner-fade-enter-active,
|
||||
.banner-fade-leave-active { transition: opacity 0.22s ease, transform 0.22s ease; }
|
||||
.banner-fade-enter-from { opacity: 0; transform: translateY(-6px); }
|
||||
.banner-fade-leave-to { opacity: 0; transform: translateY(-4px); }
|
||||
.banner-fade-leave-active {
|
||||
transition: opacity 0.22s ease, transform 0.22s ease;
|
||||
}
|
||||
.banner-fade-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateY(-6px);
|
||||
}
|
||||
.banner-fade-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
|
||||
.dropdown-pop-enter-active,
|
||||
.dropdown-pop-leave-active { transition: opacity 0.18s ease, transform 0.18s ease; }
|
||||
.dropdown-pop-enter-from { opacity: 0; transform: translateY(-8px) scale(0.97); }
|
||||
.dropdown-pop-leave-to { opacity: 0; transform: translateY(-4px) scale(0.98); }
|
||||
.dropdown-pop-leave-active {
|
||||
transition: opacity 0.18s ease, transform 0.18s ease;
|
||||
}
|
||||
.dropdown-pop-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateY(-8px) scale(0.97);
|
||||
}
|
||||
.dropdown-pop-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-4px) scale(0.98);
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -4,14 +4,20 @@
|
||||
<div class="col-lg-10 mx-auto">
|
||||
<div class="card mb-4">
|
||||
<div class="card-header p-3 pb-0">
|
||||
<div class="d-flex flex-wrap align-items-center justify-content-between gap-3">
|
||||
<div
|
||||
class="d-flex flex-wrap align-items-center justify-content-between gap-3"
|
||||
>
|
||||
<div class="d-flex align-items-center gap-3">
|
||||
<div class="icon icon-shape icon-sm bg-gradient-primary shadow text-center border-radius-md">
|
||||
<div
|
||||
class="icon icon-shape icon-sm bg-gradient-primary shadow text-center border-radius-md"
|
||||
>
|
||||
<i class="fas fa-file-invoice text-white text-sm"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h6 class="mb-0">Nouveau Devis</h6>
|
||||
<p class="text-sm text-muted mb-0">Créer un devis pour un client</p>
|
||||
<p class="text-sm text-muted mb-0">
|
||||
Créer un devis pour un client
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex flex-wrap align-items-center gap-2">
|
||||
@ -25,7 +31,9 @@
|
||||
|
||||
<div class="row g-3">
|
||||
<div class="col-lg-6 col-md-6 col-12">
|
||||
<div class="card card-body border card-plain border-radius-lg h-100">
|
||||
<div
|
||||
class="card card-body border card-plain border-radius-lg h-100"
|
||||
>
|
||||
<div class="d-flex align-items-center mb-3">
|
||||
<i class="fas fa-user-circle me-2 text-primary"></i>
|
||||
<h6 class="mb-0">Client</h6>
|
||||
@ -35,7 +43,9 @@
|
||||
</div>
|
||||
|
||||
<div class="col-lg-6 col-md-6 col-12">
|
||||
<div class="card card-body border card-plain border-radius-lg h-100">
|
||||
<div
|
||||
class="card card-body border card-plain border-radius-lg h-100"
|
||||
>
|
||||
<div class="d-flex align-items-center mb-3">
|
||||
<i class="fas fa-calendar-alt me-2 text-primary"></i>
|
||||
<h6 class="mb-0">Informations</h6>
|
||||
@ -49,7 +59,9 @@
|
||||
|
||||
<div class="row g-3">
|
||||
<div class="col-lg-8 col-12">
|
||||
<div class="card card-body border card-plain border-radius-lg h-100">
|
||||
<div
|
||||
class="card card-body border card-plain border-radius-lg h-100"
|
||||
>
|
||||
<div class="d-flex align-items-center mb-3">
|
||||
<i class="fas fa-boxes me-2 text-primary"></i>
|
||||
<h6 class="mb-0">Produits & Services</h6>
|
||||
@ -59,7 +71,9 @@
|
||||
</div>
|
||||
|
||||
<div class="col-lg-4 col-12">
|
||||
<div class="card card-body border card-plain border-radius-lg h-100">
|
||||
<div
|
||||
class="card card-body border card-plain border-radius-lg h-100"
|
||||
>
|
||||
<div class="d-flex align-items-center mb-3">
|
||||
<i class="fas fa-calculator me-2 text-primary"></i>
|
||||
<h6 class="mb-0">Récapitulatif</h6>
|
||||
|
||||
@ -93,7 +93,8 @@ const practitioners = computed(
|
||||
const collaborators = computed(() =>
|
||||
practitioners.value.map((p) => ({
|
||||
id: p.id,
|
||||
name: `${p.employee?.first_name || ""} ${p.employee?.last_name || ""}`.trim() ||
|
||||
name:
|
||||
`${p.employee?.first_name || ""} ${p.employee?.last_name || ""}`.trim() ||
|
||||
`Collaborateur #${p.id}`,
|
||||
}))
|
||||
);
|
||||
@ -117,7 +118,9 @@ const uiToBackendStatus = {
|
||||
|
||||
const mapInterventionToPlanning = (item) => {
|
||||
const practitioner =
|
||||
item.principal_practitioner || item.assigned_practitioner || item.practitioners?.[0];
|
||||
item.principal_practitioner ||
|
||||
item.assigned_practitioner ||
|
||||
item.practitioners?.[0];
|
||||
const collaborator = `${practitioner?.employee?.first_name || ""} ${
|
||||
practitioner?.employee?.last_name || ""
|
||||
}`.trim();
|
||||
@ -127,10 +130,16 @@ const mapInterventionToPlanning = (item) => {
|
||||
date: item.scheduled_at || item.date || item.created_at,
|
||||
end: item.estimated_end_at || item.end,
|
||||
type:
|
||||
item.product?.nom || item.product_name || item.type_label || item.type || "Intervention",
|
||||
item.product?.nom ||
|
||||
item.product_name ||
|
||||
item.type_label ||
|
||||
item.type ||
|
||||
"Intervention",
|
||||
deceased:
|
||||
item.deceased_name ||
|
||||
`${item.deceased?.first_name || ""} ${item.deceased?.last_name || ""}`.trim() ||
|
||||
`${item.deceased?.first_name || ""} ${
|
||||
item.deceased?.last_name || ""
|
||||
}`.trim() ||
|
||||
"Non spécifié",
|
||||
client: item.client_name || item.client?.name || "-",
|
||||
collaborator: collaborator || "-",
|
||||
@ -149,9 +158,7 @@ const weekRange = computed(() => {
|
||||
});
|
||||
|
||||
const apiInterventions = computed(() =>
|
||||
Object.values(monthBuckets.value)
|
||||
.flat()
|
||||
.map(mapInterventionToPlanning)
|
||||
Object.values(monthBuckets.value).flat().map(mapInterventionToPlanning)
|
||||
);
|
||||
|
||||
const interventions = computed(() => {
|
||||
@ -167,11 +174,15 @@ const interventions = computed(() => {
|
||||
|
||||
// Lifecycle
|
||||
onMounted(async () => {
|
||||
await Promise.all([fetchData(), thanatopractitionerStore.fetchThanatopractitioners()]);
|
||||
await Promise.all([
|
||||
fetchData(),
|
||||
thanatopractitionerStore.fetchThanatopractitioners(),
|
||||
]);
|
||||
});
|
||||
|
||||
// Methods
|
||||
const getMonthKey = (d) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`;
|
||||
const getMonthKey = (d) =>
|
||||
`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`;
|
||||
|
||||
const bumpMonthVersion = (key) => {
|
||||
monthVersions.value[key] = (monthVersions.value[key] || 0) + 1;
|
||||
@ -192,7 +203,10 @@ const fetchMonth = async (date, force = false) => {
|
||||
|
||||
const year = date.getFullYear();
|
||||
const month = date.getMonth() + 1;
|
||||
const response = await interventionStore.fetchInterventionsByMonth(year, month);
|
||||
const response = await interventionStore.fetchInterventionsByMonth(
|
||||
year,
|
||||
month
|
||||
);
|
||||
|
||||
// Guard against stale async responses overriding newer local/server updates
|
||||
if (monthRequestIds.value[key] !== requestId) return;
|
||||
@ -349,8 +363,13 @@ const handleUpdateStatus = async (payload) => {
|
||||
const status = uiToBackendStatus[payload.status] || payload.status;
|
||||
|
||||
try {
|
||||
const updated = await interventionStore.updateInterventionStatus(interventionId, status);
|
||||
const monthKey = getMonthKey(new Date(updated.scheduled_at || updated.date || currentDate.value));
|
||||
const updated = await interventionStore.updateInterventionStatus(
|
||||
interventionId,
|
||||
status
|
||||
);
|
||||
const monthKey = getMonthKey(
|
||||
new Date(updated.scheduled_at || updated.date || currentDate.value)
|
||||
);
|
||||
const monthItems = monthBuckets.value[monthKey] || [];
|
||||
const idx = monthItems.findIndex((i) => i.id === interventionId);
|
||||
if (idx !== -1) {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user