Feat: redesing form on new
This commit is contained in:
parent
ebd171e9de
commit
dd6fc4665c
@ -82,7 +82,10 @@ class InterventionRepository implements InterventionRepositoryInterface
|
|||||||
'practitioners',
|
'practitioners',
|
||||||
'attachments',
|
'attachments',
|
||||||
'notifications',
|
'notifications',
|
||||||
'quote'
|
'quote',
|
||||||
|
'quote.client',
|
||||||
|
'quote.lines',
|
||||||
|
'quote.history'
|
||||||
])->findOrFail($id);
|
])->findOrFail($id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="container-fluid py-4">
|
<div class="container-fluid py-4 new-commande-page">
|
||||||
<div class="row">
|
<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">
|
||||||
<div class="card-header pb-0 p-3">
|
<div class="card-header pb-0 p-3">
|
||||||
<div class="d-flex justify-content-between align-items-center">
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
@ -17,7 +17,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body p-3">
|
<div class="card-body p-3">
|
||||||
<new-commande-form @submit="handleSubmit" />
|
<new-commande-form
|
||||||
|
@submit="handleSubmit"
|
||||||
|
@created="handleCreated"
|
||||||
|
@cancel="goBack"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -37,6 +41,16 @@ const goBack = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = (formData) => {
|
const handleSubmit = (formData) => {
|
||||||
|
return formData;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreated = () => {
|
||||||
router.push("/fournisseurs/commandes");
|
router.push("/fournisseurs/commandes");
|
||||||
};
|
};
|
||||||
</script>
|
</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 v-if="activeTab === 'quote'" class="tab-pane fade show active">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header pb-0">
|
<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>
|
<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>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div v-if="intervention.quote" class="row mt-4">
|
<div v-if="intervention.quote" class="row mt-4">
|
||||||
<div class="col-md-6 mb-3">
|
<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">
|
<ul class="list-group list-group-flush">
|
||||||
<li class="list-group-item mx-0 px-0">
|
<li class="list-group-item mx-0 px-0">
|
||||||
<strong class="text-dark">Référence:</strong>
|
<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>
|
||||||
<li class="list-group-item mx-0 px-0">
|
<li class="list-group-item mx-0 px-0">
|
||||||
<strong class="text-dark">Date:</strong>
|
<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>
|
||||||
<li class="list-group-item mx-0 px-0">
|
<li class="list-group-item mx-0 px-0">
|
||||||
<strong class="text-dark">Statut:</strong>
|
<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>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</InfoCard>
|
</InfoCard>
|
||||||
@ -296,25 +325,76 @@
|
|||||||
<div class="col-md-6 mb-3">
|
<div class="col-md-6 mb-3">
|
||||||
<InfoCard title="Montants" icon="fas fa-euro-sign text-success">
|
<InfoCard title="Montants" icon="fas fa-euro-sign text-success">
|
||||||
<ul class="list-group list-group-flush">
|
<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>
|
<strong class="text-dark">Total HT:</strong>
|
||||||
<span>{{ intervention.quote.total_ht || '0.00' }} €</span>
|
<span>{{
|
||||||
|
formatCurrency(intervention.quote.total_ht)
|
||||||
|
}}</span>
|
||||||
</li>
|
</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>
|
<strong class="text-dark">Total TVA:</strong>
|
||||||
<span>{{ intervention.quote.total_tva || '0.00' }} €</span>
|
<span>{{
|
||||||
|
formatCurrency(intervention.quote.total_tva)
|
||||||
|
}}</span>
|
||||||
</li>
|
</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>
|
<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>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</InfoCard>
|
</InfoCard>
|
||||||
</div>
|
</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>
|
||||||
<div v-else class="text-center py-5">
|
<div v-else class="text-center py-5">
|
||||||
<div class="avatar avatar-xl mb-3">
|
<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>
|
<i class="fas fa-file-invoice"></i>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -364,12 +444,45 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
|
import { RouterLink } from "vue-router";
|
||||||
import InfoCard from "@/components/atoms/client/InfoCard.vue";
|
import InfoCard from "@/components/atoms/client/InfoCard.vue";
|
||||||
import InterventionDetails from "@/components/molecules/Interventions/interventionDetails.vue";
|
import InterventionDetails from "@/components/molecules/Interventions/interventionDetails.vue";
|
||||||
import DocumentManagement from "@/components/molecules/Interventions/DocumentManagement.vue";
|
import DocumentManagement from "@/components/molecules/Interventions/DocumentManagement.vue";
|
||||||
import { useDocumentAttachmentStore } from "@/stores/documentAttachmentStore";
|
import { useDocumentAttachmentStore } from "@/stores/documentAttachmentStore";
|
||||||
import { defineProps, defineEmits, computed, watch, onMounted } from "vue";
|
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({
|
const props = defineProps({
|
||||||
activeTab: {
|
activeTab: {
|
||||||
type: String,
|
type: String,
|
||||||
|
|||||||
@ -17,7 +17,9 @@
|
|||||||
<!-- Content -->
|
<!-- Content -->
|
||||||
<invoice-detail-template v-else-if="invoice">
|
<invoice-detail-template v-else-if="invoice">
|
||||||
<template #header>
|
<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>
|
<div>
|
||||||
<h6 class="mb-1">Détails Facture</h6>
|
<h6 class="mb-1">Détails Facture</h6>
|
||||||
<p class="text-sm mb-0">
|
<p class="text-sm mb-0">
|
||||||
@ -33,7 +35,10 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="d-flex align-items-center flex-wrap gap-2">
|
<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>
|
<i :class="statusIcon(invoice.status) + ' me-1'"></i>
|
||||||
{{ getStatusLabel(invoice.status) }}
|
{{ getStatusLabel(invoice.status) }}
|
||||||
</soft-badge>
|
</soft-badge>
|
||||||
@ -50,9 +55,17 @@
|
|||||||
<i class="fas fa-file-invoice-dollar"></i>
|
<i class="fas fa-file-invoice-dollar"></i>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h6 class="text-lg mb-0 mt-1">{{ invoice.client?.name || "Client inconnu" }}</h6>
|
<h6 class="text-lg mb-0 mt-1">
|
||||||
<p class="text-sm mb-3">{{ invoice.lines?.length || 0 }} ligne(s) dans cette facture.</p>
|
{{ invoice.client?.name || "Client inconnu" }}
|
||||||
<soft-badge :color="statusBadgeColor(invoice.status)" variant="gradient" size="sm">
|
</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) }}
|
{{ getStatusLabel(invoice.status) }}
|
||||||
</soft-badge>
|
</soft-badge>
|
||||||
</div>
|
</div>
|
||||||
@ -91,20 +104,30 @@
|
|||||||
|
|
||||||
<h6 class="mb-3 mt-4">Informations Client</h6>
|
<h6 class="mb-3 mt-4">Informations Client</h6>
|
||||||
<ul class="list-group">
|
<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">
|
<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">
|
<span class="mb-2 text-xs">
|
||||||
Adresse email :
|
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>
|
||||||
<span class="mb-2 text-xs">
|
<span class="mb-2 text-xs">
|
||||||
Téléphone :
|
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>
|
||||||
<span class="text-xs">
|
<span class="text-xs">
|
||||||
Référence facture :
|
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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
@ -115,15 +138,21 @@
|
|||||||
<h6 class="mb-3">Résumé Facture</h6>
|
<h6 class="mb-3">Résumé Facture</h6>
|
||||||
<div class="d-flex justify-content-between">
|
<div class="d-flex justify-content-between">
|
||||||
<span class="mb-2 text-sm">Total HT :</span>
|
<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>
|
||||||
<div class="d-flex justify-content-between">
|
<div class="d-flex justify-content-between">
|
||||||
<span class="mb-2 text-sm">TVA :</span>
|
<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>
|
||||||
<div class="d-flex justify-content-between mt-4">
|
<div class="d-flex justify-content-between mt-4">
|
||||||
<span class="mb-2 text-lg">Total TTC :</span>
|
<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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</invoice-detail-template>
|
</invoice-detail-template>
|
||||||
@ -266,7 +295,11 @@ const changeStatus = (id, newStatus) => {
|
|||||||
})
|
})
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
console.error(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(() => {
|
.finally(() => {
|
||||||
updating.value = false;
|
updating.value = false;
|
||||||
@ -283,8 +316,8 @@ const changeStatus = (id, newStatus) => {
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
min-height: 60vh;
|
min-height: 60vh;
|
||||||
color: #8898aa;
|
color: #8898aa;
|
||||||
font-size: .9rem;
|
font-size: 0.9rem;
|
||||||
gap: .6rem;
|
gap: 0.6rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail-state--error i {
|
.detail-state--error i {
|
||||||
@ -298,24 +331,30 @@ const changeStatus = (id, newStatus) => {
|
|||||||
border: 3px solid #e9ecef;
|
border: 3px solid #e9ecef;
|
||||||
border-top-color: #5e72e4;
|
border-top-color: #5e72e4;
|
||||||
border-radius: 50%;
|
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 {
|
.btn-retry {
|
||||||
margin-top: .25rem;
|
margin-top: 0.25rem;
|
||||||
padding: .4rem 1.1rem;
|
padding: 0.4rem 1.1rem;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
border: 1.5px solid #f5365c;
|
border: 1.5px solid #f5365c;
|
||||||
background: none;
|
background: none;
|
||||||
color: #f5365c;
|
color: #f5365c;
|
||||||
font-size: .8rem;
|
font-size: 0.8rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background .15s;
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
.btn-retry:hover {
|
||||||
|
background: #fff0f3;
|
||||||
}
|
}
|
||||||
.btn-retry:hover { background: #fff0f3; }
|
|
||||||
|
|
||||||
.inv-visual {
|
.inv-visual {
|
||||||
width: 64px;
|
width: 64px;
|
||||||
|
|||||||
@ -2,10 +2,20 @@
|
|||||||
<create-quote-template>
|
<create-quote-template>
|
||||||
<!-- ── Actions ── -->
|
<!-- ── Actions ── -->
|
||||||
<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
|
<i class="fas fa-times me-1"></i> Annuler
|
||||||
</soft-button>
|
</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>
|
<i class="fas fa-save me-1"></i>
|
||||||
{{ loading ? "Enregistrement..." : "Enregistrer le devis" }}
|
{{ loading ? "Enregistrement..." : "Enregistrer le devis" }}
|
||||||
</soft-button>
|
</soft-button>
|
||||||
@ -14,7 +24,9 @@
|
|||||||
<!-- ── Client Selection ── -->
|
<!-- ── Client Selection ── -->
|
||||||
<template #client-selection>
|
<template #client-selection>
|
||||||
<div class="field-group">
|
<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">
|
<select v-model="form.client_id" class="form-select field-select">
|
||||||
<option value="" disabled>— Sélectionner un client —</option>
|
<option value="" disabled>— Sélectionner un client —</option>
|
||||||
<option v-for="client in clients" :key="client.id" :value="client.id">
|
<option v-for="client in clients" :key="client.id" :value="client.id">
|
||||||
@ -62,11 +74,7 @@
|
|||||||
|
|
||||||
<!-- Lines -->
|
<!-- Lines -->
|
||||||
<transition-group name="line-fade" tag="div">
|
<transition-group name="line-fade" tag="div">
|
||||||
<div
|
<div v-for="(line, index) in form.lines" :key="index" class="line-row">
|
||||||
v-for="(line, index) in form.lines"
|
|
||||||
:key="index"
|
|
||||||
class="line-row"
|
|
||||||
>
|
|
||||||
<product-line-item
|
<product-line-item
|
||||||
v-model="form.lines[index]"
|
v-model="form.lines[index]"
|
||||||
@remove="removeLine(index)"
|
@remove="removeLine(index)"
|
||||||
@ -82,7 +90,13 @@
|
|||||||
|
|
||||||
<!-- Add Line -->
|
<!-- Add Line -->
|
||||||
<div class="lines-footer">
|
<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
|
<i class="fas fa-plus-circle me-2"></i>Ajouter une ligne
|
||||||
</soft-button>
|
</soft-button>
|
||||||
</div>
|
</div>
|
||||||
@ -101,7 +115,9 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="totals-row totals-row--final">
|
<div class="totals-row totals-row--final">
|
||||||
<span class="totals-label">Total TTC</span>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@ -128,9 +144,24 @@ const loading = ref(false);
|
|||||||
const attempted = ref(false);
|
const attempted = ref(false);
|
||||||
|
|
||||||
const statuses = [
|
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: "brouillon",
|
||||||
{ value: "accepte", label: "Accepté", icon: "fas fa-check", color: "success" },
|
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 = () => ({
|
const defaultLine = () => ({
|
||||||
@ -155,7 +186,8 @@ const totals = computed(() => {
|
|||||||
let ht = 0;
|
let ht = 0;
|
||||||
let tva = 0;
|
let tva = 0;
|
||||||
form.value.lines.forEach((line) => {
|
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;
|
ht += afterDiscount;
|
||||||
tva += afterDiscount * (line.tva / 100);
|
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 removeLine = (index) => form.value.lines.splice(index, 1);
|
||||||
|
|
||||||
const formatCurrency = (value) =>
|
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 () => {
|
const saveQuote = async () => {
|
||||||
attempted.value = true;
|
attempted.value = true;
|
||||||
@ -186,7 +220,10 @@ const saveQuote = async () => {
|
|||||||
lines: form.value.lines.map((line) => ({
|
lines: form.value.lines.map((line) => ({
|
||||||
...line,
|
...line,
|
||||||
discount_pct: line.discount_pct || 0,
|
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",
|
description: line.product_name || "Produit sans nom",
|
||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
@ -245,13 +282,32 @@ onMounted(() => clientStore.fetchClients());
|
|||||||
color: #adb5bd;
|
color: #adb5bd;
|
||||||
}
|
}
|
||||||
|
|
||||||
.line-col--product { flex: 3; }
|
.line-col--product {
|
||||||
.line-col--qty { flex: 1; text-align: center; }
|
flex: 3;
|
||||||
.line-col--price { flex: 1.5; text-align: right; }
|
}
|
||||||
.line-col--tva { flex: 1; text-align: center; }
|
.line-col--qty {
|
||||||
.line-col--discount{ flex: 1; text-align: center; }
|
flex: 1;
|
||||||
.line-col--total { flex: 1.5; text-align: right; }
|
text-align: center;
|
||||||
.line-col--action { width: 36px; }
|
}
|
||||||
|
.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 ── */
|
||||||
.line-row {
|
.line-row {
|
||||||
|
|||||||
@ -17,7 +17,9 @@
|
|||||||
<!-- Content -->
|
<!-- Content -->
|
||||||
<quote-detail-template v-else-if="quote">
|
<quote-detail-template v-else-if="quote">
|
||||||
<template #header>
|
<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>
|
<div>
|
||||||
<h6 class="mb-1">Quote Details</h6>
|
<h6 class="mb-1">Quote Details</h6>
|
||||||
<p class="text-sm mb-0">
|
<p class="text-sm mb-0">
|
||||||
@ -33,7 +35,10 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="d-flex align-items-center flex-wrap gap-2">
|
<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>
|
<i :class="statusIcon(quote.status) + ' me-1'"></i>
|
||||||
{{ getStatusLabel(quote.status) }}
|
{{ getStatusLabel(quote.status) }}
|
||||||
</soft-badge>
|
</soft-badge>
|
||||||
@ -50,9 +55,17 @@
|
|||||||
<i class="fas fa-file-signature"></i>
|
<i class="fas fa-file-signature"></i>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h6 class="text-lg mb-0 mt-1">{{ quote.client?.name || "Client inconnu" }}</h6>
|
<h6 class="text-lg mb-0 mt-1">
|
||||||
<p class="text-sm mb-3">{{ quote.lines?.length || 0 }} ligne(s) dans ce devis.</p>
|
{{ quote.client?.name || "Client inconnu" }}
|
||||||
<soft-badge :color="statusBadgeColor(quote.status)" variant="gradient" size="sm">
|
</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) }}
|
{{ getStatusLabel(quote.status) }}
|
||||||
</soft-badge>
|
</soft-badge>
|
||||||
</div>
|
</div>
|
||||||
@ -91,20 +104,30 @@
|
|||||||
|
|
||||||
<h6 class="mb-3 mt-4">Billing Information</h6>
|
<h6 class="mb-3 mt-4">Billing Information</h6>
|
||||||
<ul class="list-group">
|
<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">
|
<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">
|
<span class="mb-2 text-xs">
|
||||||
Email Address:
|
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>
|
||||||
<span class="mb-2 text-xs">
|
<span class="mb-2 text-xs">
|
||||||
Phone:
|
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>
|
||||||
<span class="text-xs">
|
<span class="text-xs">
|
||||||
Quote Reference:
|
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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
@ -115,15 +138,21 @@
|
|||||||
<h6 class="mb-3">Quote Summary</h6>
|
<h6 class="mb-3">Quote Summary</h6>
|
||||||
<div class="d-flex justify-content-between">
|
<div class="d-flex justify-content-between">
|
||||||
<span class="mb-2 text-sm">Total HT:</span>
|
<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>
|
||||||
<div class="d-flex justify-content-between">
|
<div class="d-flex justify-content-between">
|
||||||
<span class="mb-2 text-sm">TVA:</span>
|
<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>
|
||||||
<div class="d-flex justify-content-between mt-4">
|
<div class="d-flex justify-content-between mt-4">
|
||||||
<span class="mb-2 text-lg">Total TTC:</span>
|
<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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</quote-detail-template>
|
</quote-detail-template>
|
||||||
@ -188,7 +217,14 @@ const formatCurrency = (value) => {
|
|||||||
}).format(amount);
|
}).format(amount);
|
||||||
};
|
};
|
||||||
|
|
||||||
const availableStatuses = ["brouillon", "envoye", "accepte", "refuse", "expire", "annule"];
|
const availableStatuses = [
|
||||||
|
"brouillon",
|
||||||
|
"envoye",
|
||||||
|
"accepte",
|
||||||
|
"refuse",
|
||||||
|
"expire",
|
||||||
|
"annule",
|
||||||
|
];
|
||||||
|
|
||||||
const statusLabels = {
|
const statusLabels = {
|
||||||
brouillon: "Brouillon",
|
brouillon: "Brouillon",
|
||||||
@ -248,7 +284,11 @@ const changeStatus = (id, newStatus) => {
|
|||||||
})
|
})
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
console.error(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(() => {
|
.finally(() => {
|
||||||
updating.value = false;
|
updating.value = false;
|
||||||
@ -265,8 +305,8 @@ const changeStatus = (id, newStatus) => {
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
min-height: 60vh;
|
min-height: 60vh;
|
||||||
color: #8898aa;
|
color: #8898aa;
|
||||||
font-size: .9rem;
|
font-size: 0.9rem;
|
||||||
gap: .6rem;
|
gap: 0.6rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail-state--error i {
|
.detail-state--error i {
|
||||||
@ -280,24 +320,30 @@ const changeStatus = (id, newStatus) => {
|
|||||||
border: 3px solid #e9ecef;
|
border: 3px solid #e9ecef;
|
||||||
border-top-color: #5e72e4;
|
border-top-color: #5e72e4;
|
||||||
border-radius: 50%;
|
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 {
|
.btn-retry {
|
||||||
margin-top: .25rem;
|
margin-top: 0.25rem;
|
||||||
padding: .4rem 1.1rem;
|
padding: 0.4rem 1.1rem;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
border: 1.5px solid #f5365c;
|
border: 1.5px solid #f5365c;
|
||||||
background: none;
|
background: none;
|
||||||
color: #f5365c;
|
color: #f5365c;
|
||||||
font-size: .8rem;
|
font-size: 0.8rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background .15s;
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
.btn-retry:hover {
|
||||||
|
background: #fff0f3;
|
||||||
}
|
}
|
||||||
.btn-retry:hover { background: #fff0f3; }
|
|
||||||
|
|
||||||
.qd-visual {
|
.qd-visual {
|
||||||
width: 64px;
|
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"
|
selectedItem.email || "Pas d'email"
|
||||||
}})
|
}})
|
||||||
</div>
|
</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 }}
|
{{ fieldErrors.client_id }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -47,7 +50,10 @@
|
|||||||
Sélectionné: {{ selectedDeceased.last_name }}
|
Sélectionné: {{ selectedDeceased.last_name }}
|
||||||
{{ selectedDeceased.first_name || "" }}
|
{{ selectedDeceased.first_name || "" }}
|
||||||
</div>
|
</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 }}
|
{{ fieldErrors.deceased_id }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -88,7 +94,10 @@
|
|||||||
:class="{ 'is-invalid': fieldErrors.scheduled_at }"
|
:class="{ 'is-invalid': fieldErrors.scheduled_at }"
|
||||||
type="date"
|
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 }}
|
{{ fieldErrors.scheduled_at }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -100,7 +109,10 @@
|
|||||||
:class="{ 'is-invalid': fieldErrors.scheduled_at }"
|
:class="{ 'is-invalid': fieldErrors.scheduled_at }"
|
||||||
type="time"
|
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 }}
|
{{ fieldErrors.scheduled_at }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -118,7 +130,10 @@
|
|||||||
min="1"
|
min="1"
|
||||||
placeholder="ex. 90"
|
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 }}
|
{{ fieldErrors.duration_min }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -153,7 +168,10 @@
|
|||||||
type="text"
|
type="text"
|
||||||
placeholder="Nom du donneur d'ordre"
|
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 }}
|
{{ fieldErrors.order_giver }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -40,11 +40,7 @@ const props = defineProps({
|
|||||||
|
|
||||||
// Use provided data if available, otherwise fall back to default
|
// Use provided data if available, otherwise fall back to default
|
||||||
const interventions = computed(() => {
|
const interventions = computed(() => {
|
||||||
return props.interventionData.length > 0
|
return props.interventionData.length > 0 ? props.interventionData : [];
|
||||||
? props.interventionData
|
|
||||||
: [
|
|
||||||
|
|
||||||
];
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@ -1,27 +1,14 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="d-grid gap-2">
|
<div class="d-grid gap-2">
|
||||||
<soft-button
|
<soft-button color="info" @click="$emit('select-type', 'intervention')">
|
||||||
color="info"
|
|
||||||
@click="$emit('select-type', 'intervention')"
|
|
||||||
>
|
|
||||||
|
|
||||||
Créer une intervention
|
Créer une intervention
|
||||||
</soft-button>
|
</soft-button>
|
||||||
|
|
||||||
<soft-button
|
<soft-button color="warning" @click="$emit('select-type', 'leave')">
|
||||||
color="warning"
|
|
||||||
|
|
||||||
@click="$emit('select-type', 'leave')"
|
|
||||||
>
|
|
||||||
|
|
||||||
Demande de congé employé
|
Demande de congé employé
|
||||||
</soft-button>
|
</soft-button>
|
||||||
|
|
||||||
<soft-button
|
<soft-button color="success" @click="$emit('select-type', 'event')">
|
||||||
color="success"
|
|
||||||
@click="$emit('select-type', 'event')"
|
|
||||||
>
|
|
||||||
|
|
||||||
Créer un événement
|
Créer un événement
|
||||||
</soft-button>
|
</soft-button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,10 +1,9 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="planning-kanban-root">
|
<div class="planning-kanban-root">
|
||||||
|
|
||||||
<!-- Toolbar -->
|
<!-- Toolbar -->
|
||||||
<div class="kanban-toolbar">
|
<div class="kanban-toolbar">
|
||||||
<div class="toolbar-left">
|
<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-dot" :style="{ background: col.color }"></span>
|
||||||
<span class="stat-label">{{ col.title }}</span>
|
<span class="stat-label">{{ col.title }}</span>
|
||||||
<span class="stat-count">{{ countByStatus(col.status) }}</span>
|
<span class="stat-count">{{ countByStatus(col.status) }}</span>
|
||||||
@ -13,7 +12,9 @@
|
|||||||
<div class="toolbar-right">
|
<div class="toolbar-right">
|
||||||
<span class="total-badge">
|
<span class="total-badge">
|
||||||
<i class="fas fa-list-ul me-1"></i>
|
<i class="fas fa-list-ul me-1"></i>
|
||||||
{{ interventions.length }} intervention{{ interventions.length > 1 ? 's' : '' }}
|
{{ interventions.length }} intervention{{
|
||||||
|
interventions.length > 1 ? "s" : ""
|
||||||
|
}}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -21,7 +22,6 @@
|
|||||||
<!-- Kanban Board -->
|
<!-- Kanban Board -->
|
||||||
<div class="kanban-scroll-area">
|
<div class="kanban-scroll-area">
|
||||||
<div class="kanban-columns">
|
<div class="kanban-columns">
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-for="col in columnsConfig"
|
v-for="col in columnsConfig"
|
||||||
:key="col.id"
|
:key="col.id"
|
||||||
@ -74,7 +74,7 @@
|
|||||||
<div class="card-top">
|
<div class="card-top">
|
||||||
<span class="type-badge">
|
<span class="type-badge">
|
||||||
<i :class="getTypeIcon(item.type)" class="type-icon"></i>
|
<i :class="getTypeIcon(item.type)" class="type-icon"></i>
|
||||||
{{ item.type || 'Soin' }}
|
{{ item.type || "Soin" }}
|
||||||
</span>
|
</span>
|
||||||
<span class="card-time">
|
<span class="card-time">
|
||||||
<i class="far fa-clock time-icon"></i>
|
<i class="far fa-clock time-icon"></i>
|
||||||
@ -84,13 +84,13 @@
|
|||||||
|
|
||||||
<!-- Deceased name -->
|
<!-- Deceased name -->
|
||||||
<div class="card-deceased">
|
<div class="card-deceased">
|
||||||
{{ item.deceased || 'Non spécifié' }}
|
{{ item.deceased || "Non spécifié" }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Client -->
|
<!-- Client -->
|
||||||
<div class="card-client">
|
<div class="card-client">
|
||||||
<i class="fas fa-user card-client-icon"></i>
|
<i class="fas fa-user card-client-icon"></i>
|
||||||
<span>{{ item.client || '–' }}</span>
|
<span>{{ item.client || "–" }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Bottom row: date + collaborator -->
|
<!-- Bottom row: date + collaborator -->
|
||||||
@ -107,7 +107,11 @@
|
|||||||
|
|
||||||
<!-- Hover action bar -->
|
<!-- Hover action bar -->
|
||||||
<div class="card-hover-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>
|
<i class="fas fa-pen"></i>
|
||||||
</button>
|
</button>
|
||||||
<div class="hover-divider"></div>
|
<div class="hover-divider"></div>
|
||||||
@ -117,13 +121,15 @@
|
|||||||
</transition-group>
|
</transition-group>
|
||||||
|
|
||||||
<!-- Empty state -->
|
<!-- 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>
|
<i class="fas fa-inbox empty-icon"></i>
|
||||||
<span>Aucune intervention</span>
|
<span>Aucune intervention</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -135,8 +141,8 @@ import { ref, defineProps, defineEmits } from "vue";
|
|||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
interventions: {
|
interventions: {
|
||||||
type: Array,
|
type: Array,
|
||||||
default: () => []
|
default: () => [],
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits(["edit", "update-status"]);
|
const emit = defineEmits(["edit", "update-status"]);
|
||||||
@ -146,40 +152,85 @@ const draggingItem = ref(null);
|
|||||||
const dragOverCol = ref(null);
|
const dragOverCol = ref(null);
|
||||||
|
|
||||||
const columnsConfig = [
|
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: "todo",
|
||||||
{ id: 'in-progress', title: 'En cours', status: 'En cours', color: '#8b5cf6', icon: 'fas fa-bolt' },
|
title: "À planifier",
|
||||||
{ id: 'done', title: 'Terminé', status: 'Terminé', color: '#10b981', icon: 'fas fa-check-circle' },
|
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 = {
|
const typeColors = {
|
||||||
'Soin': '#3b82f6',
|
Soin: "#3b82f6",
|
||||||
'Transport': '#10b981',
|
Transport: "#10b981",
|
||||||
'Mise en bière': '#f59e0b',
|
"Mise en bière": "#f59e0b",
|
||||||
'Cérémonie': '#8b5cf6',
|
Cérémonie: "#8b5cf6",
|
||||||
};
|
};
|
||||||
|
|
||||||
const typeIcons = {
|
const typeIcons = {
|
||||||
'Soin': 'fas fa-heartbeat',
|
Soin: "fas fa-heartbeat",
|
||||||
'Transport': 'fas fa-car',
|
Transport: "fas fa-car",
|
||||||
'Mise en bière': 'fas fa-box',
|
"Mise en bière": "fas fa-box",
|
||||||
'Cérémonie': 'fas fa-dove',
|
Cérémonie: "fas fa-dove",
|
||||||
};
|
};
|
||||||
|
|
||||||
const getTypeColor = (type) => typeColors[type] || '#6b7280';
|
const getTypeColor = (type) => typeColors[type] || "#6b7280";
|
||||||
const getTypeIcon = (type) => typeIcons[type] || 'fas fa-briefcase-medical';
|
const getTypeIcon = (type) => typeIcons[type] || "fas fa-briefcase-medical";
|
||||||
|
|
||||||
const formatTime = (d) => {
|
const formatTime = (d) => {
|
||||||
try { return new Date(d).toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' }); }
|
try {
|
||||||
catch { return '--:--'; }
|
return new Date(d).toLocaleTimeString("fr-FR", {
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return "--:--";
|
||||||
|
}
|
||||||
};
|
};
|
||||||
const formatDate = (d) => {
|
const formatDate = (d) => {
|
||||||
try { return new Date(d).toLocaleDateString('fr-FR', { day: 'numeric', month: 'short' }); }
|
try {
|
||||||
catch { return '–'; }
|
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 countByStatus = (status) => itemsByStatus(status).length;
|
||||||
const progressWidth = (status) => {
|
const progressWidth = (status) => {
|
||||||
if (!props.interventions.length) return 0;
|
if (!props.interventions.length) return 0;
|
||||||
@ -190,39 +241,43 @@ const progressWidth = (status) => {
|
|||||||
const onDragStart = (e, item) => {
|
const onDragStart = (e, item) => {
|
||||||
draggingId.value = item.id.toString();
|
draggingId.value = item.id.toString();
|
||||||
draggingItem.value = item;
|
draggingItem.value = item;
|
||||||
e.dataTransfer.effectAllowed = 'move';
|
e.dataTransfer.effectAllowed = "move";
|
||||||
e.dataTransfer.setData('text/plain', item.id.toString());
|
e.dataTransfer.setData("text/plain", item.id.toString());
|
||||||
};
|
};
|
||||||
|
|
||||||
const onDragEnd = () => {
|
const onDragEnd = () => {
|
||||||
draggingId.value = null;
|
draggingId.value = null;
|
||||||
draggingItem.value = null;
|
draggingItem.value = null;
|
||||||
dragOverCol.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) => {
|
const onDragOver = (e, colId) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.dataTransfer.dropEffect = 'move';
|
e.dataTransfer.dropEffect = "move";
|
||||||
if (dragOverCol.value !== colId) {
|
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;
|
dragOverCol.value = colId;
|
||||||
e.currentTarget.classList.add('drag-over');
|
e.currentTarget.classList.add("drag-over");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const onDragLeave = (e) => {
|
const onDragLeave = (e) => {
|
||||||
if (!e.currentTarget.contains(e.relatedTarget)) {
|
if (!e.currentTarget.contains(e.relatedTarget)) {
|
||||||
e.currentTarget.classList.remove('drag-over');
|
e.currentTarget.classList.remove("drag-over");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const onDrop = (e, col) => {
|
const onDrop = (e, col) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.currentTarget.classList.remove('drag-over');
|
e.currentTarget.classList.remove("drag-over");
|
||||||
const id = e.dataTransfer.getData('text/plain');
|
const id = e.dataTransfer.getData("text/plain");
|
||||||
if (id && draggingItem.value) {
|
if (id && draggingItem.value) {
|
||||||
emit('update-status', { id, status: col.status });
|
emit("update-status", { id, status: col.status });
|
||||||
}
|
}
|
||||||
draggingId.value = null;
|
draggingId.value = null;
|
||||||
draggingItem.value = null;
|
draggingItem.value = null;
|
||||||
@ -237,7 +292,6 @@ const onDrop = (e, col) => {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
height: calc(100vh - 180px);
|
height: calc(100vh - 180px);
|
||||||
min-height: 500px;
|
min-height: 500px;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ─── Toolbar ─── */
|
/* ─── Toolbar ─── */
|
||||||
@ -304,10 +358,20 @@ const onDrop = (e, col) => {
|
|||||||
padding-bottom: 8px;
|
padding-bottom: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.kanban-scroll-area::-webkit-scrollbar { height: 5px; }
|
.kanban-scroll-area::-webkit-scrollbar {
|
||||||
.kanban-scroll-area::-webkit-scrollbar-track { background: #f1f5f9; border-radius: 10px; }
|
height: 5px;
|
||||||
.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-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 ─── */
|
/* ─── Columns ─── */
|
||||||
.kanban-columns {
|
.kanban-columns {
|
||||||
@ -407,9 +471,16 @@ const onDrop = (e, col) => {
|
|||||||
min-height: 100px;
|
min-height: 100px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cards-zone::-webkit-scrollbar { width: 4px; }
|
.cards-zone::-webkit-scrollbar {
|
||||||
.cards-zone::-webkit-scrollbar-track { background: transparent; }
|
width: 4px;
|
||||||
.cards-zone::-webkit-scrollbar-thumb { background: #e2e8f0; border-radius: 4px; }
|
}
|
||||||
|
.cards-zone::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
.cards-zone::-webkit-scrollbar-thumb {
|
||||||
|
background: #e2e8f0;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
.cards-zone.drag-over {
|
.cards-zone.drag-over {
|
||||||
background: color-mix(in srgb, #3b82f6 6%, #f8fafc);
|
background: color-mix(in srgb, #3b82f6 6%, #f8fafc);
|
||||||
@ -427,12 +498,12 @@ const onDrop = (e, col) => {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
cursor: grab;
|
cursor: grab;
|
||||||
transition: transform 0.18s ease, box-shadow 0.18s ease, opacity 0.18s;
|
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 {
|
.kanban-card:hover {
|
||||||
transform: translateY(-3px);
|
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;
|
border-color: #dde3ea;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -650,4 +721,4 @@ const onDrop = (e, col) => {
|
|||||||
.card-list-move {
|
.card-list-move {
|
||||||
transition: transform 0.28s ease;
|
transition: transform 0.28s ease;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="calendar-root">
|
<div class="calendar-root">
|
||||||
|
|
||||||
<!-- Custom Header / Navigation -->
|
<!-- Custom Header / Navigation -->
|
||||||
<div class="calendar-header">
|
<div class="calendar-header">
|
||||||
<div class="header-left">
|
<div class="header-left">
|
||||||
@ -9,18 +8,26 @@
|
|||||||
<span class="week-range">{{ weekRangeLabel }}</span>
|
<span class="week-range">{{ weekRangeLabel }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="legend-row">
|
<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>
|
<span class="legend-dot" :style="{ background: color }"></span>
|
||||||
{{ type }}
|
{{ type }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-right">
|
<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>
|
<i class="fas fa-chevron-left"></i>
|
||||||
</button>
|
</button>
|
||||||
<button class="today-btn" @click="goToday">Aujourd'hui</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>
|
<i class="fas fa-chevron-right"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@ -39,23 +46,48 @@
|
|||||||
:style="popoverStyle"
|
:style="popoverStyle"
|
||||||
@click.stop
|
@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-body">
|
||||||
<div class="popover-top">
|
<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>
|
<i :class="getTypeIcon(activePopover.type)" class="me-1"></i>
|
||||||
{{ activePopover.type }}
|
{{ activePopover.type }}
|
||||||
</span>
|
</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>
|
||||||
<div class="popover-deceased">{{ activePopover.deceased || 'Non spécifié' }}</div>
|
|
||||||
<div class="popover-meta">
|
<div class="popover-meta">
|
||||||
<span><i class="fas fa-user me-1"></i>{{ activePopover.client || '–' }}</span>
|
<span
|
||||||
<span><i class="far fa-clock me-1"></i>{{ activePopover.timeLabel }}</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>
|
||||||
<div class="popover-status">
|
<div class="popover-status">
|
||||||
<span class="status-pill" :style="{ background: activePopover.color + '20', color: activePopover.color }">
|
<span
|
||||||
{{ activePopover.status || 'Planifié' }}
|
class="status-pill"
|
||||||
|
:style="{
|
||||||
|
background: activePopover.color + '20',
|
||||||
|
color: activePopover.color,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
{{ activePopover.status || "Planifié" }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<button class="popover-edit-btn" @click="editFromPopover">
|
<button class="popover-edit-btn" @click="editFromPopover">
|
||||||
@ -64,13 +96,24 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</transition>
|
</transition>
|
||||||
<div v-if="activePopover" class="popover-backdrop" @click="closePopover"></div>
|
<div
|
||||||
|
v-if="activePopover"
|
||||||
|
class="popover-backdrop"
|
||||||
|
@click="closePopover"
|
||||||
|
></div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<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 { Calendar } from "@fullcalendar/core";
|
||||||
import timeGridPlugin from "@fullcalendar/timegrid";
|
import timeGridPlugin from "@fullcalendar/timegrid";
|
||||||
import interactionPlugin from "@fullcalendar/interaction";
|
import interactionPlugin from "@fullcalendar/interaction";
|
||||||
@ -91,28 +134,29 @@ const popoverStyle = ref({});
|
|||||||
const currentDate = ref(props.startDate);
|
const currentDate = ref(props.startDate);
|
||||||
|
|
||||||
const typeColors = {
|
const typeColors = {
|
||||||
'Soin': '#3b82f6',
|
Soin: "#3b82f6",
|
||||||
'Transport': '#10b981',
|
Transport: "#10b981",
|
||||||
'Mise en bière': '#f59e0b',
|
"Mise en bière": "#f59e0b",
|
||||||
'Cérémonie': '#8b5cf6',
|
Cérémonie: "#8b5cf6",
|
||||||
};
|
};
|
||||||
|
|
||||||
const typeIcons = {
|
const typeIcons = {
|
||||||
'Soin': 'fas fa-heartbeat',
|
Soin: "fas fa-heartbeat",
|
||||||
'Transport': 'fas fa-car',
|
Transport: "fas fa-car",
|
||||||
'Mise en bière': 'fas fa-box',
|
"Mise en bière": "fas fa-box",
|
||||||
'Cérémonie': 'fas fa-dove',
|
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) => {
|
const parseInterventionDate = (value) => {
|
||||||
if (!value) return null;
|
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)
|
// 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);
|
const parsed = new Date(normalized);
|
||||||
if (!Number.isNaN(parsed.getTime())) return parsed;
|
if (!Number.isNaN(parsed.getTime())) return parsed;
|
||||||
}
|
}
|
||||||
@ -122,41 +166,54 @@ const parseInterventionDate = (value) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const weekRangeLabel = computed(() => {
|
const weekRangeLabel = computed(() => {
|
||||||
if (!calendar) return '';
|
if (!calendar) return "";
|
||||||
try {
|
try {
|
||||||
const view = calendar.view;
|
const view = calendar.view;
|
||||||
const start = view.currentStart;
|
const start = view.currentStart;
|
||||||
const end = new Date(view.currentEnd);
|
const end = new Date(view.currentEnd);
|
||||||
end.setDate(end.getDate() - 1);
|
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()}`;
|
return `${fmt(start)} – ${fmt(end)} ${start.getFullYear()}`;
|
||||||
} catch { return ''; }
|
} catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const mapEvents = (interventions) => interventions.map((i) => {
|
const mapEvents = (interventions) =>
|
||||||
const color = typeColors[i.type] || '#6b7280';
|
interventions
|
||||||
const start = parseInterventionDate(i.date);
|
.map((i) => {
|
||||||
if (!start) return null;
|
const color = typeColors[i.type] || "#6b7280";
|
||||||
|
const start = parseInterventionDate(i.date);
|
||||||
|
if (!start) return null;
|
||||||
|
|
||||||
const parsedEnd = parseInterventionDate(i.end);
|
const parsedEnd = parseInterventionDate(i.end);
|
||||||
const end = parsedEnd || new Date(start.getTime() + 60 * 60 * 1000);
|
const end = parsedEnd || new Date(start.getTime() + 60 * 60 * 1000);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: String(i.id),
|
id: String(i.id),
|
||||||
title: i.deceased || i.type || 'Intervention',
|
title: i.deceased || i.type || "Intervention",
|
||||||
start, end,
|
start,
|
||||||
backgroundColor: color + 'dd',
|
end,
|
||||||
borderColor: color,
|
backgroundColor: color + "dd",
|
||||||
textColor: '#fff',
|
borderColor: color,
|
||||||
extendedProps: { originalData: i, color },
|
textColor: "#fff",
|
||||||
};
|
extendedProps: { originalData: i, color },
|
||||||
}).filter(Boolean);
|
};
|
||||||
|
})
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
const showPopover = (jsEvent, originalData, color) => {
|
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 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 = {
|
activePopover.value = {
|
||||||
...originalData,
|
...originalData,
|
||||||
@ -177,14 +234,16 @@ const showPopover = (jsEvent, originalData, color) => {
|
|||||||
if (top + 200 > rootRect.height) top = rootRect.height - 210;
|
if (top + 200 > rootRect.height) top = rootRect.height - 210;
|
||||||
if (top < 0) top = 8;
|
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 = () => {
|
const editFromPopover = () => {
|
||||||
if (activePopover.value) {
|
if (activePopover.value) {
|
||||||
emit('edit', activePopover.value);
|
emit("edit", activePopover.value);
|
||||||
closePopover();
|
closePopover();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -208,19 +267,19 @@ const initCalendar = () => {
|
|||||||
|
|
||||||
calendar = new Calendar(calendarEl.value, {
|
calendar = new Calendar(calendarEl.value, {
|
||||||
plugins: [timeGridPlugin, interactionPlugin],
|
plugins: [timeGridPlugin, interactionPlugin],
|
||||||
initialView: 'timeGridWeek',
|
initialView: "timeGridWeek",
|
||||||
locale: frLocale,
|
locale: frLocale,
|
||||||
headerToolbar: false,
|
headerToolbar: false,
|
||||||
initialDate: props.startDate,
|
initialDate: props.startDate,
|
||||||
allDaySlot: false,
|
allDaySlot: false,
|
||||||
slotMinTime: '00:00:00',
|
slotMinTime: "00:00:00",
|
||||||
slotMaxTime: '24:00:00',
|
slotMaxTime: "24:00:00",
|
||||||
height: 'auto',
|
height: "auto",
|
||||||
expandRows: true,
|
expandRows: true,
|
||||||
stickyHeaderDates: true,
|
stickyHeaderDates: true,
|
||||||
nowIndicator: true,
|
nowIndicator: true,
|
||||||
slotDuration: '00:30:00',
|
slotDuration: "00:30:00",
|
||||||
dayHeaderFormat: { weekday: 'short', day: 'numeric', month: 'short' },
|
dayHeaderFormat: { weekday: "short", day: "numeric", month: "short" },
|
||||||
events: mapEvents(props.interventions),
|
events: mapEvents(props.interventions),
|
||||||
|
|
||||||
eventClick: (info) => {
|
eventClick: (info) => {
|
||||||
@ -232,14 +291,18 @@ const initCalendar = () => {
|
|||||||
|
|
||||||
dateClick: (info) => {
|
dateClick: (info) => {
|
||||||
closePopover();
|
closePopover();
|
||||||
emit('cell-click', { date: info.date });
|
emit("cell-click", { date: info.date });
|
||||||
},
|
},
|
||||||
|
|
||||||
eventContent: (arg) => {
|
eventContent: (arg) => {
|
||||||
const data = arg.event.extendedProps.originalData;
|
const data = arg.event.extendedProps.originalData;
|
||||||
const color = arg.event.extendedProps.color;
|
const color = arg.event.extendedProps.color;
|
||||||
const icon = typeIcons[data?.type] || 'fas fa-briefcase-medical';
|
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 fmt = (d) =>
|
||||||
|
new Date(d).toLocaleTimeString("fr-FR", {
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
});
|
||||||
return {
|
return {
|
||||||
html: `
|
html: `
|
||||||
<div class="fc-event-custom">
|
<div class="fc-event-custom">
|
||||||
@ -248,9 +311,9 @@ const initCalendar = () => {
|
|||||||
<span class="fce-time">${fmt(arg.event.start)}</span>
|
<span class="fce-time">${fmt(arg.event.start)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="fce-title">${arg.event.title}</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>
|
</div>
|
||||||
`
|
`,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
@ -264,15 +327,29 @@ const initCalendar = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
onMounted(() => 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(
|
||||||
watch(() => props.interventions, (v) => {
|
() => props.startDate,
|
||||||
if (calendar) {
|
(d) => {
|
||||||
calendar.removeAllEvents();
|
if (calendar) {
|
||||||
calendar.addEventSource(mapEvents(v));
|
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
|
// Force label recompute when currentDate changes
|
||||||
watch(currentDate, () => {}); // just triggers computed re-eval
|
watch(currentDate, () => {}); // just triggers computed re-eval
|
||||||
@ -287,7 +364,7 @@ watch(currentDate, () => {}); // just triggers computed re-eval
|
|||||||
background: #fff;
|
background: #fff;
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
border: 1px solid #e5e7eb;
|
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;
|
overflow: hidden;
|
||||||
min-height: 620px;
|
min-height: 620px;
|
||||||
}
|
}
|
||||||
@ -407,7 +484,7 @@ watch(currentDate, () => {}); // just triggers computed re-eval
|
|||||||
|
|
||||||
/* ─── FullCalendar Overrides ─── */
|
/* ─── FullCalendar Overrides ─── */
|
||||||
:deep(.fc) {
|
:deep(.fc) {
|
||||||
font-family: 'Segoe UI', system-ui, sans-serif;
|
font-family: "Segoe UI", system-ui, sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(.fc-scrollgrid) {
|
:deep(.fc-scrollgrid) {
|
||||||
@ -486,12 +563,12 @@ watch(currentDate, () => {}); // just triggers computed re-eval
|
|||||||
transition: transform 0.15s, box-shadow 0.15s !important;
|
transition: transform 0.15s, box-shadow 0.15s !important;
|
||||||
padding: 0 !important;
|
padding: 0 !important;
|
||||||
margin: 1px 2px !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) {
|
:deep(.fc-event:hover) {
|
||||||
transform: scale(1.02);
|
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;
|
z-index: 10 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -546,7 +623,7 @@ watch(currentDate, () => {}); // just triggers computed re-eval
|
|||||||
width: 260px;
|
width: 260px;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
border-radius: 12px;
|
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;
|
z-index: 200;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border: 1px solid #e5e7eb;
|
border: 1px solid #e5e7eb;
|
||||||
@ -595,7 +672,9 @@ watch(currentDate, () => {}); // just triggers computed re-eval
|
|||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
transition: color 0.15s;
|
transition: color 0.15s;
|
||||||
}
|
}
|
||||||
.popover-close:hover { color: #374151; }
|
.popover-close:hover {
|
||||||
|
color: #374151;
|
||||||
|
}
|
||||||
|
|
||||||
.popover-deceased {
|
.popover-deceased {
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
@ -644,7 +723,9 @@ watch(currentDate, () => {}); // just triggers computed re-eval
|
|||||||
transition: background 0.15s;
|
transition: background 0.15s;
|
||||||
margin-top: 2px;
|
margin-top: 2px;
|
||||||
}
|
}
|
||||||
.popover-edit-btn:hover { background: #2d4a6e; }
|
.popover-edit-btn:hover {
|
||||||
|
background: #2d4a6e;
|
||||||
|
}
|
||||||
|
|
||||||
/* ─── Popover animation ─── */
|
/* ─── Popover animation ─── */
|
||||||
.popover-fade-enter-active,
|
.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>
|
<p class="mb-0 text-sm">Associez un contact à un client existant</p>
|
||||||
|
|
||||||
<transition name="banner-fade">
|
<transition name="banner-fade">
|
||||||
<div v-if="props.success" class="alert alert-success text-white mt-3 mb-0 py-2" role="alert">
|
<div
|
||||||
<span class="text-sm"><strong>Contact créé avec succès !</strong> Il est maintenant lié au client.</span>
|
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>
|
</div>
|
||||||
</transition>
|
</transition>
|
||||||
|
|
||||||
<transition name="banner-fade">
|
<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>
|
<span class="text-sm">{{ fieldErrors.general }}</span>
|
||||||
</div>
|
</div>
|
||||||
</transition>
|
</transition>
|
||||||
@ -18,10 +29,15 @@
|
|||||||
<div class="multisteps-form__content">
|
<div class="multisteps-form__content">
|
||||||
<div class="row mt-3">
|
<div class="row mt-3">
|
||||||
<div class="col-12">
|
<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 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>
|
<i class="fas fa-search search-pfx"></i>
|
||||||
<input
|
<input
|
||||||
:value="searchQuery"
|
:value="searchQuery"
|
||||||
@ -32,12 +48,21 @@
|
|||||||
@blur="onInputBlur"
|
@blur="onInputBlur"
|
||||||
/>
|
/>
|
||||||
<span v-if="searchQuery" class="search-loader">
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<transition name="dropdown-pop">
|
<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
|
<button
|
||||||
v-for="client in props.searchResults"
|
v-for="client in props.searchResults"
|
||||||
:key="client.id"
|
:key="client.id"
|
||||||
@ -48,11 +73,21 @@
|
|||||||
<span class="dr-avatar">{{ (client.name || "?")[0] }}</span>
|
<span class="dr-avatar">{{ (client.name || "?")[0] }}</span>
|
||||||
<span class="dr-info">
|
<span class="dr-info">
|
||||||
<span class="dr-name">{{ client.name }}</span>
|
<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>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
Aucun résultat pour <strong>« {{ searchQuery }} »</strong>
|
||||||
</div>
|
</div>
|
||||||
</transition>
|
</transition>
|
||||||
@ -63,15 +98,27 @@
|
|||||||
<span class="sc-info">
|
<span class="sc-info">
|
||||||
<span class="sc-label">Client sélectionné</span>
|
<span class="sc-label">Client sélectionné</span>
|
||||||
<span class="sc-name">{{ selectedClient.name }}</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>
|
</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
|
Changer
|
||||||
</soft-button>
|
</soft-button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="fieldErrors.client_id" class="invalid-feedback d-block">{{ fieldErrors.client_id }}</div>
|
<div v-if="fieldErrors.client_id" class="invalid-feedback d-block">
|
||||||
<p class="text-xs text-secondary mb-0 mt-1">Le contact sera rattaché à ce client</p>
|
{{ fieldErrors.client_id }}
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-secondary mb-0 mt-1">
|
||||||
|
Le contact sera rattaché à ce client
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -85,7 +132,9 @@
|
|||||||
type="text"
|
type="text"
|
||||||
placeholder="Jean"
|
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>
|
||||||
<div class="col-12 col-sm-6 mt-3 mt-sm-0">
|
<div class="col-12 col-sm-6 mt-3 mt-sm-0">
|
||||||
<label class="form-label">Nom</label>
|
<label class="form-label">Nom</label>
|
||||||
@ -96,7 +145,9 @@
|
|||||||
type="text"
|
type="text"
|
||||||
placeholder="Dupont"
|
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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -110,7 +161,9 @@
|
|||||||
type="text"
|
type="text"
|
||||||
placeholder="ex. Directeur Commercial, Responsable RH..."
|
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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -124,7 +177,9 @@
|
|||||||
type="email"
|
type="email"
|
||||||
placeholder="jean.dupont@entreprise.com"
|
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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -138,7 +193,9 @@
|
|||||||
type="text"
|
type="text"
|
||||||
placeholder="+33 1 23 45 67 89"
|
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>
|
||||||
<div class="col-12 col-sm-6 mt-3 mt-sm-0">
|
<div class="col-12 col-sm-6 mt-3 mt-sm-0">
|
||||||
<label class="form-label">Mobile</label>
|
<label class="form-label">Mobile</label>
|
||||||
@ -149,7 +206,9 @@
|
|||||||
type="text"
|
type="text"
|
||||||
placeholder="+33 6 12 34 56 78"
|
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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -164,18 +223,32 @@
|
|||||||
maxlength="1000"
|
maxlength="1000"
|
||||||
@input="form.notes = $event.target.value"
|
@input="form.notes = $event.target.value"
|
||||||
></textarea>
|
></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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<transition name="banner-fade">
|
<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">
|
<div
|
||||||
<span class="text-sm">Renseignez au moins un champ parmi : prénom, nom, email ou téléphone.</span>
|
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>
|
</div>
|
||||||
</transition>
|
</transition>
|
||||||
|
|
||||||
<div class="d-flex mt-4 justify-content-between gap-2 flex-wrap">
|
<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
|
Réinitialiser
|
||||||
</soft-button>
|
</soft-button>
|
||||||
<soft-button
|
<soft-button
|
||||||
@ -185,8 +258,14 @@
|
|||||||
:disabled="props.loading || !isFormValid"
|
:disabled="props.loading || !isFormValid"
|
||||||
@click="submitForm"
|
@click="submitForm"
|
||||||
>
|
>
|
||||||
<span v-if="props.loading" class="spinner-border spinner-border-sm me-2" role="status"></span>
|
<span
|
||||||
<span>{{ props.loading ? "Création en cours…" : "Créer le contact" }}</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>
|
</soft-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -214,26 +293,50 @@ const showDropdown = ref(false);
|
|||||||
const searchTimeout = ref(null);
|
const searchTimeout = ref(null);
|
||||||
|
|
||||||
const form = ref({
|
const form = ref({
|
||||||
client_id: null, first_name: "", last_name: "",
|
client_id: null,
|
||||||
email: "", phone: "", mobile: "", role: "", notes: "",
|
first_name: "",
|
||||||
|
last_name: "",
|
||||||
|
email: "",
|
||||||
|
phone: "",
|
||||||
|
mobile: "",
|
||||||
|
role: "",
|
||||||
|
notes: "",
|
||||||
});
|
});
|
||||||
|
|
||||||
const showValidationWarning = computed(() =>
|
const showValidationWarning = computed(
|
||||||
!form.value.first_name && !form.value.last_name &&
|
() =>
|
||||||
!form.value.email && !form.value.phone && !form.value.mobile
|
!form.value.first_name &&
|
||||||
|
!form.value.last_name &&
|
||||||
|
!form.value.email &&
|
||||||
|
!form.value.phone &&
|
||||||
|
!form.value.mobile
|
||||||
);
|
);
|
||||||
|
|
||||||
const isFormValid = computed(() =>
|
const isFormValid = computed(
|
||||||
!!form.value.client_id && !showValidationWarning.value
|
() => !!form.value.client_id && !showValidationWarning.value
|
||||||
);
|
);
|
||||||
|
|
||||||
watch(() => props.validationErrors, (v) => { fieldErrors.value = { ...v }; }, { deep: true });
|
watch(
|
||||||
watch(() => props.success, (v) => { if (v) resetForm(); });
|
() => props.validationErrors,
|
||||||
|
(v) => {
|
||||||
|
fieldErrors.value = { ...v };
|
||||||
|
},
|
||||||
|
{ deep: true }
|
||||||
|
);
|
||||||
|
watch(
|
||||||
|
() => props.success,
|
||||||
|
(v) => {
|
||||||
|
if (v) resetForm();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
const handleSearchInput = (value) => {
|
const handleSearchInput = (value) => {
|
||||||
searchQuery.value = value;
|
searchQuery.value = value;
|
||||||
if (searchTimeout.value) clearTimeout(searchTimeout.value);
|
if (searchTimeout.value) clearTimeout(searchTimeout.value);
|
||||||
if (!value.trim()) { showDropdown.value = false; return; }
|
if (!value.trim()) {
|
||||||
|
showDropdown.value = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
showDropdown.value = true;
|
showDropdown.value = true;
|
||||||
searchTimeout.value = setTimeout(() => emit("searchClient", value), 300);
|
searchTimeout.value = setTimeout(() => emit("searchClient", value), 300);
|
||||||
};
|
};
|
||||||
@ -255,22 +358,31 @@ const clearSelection = () => {
|
|||||||
emit("clientSelected", null);
|
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 isValidEmail = (e) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(e);
|
||||||
|
|
||||||
const validateForm = () => {
|
const validateForm = () => {
|
||||||
const errs = {};
|
const errs = {};
|
||||||
if (!form.value.client_id) errs.client_id = "Le client est obligatoire.";
|
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 (form.value.email && !isValidEmail(form.value.email))
|
||||||
if (showValidationWarning.value) errs.general = "Au moins un champ (prénom, nom, email ou téléphone) doit être renseigné.";
|
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;
|
return errs;
|
||||||
};
|
};
|
||||||
|
|
||||||
const submitForm = () => {
|
const submitForm = () => {
|
||||||
fieldErrors.value = {};
|
fieldErrors.value = {};
|
||||||
const localErrors = validateForm();
|
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(
|
const cleaned = Object.fromEntries(
|
||||||
Object.entries(form.value).map(([k, v]) => [k, v === "" ? null : v])
|
Object.entries(form.value).map(([k, v]) => [k, v === "" ? null : v])
|
||||||
);
|
);
|
||||||
@ -278,7 +390,16 @@ const submitForm = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const resetForm = () => {
|
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;
|
selectedClient.value = null;
|
||||||
searchQuery.value = "";
|
searchQuery.value = "";
|
||||||
showDropdown.value = false;
|
showDropdown.value = false;
|
||||||
@ -299,9 +420,11 @@ const resetForm = () => {
|
|||||||
}
|
}
|
||||||
.search-wrap:focus-within {
|
.search-wrap:focus-within {
|
||||||
border-color: #8392ab;
|
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 {
|
.search-pfx {
|
||||||
padding: 0 0.75rem;
|
padding: 0 0.75rem;
|
||||||
@ -317,9 +440,14 @@ const resetForm = () => {
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
outline: none;
|
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 {
|
.search-dropdown {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@ -329,15 +457,20 @@ const resetForm = () => {
|
|||||||
background: #fff;
|
background: #fff;
|
||||||
border: 1px solid #e9ecef;
|
border: 1px solid #e9ecef;
|
||||||
border-radius: 0.5rem;
|
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;
|
z-index: 1050;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
max-height: 260px;
|
max-height: 260px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-dropdown::-webkit-scrollbar { width: 4px; }
|
.search-dropdown::-webkit-scrollbar {
|
||||||
.search-dropdown::-webkit-scrollbar-thumb { background: #e2e8f0; border-radius: 4px; }
|
width: 4px;
|
||||||
|
}
|
||||||
|
.search-dropdown::-webkit-scrollbar-thumb {
|
||||||
|
background: #e2e8f0;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
.dropdown-row {
|
.dropdown-row {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@ -352,8 +485,12 @@ const resetForm = () => {
|
|||||||
border-bottom: 1px solid #f3f4f6;
|
border-bottom: 1px solid #f3f4f6;
|
||||||
transition: background 0.15s;
|
transition: background 0.15s;
|
||||||
}
|
}
|
||||||
.dropdown-row:last-child { border-bottom: none; }
|
.dropdown-row:last-child {
|
||||||
.dropdown-row:hover { background: #f5f8ff; }
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
.dropdown-row:hover {
|
||||||
|
background: #f5f8ff;
|
||||||
|
}
|
||||||
|
|
||||||
.dr-avatar {
|
.dr-avatar {
|
||||||
width: 2rem;
|
width: 2rem;
|
||||||
@ -452,12 +589,28 @@ const resetForm = () => {
|
|||||||
|
|
||||||
/* ─── Animations ─── */
|
/* ─── Animations ─── */
|
||||||
.banner-fade-enter-active,
|
.banner-fade-enter-active,
|
||||||
.banner-fade-leave-active { transition: opacity 0.22s ease, transform 0.22s ease; }
|
.banner-fade-leave-active {
|
||||||
.banner-fade-enter-from { opacity: 0; transform: translateY(-6px); }
|
transition: opacity 0.22s ease, transform 0.22s ease;
|
||||||
.banner-fade-leave-to { opacity: 0; transform: translateY(-4px); }
|
}
|
||||||
|
.banner-fade-enter-from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-6px);
|
||||||
|
}
|
||||||
|
.banner-fade-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-4px);
|
||||||
|
}
|
||||||
|
|
||||||
.dropdown-pop-enter-active,
|
.dropdown-pop-enter-active,
|
||||||
.dropdown-pop-leave-active { transition: opacity 0.18s ease, transform 0.18s ease; }
|
.dropdown-pop-leave-active {
|
||||||
.dropdown-pop-enter-from { opacity: 0; transform: translateY(-8px) scale(0.97); }
|
transition: opacity 0.18s ease, transform 0.18s ease;
|
||||||
.dropdown-pop-leave-to { opacity: 0; transform: translateY(-4px) scale(0.98); }
|
}
|
||||||
|
.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>
|
</style>
|
||||||
|
|||||||
@ -4,14 +4,20 @@
|
|||||||
<div class="col-lg-10 mx-auto">
|
<div class="col-lg-10 mx-auto">
|
||||||
<div class="card mb-4">
|
<div class="card mb-4">
|
||||||
<div class="card-header p-3 pb-0">
|
<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="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>
|
<i class="fas fa-file-invoice text-white text-sm"></i>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h6 class="mb-0">Nouveau Devis</h6>
|
<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>
|
</div>
|
||||||
<div class="d-flex flex-wrap align-items-center gap-2">
|
<div class="d-flex flex-wrap align-items-center gap-2">
|
||||||
@ -25,7 +31,9 @@
|
|||||||
|
|
||||||
<div class="row g-3">
|
<div class="row g-3">
|
||||||
<div class="col-lg-6 col-md-6 col-12">
|
<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">
|
<div class="d-flex align-items-center mb-3">
|
||||||
<i class="fas fa-user-circle me-2 text-primary"></i>
|
<i class="fas fa-user-circle me-2 text-primary"></i>
|
||||||
<h6 class="mb-0">Client</h6>
|
<h6 class="mb-0">Client</h6>
|
||||||
@ -35,7 +43,9 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-lg-6 col-md-6 col-12">
|
<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">
|
<div class="d-flex align-items-center mb-3">
|
||||||
<i class="fas fa-calendar-alt me-2 text-primary"></i>
|
<i class="fas fa-calendar-alt me-2 text-primary"></i>
|
||||||
<h6 class="mb-0">Informations</h6>
|
<h6 class="mb-0">Informations</h6>
|
||||||
@ -49,7 +59,9 @@
|
|||||||
|
|
||||||
<div class="row g-3">
|
<div class="row g-3">
|
||||||
<div class="col-lg-8 col-12">
|
<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">
|
<div class="d-flex align-items-center mb-3">
|
||||||
<i class="fas fa-boxes me-2 text-primary"></i>
|
<i class="fas fa-boxes me-2 text-primary"></i>
|
||||||
<h6 class="mb-0">Produits & Services</h6>
|
<h6 class="mb-0">Produits & Services</h6>
|
||||||
@ -59,7 +71,9 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-lg-4 col-12">
|
<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">
|
<div class="d-flex align-items-center mb-3">
|
||||||
<i class="fas fa-calculator me-2 text-primary"></i>
|
<i class="fas fa-calculator me-2 text-primary"></i>
|
||||||
<h6 class="mb-0">Récapitulatif</h6>
|
<h6 class="mb-0">Récapitulatif</h6>
|
||||||
|
|||||||
@ -93,7 +93,8 @@ const practitioners = computed(
|
|||||||
const collaborators = computed(() =>
|
const collaborators = computed(() =>
|
||||||
practitioners.value.map((p) => ({
|
practitioners.value.map((p) => ({
|
||||||
id: p.id,
|
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}`,
|
`Collaborateur #${p.id}`,
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
@ -117,7 +118,9 @@ const uiToBackendStatus = {
|
|||||||
|
|
||||||
const mapInterventionToPlanning = (item) => {
|
const mapInterventionToPlanning = (item) => {
|
||||||
const practitioner =
|
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 || ""} ${
|
const collaborator = `${practitioner?.employee?.first_name || ""} ${
|
||||||
practitioner?.employee?.last_name || ""
|
practitioner?.employee?.last_name || ""
|
||||||
}`.trim();
|
}`.trim();
|
||||||
@ -127,10 +130,16 @@ const mapInterventionToPlanning = (item) => {
|
|||||||
date: item.scheduled_at || item.date || item.created_at,
|
date: item.scheduled_at || item.date || item.created_at,
|
||||||
end: item.estimated_end_at || item.end,
|
end: item.estimated_end_at || item.end,
|
||||||
type:
|
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:
|
deceased:
|
||||||
item.deceased_name ||
|
item.deceased_name ||
|
||||||
`${item.deceased?.first_name || ""} ${item.deceased?.last_name || ""}`.trim() ||
|
`${item.deceased?.first_name || ""} ${
|
||||||
|
item.deceased?.last_name || ""
|
||||||
|
}`.trim() ||
|
||||||
"Non spécifié",
|
"Non spécifié",
|
||||||
client: item.client_name || item.client?.name || "-",
|
client: item.client_name || item.client?.name || "-",
|
||||||
collaborator: collaborator || "-",
|
collaborator: collaborator || "-",
|
||||||
@ -149,9 +158,7 @@ const weekRange = computed(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const apiInterventions = computed(() =>
|
const apiInterventions = computed(() =>
|
||||||
Object.values(monthBuckets.value)
|
Object.values(monthBuckets.value).flat().map(mapInterventionToPlanning)
|
||||||
.flat()
|
|
||||||
.map(mapInterventionToPlanning)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const interventions = computed(() => {
|
const interventions = computed(() => {
|
||||||
@ -167,11 +174,15 @@ const interventions = computed(() => {
|
|||||||
|
|
||||||
// Lifecycle
|
// Lifecycle
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await Promise.all([fetchData(), thanatopractitionerStore.fetchThanatopractitioners()]);
|
await Promise.all([
|
||||||
|
fetchData(),
|
||||||
|
thanatopractitionerStore.fetchThanatopractitioners(),
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Methods
|
// 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) => {
|
const bumpMonthVersion = (key) => {
|
||||||
monthVersions.value[key] = (monthVersions.value[key] || 0) + 1;
|
monthVersions.value[key] = (monthVersions.value[key] || 0) + 1;
|
||||||
@ -192,7 +203,10 @@ const fetchMonth = async (date, force = false) => {
|
|||||||
|
|
||||||
const year = date.getFullYear();
|
const year = date.getFullYear();
|
||||||
const month = date.getMonth() + 1;
|
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
|
// Guard against stale async responses overriding newer local/server updates
|
||||||
if (monthRequestIds.value[key] !== requestId) return;
|
if (monthRequestIds.value[key] !== requestId) return;
|
||||||
@ -349,8 +363,13 @@ const handleUpdateStatus = async (payload) => {
|
|||||||
const status = uiToBackendStatus[payload.status] || payload.status;
|
const status = uiToBackendStatus[payload.status] || payload.status;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const updated = await interventionStore.updateInterventionStatus(interventionId, status);
|
const updated = await interventionStore.updateInterventionStatus(
|
||||||
const monthKey = getMonthKey(new Date(updated.scheduled_at || updated.date || currentDate.value));
|
interventionId,
|
||||||
|
status
|
||||||
|
);
|
||||||
|
const monthKey = getMonthKey(
|
||||||
|
new Date(updated.scheduled_at || updated.date || currentDate.value)
|
||||||
|
);
|
||||||
const monthItems = monthBuckets.value[monthKey] || [];
|
const monthItems = monthBuckets.value[monthKey] || [];
|
||||||
const idx = monthItems.findIndex((i) => i.id === interventionId);
|
const idx = monthItems.findIndex((i) => i.id === interventionId);
|
||||||
if (idx !== -1) {
|
if (idx !== -1) {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user