Feat: redesing form on new

This commit is contained in:
nyavokevin 2026-03-24 14:19:49 +03:00
parent ebd171e9de
commit dd6fc4665c
27 changed files with 5959 additions and 2946 deletions

View File

@ -82,7 +82,10 @@ class InterventionRepository implements InterventionRepositoryInterface
'practitioners',
'attachments',
'notifications',
'quote'
'quote',
'quote.client',
'quote.lines',
'quote.history'
])->findOrFail($id);
}

View File

@ -1,7 +1,7 @@
<template>
<div class="container-fluid py-4">
<div class="container-fluid py-4 new-commande-page">
<div class="row">
<div class="col-lg-8 mx-auto">
<div class="col-12 col-xl-11 mx-auto">
<div class="card">
<div class="card-header pb-0 p-3">
<div class="d-flex justify-content-between align-items-center">
@ -17,7 +17,11 @@
</div>
</div>
<div class="card-body p-3">
<new-commande-form @submit="handleSubmit" />
<new-commande-form
@submit="handleSubmit"
@created="handleCreated"
@cancel="goBack"
/>
</div>
</div>
</div>
@ -37,6 +41,16 @@ const goBack = () => {
};
const handleSubmit = (formData) => {
return formData;
};
const handleCreated = () => {
router.push("/fournisseurs/commandes");
};
</script>
<style scoped>
.new-commande-page {
background: #f8f9fe;
}
</style>

View File

@ -269,26 +269,55 @@
<div v-if="activeTab === 'quote'" class="tab-pane fade show active">
<div class="card">
<div class="card-header pb-0">
<div class="d-flex align-items-center">
<div
class="d-flex align-items-center justify-content-between flex-wrap gap-2"
>
<h6 class="mb-0">Détails du devis</h6>
<router-link
v-if="intervention.quote?.id"
:to="`/ventes/devis/${intervention.quote.id}`"
class="btn btn-sm btn-outline-success"
>
Ouvrir le devis
</router-link>
</div>
</div>
<div class="card-body">
<div v-if="intervention.quote" class="row mt-4">
<div class="col-md-6 mb-3">
<InfoCard title="Informations" icon="fas fa-file-invoice text-info">
<InfoCard
title="Informations"
icon="fas fa-file-invoice text-info"
>
<ul class="list-group list-group-flush">
<li class="list-group-item mx-0 px-0">
<strong class="text-dark">Référence:</strong>
<span class="ms-2">{{ intervention.quote.reference || '-' }}</span>
<span class="ms-2">{{
intervention.quote.reference || "-"
}}</span>
</li>
<li class="list-group-item mx-0 px-0">
<strong class="text-dark">Date:</strong>
<span class="ms-2">{{ intervention.quote.quote_date || '-' }}</span>
<span class="ms-2">{{
intervention.quote.quote_date || "-"
}}</span>
</li>
<li class="list-group-item mx-0 px-0">
<strong class="text-dark">Validité:</strong>
<span class="ms-2">{{
intervention.quote.valid_until || "-"
}}</span>
</li>
<li class="list-group-item mx-0 px-0">
<strong class="text-dark">Statut:</strong>
<span class="ms-2 badge badge-sm bg-gradient-success">{{ intervention.quote.status || '-' }}</span>
<span
class="ms-2 badge badge-sm"
:class="`bg-gradient-${getQuoteStatusColor(
intervention.quote.status
)}`"
>
{{ getQuoteStatusLabel(intervention.quote.status) }}
</span>
</li>
</ul>
</InfoCard>
@ -296,25 +325,76 @@
<div class="col-md-6 mb-3">
<InfoCard title="Montants" icon="fas fa-euro-sign text-success">
<ul class="list-group list-group-flush">
<li class="list-group-item mx-0 px-0 d-flex justify-content-between">
<li
class="list-group-item mx-0 px-0 d-flex justify-content-between"
>
<strong class="text-dark">Total HT:</strong>
<span>{{ intervention.quote.total_ht || '0.00' }} </span>
<span>{{
formatCurrency(intervention.quote.total_ht)
}}</span>
</li>
<li class="list-group-item mx-0 px-0 d-flex justify-content-between">
<li
class="list-group-item mx-0 px-0 d-flex justify-content-between"
>
<strong class="text-dark">Total TVA:</strong>
<span>{{ intervention.quote.total_tva || '0.00' }} </span>
<span>{{
formatCurrency(intervention.quote.total_tva)
}}</span>
</li>
<li class="list-group-item mx-0 px-0 d-flex justify-content-between border-0">
<li
class="list-group-item mx-0 px-0 d-flex justify-content-between border-0"
>
<strong class="text-dark">Total TTC:</strong>
<span class="fw-bold">{{ intervention.quote.total_ttc || '0.00' }} </span>
<span class="fw-bold">{{
formatCurrency(intervention.quote.total_ttc)
}}</span>
</li>
</ul>
</InfoCard>
</div>
<div class="col-12">
<InfoCard title="Lignes du devis" icon="fas fa-list text-dark">
<div
v-if="intervention.quote.lines?.length"
class="table-responsive"
>
<table class="table table-sm align-middle mb-0">
<thead>
<tr>
<th>Description</th>
<th class="text-center">Qté</th>
<th class="text-end">Prix unitaire</th>
<th class="text-end">Total HT</th>
</tr>
</thead>
<tbody>
<tr
v-for="line in intervention.quote.lines"
:key="line.id"
>
<td>{{ line.description || "-" }}</td>
<td class="text-center">{{ line.units_qty || 0 }}</td>
<td class="text-end">
{{ formatCurrency(line.unit_price) }}
</td>
<td class="text-end">
{{ formatCurrency(line.total_ht) }}
</td>
</tr>
</tbody>
</table>
</div>
<p v-else class="text-sm text-muted mb-0">
Aucune ligne de devis disponible.
</p>
</InfoCard>
</div>
</div>
<div v-else class="text-center py-5">
<div class="avatar avatar-xl mb-3">
<div class="avatar-title bg-gradient-secondary text-white h5 mb-0">
<div
class="avatar-title bg-gradient-secondary text-white h5 mb-0"
>
<i class="fas fa-file-invoice"></i>
</div>
</div>
@ -364,12 +444,45 @@
</template>
<script setup>
import { RouterLink } from "vue-router";
import InfoCard from "@/components/atoms/client/InfoCard.vue";
import InterventionDetails from "@/components/molecules/Interventions/interventionDetails.vue";
import DocumentManagement from "@/components/molecules/Interventions/DocumentManagement.vue";
import { useDocumentAttachmentStore } from "@/stores/documentAttachmentStore";
import { defineProps, defineEmits, computed, watch, onMounted } from "vue";
const getQuoteStatusLabel = (status) => {
const statusLabels = {
brouillon: "Brouillon",
envoye: "Envoyé",
accepte: "Accepté",
refuse: "Refusé",
expire: "Expiré",
};
return statusLabels[status] || status || "Inconnu";
};
const getQuoteStatusColor = (status) => {
const statusColors = {
brouillon: "secondary",
envoye: "info",
accepte: "success",
refuse: "danger",
expire: "warning",
};
return statusColors[status] || "secondary";
};
const formatCurrency = (value) => {
return new Intl.NumberFormat("fr-FR", {
style: "currency",
currency: "EUR",
minimumFractionDigits: 2,
}).format(Number(value || 0));
};
const props = defineProps({
activeTab: {
type: String,

View File

@ -17,7 +17,9 @@
<!-- Content -->
<invoice-detail-template v-else-if="invoice">
<template #header>
<div class="d-flex justify-content-between align-items-start flex-wrap gap-3">
<div
class="d-flex justify-content-between align-items-start flex-wrap gap-3"
>
<div>
<h6 class="mb-1">Détails Facture</h6>
<p class="text-sm mb-0">
@ -33,7 +35,10 @@
</div>
<div class="d-flex align-items-center flex-wrap gap-2">
<soft-badge :color="statusBadgeColor(invoice.status)" variant="gradient">
<soft-badge
:color="statusBadgeColor(invoice.status)"
variant="gradient"
>
<i :class="statusIcon(invoice.status) + ' me-1'"></i>
{{ getStatusLabel(invoice.status) }}
</soft-badge>
@ -50,9 +55,17 @@
<i class="fas fa-file-invoice-dollar"></i>
</div>
<div>
<h6 class="text-lg mb-0 mt-1">{{ invoice.client?.name || "Client inconnu" }}</h6>
<p class="text-sm mb-3">{{ invoice.lines?.length || 0 }} ligne(s) dans cette facture.</p>
<soft-badge :color="statusBadgeColor(invoice.status)" variant="gradient" size="sm">
<h6 class="text-lg mb-0 mt-1">
{{ invoice.client?.name || "Client inconnu" }}
</h6>
<p class="text-sm mb-3">
{{ invoice.lines?.length || 0 }} ligne(s) dans cette facture.
</p>
<soft-badge
:color="statusBadgeColor(invoice.status)"
variant="gradient"
size="sm"
>
{{ getStatusLabel(invoice.status) }}
</soft-badge>
</div>
@ -91,20 +104,30 @@
<h6 class="mb-3 mt-4">Informations Client</h6>
<ul class="list-group">
<li class="list-group-item border-0 d-flex p-4 mb-2 bg-gray-100 border-radius-lg">
<li
class="list-group-item border-0 d-flex p-4 mb-2 bg-gray-100 border-radius-lg"
>
<div class="d-flex flex-column">
<h6 class="mb-3 text-sm">{{ invoice.client?.name || "Client inconnu" }}</h6>
<h6 class="mb-3 text-sm">
{{ invoice.client?.name || "Client inconnu" }}
</h6>
<span class="mb-2 text-xs">
Adresse email :
<span class="text-dark ms-2 font-weight-bold">{{ invoice.client?.email || "—" }}</span>
<span class="text-dark ms-2 font-weight-bold">{{
invoice.client?.email || "—"
}}</span>
</span>
<span class="mb-2 text-xs">
Téléphone :
<span class="text-dark ms-2 font-weight-bold">{{ invoice.client?.phone || "—" }}</span>
<span class="text-dark ms-2 font-weight-bold">{{
invoice.client?.phone || "—"
}}</span>
</span>
<span class="text-xs">
Référence facture :
<span class="text-dark ms-2 font-weight-bold">{{ invoice.invoice_number || "—" }}</span>
<span class="text-dark ms-2 font-weight-bold">{{
invoice.invoice_number || "—"
}}</span>
</span>
</div>
</li>
@ -115,15 +138,21 @@
<h6 class="mb-3">Résumé Facture</h6>
<div class="d-flex justify-content-between">
<span class="mb-2 text-sm">Total HT :</span>
<span class="text-dark font-weight-bold ms-2">{{ formatCurrency(invoice.total_ht) }}</span>
<span class="text-dark font-weight-bold ms-2">{{
formatCurrency(invoice.total_ht)
}}</span>
</div>
<div class="d-flex justify-content-between">
<span class="mb-2 text-sm">TVA :</span>
<span class="text-dark ms-2 font-weight-bold">{{ formatCurrency(invoice.total_tva) }}</span>
<span class="text-dark ms-2 font-weight-bold">{{
formatCurrency(invoice.total_tva)
}}</span>
</div>
<div class="d-flex justify-content-between mt-4">
<span class="mb-2 text-lg">Total TTC :</span>
<span class="text-dark text-lg ms-2 font-weight-bold">{{ formatCurrency(invoice.total_ttc) }}</span>
<span class="text-dark text-lg ms-2 font-weight-bold">{{
formatCurrency(invoice.total_ttc)
}}</span>
</div>
</template>
</invoice-detail-template>
@ -266,7 +295,11 @@ const changeStatus = (id, newStatus) => {
})
.catch((e) => {
console.error(e);
notificationStore.error("Erreur", "Impossible de mettre à jour le statut", 3000);
notificationStore.error(
"Erreur",
"Impossible de mettre à jour le statut",
3000
);
})
.finally(() => {
updating.value = false;
@ -283,8 +316,8 @@ const changeStatus = (id, newStatus) => {
justify-content: center;
min-height: 60vh;
color: #8898aa;
font-size: .9rem;
gap: .6rem;
font-size: 0.9rem;
gap: 0.6rem;
}
.detail-state--error i {
@ -298,24 +331,30 @@ const changeStatus = (id, newStatus) => {
border: 3px solid #e9ecef;
border-top-color: #5e72e4;
border-radius: 50%;
animation: spin .8s linear infinite;
animation: spin 0.8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.btn-retry {
margin-top: .25rem;
padding: .4rem 1.1rem;
margin-top: 0.25rem;
padding: 0.4rem 1.1rem;
border-radius: 8px;
border: 1.5px solid #f5365c;
background: none;
color: #f5365c;
font-size: .8rem;
font-size: 0.8rem;
font-weight: 700;
cursor: pointer;
transition: background .15s;
transition: background 0.15s;
}
.btn-retry:hover {
background: #fff0f3;
}
.btn-retry:hover { background: #fff0f3; }
.inv-visual {
width: 64px;

View File

@ -2,10 +2,20 @@
<create-quote-template>
<!-- Actions -->
<template #actions>
<soft-button color="secondary" variant="outline" class="me-2" @click="cancel">
<soft-button
color="secondary"
variant="outline"
class="me-2"
@click="cancel"
>
<i class="fas fa-times me-1"></i> Annuler
</soft-button>
<soft-button color="primary" variant="gradient" :disabled="loading" @click="saveQuote">
<soft-button
color="primary"
variant="gradient"
:disabled="loading"
@click="saveQuote"
>
<i class="fas fa-save me-1"></i>
{{ loading ? "Enregistrement..." : "Enregistrer le devis" }}
</soft-button>
@ -14,7 +24,9 @@
<!-- Client Selection -->
<template #client-selection>
<div class="field-group">
<label class="field-label">Client <span class="text-danger">*</span></label>
<label class="field-label"
>Client <span class="text-danger">*</span></label
>
<select v-model="form.client_id" class="form-select field-select">
<option value="" disabled> Sélectionner un client </option>
<option v-for="client in clients" :key="client.id" :value="client.id">
@ -62,11 +74,7 @@
<!-- Lines -->
<transition-group name="line-fade" tag="div">
<div
v-for="(line, index) in form.lines"
:key="index"
class="line-row"
>
<div v-for="(line, index) in form.lines" :key="index" class="line-row">
<product-line-item
v-model="form.lines[index]"
@remove="removeLine(index)"
@ -82,7 +90,13 @@
<!-- Add Line -->
<div class="lines-footer">
<soft-button type="button" color="info" variant="outline" class="add-line-btn" @click="addLine">
<soft-button
type="button"
color="info"
variant="outline"
class="add-line-btn"
@click="addLine"
>
<i class="fas fa-plus-circle me-2"></i>Ajouter une ligne
</soft-button>
</div>
@ -101,7 +115,9 @@
</div>
<div class="totals-row totals-row--final">
<span class="totals-label">Total TTC</span>
<span class="totals-value totals-value--final">{{ formatCurrency(totals.ttc) }}</span>
<span class="totals-value totals-value--final">{{
formatCurrency(totals.ttc)
}}</span>
</div>
</div>
</template>
@ -128,9 +144,24 @@ const loading = ref(false);
const attempted = ref(false);
const statuses = [
{ value: "brouillon", label: "Brouillon", icon: "fas fa-pencil-alt", color: "warning" },
{ value: "envoye", label: "Envoyé", icon: "fas fa-paper-plane", color: "info" },
{ value: "accepte", label: "Accepté", icon: "fas fa-check", color: "success" },
{
value: "brouillon",
label: "Brouillon",
icon: "fas fa-pencil-alt",
color: "warning",
},
{
value: "envoye",
label: "Envoyé",
icon: "fas fa-paper-plane",
color: "info",
},
{
value: "accepte",
label: "Accepté",
icon: "fas fa-check",
color: "success",
},
];
const defaultLine = () => ({
@ -155,7 +186,8 @@ const totals = computed(() => {
let ht = 0;
let tva = 0;
form.value.lines.forEach((line) => {
const afterDiscount = line.quantity * line.unit_price * (1 - (line.discount_pct || 0) / 100);
const afterDiscount =
line.quantity * line.unit_price * (1 - (line.discount_pct || 0) / 100);
ht += afterDiscount;
tva += afterDiscount * (line.tva / 100);
});
@ -166,7 +198,9 @@ const addLine = () => form.value.lines.push(defaultLine());
const removeLine = (index) => form.value.lines.splice(index, 1);
const formatCurrency = (value) =>
new Intl.NumberFormat("fr-FR", { style: "currency", currency: "EUR" }).format(value);
new Intl.NumberFormat("fr-FR", { style: "currency", currency: "EUR" }).format(
value
);
const saveQuote = async () => {
attempted.value = true;
@ -186,7 +220,10 @@ const saveQuote = async () => {
lines: form.value.lines.map((line) => ({
...line,
discount_pct: line.discount_pct || 0,
total_ht: line.quantity * line.unit_price * (1 - (line.discount_pct || 0) / 100),
total_ht:
line.quantity *
line.unit_price *
(1 - (line.discount_pct || 0) / 100),
description: line.product_name || "Produit sans nom",
})),
});
@ -245,13 +282,32 @@ onMounted(() => clientStore.fetchClients());
color: #adb5bd;
}
.line-col--product { flex: 3; }
.line-col--qty { flex: 1; text-align: center; }
.line-col--price { flex: 1.5; text-align: right; }
.line-col--tva { flex: 1; text-align: center; }
.line-col--discount{ flex: 1; text-align: center; }
.line-col--total { flex: 1.5; text-align: right; }
.line-col--action { width: 36px; }
.line-col--product {
flex: 3;
}
.line-col--qty {
flex: 1;
text-align: center;
}
.line-col--price {
flex: 1.5;
text-align: right;
}
.line-col--tva {
flex: 1;
text-align: center;
}
.line-col--discount {
flex: 1;
text-align: center;
}
.line-col--total {
flex: 1.5;
text-align: right;
}
.line-col--action {
width: 36px;
}
/* ── Line Row ── */
.line-row {

View File

@ -17,7 +17,9 @@
<!-- Content -->
<quote-detail-template v-else-if="quote">
<template #header>
<div class="d-flex justify-content-between align-items-start flex-wrap gap-3">
<div
class="d-flex justify-content-between align-items-start flex-wrap gap-3"
>
<div>
<h6 class="mb-1">Quote Details</h6>
<p class="text-sm mb-0">
@ -33,7 +35,10 @@
</div>
<div class="d-flex align-items-center flex-wrap gap-2">
<soft-badge :color="statusBadgeColor(quote.status)" variant="gradient">
<soft-badge
:color="statusBadgeColor(quote.status)"
variant="gradient"
>
<i :class="statusIcon(quote.status) + ' me-1'"></i>
{{ getStatusLabel(quote.status) }}
</soft-badge>
@ -50,9 +55,17 @@
<i class="fas fa-file-signature"></i>
</div>
<div>
<h6 class="text-lg mb-0 mt-1">{{ quote.client?.name || "Client inconnu" }}</h6>
<p class="text-sm mb-3">{{ quote.lines?.length || 0 }} ligne(s) dans ce devis.</p>
<soft-badge :color="statusBadgeColor(quote.status)" variant="gradient" size="sm">
<h6 class="text-lg mb-0 mt-1">
{{ quote.client?.name || "Client inconnu" }}
</h6>
<p class="text-sm mb-3">
{{ quote.lines?.length || 0 }} ligne(s) dans ce devis.
</p>
<soft-badge
:color="statusBadgeColor(quote.status)"
variant="gradient"
size="sm"
>
{{ getStatusLabel(quote.status) }}
</soft-badge>
</div>
@ -91,20 +104,30 @@
<h6 class="mb-3 mt-4">Billing Information</h6>
<ul class="list-group">
<li class="list-group-item border-0 d-flex p-4 mb-2 bg-gray-100 border-radius-lg">
<li
class="list-group-item border-0 d-flex p-4 mb-2 bg-gray-100 border-radius-lg"
>
<div class="d-flex flex-column">
<h6 class="mb-3 text-sm">{{ quote.client?.name || "Client inconnu" }}</h6>
<h6 class="mb-3 text-sm">
{{ quote.client?.name || "Client inconnu" }}
</h6>
<span class="mb-2 text-xs">
Email Address:
<span class="text-dark ms-2 font-weight-bold">{{ quote.client?.email || "—" }}</span>
<span class="text-dark ms-2 font-weight-bold">{{
quote.client?.email || "—"
}}</span>
</span>
<span class="mb-2 text-xs">
Phone:
<span class="text-dark ms-2 font-weight-bold">{{ quote.client?.phone || "—" }}</span>
<span class="text-dark ms-2 font-weight-bold">{{
quote.client?.phone || "—"
}}</span>
</span>
<span class="text-xs">
Quote Reference:
<span class="text-dark ms-2 font-weight-bold">{{ quote.reference || "—" }}</span>
<span class="text-dark ms-2 font-weight-bold">{{
quote.reference || "—"
}}</span>
</span>
</div>
</li>
@ -115,15 +138,21 @@
<h6 class="mb-3">Quote Summary</h6>
<div class="d-flex justify-content-between">
<span class="mb-2 text-sm">Total HT:</span>
<span class="text-dark font-weight-bold ms-2">{{ formatCurrency(quote.total_ht) }}</span>
<span class="text-dark font-weight-bold ms-2">{{
formatCurrency(quote.total_ht)
}}</span>
</div>
<div class="d-flex justify-content-between">
<span class="mb-2 text-sm">TVA:</span>
<span class="text-dark ms-2 font-weight-bold">{{ formatCurrency(quote.total_tva) }}</span>
<span class="text-dark ms-2 font-weight-bold">{{
formatCurrency(quote.total_tva)
}}</span>
</div>
<div class="d-flex justify-content-between mt-4">
<span class="mb-2 text-lg">Total TTC:</span>
<span class="text-dark text-lg ms-2 font-weight-bold">{{ formatCurrency(quote.total_ttc) }}</span>
<span class="text-dark text-lg ms-2 font-weight-bold">{{
formatCurrency(quote.total_ttc)
}}</span>
</div>
</template>
</quote-detail-template>
@ -188,7 +217,14 @@ const formatCurrency = (value) => {
}).format(amount);
};
const availableStatuses = ["brouillon", "envoye", "accepte", "refuse", "expire", "annule"];
const availableStatuses = [
"brouillon",
"envoye",
"accepte",
"refuse",
"expire",
"annule",
];
const statusLabels = {
brouillon: "Brouillon",
@ -248,7 +284,11 @@ const changeStatus = (id, newStatus) => {
})
.catch((e) => {
console.error(e);
notificationStore.error("Erreur", "Impossible de mettre à jour le statut", 3000);
notificationStore.error(
"Erreur",
"Impossible de mettre à jour le statut",
3000
);
})
.finally(() => {
updating.value = false;
@ -265,8 +305,8 @@ const changeStatus = (id, newStatus) => {
justify-content: center;
min-height: 60vh;
color: #8898aa;
font-size: .9rem;
gap: .6rem;
font-size: 0.9rem;
gap: 0.6rem;
}
.detail-state--error i {
@ -280,24 +320,30 @@ const changeStatus = (id, newStatus) => {
border: 3px solid #e9ecef;
border-top-color: #5e72e4;
border-radius: 50%;
animation: spin .8s linear infinite;
animation: spin 0.8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.btn-retry {
margin-top: .25rem;
padding: .4rem 1.1rem;
margin-top: 0.25rem;
padding: 0.4rem 1.1rem;
border-radius: 8px;
border: 1.5px solid #f5365c;
background: none;
color: #f5365c;
font-size: .8rem;
font-size: 0.8rem;
font-weight: 700;
cursor: pointer;
transition: background .15s;
transition: background 0.15s;
}
.btn-retry:hover {
background: #fff0f3;
}
.btn-retry:hover { background: #fff0f3; }
.qd-visual {
width: 64px;

View File

@ -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>

View 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>

View File

@ -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>

View File

@ -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>

View File

@ -24,7 +24,10 @@
selectedItem.email || "Pas d'email"
}})
</div>
<div v-if="fieldErrors.client_id" class="invalid-feedback small-error">
<div
v-if="fieldErrors.client_id"
class="invalid-feedback small-error"
>
{{ fieldErrors.client_id }}
</div>
</div>
@ -47,7 +50,10 @@
Sélectionné: {{ selectedDeceased.last_name }}
{{ selectedDeceased.first_name || "" }}
</div>
<div v-if="fieldErrors.deceased_id" class="invalid-feedback small-error">
<div
v-if="fieldErrors.deceased_id"
class="invalid-feedback small-error"
>
{{ fieldErrors.deceased_id }}
</div>
</div>
@ -88,7 +94,10 @@
:class="{ 'is-invalid': fieldErrors.scheduled_at }"
type="date"
/>
<div v-if="fieldErrors.scheduled_at" class="invalid-feedback small-error">
<div
v-if="fieldErrors.scheduled_at"
class="invalid-feedback small-error"
>
{{ fieldErrors.scheduled_at }}
</div>
</div>
@ -100,7 +109,10 @@
:class="{ 'is-invalid': fieldErrors.scheduled_at }"
type="time"
/>
<div v-if="fieldErrors.scheduled_at" class="invalid-feedback small-error">
<div
v-if="fieldErrors.scheduled_at"
class="invalid-feedback small-error"
>
{{ fieldErrors.scheduled_at }}
</div>
</div>
@ -118,7 +130,10 @@
min="1"
placeholder="ex. 90"
/>
<div v-if="fieldErrors.duration_min" class="invalid-feedback small-error">
<div
v-if="fieldErrors.duration_min"
class="invalid-feedback small-error"
>
{{ fieldErrors.duration_min }}
</div>
</div>
@ -153,7 +168,10 @@
type="text"
placeholder="Nom du donneur d'ordre"
/>
<div v-if="fieldErrors.order_giver" class="invalid-feedback small-error">
<div
v-if="fieldErrors.order_giver"
class="invalid-feedback small-error"
>
{{ fieldErrors.order_giver }}
</div>
</div>

View File

@ -40,11 +40,7 @@ const props = defineProps({
// Use provided data if available, otherwise fall back to default
const interventions = computed(() => {
return props.interventionData.length > 0
? props.interventionData
: [
];
return props.interventionData.length > 0 ? props.interventionData : [];
});
</script>

View File

@ -1,27 +1,14 @@
<template>
<div class="d-grid gap-2">
<soft-button
color="info"
@click="$emit('select-type', 'intervention')"
>
<soft-button color="info" @click="$emit('select-type', 'intervention')">
Créer une intervention
</soft-button>
<soft-button
color="warning"
@click="$emit('select-type', 'leave')"
>
<soft-button color="warning" @click="$emit('select-type', 'leave')">
Demande de congé employé
</soft-button>
<soft-button
color="success"
@click="$emit('select-type', 'event')"
>
<soft-button color="success" @click="$emit('select-type', 'event')">
Créer un événement
</soft-button>
</div>

View File

@ -1,10 +1,9 @@
<template>
<div class="planning-kanban-root">
<!-- Toolbar -->
<div class="kanban-toolbar">
<div class="toolbar-left">
<div class="toolbar-stat" v-for="col in columnsConfig" :key="col.id">
<div v-for="col in columnsConfig" :key="col.id" class="toolbar-stat">
<span class="stat-dot" :style="{ background: col.color }"></span>
<span class="stat-label">{{ col.title }}</span>
<span class="stat-count">{{ countByStatus(col.status) }}</span>
@ -13,7 +12,9 @@
<div class="toolbar-right">
<span class="total-badge">
<i class="fas fa-list-ul me-1"></i>
{{ interventions.length }} intervention{{ interventions.length > 1 ? 's' : '' }}
{{ interventions.length }} intervention{{
interventions.length > 1 ? "s" : ""
}}
</span>
</div>
</div>
@ -21,7 +22,6 @@
<!-- Kanban Board -->
<div class="kanban-scroll-area">
<div class="kanban-columns">
<div
v-for="col in columnsConfig"
:key="col.id"
@ -74,7 +74,7 @@
<div class="card-top">
<span class="type-badge">
<i :class="getTypeIcon(item.type)" class="type-icon"></i>
{{ item.type || 'Soin' }}
{{ item.type || "Soin" }}
</span>
<span class="card-time">
<i class="far fa-clock time-icon"></i>
@ -84,13 +84,13 @@
<!-- Deceased name -->
<div class="card-deceased">
{{ item.deceased || 'Non spécifié' }}
{{ item.deceased || "Non spécifié" }}
</div>
<!-- Client -->
<div class="card-client">
<i class="fas fa-user card-client-icon"></i>
<span>{{ item.client || '' }}</span>
<span>{{ item.client || "" }}</span>
</div>
<!-- Bottom row: date + collaborator -->
@ -107,7 +107,11 @@
<!-- Hover action bar -->
<div class="card-hover-bar">
<button class="hover-action" @click.stop="emit('edit', item)" title="Modifier">
<button
class="hover-action"
title="Modifier"
@click.stop="emit('edit', item)"
>
<i class="fas fa-pen"></i>
</button>
<div class="hover-divider"></div>
@ -117,13 +121,15 @@
</transition-group>
<!-- Empty state -->
<div v-if="itemsByStatus(col.status).length === 0" class="empty-column">
<div
v-if="itemsByStatus(col.status).length === 0"
class="empty-column"
>
<i class="fas fa-inbox empty-icon"></i>
<span>Aucune intervention</span>
</div>
</div>
</div>
</div>
</div>
</div>
@ -135,8 +141,8 @@ import { ref, defineProps, defineEmits } from "vue";
const props = defineProps({
interventions: {
type: Array,
default: () => []
}
default: () => [],
},
});
const emit = defineEmits(["edit", "update-status"]);
@ -146,40 +152,85 @@ const draggingItem = ref(null);
const dragOverCol = ref(null);
const columnsConfig = [
{ id: 'todo', title: 'À planifier', status: 'En attente', color: '#f59e0b', icon: 'fas fa-hourglass-half' },
{ id: 'planned', title: 'Confirmé', status: 'Confirmé', color: '#3b82f6', icon: 'fas fa-calendar-check' },
{ id: 'in-progress', title: 'En cours', status: 'En cours', color: '#8b5cf6', icon: 'fas fa-bolt' },
{ id: 'done', title: 'Terminé', status: 'Terminé', color: '#10b981', icon: 'fas fa-check-circle' },
{
id: "todo",
title: "À planifier",
status: "En attente",
color: "#f59e0b",
icon: "fas fa-hourglass-half",
},
{
id: "planned",
title: "Confirmé",
status: "Confirmé",
color: "#3b82f6",
icon: "fas fa-calendar-check",
},
{
id: "in-progress",
title: "En cours",
status: "En cours",
color: "#8b5cf6",
icon: "fas fa-bolt",
},
{
id: "done",
title: "Terminé",
status: "Terminé",
color: "#10b981",
icon: "fas fa-check-circle",
},
];
const typeColors = {
'Soin': '#3b82f6',
'Transport': '#10b981',
'Mise en bière': '#f59e0b',
'Cérémonie': '#8b5cf6',
Soin: "#3b82f6",
Transport: "#10b981",
"Mise en bière": "#f59e0b",
Cérémonie: "#8b5cf6",
};
const typeIcons = {
'Soin': 'fas fa-heartbeat',
'Transport': 'fas fa-car',
'Mise en bière': 'fas fa-box',
'Cérémonie': 'fas fa-dove',
Soin: "fas fa-heartbeat",
Transport: "fas fa-car",
"Mise en bière": "fas fa-box",
Cérémonie: "fas fa-dove",
};
const getTypeColor = (type) => typeColors[type] || '#6b7280';
const getTypeIcon = (type) => typeIcons[type] || 'fas fa-briefcase-medical';
const getTypeColor = (type) => typeColors[type] || "#6b7280";
const getTypeIcon = (type) => typeIcons[type] || "fas fa-briefcase-medical";
const formatTime = (d) => {
try { return new Date(d).toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' }); }
catch { return '--:--'; }
try {
return new Date(d).toLocaleTimeString("fr-FR", {
hour: "2-digit",
minute: "2-digit",
});
} catch {
return "--:--";
}
};
const formatDate = (d) => {
try { return new Date(d).toLocaleDateString('fr-FR', { day: 'numeric', month: 'short' }); }
catch { return ''; }
try {
return new Date(d).toLocaleDateString("fr-FR", {
day: "numeric",
month: "short",
});
} catch {
return "";
}
};
const getInitials = (n) => n ? n.split(' ').map(s => s[0]).join('').substring(0, 2).toUpperCase() : '?';
const getInitials = (n) =>
n
? n
.split(" ")
.map((s) => s[0])
.join("")
.substring(0, 2)
.toUpperCase()
: "?";
const itemsByStatus = (status) => props.interventions.filter(i => i.status === status);
const itemsByStatus = (status) =>
props.interventions.filter((i) => i.status === status);
const countByStatus = (status) => itemsByStatus(status).length;
const progressWidth = (status) => {
if (!props.interventions.length) return 0;
@ -190,39 +241,43 @@ const progressWidth = (status) => {
const onDragStart = (e, item) => {
draggingId.value = item.id.toString();
draggingItem.value = item;
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', item.id.toString());
e.dataTransfer.effectAllowed = "move";
e.dataTransfer.setData("text/plain", item.id.toString());
};
const onDragEnd = () => {
draggingId.value = null;
draggingItem.value = null;
dragOverCol.value = null;
document.querySelectorAll('.cards-zone').forEach(z => z.classList.remove('drag-over'));
document
.querySelectorAll(".cards-zone")
.forEach((z) => z.classList.remove("drag-over"));
};
const onDragOver = (e, colId) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
e.dataTransfer.dropEffect = "move";
if (dragOverCol.value !== colId) {
document.querySelectorAll('.cards-zone').forEach(z => z.classList.remove('drag-over'));
document
.querySelectorAll(".cards-zone")
.forEach((z) => z.classList.remove("drag-over"));
dragOverCol.value = colId;
e.currentTarget.classList.add('drag-over');
e.currentTarget.classList.add("drag-over");
}
};
const onDragLeave = (e) => {
if (!e.currentTarget.contains(e.relatedTarget)) {
e.currentTarget.classList.remove('drag-over');
e.currentTarget.classList.remove("drag-over");
}
};
const onDrop = (e, col) => {
e.preventDefault();
e.currentTarget.classList.remove('drag-over');
const id = e.dataTransfer.getData('text/plain');
e.currentTarget.classList.remove("drag-over");
const id = e.dataTransfer.getData("text/plain");
if (id && draggingItem.value) {
emit('update-status', { id, status: col.status });
emit("update-status", { id, status: col.status });
}
draggingId.value = null;
draggingItem.value = null;
@ -237,7 +292,6 @@ const onDrop = (e, col) => {
flex-direction: column;
height: calc(100vh - 180px);
min-height: 500px;
}
/* ─── Toolbar ─── */
@ -304,10 +358,20 @@ const onDrop = (e, col) => {
padding-bottom: 8px;
}
.kanban-scroll-area::-webkit-scrollbar { height: 5px; }
.kanban-scroll-area::-webkit-scrollbar-track { background: #f1f5f9; border-radius: 10px; }
.kanban-scroll-area::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 10px; }
.kanban-scroll-area::-webkit-scrollbar-thumb:hover { background: #94a3b8; }
.kanban-scroll-area::-webkit-scrollbar {
height: 5px;
}
.kanban-scroll-area::-webkit-scrollbar-track {
background: #f1f5f9;
border-radius: 10px;
}
.kanban-scroll-area::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 10px;
}
.kanban-scroll-area::-webkit-scrollbar-thumb:hover {
background: #94a3b8;
}
/* ─── Columns ─── */
.kanban-columns {
@ -407,9 +471,16 @@ const onDrop = (e, col) => {
min-height: 100px;
}
.cards-zone::-webkit-scrollbar { width: 4px; }
.cards-zone::-webkit-scrollbar-track { background: transparent; }
.cards-zone::-webkit-scrollbar-thumb { background: #e2e8f0; border-radius: 4px; }
.cards-zone::-webkit-scrollbar {
width: 4px;
}
.cards-zone::-webkit-scrollbar-track {
background: transparent;
}
.cards-zone::-webkit-scrollbar-thumb {
background: #e2e8f0;
border-radius: 4px;
}
.cards-zone.drag-over {
background: color-mix(in srgb, #3b82f6 6%, #f8fafc);
@ -427,12 +498,12 @@ const onDrop = (e, col) => {
overflow: hidden;
cursor: grab;
transition: transform 0.18s ease, box-shadow 0.18s ease, opacity 0.18s;
box-shadow: 0 1px 3px rgba(0,0,0,0.06);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
}
.kanban-card:hover {
transform: translateY(-3px);
box-shadow: 0 8px 20px rgba(0,0,0,0.1);
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.1);
border-color: #dde3ea;
}

View File

@ -1,6 +1,5 @@
<template>
<div class="calendar-root">
<!-- Custom Header / Navigation -->
<div class="calendar-header">
<div class="header-left">
@ -9,18 +8,26 @@
<span class="week-range">{{ weekRangeLabel }}</span>
</div>
<div class="legend-row">
<span v-for="(color, type) in typeColors" :key="type" class="legend-item">
<span
v-for="(color, type) in typeColors"
:key="type"
class="legend-item"
>
<span class="legend-dot" :style="{ background: color }"></span>
{{ type }}
</span>
</div>
</div>
<div class="header-right">
<button class="nav-btn" @click="navigate(-1)" title="Semaine précédente">
<button
class="nav-btn"
title="Semaine précédente"
@click="navigate(-1)"
>
<i class="fas fa-chevron-left"></i>
</button>
<button class="today-btn" @click="goToday">Aujourd'hui</button>
<button class="nav-btn" @click="navigate(1)" title="Semaine suivante">
<button class="nav-btn" title="Semaine suivante" @click="navigate(1)">
<i class="fas fa-chevron-right"></i>
</button>
</div>
@ -39,23 +46,48 @@
:style="popoverStyle"
@click.stop
>
<div class="popover-strip" :style="{ background: activePopover.color }"></div>
<div
class="popover-strip"
:style="{ background: activePopover.color }"
></div>
<div class="popover-body">
<div class="popover-top">
<span class="popover-type" :style="{ color: activePopover.color, background: activePopover.color + '18' }">
<span
class="popover-type"
:style="{
color: activePopover.color,
background: activePopover.color + '18',
}"
>
<i :class="getTypeIcon(activePopover.type)" class="me-1"></i>
{{ activePopover.type }}
</span>
<button class="popover-close" @click="closePopover"><i class="fas fa-times"></i></button>
<button class="popover-close" @click="closePopover">
<i class="fas fa-times"></i>
</button>
</div>
<div class="popover-deceased">
{{ activePopover.deceased || "Non spécifié" }}
</div>
<div class="popover-deceased">{{ activePopover.deceased || 'Non spécifié' }}</div>
<div class="popover-meta">
<span><i class="fas fa-user me-1"></i>{{ activePopover.client || '' }}</span>
<span><i class="far fa-clock me-1"></i>{{ activePopover.timeLabel }}</span>
<span
><i class="fas fa-user me-1"></i
>{{ activePopover.client || "" }}</span
>
<span
><i class="far fa-clock me-1"></i
>{{ activePopover.timeLabel }}</span
>
</div>
<div class="popover-status">
<span class="status-pill" :style="{ background: activePopover.color + '20', color: activePopover.color }">
{{ activePopover.status || 'Planifié' }}
<span
class="status-pill"
:style="{
background: activePopover.color + '20',
color: activePopover.color,
}"
>
{{ activePopover.status || "Planifié" }}
</span>
</div>
<button class="popover-edit-btn" @click="editFromPopover">
@ -64,13 +96,24 @@
</div>
</div>
</transition>
<div v-if="activePopover" class="popover-backdrop" @click="closePopover"></div>
<div
v-if="activePopover"
class="popover-backdrop"
@click="closePopover"
></div>
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted, watch, computed, defineProps, defineEmits } from "vue";
import {
ref,
onMounted,
onUnmounted,
watch,
computed,
defineProps,
defineEmits,
} from "vue";
import { Calendar } from "@fullcalendar/core";
import timeGridPlugin from "@fullcalendar/timegrid";
import interactionPlugin from "@fullcalendar/interaction";
@ -91,28 +134,29 @@ const popoverStyle = ref({});
const currentDate = ref(props.startDate);
const typeColors = {
'Soin': '#3b82f6',
'Transport': '#10b981',
'Mise en bière': '#f59e0b',
'Cérémonie': '#8b5cf6',
Soin: "#3b82f6",
Transport: "#10b981",
"Mise en bière": "#f59e0b",
Cérémonie: "#8b5cf6",
};
const typeIcons = {
'Soin': 'fas fa-heartbeat',
'Transport': 'fas fa-car',
'Mise en bière': 'fas fa-box',
'Cérémonie': 'fas fa-dove',
Soin: "fas fa-heartbeat",
Transport: "fas fa-car",
"Mise en bière": "fas fa-box",
Cérémonie: "fas fa-dove",
};
const getTypeIcon = (type) => typeIcons[type] || 'fas fa-briefcase-medical';
const getTypeIcon = (type) => typeIcons[type] || "fas fa-briefcase-medical";
const parseInterventionDate = (value) => {
if (!value) return null;
if (value instanceof Date) return Number.isNaN(value.getTime()) ? null : value;
if (value instanceof Date)
return Number.isNaN(value.getTime()) ? null : value;
if (typeof value === 'string') {
if (typeof value === "string") {
// Support backend format "YYYY-MM-DD HH:mm:ss" (without timezone)
const normalized = value.includes(' ') ? value.replace(' ', 'T') : value;
const normalized = value.includes(" ") ? value.replace(" ", "T") : value;
const parsed = new Date(normalized);
if (!Number.isNaN(parsed.getTime())) return parsed;
}
@ -122,41 +166,54 @@ const parseInterventionDate = (value) => {
};
const weekRangeLabel = computed(() => {
if (!calendar) return '';
if (!calendar) return "";
try {
const view = calendar.view;
const start = view.currentStart;
const end = new Date(view.currentEnd);
end.setDate(end.getDate() - 1);
const fmt = (d) => d.toLocaleDateString('fr-FR', { day: 'numeric', month: 'short' });
const fmt = (d) =>
d.toLocaleDateString("fr-FR", { day: "numeric", month: "short" });
return `${fmt(start)} ${fmt(end)} ${start.getFullYear()}`;
} catch { return ''; }
} catch {
return "";
}
});
const mapEvents = (interventions) => interventions.map((i) => {
const color = typeColors[i.type] || '#6b7280';
const start = parseInterventionDate(i.date);
if (!start) return null;
const mapEvents = (interventions) =>
interventions
.map((i) => {
const color = typeColors[i.type] || "#6b7280";
const start = parseInterventionDate(i.date);
if (!start) return null;
const parsedEnd = parseInterventionDate(i.end);
const end = parsedEnd || new Date(start.getTime() + 60 * 60 * 1000);
const parsedEnd = parseInterventionDate(i.end);
const end = parsedEnd || new Date(start.getTime() + 60 * 60 * 1000);
return {
id: String(i.id),
title: i.deceased || i.type || 'Intervention',
start, end,
backgroundColor: color + 'dd',
borderColor: color,
textColor: '#fff',
extendedProps: { originalData: i, color },
};
}).filter(Boolean);
return {
id: String(i.id),
title: i.deceased || i.type || "Intervention",
start,
end,
backgroundColor: color + "dd",
borderColor: color,
textColor: "#fff",
extendedProps: { originalData: i, color },
};
})
.filter(Boolean);
const showPopover = (jsEvent, originalData, color) => {
const rect = jsEvent.target.closest('.fc-event')?.getBoundingClientRect() || jsEvent.target.getBoundingClientRect();
const rect =
jsEvent.target.closest(".fc-event")?.getBoundingClientRect() ||
jsEvent.target.getBoundingClientRect();
const rootRect = calendarEl.value.getBoundingClientRect();
const fmt = (d) => new Date(d).toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' });
const fmt = (d) =>
new Date(d).toLocaleTimeString("fr-FR", {
hour: "2-digit",
minute: "2-digit",
});
activePopover.value = {
...originalData,
@ -177,14 +234,16 @@ const showPopover = (jsEvent, originalData, color) => {
if (top + 200 > rootRect.height) top = rootRect.height - 210;
if (top < 0) top = 8;
popoverStyle.value = { left: left + 'px', top: top + 'px' };
popoverStyle.value = { left: left + "px", top: top + "px" };
};
const closePopover = () => { activePopover.value = null; };
const closePopover = () => {
activePopover.value = null;
};
const editFromPopover = () => {
if (activePopover.value) {
emit('edit', activePopover.value);
emit("edit", activePopover.value);
closePopover();
}
};
@ -208,19 +267,19 @@ const initCalendar = () => {
calendar = new Calendar(calendarEl.value, {
plugins: [timeGridPlugin, interactionPlugin],
initialView: 'timeGridWeek',
locale: frLocale,
headerToolbar: false,
initialDate: props.startDate,
allDaySlot: false,
slotMinTime: '00:00:00',
slotMaxTime: '24:00:00',
height: 'auto',
initialView: "timeGridWeek",
locale: frLocale,
headerToolbar: false,
initialDate: props.startDate,
allDaySlot: false,
slotMinTime: "00:00:00",
slotMaxTime: "24:00:00",
height: "auto",
expandRows: true,
stickyHeaderDates: true,
nowIndicator: true,
slotDuration: '00:30:00',
dayHeaderFormat: { weekday: 'short', day: 'numeric', month: 'short' },
slotDuration: "00:30:00",
dayHeaderFormat: { weekday: "short", day: "numeric", month: "short" },
events: mapEvents(props.interventions),
eventClick: (info) => {
@ -232,14 +291,18 @@ const initCalendar = () => {
dateClick: (info) => {
closePopover();
emit('cell-click', { date: info.date });
emit("cell-click", { date: info.date });
},
eventContent: (arg) => {
const data = arg.event.extendedProps.originalData;
const color = arg.event.extendedProps.color;
const icon = typeIcons[data?.type] || 'fas fa-briefcase-medical';
const fmt = (d) => new Date(d).toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' });
const icon = typeIcons[data?.type] || "fas fa-briefcase-medical";
const fmt = (d) =>
new Date(d).toLocaleTimeString("fr-FR", {
hour: "2-digit",
minute: "2-digit",
});
return {
html: `
<div class="fc-event-custom">
@ -248,9 +311,9 @@ const initCalendar = () => {
<span class="fce-time">${fmt(arg.event.start)}</span>
</div>
<div class="fce-title">${arg.event.title}</div>
${data?.client ? `<div class="fce-sub">${data.client}</div>` : ''}
${data?.client ? `<div class="fce-sub">${data.client}</div>` : ""}
</div>
`
`,
};
},
@ -264,15 +327,29 @@ const initCalendar = () => {
};
onMounted(() => initCalendar());
onUnmounted(() => { if (calendar) calendar.destroy(); });
onUnmounted(() => {
if (calendar) calendar.destroy();
});
watch(() => props.startDate, (d) => { if (calendar) { calendar.gotoDate(d); currentDate.value = d; } });
watch(() => props.interventions, (v) => {
if (calendar) {
calendar.removeAllEvents();
calendar.addEventSource(mapEvents(v));
watch(
() => props.startDate,
(d) => {
if (calendar) {
calendar.gotoDate(d);
currentDate.value = d;
}
}
}, { deep: true });
);
watch(
() => props.interventions,
(v) => {
if (calendar) {
calendar.removeAllEvents();
calendar.addEventSource(mapEvents(v));
}
},
{ deep: true }
);
// Force label recompute when currentDate changes
watch(currentDate, () => {}); // just triggers computed re-eval
@ -287,7 +364,7 @@ watch(currentDate, () => {}); // just triggers computed re-eval
background: #fff;
border-radius: 16px;
border: 1px solid #e5e7eb;
box-shadow: 0 4px 20px rgba(0,0,0,0.06);
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.06);
overflow: hidden;
min-height: 620px;
}
@ -407,7 +484,7 @@ watch(currentDate, () => {}); // just triggers computed re-eval
/* ─── FullCalendar Overrides ─── */
:deep(.fc) {
font-family: 'Segoe UI', system-ui, sans-serif;
font-family: "Segoe UI", system-ui, sans-serif;
}
:deep(.fc-scrollgrid) {
@ -486,12 +563,12 @@ watch(currentDate, () => {}); // just triggers computed re-eval
transition: transform 0.15s, box-shadow 0.15s !important;
padding: 0 !important;
margin: 1px 2px !important;
box-shadow: 0 1px 4px rgba(0,0,0,0.1) !important;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1) !important;
}
:deep(.fc-event:hover) {
transform: scale(1.02);
box-shadow: 0 4px 12px rgba(0,0,0,0.15) !important;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15) !important;
z-index: 10 !important;
}
@ -546,7 +623,7 @@ watch(currentDate, () => {}); // just triggers computed re-eval
width: 260px;
background: #fff;
border-radius: 12px;
box-shadow: 0 16px 40px rgba(0,0,0,0.14), 0 4px 12px rgba(0,0,0,0.08);
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.14), 0 4px 12px rgba(0, 0, 0, 0.08);
z-index: 200;
overflow: hidden;
border: 1px solid #e5e7eb;
@ -595,7 +672,9 @@ watch(currentDate, () => {}); // just triggers computed re-eval
border-radius: 5px;
transition: color 0.15s;
}
.popover-close:hover { color: #374151; }
.popover-close:hover {
color: #374151;
}
.popover-deceased {
font-size: 0.9rem;
@ -644,7 +723,9 @@ watch(currentDate, () => {}); // just triggers computed re-eval
transition: background 0.15s;
margin-top: 2px;
}
.popover-edit-btn:hover { background: #2d4a6e; }
.popover-edit-btn:hover {
background: #2d4a6e;
}
/* ─── Popover animation ─── */
.popover-fade-enter-active,

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -4,13 +4,24 @@
<p class="mb-0 text-sm">Associez un contact à un client existant</p>
<transition name="banner-fade">
<div v-if="props.success" class="alert alert-success text-white mt-3 mb-0 py-2" role="alert">
<span class="text-sm"><strong>Contact créé avec succès !</strong> Il est maintenant lié au client.</span>
<div
v-if="props.success"
class="alert alert-success text-white mt-3 mb-0 py-2"
role="alert"
>
<span class="text-sm"
><strong>Contact créé avec succès !</strong> Il est maintenant lié au
client.</span
>
</div>
</transition>
<transition name="banner-fade">
<div v-if="fieldErrors.general" class="alert alert-danger text-white mt-3 mb-0 py-2" role="alert">
<div
v-if="fieldErrors.general"
class="alert alert-danger text-white mt-3 mb-0 py-2"
role="alert"
>
<span class="text-sm">{{ fieldErrors.general }}</span>
</div>
</transition>
@ -18,10 +29,15 @@
<div class="multisteps-form__content">
<div class="row mt-3">
<div class="col-12">
<label class="form-label">Client associé <span class="text-danger">*</span></label>
<label class="form-label"
>Client associé <span class="text-danger">*</span></label
>
<div v-if="!selectedClient" class="position-relative">
<div class="search-wrap" :class="{ 'has-error': fieldErrors.client_id }">
<div
class="search-wrap"
:class="{ 'has-error': fieldErrors.client_id }"
>
<i class="fas fa-search search-pfx"></i>
<input
:value="searchQuery"
@ -32,12 +48,21 @@
@blur="onInputBlur"
/>
<span v-if="searchQuery" class="search-loader">
<i class="fas fa-circle-notch fa-spin text-secondary text-xs"></i>
<i
class="fas fa-circle-notch fa-spin text-secondary text-xs"
></i>
</span>
</div>
<transition name="dropdown-pop">
<div v-if="showDropdown && props.searchResults && props.searchResults.length > 0" class="search-dropdown">
<div
v-if="
showDropdown &&
props.searchResults &&
props.searchResults.length > 0
"
class="search-dropdown"
>
<button
v-for="client in props.searchResults"
:key="client.id"
@ -48,11 +73,21 @@
<span class="dr-avatar">{{ (client.name || "?")[0] }}</span>
<span class="dr-info">
<span class="dr-name">{{ client.name }}</span>
<span v-if="client.email" class="dr-meta">{{ client.email }}</span>
<span v-if="client.email" class="dr-meta">{{
client.email
}}</span>
</span>
</button>
</div>
<div v-else-if="showDropdown && searchQuery && props.searchResults && props.searchResults.length === 0" class="search-dropdown py-3 text-center text-sm text-secondary">
<div
v-else-if="
showDropdown &&
searchQuery &&
props.searchResults &&
props.searchResults.length === 0
"
class="search-dropdown py-3 text-center text-sm text-secondary"
>
Aucun résultat pour <strong>« {{ searchQuery }} »</strong>
</div>
</transition>
@ -63,15 +98,27 @@
<span class="sc-info">
<span class="sc-label">Client sélectionné</span>
<span class="sc-name">{{ selectedClient.name }}</span>
<span v-if="selectedClient.email" class="sc-meta">{{ selectedClient.email }}</span>
<span v-if="selectedClient.email" class="sc-meta">{{
selectedClient.email
}}</span>
</span>
<soft-button type="button" color="secondary" variant="outline" size="sm" @click="clearSelection">
<soft-button
type="button"
color="secondary"
variant="outline"
size="sm"
@click="clearSelection"
>
Changer
</soft-button>
</div>
<div v-if="fieldErrors.client_id" class="invalid-feedback d-block">{{ fieldErrors.client_id }}</div>
<p class="text-xs text-secondary mb-0 mt-1">Le contact sera rattaché à ce client</p>
<div v-if="fieldErrors.client_id" class="invalid-feedback d-block">
{{ fieldErrors.client_id }}
</div>
<p class="text-xs text-secondary mb-0 mt-1">
Le contact sera rattaché à ce client
</p>
</div>
</div>
@ -85,7 +132,9 @@
type="text"
placeholder="Jean"
/>
<div v-if="fieldErrors.first_name" class="invalid-feedback d-block">{{ fieldErrors.first_name }}</div>
<div v-if="fieldErrors.first_name" class="invalid-feedback d-block">
{{ fieldErrors.first_name }}
</div>
</div>
<div class="col-12 col-sm-6 mt-3 mt-sm-0">
<label class="form-label">Nom</label>
@ -96,7 +145,9 @@
type="text"
placeholder="Dupont"
/>
<div v-if="fieldErrors.last_name" class="invalid-feedback d-block">{{ fieldErrors.last_name }}</div>
<div v-if="fieldErrors.last_name" class="invalid-feedback d-block">
{{ fieldErrors.last_name }}
</div>
</div>
</div>
@ -110,7 +161,9 @@
type="text"
placeholder="ex. Directeur Commercial, Responsable RH..."
/>
<div v-if="fieldErrors.role" class="invalid-feedback d-block">{{ fieldErrors.role }}</div>
<div v-if="fieldErrors.role" class="invalid-feedback d-block">
{{ fieldErrors.role }}
</div>
</div>
</div>
@ -124,7 +177,9 @@
type="email"
placeholder="jean.dupont@entreprise.com"
/>
<div v-if="fieldErrors.email" class="invalid-feedback d-block">{{ fieldErrors.email }}</div>
<div v-if="fieldErrors.email" class="invalid-feedback d-block">
{{ fieldErrors.email }}
</div>
</div>
</div>
@ -138,7 +193,9 @@
type="text"
placeholder="+33 1 23 45 67 89"
/>
<div v-if="fieldErrors.phone" class="invalid-feedback d-block">{{ fieldErrors.phone }}</div>
<div v-if="fieldErrors.phone" class="invalid-feedback d-block">
{{ fieldErrors.phone }}
</div>
</div>
<div class="col-12 col-sm-6 mt-3 mt-sm-0">
<label class="form-label">Mobile</label>
@ -149,7 +206,9 @@
type="text"
placeholder="+33 6 12 34 56 78"
/>
<div v-if="fieldErrors.mobile" class="invalid-feedback d-block">{{ fieldErrors.mobile }}</div>
<div v-if="fieldErrors.mobile" class="invalid-feedback d-block">
{{ fieldErrors.mobile }}
</div>
</div>
</div>
@ -164,18 +223,32 @@
maxlength="1000"
@input="form.notes = $event.target.value"
></textarea>
<div class="text-end text-xs text-secondary mt-1">{{ (form.notes || "").length }}/1000</div>
<div class="text-end text-xs text-secondary mt-1">
{{ (form.notes || "").length }}/1000
</div>
</div>
</div>
<transition name="banner-fade">
<div v-if="showValidationWarning && form.client_id" class="alert alert-warning text-dark mt-3 py-2 mb-0" role="alert">
<span class="text-sm">Renseignez au moins un champ parmi : prénom, nom, email ou téléphone.</span>
<div
v-if="showValidationWarning && form.client_id"
class="alert alert-warning text-dark mt-3 py-2 mb-0"
role="alert"
>
<span class="text-sm"
>Renseignez au moins un champ parmi : prénom, nom, email ou
téléphone.</span
>
</div>
</transition>
<div class="d-flex mt-4 justify-content-between gap-2 flex-wrap">
<soft-button type="button" color="secondary" variant="outline" @click="resetForm">
<soft-button
type="button"
color="secondary"
variant="outline"
@click="resetForm"
>
Réinitialiser
</soft-button>
<soft-button
@ -185,8 +258,14 @@
:disabled="props.loading || !isFormValid"
@click="submitForm"
>
<span v-if="props.loading" class="spinner-border spinner-border-sm me-2" role="status"></span>
<span>{{ props.loading ? "Création en cours…" : "Créer le contact" }}</span>
<span
v-if="props.loading"
class="spinner-border spinner-border-sm me-2"
role="status"
></span>
<span>{{
props.loading ? "Création en cours…" : "Créer le contact"
}}</span>
</soft-button>
</div>
</div>
@ -214,26 +293,50 @@ const showDropdown = ref(false);
const searchTimeout = ref(null);
const form = ref({
client_id: null, first_name: "", last_name: "",
email: "", phone: "", mobile: "", role: "", notes: "",
client_id: null,
first_name: "",
last_name: "",
email: "",
phone: "",
mobile: "",
role: "",
notes: "",
});
const showValidationWarning = computed(() =>
!form.value.first_name && !form.value.last_name &&
!form.value.email && !form.value.phone && !form.value.mobile
const showValidationWarning = computed(
() =>
!form.value.first_name &&
!form.value.last_name &&
!form.value.email &&
!form.value.phone &&
!form.value.mobile
);
const isFormValid = computed(() =>
!!form.value.client_id && !showValidationWarning.value
const isFormValid = computed(
() => !!form.value.client_id && !showValidationWarning.value
);
watch(() => props.validationErrors, (v) => { fieldErrors.value = { ...v }; }, { deep: true });
watch(() => props.success, (v) => { if (v) resetForm(); });
watch(
() => props.validationErrors,
(v) => {
fieldErrors.value = { ...v };
},
{ deep: true }
);
watch(
() => props.success,
(v) => {
if (v) resetForm();
}
);
const handleSearchInput = (value) => {
searchQuery.value = value;
if (searchTimeout.value) clearTimeout(searchTimeout.value);
if (!value.trim()) { showDropdown.value = false; return; }
if (!value.trim()) {
showDropdown.value = false;
return;
}
showDropdown.value = true;
searchTimeout.value = setTimeout(() => emit("searchClient", value), 300);
};
@ -255,22 +358,31 @@ const clearSelection = () => {
emit("clientSelected", null);
};
const onInputBlur = () => setTimeout(() => { showDropdown.value = false; }, 200);
const onInputBlur = () =>
setTimeout(() => {
showDropdown.value = false;
}, 200);
const isValidEmail = (e) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(e);
const validateForm = () => {
const errs = {};
if (!form.value.client_id) errs.client_id = "Le client est obligatoire.";
if (form.value.email && !isValidEmail(form.value.email)) errs.email = "Adresse email invalide.";
if (showValidationWarning.value) errs.general = "Au moins un champ (prénom, nom, email ou téléphone) doit être renseigné.";
if (form.value.email && !isValidEmail(form.value.email))
errs.email = "Adresse email invalide.";
if (showValidationWarning.value)
errs.general =
"Au moins un champ (prénom, nom, email ou téléphone) doit être renseigné.";
return errs;
};
const submitForm = () => {
fieldErrors.value = {};
const localErrors = validateForm();
if (Object.keys(localErrors).length > 0) { fieldErrors.value = localErrors; return; }
if (Object.keys(localErrors).length > 0) {
fieldErrors.value = localErrors;
return;
}
const cleaned = Object.fromEntries(
Object.entries(form.value).map(([k, v]) => [k, v === "" ? null : v])
);
@ -278,7 +390,16 @@ const submitForm = () => {
};
const resetForm = () => {
form.value = { client_id: null, first_name: "", last_name: "", email: "", phone: "", mobile: "", role: "", notes: "" };
form.value = {
client_id: null,
first_name: "",
last_name: "",
email: "",
phone: "",
mobile: "",
role: "",
notes: "",
};
selectedClient.value = null;
searchQuery.value = "";
showDropdown.value = false;
@ -299,9 +420,11 @@ const resetForm = () => {
}
.search-wrap:focus-within {
border-color: #8392ab;
box-shadow: 0 0 0 2px rgba(131,146,171,0.25);
box-shadow: 0 0 0 2px rgba(131, 146, 171, 0.25);
}
.search-wrap.has-error {
border-color: #fd5c70;
}
.search-wrap.has-error { border-color: #fd5c70; }
.search-pfx {
padding: 0 0.75rem;
@ -317,9 +440,14 @@ const resetForm = () => {
min-width: 0;
outline: none;
}
.search-input:focus { box-shadow: none; }
.search-input:focus {
box-shadow: none;
}
.search-loader { padding: 0 0.75rem; flex-shrink: 0; }
.search-loader {
padding: 0 0.75rem;
flex-shrink: 0;
}
.search-dropdown {
position: absolute;
@ -329,15 +457,20 @@ const resetForm = () => {
background: #fff;
border: 1px solid #e9ecef;
border-radius: 0.5rem;
box-shadow: 0 12px 32px rgba(0,0,0,0.12);
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.12);
z-index: 1050;
overflow: hidden;
max-height: 260px;
overflow-y: auto;
}
.search-dropdown::-webkit-scrollbar { width: 4px; }
.search-dropdown::-webkit-scrollbar-thumb { background: #e2e8f0; border-radius: 4px; }
.search-dropdown::-webkit-scrollbar {
width: 4px;
}
.search-dropdown::-webkit-scrollbar-thumb {
background: #e2e8f0;
border-radius: 4px;
}
.dropdown-row {
width: 100%;
@ -352,8 +485,12 @@ const resetForm = () => {
border-bottom: 1px solid #f3f4f6;
transition: background 0.15s;
}
.dropdown-row:last-child { border-bottom: none; }
.dropdown-row:hover { background: #f5f8ff; }
.dropdown-row:last-child {
border-bottom: none;
}
.dropdown-row:hover {
background: #f5f8ff;
}
.dr-avatar {
width: 2rem;
@ -452,12 +589,28 @@ const resetForm = () => {
/* ─── Animations ─── */
.banner-fade-enter-active,
.banner-fade-leave-active { transition: opacity 0.22s ease, transform 0.22s ease; }
.banner-fade-enter-from { opacity: 0; transform: translateY(-6px); }
.banner-fade-leave-to { opacity: 0; transform: translateY(-4px); }
.banner-fade-leave-active {
transition: opacity 0.22s ease, transform 0.22s ease;
}
.banner-fade-enter-from {
opacity: 0;
transform: translateY(-6px);
}
.banner-fade-leave-to {
opacity: 0;
transform: translateY(-4px);
}
.dropdown-pop-enter-active,
.dropdown-pop-leave-active { transition: opacity 0.18s ease, transform 0.18s ease; }
.dropdown-pop-enter-from { opacity: 0; transform: translateY(-8px) scale(0.97); }
.dropdown-pop-leave-to { opacity: 0; transform: translateY(-4px) scale(0.98); }
.dropdown-pop-leave-active {
transition: opacity 0.18s ease, transform 0.18s ease;
}
.dropdown-pop-enter-from {
opacity: 0;
transform: translateY(-8px) scale(0.97);
}
.dropdown-pop-leave-to {
opacity: 0;
transform: translateY(-4px) scale(0.98);
}
</style>

View File

@ -4,14 +4,20 @@
<div class="col-lg-10 mx-auto">
<div class="card mb-4">
<div class="card-header p-3 pb-0">
<div class="d-flex flex-wrap align-items-center justify-content-between gap-3">
<div
class="d-flex flex-wrap align-items-center justify-content-between gap-3"
>
<div class="d-flex align-items-center gap-3">
<div class="icon icon-shape icon-sm bg-gradient-primary shadow text-center border-radius-md">
<div
class="icon icon-shape icon-sm bg-gradient-primary shadow text-center border-radius-md"
>
<i class="fas fa-file-invoice text-white text-sm"></i>
</div>
<div>
<h6 class="mb-0">Nouveau Devis</h6>
<p class="text-sm text-muted mb-0">Créer un devis pour un client</p>
<p class="text-sm text-muted mb-0">
Créer un devis pour un client
</p>
</div>
</div>
<div class="d-flex flex-wrap align-items-center gap-2">
@ -25,7 +31,9 @@
<div class="row g-3">
<div class="col-lg-6 col-md-6 col-12">
<div class="card card-body border card-plain border-radius-lg h-100">
<div
class="card card-body border card-plain border-radius-lg h-100"
>
<div class="d-flex align-items-center mb-3">
<i class="fas fa-user-circle me-2 text-primary"></i>
<h6 class="mb-0">Client</h6>
@ -35,7 +43,9 @@
</div>
<div class="col-lg-6 col-md-6 col-12">
<div class="card card-body border card-plain border-radius-lg h-100">
<div
class="card card-body border card-plain border-radius-lg h-100"
>
<div class="d-flex align-items-center mb-3">
<i class="fas fa-calendar-alt me-2 text-primary"></i>
<h6 class="mb-0">Informations</h6>
@ -49,7 +59,9 @@
<div class="row g-3">
<div class="col-lg-8 col-12">
<div class="card card-body border card-plain border-radius-lg h-100">
<div
class="card card-body border card-plain border-radius-lg h-100"
>
<div class="d-flex align-items-center mb-3">
<i class="fas fa-boxes me-2 text-primary"></i>
<h6 class="mb-0">Produits &amp; Services</h6>
@ -59,7 +71,9 @@
</div>
<div class="col-lg-4 col-12">
<div class="card card-body border card-plain border-radius-lg h-100">
<div
class="card card-body border card-plain border-radius-lg h-100"
>
<div class="d-flex align-items-center mb-3">
<i class="fas fa-calculator me-2 text-primary"></i>
<h6 class="mb-0">Récapitulatif</h6>

View File

@ -93,7 +93,8 @@ const practitioners = computed(
const collaborators = computed(() =>
practitioners.value.map((p) => ({
id: p.id,
name: `${p.employee?.first_name || ""} ${p.employee?.last_name || ""}`.trim() ||
name:
`${p.employee?.first_name || ""} ${p.employee?.last_name || ""}`.trim() ||
`Collaborateur #${p.id}`,
}))
);
@ -117,7 +118,9 @@ const uiToBackendStatus = {
const mapInterventionToPlanning = (item) => {
const practitioner =
item.principal_practitioner || item.assigned_practitioner || item.practitioners?.[0];
item.principal_practitioner ||
item.assigned_practitioner ||
item.practitioners?.[0];
const collaborator = `${practitioner?.employee?.first_name || ""} ${
practitioner?.employee?.last_name || ""
}`.trim();
@ -127,10 +130,16 @@ const mapInterventionToPlanning = (item) => {
date: item.scheduled_at || item.date || item.created_at,
end: item.estimated_end_at || item.end,
type:
item.product?.nom || item.product_name || item.type_label || item.type || "Intervention",
item.product?.nom ||
item.product_name ||
item.type_label ||
item.type ||
"Intervention",
deceased:
item.deceased_name ||
`${item.deceased?.first_name || ""} ${item.deceased?.last_name || ""}`.trim() ||
`${item.deceased?.first_name || ""} ${
item.deceased?.last_name || ""
}`.trim() ||
"Non spécifié",
client: item.client_name || item.client?.name || "-",
collaborator: collaborator || "-",
@ -149,9 +158,7 @@ const weekRange = computed(() => {
});
const apiInterventions = computed(() =>
Object.values(monthBuckets.value)
.flat()
.map(mapInterventionToPlanning)
Object.values(monthBuckets.value).flat().map(mapInterventionToPlanning)
);
const interventions = computed(() => {
@ -167,11 +174,15 @@ const interventions = computed(() => {
// Lifecycle
onMounted(async () => {
await Promise.all([fetchData(), thanatopractitionerStore.fetchThanatopractitioners()]);
await Promise.all([
fetchData(),
thanatopractitionerStore.fetchThanatopractitioners(),
]);
});
// Methods
const getMonthKey = (d) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`;
const getMonthKey = (d) =>
`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`;
const bumpMonthVersion = (key) => {
monthVersions.value[key] = (monthVersions.value[key] || 0) + 1;
@ -192,7 +203,10 @@ const fetchMonth = async (date, force = false) => {
const year = date.getFullYear();
const month = date.getMonth() + 1;
const response = await interventionStore.fetchInterventionsByMonth(year, month);
const response = await interventionStore.fetchInterventionsByMonth(
year,
month
);
// Guard against stale async responses overriding newer local/server updates
if (monthRequestIds.value[key] !== requestId) return;
@ -349,8 +363,13 @@ const handleUpdateStatus = async (payload) => {
const status = uiToBackendStatus[payload.status] || payload.status;
try {
const updated = await interventionStore.updateInterventionStatus(interventionId, status);
const monthKey = getMonthKey(new Date(updated.scheduled_at || updated.date || currentDate.value));
const updated = await interventionStore.updateInterventionStatus(
interventionId,
status
);
const monthKey = getMonthKey(
new Date(updated.scheduled_at || updated.date || currentDate.value)
);
const monthItems = monthBuckets.value[monthKey] || [];
const idx = monthItems.findIndex((i) => i.id === interventionId);
if (idx !== -1) {