Ameloration design

This commit is contained in:
nyavokevin 2026-03-13 16:13:49 +03:00
parent dec87dfdb7
commit bd04e07f12
20 changed files with 1990 additions and 1150 deletions

2
.gitignore vendored
View File

@ -20,6 +20,8 @@
*.DS_Store
*.idea/
*.vscode/
node_modules/
*.env.local
npm-debug.log*
yarn-debug.log*

1000
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

5
package.json Normal file
View File

@ -0,0 +1,5 @@
{
"dependencies": {
"exceljs": "^4.4.0"
}
}

70
thanas
View File

@ -1,70 +0,0 @@
<template>
<thanatopractitioner-template>
<template #thanatopractitioner-new-action>
<add-button text="Ajouter" @click="goToAdd" />
</template>
<template #select-filter>
<filter-table />
</template>
<template #thanatopractitioner-other-action>
<table-action />
</template>
<template #thanatopractitioner-table>
<thanatopractitioner-table
:data="thanatopractitionerData"
:loading="loadingData"
:pagination="pagination"
@view="goToDetails"
@delete="deleteThanatopractitioner"
@change-page="$emit('change-page', $event)"
/>
</template>
</thanatopractitioner-template>
</template>
<script setup>
import ThanatopractitionerTemplate from "@/components/templates/CRM/ThanatopractitionerTemplate.vue";
import ThanatopractitionerTable from "@/components/molecules/Thanatopractitioners/ThanatopractitionerTable.vue";
import addButton from "@/components/molecules/new-button/addButton.vue";
import FilterTable from "@/components/molecules/Tables/FilterTable.vue";
import TableAction from "@/components/molecules/Tables/TableAction.vue";
import { defineProps, defineEmits } from "vue";
import { useRouter } from "vue-router";
const router = useRouter();
const emit = defineEmits(["pushDetails", "deleteThanatopractitioner", "changePage"]);
defineProps({
thanatopractitionerData: {
type: Array,
default: [],
},
loadingData: {
type: Boolean,
default: false,
},
pagination: {
type: Object,
default: () => ({
current_page: 1,
per_page: 10,
total: 0,
last_page: 1,
}),
},
});
const goToAdd = () => {
router.push({
name: "Creation thanatopractitioner",
});
};
const goToDetails = (thanatopractitioner) => {
emit("pushDetails", thanatopractitioner);
};
const deleteThanatopractitioner = (thanatopractitioner) => {
emit("deleteThanatopractitioner", thanatopractitioner);
};
</script>

View File

@ -1,60 +0,0 @@
<template>
<ul class="nav nav-pills flex-column">
<TabNavigationItem
icon="fas fa-eye"
label="Aperçu"
:is-active="activeTab === 'overview'"
spacing=""
@click="$emit('change-tab', 'overview')"
/>
<TabNavigationItem
icon="fas fa-info-circle"
label="Informations"
:is-active="activeTab === 'info'"
@click="$emit('change-tab', 'info')"
/>
<TabNavigationItem
icon="fas fa-calendar"
label="Agenda"
:is-active="activeTab === 'agenda'"
@click="$emit('change-tab', 'agenda')"
/>
<TabNavigationItem
icon="fas fa-info-circle"
label="Activités récentes"
:is-active="activeTab === 'activity'"
@click="$emit('change-tab', 'activity')"
/>
<TabNavigationItem
icon="fas fa-folder"
label="Documents"
:is-active="activeTab === 'documents'"
:badge="documentsCount > 0 ? documentsCount : null"
@click="$emit('change-tab', 'documents')"
/>
<TabNavigationItem
icon="fas fa-sticky-note"
label="Notes"
:is-active="activeTab === 'notes'"
@click="$emit('change-tab', 'notes')"
/>
</ul>
</template>
<script setup>
import TabNavigationItem from "@/components/atoms/client/TabNavigationItem.vue";
import { defineProps, defineEmits } from "vue";
defineProps({
activeTab: {
type: String,
required: true,
},
documentsCount: {
type: Number,
default: 0,
},
});
defineEmits(["change-tab"]);
</script>

View File

@ -160,9 +160,13 @@ class InterventionController extends Controller
$deceased = $this->deceasedRepository->create($deceasedData);
}
// Step 2: Create the client
// Step 2: Link existing client or create a new one
if (!empty($validated['client_id'])) {
$client = $this->clientRepository->find($validated['client_id']);
} else {
$clientData = $validated['client'];
$client = $this->clientRepository->create($clientData);
}
// Step 3: Create the contact (if provided)
$contactId = null;
@ -226,6 +230,42 @@ class InterventionController extends Controller
]);
$intervention = $this->interventionRepository->create($interventionData);
// Step 5a: Assign practitioners if provided
if (!empty($validated['intervention']['principal_practitioner_id'])) {
$this->interventionPractitionerRepository->createAssignment(
$intervention->id,
(int) $validated['intervention']['principal_practitioner_id'],
'principal'
);
}
if (!empty($validated['intervention']['assistant_practitioner_ids']) && is_array($validated['intervention']['assistant_practitioner_ids'])) {
foreach ($validated['intervention']['assistant_practitioner_ids'] as $assistantId) {
$this->interventionPractitionerRepository->createAssignment(
$intervention->id,
(int) $assistantId,
'assistant'
);
}
}
if (
empty($validated['intervention']['principal_practitioner_id']) &&
!empty($validated['intervention']['practitioners']) &&
is_array($validated['intervention']['practitioners'])
) {
foreach ($validated['intervention']['practitioners'] as $index => $practitionerId) {
$role = $index === 0 ? 'principal' : 'assistant';
$this->interventionPractitionerRepository->createAssignment(
$intervention->id,
(int) $practitionerId,
$role
);
}
}
$intervention->load('practitioners');
// Step 5b: Create a Quote for this intervention
try {
$interventionProduct = $this->productRepository->findInterventionProduct();

View File

@ -32,7 +32,8 @@ class StoreInterventionWithAllDataRequest extends FormRequest
'deceased.place_of_death' => ['nullable', 'string', 'max:255'],
'deceased.notes' => ['nullable', 'string'],
'client' => 'required|array',
'client_id' => ['nullable', 'exists:clients,id'],
'client' => 'required_without:client_id|array',
'client.name' => ['required', 'string', 'max:255'],
'client.vat_number' => ['nullable', 'string', 'max:32'],
'client.siret' => ['nullable', 'string', 'max:20'],
@ -68,23 +69,29 @@ class StoreInterventionWithAllDataRequest extends FormRequest
'documents.*.description' => ['nullable', 'string'],
'intervention' => 'required|array',
'intervention.type' => ['required', Rule::in([
'intervention.type' => [
'required',
Rule::in([
'thanatopraxie',
'toilette_mortuaire',
'exhumation',
'retrait_pacemaker',
'retrait_bijoux',
'autre'
])],
])
],
'intervention.scheduled_at' => ['nullable', 'date_format:Y-m-d H:i:s'],
'intervention.duration_min' => ['nullable', 'integer', 'min:0'],
'intervention.status' => ['sometimes', Rule::in([
'intervention.status' => [
'sometimes',
Rule::in([
'demande',
'planifie',
'en_cours',
'termine',
'annule'
])],
])
],
'intervention.practitioners' => ['nullable', 'array'],
'intervention.practitioners.*' => ['exists:thanatopractitioners,id'],
'intervention.principal_practitioner_id' => ['nullable', 'exists:thanatopractitioners,id'],

View File

@ -737,6 +737,18 @@ const handleSubmit = async () => {
if (locationForm.value.city) formData.append("location[city]", locationForm.value.city);
}
if (productForm.value.product_id) formData.append("product_id", productForm.value.product_id);
if (interventionForm.value.assigned_practitioner_id) {
formData.append(
"intervention[principal_practitioner_id]",
interventionForm.value.assigned_practitioner_id
);
formData.append(
"intervention[practitioners][0]",
interventionForm.value.assigned_practitioner_id
);
}
Object.keys(interventionForm.value).forEach((key) => {
if (interventionForm.value[key] != null) {
let value = interventionForm.value[key];

View File

@ -8,21 +8,23 @@
aria-labelledby="planningNewRequestModalLabel"
:aria-hidden="!show"
>
<div class="modal-dialog modal-dialog-centered" role="document">
<div class="modal-dialog modal-dialog-centered modal-lg" role="document">
<div class="modal-content">
<div class="modal-header">
<div class="modal-header border-bottom bg-light">
<h5 id="planningNewRequestModalLabel" class="modal-title">
Nouvelle demande
</h5>
<button
type="button"
class="btn-close"
class="btn-close d-flex align-items-center justify-content-center"
aria-label="Close"
@click="$emit('close')"
></button>
>
<i class="fas fa-times text-dark"></i>
</button>
</div>
<div class="modal-body">
<div class="modal-body p-4">
<p v-if="!creationType" class="text-sm text-muted mb-3">
Choisissez le type à créer :
</p>
@ -108,3 +110,15 @@ defineEmits([
"update:event-form",
]);
</script>
<style scoped>
.modal-content {
border: 0;
box-shadow: 0 0.5rem 1.5rem rgba(15, 23, 42, 0.12);
border-radius: 0.75rem;
}
.modal-title {
font-weight: 600;
}
</style>

View File

@ -28,7 +28,7 @@
</soft-button>
<soft-button
color="info"
variant="gradient"
variant="fill"
size="sm"
@click="$emit('new-request')"
>
@ -166,4 +166,8 @@ const handleUpdateStatus = (payload) => {
.text-sm {
font-size: 0.875rem;
}
.btn.btn-info {
box-shadow: 0 0.25rem 0.75rem rgba(17, 205, 239, 0.25);
}
</style>

View File

@ -1,75 +1,109 @@
<template>
<create-quote-template>
<!-- Actions -->
<template #actions>
<soft-button
color="secondary"
variant="outline"
class="me-2"
@click="cancel"
>
Annuler
<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"
>
{{ loading ? "Enregistrement..." : "Enregistrer" }}
<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>
</template>
<!-- Client Selection -->
<template #client-selection>
<label>Client</label>
<select v-model="form.client_id" class="form-select">
<option value="" disabled selected>Sélectionner un client</option>
<div class="field-group">
<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">
{{ client.name }}
</option>
</select>
<!-- Add client search/autocomplete if list is long -->
<p v-if="!form.client_id && attempted" class="field-error">
<i class="fas fa-exclamation-circle me-1"></i>Client requis
</p>
</div>
</template>
<!-- Quote Details -->
<template #quote-details>
<div class="row">
<div class="col-md-6">
<label>Date du devis</label>
<input v-model="form.quote_date" type="date" class="form-control" />
<div class="field-group">
<label class="field-label">Date du devis</label>
<soft-input v-model="form.quote_date" type="date" />
</div>
<div class="col-md-6">
<label>Validité (Date)</label>
<input v-model="form.valid_until" type="date" class="form-control" />
<div class="field-group mt-3">
<label class="field-label">Valide jusqu'au</label>
<soft-input v-model="form.valid_until" type="date" />
</div>
<div class="field-group mt-3">
<label class="field-label">Statut</label>
<select v-model="form.status" class="form-select field-select">
<option v-for="s in statuses" :key="s.value" :value="s.value">
{{ s.label }}
</option>
</select>
</div>
</template>
<!-- Product Lines -->
<template #product-lines>
<div v-for="(line, index) in form.lines" :key="index">
<!-- Column Headers -->
<div class="lines-header">
<div class="line-col line-col--product">Produit / Description</div>
<div class="line-col line-col--qty">Qté</div>
<div class="line-col line-col--price">P.U. HT</div>
<div class="line-col line-col--tva">TVA %</div>
<div class="line-col line-col--discount">Remise %</div>
<div class="line-col line-col--total">Total HT</div>
<div class="line-col line-col--action"></div>
</div>
<!-- Lines -->
<transition-group name="line-fade" tag="div">
<div
v-for="(line, index) in form.lines"
:key="index"
class="line-row"
>
<product-line-item
v-model="form.lines[index]"
@remove="removeLine(index)"
/>
</div>
<soft-button color="info" variant="text" size="sm" @click="addLine">
<i class="fas fa-plus me-1"></i> Ajouter une ligne
</transition-group>
<!-- Empty State -->
<div v-if="form.lines.length === 0" class="lines-empty">
<i class="fas fa-box-open mb-2"></i>
<p>Aucune ligne. Ajoutez un produit ou service.</p>
</div>
<!-- Add Line -->
<div class="lines-footer">
<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>
</template>
<!-- Totals -->
<template #totals>
<ul class="list-group">
<li class="list-group-item d-flex justify-content-between">
<span>Total HT</span>
<strong>{{ formatCurrency(totals.ht) }}</strong>
</li>
<li class="list-group-item d-flex justify-content-between">
<span>TVA</span>
<strong>{{ formatCurrency(totals.tva) }}</strong>
</li>
<li class="list-group-item d-flex justify-content-between bg-gray-100">
<span>Total TTC</span>
<strong class="text-primary">{{ formatCurrency(totals.ttc) }}</strong>
</li>
</ul>
<div class="totals-list">
<div class="totals-row">
<span class="totals-label">Sous-total HT</span>
<span class="totals-value">{{ formatCurrency(totals.ht) }}</span>
</div>
<div class="totals-row">
<span class="totals-label">TVA</span>
<span class="totals-value">{{ formatCurrency(totals.tva) }}</span>
</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>
</div>
</div>
</template>
</create-quote-template>
</template>
@ -80,6 +114,7 @@ import { useRouter } from "vue-router";
import CreateQuoteTemplate from "@/components/templates/Quote/CreateQuoteTemplate.vue";
import ProductLineItem from "@/components/molecules/Quote/ProductLineItem.vue";
import SoftButton from "@/components/SoftButton.vue";
import SoftInput from "@/components/SoftInput.vue";
import { useQuoteStore } from "@/stores/quoteStore";
import { useClientStore } from "@/stores/clientStore";
import { storeToRefs } from "pinia";
@ -87,9 +122,25 @@ import { storeToRefs } from "pinia";
const router = useRouter();
const quoteStore = useQuoteStore();
const clientStore = useClientStore();
const { clients } = storeToRefs(clientStore);
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" },
];
const defaultLine = () => ({
product_id: null,
product_name: "",
quantity: 1,
unit_price: 0,
tva: 20,
discount_pct: 0,
});
const form = ref({
client_id: "",
@ -97,63 +148,29 @@ const form = ref({
valid_until: "",
status: "brouillon",
currency: "EUR",
lines: [
{
product_id: null,
product_name: "",
quantity: 1,
unit_price: 0,
tva: 20,
discount_pct: 0,
},
],
lines: [defaultLine()],
});
const totals = computed(() => {
let ht = 0;
let tva = 0;
form.value.lines.forEach((line) => {
const lineHt = line.quantity * line.unit_price;
const lineTva = lineHt * (line.tva / 100);
ht += lineHt;
tva += lineTva;
const afterDiscount = line.quantity * line.unit_price * (1 - (line.discount_pct || 0) / 100);
ht += afterDiscount;
tva += afterDiscount * (line.tva / 100);
});
return { ht, tva, ttc: ht + tva };
});
return {
ht,
tva,
ttc: ht + tva,
};
});
const addLine = () => form.value.lines.push(defaultLine());
const removeLine = (index) => form.value.lines.splice(index, 1);
const addLine = () => {
form.value.lines.push({
product_id: null,
product_name: "",
quantity: 1,
unit_price: 0,
tva: 20,
discount_pct: 0,
});
};
const removeLine = (index) => {
form.value.lines.splice(index, 1);
};
const formatCurrency = (value) => {
return new Intl.NumberFormat("fr-FR", {
style: "currency",
currency: "EUR",
}).format(value);
};
const formatCurrency = (value) =>
new Intl.NumberFormat("fr-FR", { style: "currency", currency: "EUR" }).format(value);
const saveQuote = async () => {
if (!form.value.client_id) {
alert("Veuillez sélectionner un client");
return;
}
attempted.value = true;
if (!form.value.client_id) return;
loading.value = true;
try {
@ -166,17 +183,11 @@ const saveQuote = async () => {
total_ht: totals.value.ht,
total_tva: totals.value.tva,
total_ttc: totals.value.ttc,
// Assuming backend handles lines separately or we need to pass them
// If backend expects lines in payload, we need to add them to interface and store
lines: form.value.lines.map((line) => ({
...line,
discount_pct: line.discount_pct || 0,
// Calculate total_ht for the line: qty * unit_price * (1 - discount/100)
total_ht:
line.quantity *
line.unit_price *
(1 - (line.discount_pct || 0) / 100),
description: line.product_name || "Produit sans nom", // Ensure description is set
total_ht: line.quantity * line.unit_price * (1 - (line.discount_pct || 0) / 100),
description: line.product_name || "Produit sans nom",
})),
});
router.push("/ventes/devis");
@ -188,11 +199,145 @@ const saveQuote = async () => {
}
};
const cancel = () => {
router.back();
};
const cancel = () => router.back();
onMounted(() => {
clientStore.fetchClients();
});
onMounted(() => clientStore.fetchClients());
</script>
<style scoped>
/* ── Field Groups ── */
.field-label {
display: block;
font-size: 0.72rem;
font-weight: 700;
color: #8898aa;
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 0.4rem;
}
.field-select {
border-radius: 8px;
border-color: #e9ecef;
font-size: 0.875rem;
}
.field-error {
margin-top: 0.35rem;
font-size: 0.75rem;
color: #f5365c;
}
/* ── Lines Header ── */
.lines-header {
display: flex;
gap: 0.5rem;
padding: 0 0.5rem 0.5rem;
border-bottom: 2px solid #f0f2f8;
margin-bottom: 0.5rem;
}
.line-col {
font-size: 0.68rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
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 Row ── */
.line-row {
border-radius: 10px;
margin-bottom: 0.4rem;
transition: background 0.15s;
}
.line-row:hover {
background: #fafbff;
}
/* ── Line Transition ── */
.line-fade-enter-active,
.line-fade-leave-active {
transition: all 0.2s ease;
}
.line-fade-enter-from,
.line-fade-leave-to {
opacity: 0;
transform: translateY(-6px);
}
/* ── Empty State ── */
.lines-empty {
display: flex;
flex-direction: column;
align-items: center;
padding: 2.5rem 1rem;
color: #adb5bd;
font-size: 0.85rem;
}
.lines-empty i {
font-size: 2rem;
}
/* ── Add Line ── */
.lines-footer {
margin-top: 0.75rem;
padding-top: 0.75rem;
border-top: 1px dashed #e9ecef;
}
.add-line-btn {
font-size: 0.82rem;
font-weight: 700;
}
/* ── Totals ── */
.totals-list {
padding: 0.75rem 1.25rem;
}
.totals-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.6rem 0;
border-bottom: 1px solid #f0f2f8;
font-size: 0.85rem;
}
.totals-row:last-child {
border-bottom: none;
}
.totals-label {
color: #8898aa;
}
.totals-value {
font-weight: 600;
color: #1a1f36;
}
.totals-row--final {
margin-top: 0.25rem;
padding-top: 0.75rem;
border-top: 2px solid #e9ecef;
}
.totals-value--final {
font-size: 1.1rem;
font-weight: 800;
color: #5e72e4;
}
</style>

View File

@ -1,76 +1,131 @@
<template>
<div v-if="loading" class="text-center py-5">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Loading...</span>
<!-- Loading -->
<div v-if="loading" class="detail-state">
<div class="spinner-ring"></div>
<p>Chargement du devis</p>
</div>
<!-- Error -->
<div v-else-if="error" class="detail-state detail-state--error">
<i class="fas fa-exclamation-triangle"></i>
<p>{{ error }}</p>
<button class="btn-retry" @click="reload">
<i class="fas fa-redo me-2"></i>Réessayer
</button>
</div>
<div v-else-if="error" class="text-center py-5 text-danger">
{{ error }}
</div>
<!-- Content -->
<quote-detail-template v-else-if="quote">
<template #header>
<quote-header
:reference="quote.reference"
:date="quote.quote_date"
:code="quote.reference"
/>
<div class="d-flex justify-content-between align-items-start flex-wrap gap-3">
<div>
<h6 class="mb-1">Quote Details</h6>
<p class="text-sm mb-0">
Quote no.
<b>{{ quote.reference || "—" }}</b>
from
<b>{{ formatDate(quote.quote_date) }}</b>
</p>
<p class="text-sm mb-0">
Valid until:
<b>{{ formatDate(quote.valid_until) }}</b>
</p>
</div>
<div class="d-flex align-items-center flex-wrap gap-2">
<soft-badge :color="statusBadgeColor(quote.status)" variant="gradient">
<i :class="statusIcon(quote.status) + ' me-1'"></i>
{{ getStatusLabel(quote.status) }}
</soft-badge>
<soft-button color="secondary" variant="gradient" class="mb-0">
Export PDF
</soft-button>
</div>
</div>
</template>
<template #lines>
<quote-lines-table :lines="quote.lines" />
<template #product>
<div class="d-flex">
<div class="qd-visual me-3">
<i class="fas fa-file-signature"></i>
</div>
<div>
<h6 class="text-lg mb-0 mt-1">{{ quote.client?.name || "Client inconnu" }}</h6>
<p class="text-sm mb-3">{{ quote.lines?.length || 0 }} ligne(s) dans ce devis.</p>
<soft-badge :color="statusBadgeColor(quote.status)" variant="gradient" size="sm">
{{ getStatusLabel(quote.status) }}
</soft-badge>
</div>
</div>
</template>
<template #cta>
<div class="d-flex justify-content-end">
<div class="qd-status-select-wrap">
<label class="form-label text-xs mb-1">Change status</label>
<select
class="form-select qd-status-select"
:value="selectedStatus"
:disabled="updating"
@change="onStatusSelect"
>
<option v-for="s in availableStatuses" :key="s" :value="s">
{{ getStatusLabel(s) }}
</option>
</select>
</div>
</div>
<p class="text-sm mt-2 mb-0 text-end">
Change quote status directly from this section.
</p>
</template>
<template #timeline>
<h6 class="mb-3">Track quote</h6>
<quote-timeline :history="quote.history" />
</template>
<template #billing>
<quote-billing-info
:client-name="quote.client ? quote.client.name : 'Client inconnu'"
:client-email="quote.client ? quote.client.email : ''"
:client-phone="quote.client ? quote.client.phone : ''"
/>
<template #payment>
<h6 class="mb-3">Quote lines</h6>
<quote-lines-table :lines="quote.lines" />
<h6 class="mb-3 mt-4">Billing Information</h6>
<ul class="list-group">
<li class="list-group-item border-0 d-flex p-4 mb-2 bg-gray-100 border-radius-lg">
<div class="d-flex flex-column">
<h6 class="mb-3 text-sm">{{ quote.client?.name || "Client inconnu" }}</h6>
<span class="mb-2 text-xs">
Email Address:
<span class="text-dark ms-2 font-weight-bold">{{ quote.client?.email || "—" }}</span>
</span>
<span class="mb-2 text-xs">
Phone:
<span class="text-dark ms-2 font-weight-bold">{{ quote.client?.phone || "—" }}</span>
</span>
<span class="text-xs">
Quote Reference:
<span class="text-dark ms-2 font-weight-bold">{{ quote.reference || "—" }}</span>
</span>
</div>
</li>
</ul>
</template>
<template #summary>
<quote-summary
:ht="quote.total_ht"
:tva="quote.total_tva"
:ttc="quote.total_ttc"
/>
</template>
<template #actions>
<div class="d-flex justify-content-end">
<div class="position-relative d-inline-block me-2">
<soft-button
color="secondary"
variant="gradient"
@click="dropdownOpen = !dropdownOpen"
>
{{ getStatusLabel(quote.status) }}
<i class="fas fa-chevron-down ms-2"></i>
</soft-button>
<ul
v-if="dropdownOpen"
class="dropdown-menu show position-absolute"
style="top: 100%; left: 0; z-index: 1000"
>
<li v-for="status in availableStatuses" :key="status">
<a
class="dropdown-item"
:class="{ active: status === quote.status }"
href="javascript:;"
@click="
changeStatus(status);
dropdownOpen = false;
"
>
{{ getStatusLabel(status) }}
</a>
</li>
</ul>
<h6 class="mb-3">Quote Summary</h6>
<div class="d-flex justify-content-between">
<span class="mb-2 text-sm">Total HT:</span>
<span class="text-dark font-weight-bold ms-2">{{ formatCurrency(quote.total_ht) }}</span>
</div>
<div class="d-flex justify-content-between">
<span class="mb-2 text-sm">TVA:</span>
<span class="text-dark ms-2 font-weight-bold">{{ formatCurrency(quote.total_tva) }}</span>
</div>
<div class="d-flex justify-content-between mt-4">
<span class="mb-2 text-lg">Total TTC:</span>
<span class="text-dark text-lg ms-2 font-weight-bold">{{ formatCurrency(quote.total_ttc) }}</span>
</div>
</template>
</quote-detail-template>
@ -78,65 +133,66 @@
<script setup>
import { ref, onMounted, defineProps } from "vue";
import { useRouter } from "vue-router";
import { useQuoteStore } from "@/stores/quoteStore";
import { useNotificationStore } from "@/stores/notification";
import QuoteDetailTemplate from "@/components/templates/Quote/QuoteDetailTemplate.vue";
import QuoteHeader from "@/components/molecules/Quote/QuoteHeader.vue";
import QuoteTimeline from "@/components/molecules/Quote/QuoteTimeline.vue";
import QuoteBillingInfo from "@/components/molecules/Quote/QuoteBillingInfo.vue";
import QuoteSummary from "@/components/molecules/Quote/QuoteSummary.vue";
import QuoteLinesTable from "@/components/molecules/Quote/QuoteLinesTable.vue";
import SoftButton from "@/components/SoftButton.vue";
import SoftBadge from "@/components/SoftBadge.vue";
const props = defineProps({
quoteId: {
type: [String, Number],
required: true,
},
quoteId: { type: [String, Number], required: true },
});
const router = useRouter();
const quoteStore = useQuoteStore();
const notificationStore = useNotificationStore();
const quote = ref(null);
const loading = ref(true);
const updating = ref(false);
const error = ref(null);
const dropdownOpen = ref(false);
const selectedStatus = ref("brouillon");
onMounted(async () => {
const load = async () => {
loading.value = true;
error.value = null;
try {
const fetchedQuote = await quoteStore.fetchQuote(props.quoteId);
quote.value = fetchedQuote;
quote.value = await quoteStore.fetchQuote(props.quoteId);
selectedStatus.value = quote.value?.status || "brouillon";
} catch (e) {
error.value = "Impossible de charger le devis.";
console.error(e);
} finally {
loading.value = false;
}
});
const goBack = () => {
router.back();
};
const formatDate = (dateString) => {
if (!dateString) return "-";
return new Date(dateString).toLocaleDateString("fr-FR");
const reload = () => load();
onMounted(load);
/* ── Helpers ── */
const formatDate = (d) =>
d
? new Date(d).toLocaleDateString("fr-FR", {
day: "2-digit",
month: "short",
year: "numeric",
})
: "—";
const formatCurrency = (value) => {
const amount = Number(value || 0);
return new Intl.NumberFormat("fr-FR", {
style: "currency",
currency: "EUR",
minimumFractionDigits: 2,
}).format(amount);
};
const availableStatuses = [
"brouillon",
"envoye",
"accepte",
"refuse",
"expire",
"annule",
];
const availableStatuses = ["brouillon", "envoye", "accepte", "refuse", "expire", "annule"];
const getStatusLabel = (status) => {
const labels = {
const statusLabels = {
brouillon: "Brouillon",
envoye: "Envoyé",
accepte: "Accepté",
@ -144,43 +200,125 @@ const getStatusLabel = (status) => {
expire: "Expiré",
annule: "Annulé",
};
return labels[status] || status;
const statusIcons = {
brouillon: "fas fa-pencil-alt",
envoye: "fas fa-paper-plane",
accepte: "fas fa-check-circle",
refuse: "fas fa-times-circle",
expire: "fas fa-clock",
annule: "fas fa-ban",
};
/* eslint-disable require-atomic-updates */
const changeStatus = async (newStatus) => {
if (!quote.value?.id) return;
const statusBadgeColor = (status) => {
const map = {
brouillon: "warning",
envoye: "info",
accepte: "success",
refuse: "danger",
expire: "secondary",
annule: "dark",
};
return map[status] || "secondary";
};
// Capture the current quote ID to prevent race conditions
const currentQuoteId = quote.value.id;
const getStatusLabel = (s) => statusLabels[s] || s;
const statusIcon = (s) => statusIcons[s] || "fas fa-circle";
try {
loading.value = true;
const updated = await quoteStore.updateQuote({
id: currentQuoteId,
status: newStatus,
});
const onStatusSelect = (event) => {
const newStatus = event.target.value;
selectedStatus.value = newStatus;
if (!quote.value?.id || newStatus === quote.value.status) return;
changeStatus(quote.value.id, newStatus);
};
// Only update if we're still viewing the same quote
if (quote.value?.id === currentQuoteId) {
/* ── Status Update ── */
const changeStatus = (id, newStatus) => {
if (!id || updating.value) return;
updating.value = true;
quoteStore
.updateQuote({ id, status: newStatus })
.then((updated) => {
if (`${props.quoteId}` !== `${id}`) return;
quote.value = updated;
// Show success notification
selectedStatus.value = updated?.status || newStatus;
notificationStore.success(
"Statut mis à jour",
`Le devis est maintenant "${getStatusLabel(newStatus)}"`,
3000
);
}
} catch (e) {
console.error("Failed to update status", e);
notificationStore.error(
"Erreur",
"Impossible de mettre à jour le statut",
3000
);
} finally {
loading.value = false;
}
})
.catch((e) => {
console.error(e);
notificationStore.error("Erreur", "Impossible de mettre à jour le statut", 3000);
})
.finally(() => {
updating.value = false;
});
};
</script>
<style scoped>
/* ── States ── */
.detail-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 60vh;
color: #8898aa;
font-size: .9rem;
gap: .6rem;
}
.detail-state--error i {
font-size: 2.5rem;
color: #f5365c;
}
.spinner-ring {
width: 42px;
height: 42px;
border: 3px solid #e9ecef;
border-top-color: #5e72e4;
border-radius: 50%;
animation: spin .8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
.btn-retry {
margin-top: .25rem;
padding: .4rem 1.1rem;
border-radius: 8px;
border: 1.5px solid #f5365c;
background: none;
color: #f5365c;
font-size: .8rem;
font-weight: 700;
cursor: pointer;
transition: background .15s;
}
.btn-retry:hover { background: #fff0f3; }
.qd-visual {
width: 64px;
height: 64px;
border-radius: 12px;
background: linear-gradient(135deg, #5e72e4, #825ee4);
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-size: 1.3rem;
flex-shrink: 0;
}
.qd-status-select-wrap {
min-width: 220px;
}
.qd-status-select {
font-size: 0.85rem;
}
</style>

View File

@ -24,7 +24,7 @@
selectedItem.email || "Pas d'email"
}})
</div>
<div v-if="fieldErrors.client_id" class="invalid-feedback">
<div v-if="fieldErrors.client_id" class="invalid-feedback small-error">
{{ fieldErrors.client_id }}
</div>
</div>
@ -47,7 +47,7 @@
Sélectionné: {{ selectedDeceased.last_name }}
{{ selectedDeceased.first_name || "" }}
</div>
<div v-if="fieldErrors.deceased_id" class="invalid-feedback">
<div v-if="fieldErrors.deceased_id" class="invalid-feedback small-error">
{{ fieldErrors.deceased_id }}
</div>
</div>
@ -72,7 +72,7 @@
<option value="retrait_bijoux">Retrait bijoux</option>
<option value="autre">Autre</option>
</select>
<div v-if="fieldErrors.type" class="invalid-feedback">
<div v-if="fieldErrors.type" class="invalid-feedback small-error">
{{ fieldErrors.type }}
</div>
</div>
@ -88,7 +88,7 @@
:class="{ 'is-invalid': fieldErrors.scheduled_at }"
type="date"
/>
<div v-if="fieldErrors.scheduled_at" class="invalid-feedback">
<div v-if="fieldErrors.scheduled_at" class="invalid-feedback small-error">
{{ fieldErrors.scheduled_at }}
</div>
</div>
@ -100,7 +100,7 @@
:class="{ 'is-invalid': fieldErrors.scheduled_at }"
type="time"
/>
<div v-if="fieldErrors.scheduled_at" class="invalid-feedback">
<div v-if="fieldErrors.scheduled_at" class="invalid-feedback small-error">
{{ fieldErrors.scheduled_at }}
</div>
</div>
@ -118,7 +118,7 @@
min="1"
placeholder="ex. 90"
/>
<div v-if="fieldErrors.duration_min" class="invalid-feedback">
<div v-if="fieldErrors.duration_min" class="invalid-feedback small-error">
{{ fieldErrors.duration_min }}
</div>
</div>
@ -136,7 +136,7 @@
<option value="termine">Terminé</option>
<option value="annule">Annulé</option>
</select>
<div v-if="fieldErrors.status" class="invalid-feedback">
<div v-if="fieldErrors.status" class="invalid-feedback small-error">
{{ fieldErrors.status }}
</div>
</div>
@ -153,7 +153,7 @@
type="text"
placeholder="Nom du donneur d'ordre"
/>
<div v-if="fieldErrors.order_giver" class="invalid-feedback">
<div v-if="fieldErrors.order_giver" class="invalid-feedback small-error">
{{ fieldErrors.order_giver }}
</div>
</div>
@ -171,22 +171,12 @@
placeholder="Informations complémentaires, instructions spéciales..."
maxlength="2000"
></textarea>
<div v-if="fieldErrors.notes" class="invalid-feedback">
<div v-if="fieldErrors.notes" class="invalid-feedback small-error">
{{ fieldErrors.notes }}
</div>
</div>
</div>
<!-- Validation errors -->
<div v-if="formValidationError" class="row mt-3">
<div class="col-12">
<div class="alert alert-warning" role="alert">
<i class="fas fa-exclamation-triangle me-2"></i>
{{ formValidationError }}
</div>
</div>
</div>
<!-- Boutons -->
<div class="button-row d-flex mt-4">
<soft-button
@ -203,7 +193,7 @@
color="dark"
variant="gradient"
class="ms-auto mb-0"
:disabled="props.loading || !!formValidationError"
:disabled="props.loading"
@click="submitForm"
>
<span
@ -219,7 +209,7 @@
</template>
<script setup>
import { ref, defineProps, defineEmits, watch, computed } from "vue";
import { ref, defineProps, defineEmits, watch } from "vue";
import SoftInput from "@/components/SoftInput.vue";
import SoftButton from "@/components/SoftButton.vue";
import SearchInput from "@/components/atoms/input/SearchInput.vue";
@ -336,19 +326,6 @@ const form = ref({
notes: "",
});
// Computed property to validate form
const formValidationError = computed(() => {
if (!form.value.client_id) {
return "Le client est obligatoire";
}
if (!form.value.type || form.value.type === "") {
return "Le type d'intervention est obligatoire";
}
return null;
});
// Watch for validation errors from parent
watch(
() => props.validationErrors,
@ -372,6 +349,9 @@ watch(
watch(
() => form.value.client_id,
(newClientId) => {
if (newClientId && fieldErrors.value.client_id) {
delete fieldErrors.value.client_id;
}
if (!newClientId) {
selectedItem.value = null;
}
@ -382,31 +362,43 @@ watch(
watch(
() => form.value.deceased_id,
(newDeceasedId) => {
if (newDeceasedId && fieldErrors.value.deceased_id) {
delete fieldErrors.value.deceased_id;
}
if (!newDeceasedId) {
selectedDeceased.value = null;
}
}
);
watch(
() => form.value.type,
(newType) => {
if (newType && fieldErrors.value.type) {
delete fieldErrors.value.type;
}
}
);
const submitForm = async () => {
// Clear errors before submitting
fieldErrors.value = {};
errors.value = [];
// Check for form validation errors
if (formValidationError.value) {
errors.value.push(formValidationError.value);
return;
}
// Validate required fields
let hasErrors = false;
if (!form.value.client_id || form.value.client_id === "") {
fieldErrors.value.client_id = "Le client est obligatoire";
return;
hasErrors = true;
}
if (!form.value.type || form.value.type === "") {
fieldErrors.value.type = "Le type d'intervention est obligatoire";
hasErrors = true;
}
if (hasErrors) {
return;
}
@ -487,6 +479,11 @@ const clearErrors = () => {
display: block;
}
.small-error {
font-size: 0.75rem;
margin-top: 0.25rem;
}
.spinner-border-sm {
width: 1rem;
height: 1rem;

View File

@ -43,36 +43,7 @@ const interventions = computed(() => {
return props.interventionData.length > 0
? props.interventionData
: [
{
id: 1, // Add required id for navigation
title: "Cérémonie religieuse",
status: {
label: "Confirmé",
color: "success",
variant: "fill",
size: "md",
},
date: "15 Décembre 2024 - 14:00",
defuntName: "Jean Dupont",
lieux: "Église Saint-Pierre, Paris",
duree: "1h30",
description:
"Cérémonie religieuse traditionnelle suivie d'une bénédiction.",
action: {
label: "Voir détails",
color: "primary",
},
members: [
{
image: "/images/avatar1.jpg",
name: "Marie Curie",
},
{
image: "/images/avatar2.jpg",
name: "Pierre Durand",
},
],
},
];
});
</script>

View File

@ -106,6 +106,21 @@ const typeIcons = {
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 (typeof value === 'string') {
// Support backend format "YYYY-MM-DD HH:mm:ss" (without timezone)
const normalized = value.includes(' ') ? value.replace(' ', 'T') : value;
const parsed = new Date(normalized);
if (!Number.isNaN(parsed.getTime())) return parsed;
}
const fallback = new Date(value);
return Number.isNaN(fallback.getTime()) ? null : fallback;
};
const weekRangeLabel = computed(() => {
if (!calendar) return '';
try {
@ -120,8 +135,12 @@ const weekRangeLabel = computed(() => {
const mapEvents = (interventions) => interventions.map((i) => {
const color = typeColors[i.type] || '#6b7280';
const start = new Date(i.date);
const end = i.end ? new Date(i.end) : new Date(start.getTime() + 60 * 60 * 1000);
const start = parseInterventionDate(i.date);
if (!start) return null;
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',
@ -131,7 +150,7 @@ const mapEvents = (interventions) => interventions.map((i) => {
textColor: '#fff',
extendedProps: { originalData: i, color },
};
});
}).filter(Boolean);
const showPopover = (jsEvent, originalData, color) => {
const rect = jsEvent.target.closest('.fc-event')?.getBoundingClientRect() || jsEvent.target.getBoundingClientRect();
@ -194,8 +213,8 @@ const initCalendar = () => {
headerToolbar: false,
initialDate: props.startDate,
allDaySlot: false,
slotMinTime: '07:00:00',
slotMaxTime: '21:00:00',
slotMinTime: '00:00:00',
slotMaxTime: '24:00:00',
height: 'auto',
expandRows: true,
stickyHeaderDates: true,

View File

@ -109,7 +109,7 @@
<td class="font-weight-bold">
<div class="d-flex align-items-center">
<soft-avatar
:img="product.media.photo_url || getRandomAvatar()"
:img="product.media?.photo_url || getRandomAvatar()"
size="xs"
class="me-2"
alt="product image"

View File

@ -1,67 +1,41 @@
<template>
<div class="contact-form-root">
<div class="card p-3 border-radius-xl bg-white" data-animation="FadeIn">
<h5 class="font-weight-bolder mb-0">Nouveau Contact</h5>
<p class="mb-0 text-sm">Associez un contact à un client existant</p>
<!-- Header -->
<div class="form-header">
<div class="header-icon-wrap">
<i class="fas fa-user-plus"></i>
</div>
<div>
<h5 class="form-title">Nouveau Contact</h5>
<p class="form-subtitle">Associez un contact à un client existant</p>
</div>
</div>
<!-- Success Banner -->
<transition name="banner-fade">
<div v-if="props.success" class="status-banner success-banner">
<i class="fas fa-check-circle banner-icon"></i>
<span><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>
<!-- Error Banner -->
<transition name="banner-fade">
<div v-if="fieldErrors.general" class="status-banner error-banner">
<i class="fas fa-exclamation-circle banner-icon"></i>
<span>{{ fieldErrors.general }}</span>
<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>
<div class="form-body">
<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>
<!-- SECTION 1: Client -->
<div class="form-section">
<div class="section-label">
<span>Client associé</span>
</div>
<!-- Search -->
<div class="field-group" v-if="!selectedClient">
<label class="field-label">
Client <span class="req">*</span>
</label>
<div v-if="!selectedClient" class="position-relative">
<div class="search-wrap" :class="{ 'has-error': fieldErrors.client_id }">
<i class="fas fa-search search-pfx"></i>
<input
:value="searchQuery"
type="text"
class="field-input search-input"
class="form-control search-input"
placeholder="Rechercher un client par nom ou email..."
@input="handleSearchInput($event.target.value)"
@focus="showDropdown = true"
@blur="onInputBlur"
/>
<span v-if="searchQuery" class="search-loader">
<i class="fas fa-circle-notch fa-spin" style="color:#9ca3af;font-size:0.75rem"></i>
<i class="fas fa-circle-notch fa-spin text-secondary text-xs"></i>
</span>
</div>
<span v-if="fieldErrors.client_id" class="field-error">
<i class="fas fa-exclamation-circle"></i> {{ fieldErrors.client_id }}
</span>
<p class="hint-text"><i class="fas fa-info-circle me-1"></i>Le contact sera rattaché à ce client</p>
<!-- Dropdown -->
<transition name="dropdown-pop">
<div v-if="showDropdown && props.searchResults && props.searchResults.length > 0" class="search-dropdown">
<button
@ -71,218 +45,158 @@
class="dropdown-row"
@mousedown="selectClient(client)"
>
<div class="dr-avatar">{{ (client.name || '?')[0] }}</div>
<div class="dr-info">
<span class="dr-avatar">{{ (client.name || "?")[0] }}</span>
<span class="dr-info">
<span class="dr-name">{{ client.name }}</span>
<span class="dr-meta" v-if="client.email">{{ client.email }}</span>
</div>
<i class="fas fa-arrow-right dr-arrow"></i>
<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">
<div class="dropdown-empty">
<i class="fas fa-search empty-icon"></i>
<span>Aucun résultat pour <strong>« {{ searchQuery }} »</strong></span>
</div>
<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>
</div>
<!-- Selected Client Card -->
<transition name="banner-fade">
<div v-if="selectedClient" class="selected-client-card">
<div class="sc-avatar">{{ (selectedClient.name || '?')[0] }}</div>
<div class="sc-info">
<div v-else class="selected-client-card mt-2">
<span class="sc-avatar">{{ (selectedClient.name || "?")[0] }}</span>
<span class="sc-info">
<span class="sc-label">Client sélectionné</span>
<span class="sc-name">{{ selectedClient.name }}</span>
<span class="sc-meta" v-if="selectedClient.email">{{ selectedClient.email }}</span>
</div>
<button type="button" class="sc-change-btn" @click="clearSelection">
<i class="fas fa-exchange-alt me-1"></i>Changer
</button>
</div>
</transition>
<span v-if="selectedClient.email" class="sc-meta">{{ selectedClient.email }}</span>
</span>
<soft-button type="button" color="secondary" variant="outline" size="sm" @click="clearSelection">
Changer
</soft-button>
</div>
<div class="section-divider"></div>
<!-- SECTION 2: Identity -->
<div class="form-section">
<div class="section-label">
<span>Identité</span>
<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>
<div class="two-col">
<div class="field-group">
<label class="field-label">Prénom</label>
<input
:value="form.first_name"
<div class="row mt-3">
<div class="col-12 col-sm-6">
<label class="form-label">Prénom</label>
<soft-input
v-model="form.first_name"
class="multisteps-form__input"
:error="!!fieldErrors.first_name"
type="text"
class="field-input"
:class="{ 'is-invalid': fieldErrors.first_name }"
placeholder="Jean"
maxlength="191"
@input="form.first_name = $event.target.value"
/>
<span v-if="fieldErrors.first_name" class="field-error">
<i class="fas fa-exclamation-circle"></i> {{ fieldErrors.first_name }}
</span>
<div v-if="fieldErrors.first_name" class="invalid-feedback d-block">{{ fieldErrors.first_name }}</div>
</div>
<div class="field-group">
<label class="field-label">Nom</label>
<input
:value="form.last_name"
<div class="col-12 col-sm-6 mt-3 mt-sm-0">
<label class="form-label">Nom</label>
<soft-input
v-model="form.last_name"
class="multisteps-form__input"
:error="!!fieldErrors.last_name"
type="text"
class="field-input"
:class="{ 'is-invalid': fieldErrors.last_name }"
placeholder="Dupont"
maxlength="191"
@input="form.last_name = $event.target.value"
/>
<span v-if="fieldErrors.last_name" class="field-error">
<i class="fas fa-exclamation-circle"></i> {{ fieldErrors.last_name }}
</span>
<div v-if="fieldErrors.last_name" class="invalid-feedback d-block">{{ fieldErrors.last_name }}</div>
</div>
</div>
<div class="field-group mt-field">
<label class="field-label">
<i class="fas fa-briefcase field-pfx-icon"></i>Rôle / Poste
</label>
<input
:value="form.role"
<div class="row mt-3">
<div class="col-12">
<label class="form-label">Rôle / Poste</label>
<soft-input
v-model="form.role"
class="multisteps-form__input"
:error="!!fieldErrors.role"
type="text"
class="field-input"
:class="{ 'is-invalid': fieldErrors.role }"
placeholder="ex. Directeur Commercial, Responsable RH..."
maxlength="191"
@input="form.role = $event.target.value"
/>
<span v-if="fieldErrors.role" class="field-error">
<i class="fas fa-exclamation-circle"></i> {{ fieldErrors.role }}
</span>
<div v-if="fieldErrors.role" class="invalid-feedback d-block">{{ fieldErrors.role }}</div>
</div>
</div>
<div class="section-divider"></div>
<!-- SECTION 3: Coordonnées -->
<div class="form-section">
<div class="section-label">
<span>Coordonnées</span>
</div>
<div class="field-group">
<label class="field-label">
<i class="fas fa-envelope field-pfx-icon"></i>Email
</label>
<input
:value="form.email"
<div class="row mt-3">
<div class="col-12">
<label class="form-label">Email</label>
<soft-input
v-model="form.email"
class="multisteps-form__input"
:error="!!fieldErrors.email"
type="email"
class="field-input"
:class="{ 'is-invalid': fieldErrors.email }"
placeholder="jean.dupont@entreprise.com"
maxlength="191"
@input="form.email = $event.target.value"
/>
<span v-if="fieldErrors.email" class="field-error">
<i class="fas fa-exclamation-circle"></i> {{ fieldErrors.email }}
</span>
<div v-if="fieldErrors.email" class="invalid-feedback d-block">{{ fieldErrors.email }}</div>
</div>
</div>
<div class="two-col mt-field">
<div class="field-group">
<label class="field-label">
<i class="fas fa-phone field-pfx-icon"></i>Téléphone
</label>
<input
:value="form.phone"
<div class="row mt-3">
<div class="col-12 col-sm-6">
<label class="form-label">Téléphone</label>
<soft-input
v-model="form.phone"
class="multisteps-form__input"
:error="!!fieldErrors.phone"
type="text"
class="field-input"
:class="{ 'is-invalid': fieldErrors.phone }"
placeholder="+33 1 23 45 67 89"
maxlength="50"
@input="form.phone = $event.target.value"
/>
<span v-if="fieldErrors.phone" class="field-error">
<i class="fas fa-exclamation-circle"></i> {{ fieldErrors.phone }}
</span>
<div v-if="fieldErrors.phone" class="invalid-feedback d-block">{{ fieldErrors.phone }}</div>
</div>
<div class="field-group">
<label class="field-label">
<i class="fas fa-mobile-alt field-pfx-icon"></i>Mobile
</label>
<input
:value="form.mobile"
<div class="col-12 col-sm-6 mt-3 mt-sm-0">
<label class="form-label">Mobile</label>
<soft-input
v-model="form.mobile"
class="multisteps-form__input"
:error="!!fieldErrors.mobile"
type="text"
class="field-input"
:class="{ 'is-invalid': fieldErrors.mobile }"
placeholder="+33 6 12 34 56 78"
maxlength="50"
@input="form.mobile = $event.target.value"
/>
<span v-if="fieldErrors.mobile" class="field-error">
<i class="fas fa-exclamation-circle"></i> {{ fieldErrors.mobile }}
</span>
</div>
<div v-if="fieldErrors.mobile" class="invalid-feedback d-block">{{ fieldErrors.mobile }}</div>
</div>
</div>
<div class="section-divider"></div>
<!-- SECTION 4: Notes -->
<div class="form-section">
<div class="section-label">
<span>Notes</span>
</div>
<div class="field-group">
<label class="field-label">
<i class="fas fa-sticky-note field-pfx-icon"></i>Observations
</label>
<div class="row mt-3">
<div class="col-12">
<label class="form-label">Observations</label>
<textarea
:value="form.notes"
class="field-textarea"
class="form-control multisteps-form__input"
rows="3"
placeholder="Informations complémentaires sur ce contact..."
maxlength="1000"
@input="form.notes = $event.target.value"
></textarea>
<span class="char-count">{{ (form.notes || '').length }}/1000</span>
<div class="text-end text-xs text-secondary mt-1">{{ (form.notes || "").length }}/1000</div>
</div>
</div>
<!-- Validation Warning -->
<transition name="banner-fade">
<div v-if="showValidationWarning && form.client_id" class="warn-banner">
<i class="fas fa-exclamation-triangle warn-icon"></i>
<span>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>
<!-- Footer Actions -->
<div class="form-footer">
<button type="button" class="btn-reset" @click="resetForm">
<i class="fas fa-undo me-2"></i>Réinitialiser
</button>
<button
<div class="d-flex mt-4 justify-content-between gap-2 flex-wrap">
<soft-button type="button" color="secondary" variant="outline" @click="resetForm">
Réinitialiser
</soft-button>
<soft-button
type="button"
class="btn-submit"
color="info"
variant="gradient"
:disabled="props.loading || !isFormValid"
@click="submitForm"
>
<span v-if="props.loading" class="spinner-border spinner-border-sm me-2" role="status"></span>
<i v-else class="fas fa-user-check me-2"></i>
{{ props.loading ? "Création en cours…" : "Créer le contact" }}
</button>
<span>{{ props.loading ? "Création en cours…" : "Créer le contact" }}</span>
</soft-button>
</div>
</div>
</div>
</template>
<script setup>
import { ref, defineProps, defineEmits, watch, computed } from "vue";
import SoftInput from "@/components/SoftInput.vue";
import SoftButton from "@/components/SoftButton.vue";
const props = defineProps({
loading: { type: Boolean, default: false },
@ -293,7 +207,6 @@ const props = defineProps({
const emit = defineEmits(["createContact", "searchClient", "clientSelected"]);
const errors = ref([]);
const fieldErrors = ref({});
const searchQuery = ref("");
const selectedClient = ref(null);
@ -370,285 +283,52 @@ const resetForm = () => {
searchQuery.value = "";
showDropdown.value = false;
fieldErrors.value = {};
errors.value = [];
};
</script>
<style scoped>
/* ─── Root ─── */
.contact-form-root {
background: #fff;
border-radius: 16px;
border: 1px solid #e5e7eb;
box-shadow: 0 4px 24px rgba(0,0,0,0.07);
overflow: hidden;
display: flex;
flex-direction: column;
}
/* ─── Header ─── */
.form-header {
display: flex;
align-items: center;
gap: 14px;
padding: 20px 24px 16px;
border-bottom: 1px solid #f0f2f5;
background: linear-gradient(135deg, #1a2e4a 0%, #2d4a6e 100%);
}
.header-icon-wrap {
width: 44px;
height: 44px;
border-radius: 12px;
background: rgba(255,255,255,0.15);
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
color: #fff;
flex-shrink: 0;
}
.form-title {
color: #fff;
font-size: 1rem;
font-weight: 700;
margin: 0 0 2px;
}
.form-subtitle {
color: rgba(255,255,255,0.6);
font-size: 0.78rem;
margin: 0;
}
/* ─── Banners ─── */
.status-banner {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 24px;
font-size: 0.84rem;
}
.success-banner {
background: #f0fdf4;
border-bottom: 1px solid #bbf7d0;
color: #166534;
}
.error-banner {
background: #fff1f2;
border-bottom: 1px solid #fecdd3;
color: #9f1239;
}
.banner-icon { font-size: 1rem; flex-shrink: 0; }
.warn-banner {
display: flex;
align-items: flex-start;
gap: 10px;
background: #fffbeb;
border: 1px solid #fde68a;
border-radius: 9px;
padding: 10px 14px;
font-size: 0.8rem;
color: #92400e;
margin: 0 24px;
}
.warn-icon { color: #f59e0b; flex-shrink: 0; margin-top: 1px; }
/* ─── Body ─── */
.form-body {
flex: 1;
padding: 4px 0;
}
/* ─── Sections ─── */
.form-section {
padding: 18px 24px;
}
.section-divider {
height: 1px;
background: #f0f2f5;
margin: 0 24px;
}
.section-label {
display: flex;
align-items: center;
gap: 9px;
font-size: 0.72rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.07em;
color: #8c9ab0;
margin-bottom: 14px;
}
.section-num {
width: 20px;
height: 20px;
border-radius: 50%;
background: #e8eef7;
color: #2d4a6e;
font-size: 0.68rem;
font-weight: 800;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
/* ─── Grid ─── */
.two-col {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
@media (max-width: 540px) {
.two-col { grid-template-columns: 1fr; }
}
.mt-field { margin-top: 12px; }
/* ─── Field Group ─── */
.field-group {
display: flex;
flex-direction: column;
gap: 5px;
position: relative;
}
.field-label {
font-size: 0.79rem;
font-weight: 600;
color: #374151;
display: flex;
align-items: center;
gap: 5px;
}
.field-pfx-icon {
font-size: 0.72rem;
color: #9ca3af;
width: 14px;
text-align: center;
}
.req { color: #ef4444; }
/* ─── Inputs ─── */
.field-input {
border: 1.5px solid #e5e7eb;
border-radius: 9px;
padding: 9px 12px;
font-size: 0.875rem;
color: #111827;
outline: none;
width: 100%;
background: #fff;
transition: border-color 0.2s, box-shadow 0.2s;
font-family: inherit;
}
.field-input:focus {
border-color: #2d4a6e;
box-shadow: 0 0 0 3px rgba(45,74,110,0.08);
}
.field-input.is-invalid { border-color: #ef4444; }
.field-input::placeholder { color: #9ca3af; }
.field-textarea {
border: 1.5px solid #e5e7eb;
border-radius: 9px;
padding: 9px 12px;
font-size: 0.875rem;
color: #111827;
outline: none;
width: 100%;
background: #fff;
resize: vertical;
min-height: 80px;
transition: border-color 0.2s, box-shadow 0.2s;
font-family: inherit;
}
.field-textarea:focus {
border-color: #2d4a6e;
box-shadow: 0 0 0 3px rgba(45,74,110,0.08);
}
.field-textarea::placeholder { color: #9ca3af; }
.char-count {
font-size: 0.68rem;
color: #9ca3af;
text-align: right;
margin-top: -2px;
}
/* ─── Field Error ─── */
.field-error {
font-size: 0.74rem;
color: #ef4444;
display: flex;
align-items: center;
gap: 4px;
}
.hint-text {
font-size: 0.73rem;
color: #9ca3af;
margin: 0;
}
/* ─── Search ─── */
.search-wrap {
position: relative;
display: flex;
align-items: center;
border: 1.5px solid #e5e7eb;
border-radius: 9px;
border: 1px solid #d2d6da;
border-radius: 0.5rem;
background: #fff;
overflow: visible;
overflow: hidden;
transition: border-color 0.2s, box-shadow 0.2s;
}
.search-wrap:focus-within {
border-color: #2d4a6e;
box-shadow: 0 0 0 3px rgba(45,74,110,0.08);
border-color: #8392ab;
box-shadow: 0 0 0 2px rgba(131,146,171,0.25);
}
.search-wrap.has-error { border-color: #ef4444; }
.search-wrap.has-error { border-color: #fd5c70; }
.search-pfx {
padding: 0 10px;
padding: 0 0.75rem;
color: #9ca3af;
font-size: 0.8rem;
font-size: 0.75rem;
flex-shrink: 0;
}
.search-input {
flex: 1;
border: none !important;
border-radius: 0 !important;
box-shadow: none !important;
padding: 9px 4px !important;
border: none;
padding: 0.55rem 0;
min-width: 0;
outline: none;
}
.search-input:focus { box-shadow: none !important; border: none !important; }
.search-input:focus { box-shadow: none; }
.search-loader { padding: 0 10px; flex-shrink: 0; }
.search-loader { padding: 0 0.75rem; flex-shrink: 0; }
/* ─── Dropdown ─── */
.search-dropdown {
position: absolute;
top: calc(100% + 5px);
left: 0;
right: 0;
background: #fff;
border: 1.5px solid #e5e7eb;
border-radius: 10px;
border: 1px solid #e9ecef;
border-radius: 0.5rem;
box-shadow: 0 12px 32px rgba(0,0,0,0.12);
z-index: 1050;
overflow: hidden;
@ -663,24 +343,23 @@ const resetForm = () => {
width: 100%;
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
gap: 0.625rem;
padding: 0.625rem 0.875rem;
border: none;
background: none;
cursor: pointer;
text-align: left;
border-bottom: 1px solid #f9fafb;
border-bottom: 1px solid #f3f4f6;
transition: background 0.15s;
}
.dropdown-row:last-child { border-bottom: none; }
.dropdown-row:hover { background: #f5f8ff; }
.dropdown-row:hover .dr-arrow { opacity: 1; transform: translateX(0); }
.dr-avatar {
width: 32px;
height: 32px;
border-radius: 9px;
background: linear-gradient(135deg, #1a2e4a, #2d4a6e);
width: 2rem;
height: 2rem;
border-radius: 0.5rem;
background: linear-gradient(310deg, #2152ff, #21d4fd);
color: #fff;
font-size: 0.8rem;
font-weight: 700;
@ -701,7 +380,7 @@ const resetForm = () => {
.dr-name {
font-size: 0.84rem;
font-weight: 600;
font-weight: 700;
color: #111827;
white-space: nowrap;
overflow: hidden;
@ -716,46 +395,23 @@ const resetForm = () => {
text-overflow: ellipsis;
}
.dr-arrow {
font-size: 0.72rem;
color: #2d4a6e;
opacity: 0;
transform: translateX(-4px);
transition: opacity 0.15s, transform 0.15s;
flex-shrink: 0;
}
.dropdown-empty {
padding: 20px;
text-align: center;
color: #9ca3af;
font-size: 0.82rem;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.empty-icon { font-size: 1.4rem; opacity: 0.4; }
/* ─── Selected Client Card ─── */
.selected-client-card {
display: flex;
align-items: center;
gap: 12px;
background: #f0fdf4;
border: 1.5px solid #bbf7d0;
border-radius: 10px;
padding: 12px 14px;
gap: 0.75rem;
background: #f8f9fa;
border: 1px solid #e9ecef;
border-radius: 0.75rem;
padding: 0.75rem;
}
.sc-avatar {
width: 40px;
height: 40px;
border-radius: 10px;
background: linear-gradient(135deg, #059669, #10b981);
width: 2.25rem;
height: 2.25rem;
border-radius: 0.5rem;
background: linear-gradient(310deg, #17ad37, #98ec2d);
color: #fff;
font-size: 1rem;
font-size: 0.875rem;
font-weight: 700;
display: flex;
align-items: center;
@ -773,90 +429,25 @@ const resetForm = () => {
}
.sc-label {
font-size: 0.68rem;
font-size: 0.65rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #059669;
color: #8392ab;
}
.sc-name {
font-size: 0.88rem;
font-size: 0.85rem;
font-weight: 700;
color: #064e3b;
color: #344767;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.sc-meta {
font-size: 0.73rem;
color: #6b7280;
}
.sc-change-btn {
background: #fff;
border: 1.5px solid #bbf7d0;
border-radius: 8px;
color: #059669;
font-size: 0.75rem;
font-weight: 600;
padding: 5px 12px;
cursor: pointer;
flex-shrink: 0;
transition: all 0.15s;
white-space: nowrap;
}
.sc-change-btn:hover { background: #dcfce7; border-color: #86efac; }
/* ─── Footer ─── */
.form-footer {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 24px;
background: #f8fafc;
border-top: 1px solid #f0f2f5;
gap: 10px;
flex-wrap: wrap;
}
.btn-reset {
font-size: 0.82rem;
color: #6b7280;
background: #fff;
border: 1.5px solid #e5e7eb;
border-radius: 9px;
padding: 8px 16px;
cursor: pointer;
font-weight: 500;
transition: all 0.15s;
}
.btn-reset:hover { background: #f3f4f6; border-color: #d1d5db; }
.btn-submit {
font-size: 0.875rem;
color: #fff;
background: linear-gradient(135deg, #1a2e4a 0%, #2d4a6e 100%);
border: none;
border-radius: 9px;
padding: 9px 22px;
cursor: pointer;
font-weight: 600;
display: flex;
align-items: center;
transition: all 0.2s;
box-shadow: 0 2px 8px rgba(45,74,110,0.25);
margin-left: auto;
}
.btn-submit:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 4px 14px rgba(45,74,110,0.35);
}
.btn-submit:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
font-size: 0.72rem;
color: #8392ab;
}
/* ─── Animations ─── */

View File

@ -1,17 +1,23 @@
<template>
<div class="planning-container p-2 p-md-4">
<div class="planning-container container-fluid py-4">
<div class="container-max mx-auto h-100">
<!-- Header Section -->
<div class="card border-0 shadow-sm mb-4">
<div
class="d-flex flex-column flex-md-row justify-content-between align-items-start align-items-md-center mb-4 gap-3"
class="card-body p-3 p-md-4 d-flex flex-column flex-md-row justify-content-between align-items-start align-items-md-center gap-3"
>
<slot name="header"></slot>
</div>
</div>
<!-- View Toggles -->
<div class="d-flex gap-2 mb-4 overflow-auto pb-2">
<div class="card border-0 shadow-sm mb-4">
<div class="card-body p-2 p-md-3">
<div class="d-flex gap-2 overflow-auto pb-1">
<slot name="view-toggles"></slot>
</div>
</div>
</div>
<div class="content-wrapper h-100">
<div class="row g-4">
@ -27,12 +33,16 @@
<!-- Calendar Grid Column (Right on desktop) -->
<div class="col-12 col-xl-12 order-1 order-xl-2">
<div class="card border-0 shadow-sm">
<div class="card-body p-2 p-md-3 p-lg-4">
<slot name="calendar-grid"></slot>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
@ -42,7 +52,7 @@
<style scoped>
.planning-container {
min-height: 100vh;
background: linear-gradient(135deg, #f0f7ff 0%, #eef2ff 50%, #f5f3ff 100%);
background-color: #f8f9fa;
}
.container-max {
@ -56,10 +66,6 @@
min-height: calc(100vh - 12rem);
}
.gap-3 {
gap: 1rem;
}
/* Custom scrollbar for view toggles on mobile */
.overflow-auto::-webkit-scrollbar {
height: 4px;

View File

@ -1,50 +1,69 @@
<template>
<div class="container-fluid py-4">
<div class="row">
<div class="col-12">
<div class="card">
<div class="card-header pb-0">
<div class="d-lg-flex">
<div>
<h5 class="mb-0">Nouveau Devis</h5>
<p class="text-sm mb-0">
Créer un nouveau devis pour un client.
</p>
<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 align-items-center gap-3">
<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 class="ms-auto my-auto mt-lg-0 mt-4">
<div class="ms-auto my-auto">
<div>
<h6 class="mb-0">Nouveau Devis</h6>
<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">
<slot name="actions"></slot>
</div>
</div>
</div>
<div class="card-body p-3 pt-0">
<hr class="horizontal dark mt-0 mb-4" />
<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="d-flex align-items-center mb-3">
<i class="fas fa-user-circle me-2 text-primary"></i>
<h6 class="mb-0">Client</h6>
</div>
<div class="card-body">
<div class="row">
<!-- Client Selection -->
<div class="col-12 col-lg-4 mb-4">
<slot name="client-selection"></slot>
</div>
<!-- Quote Details -->
<div class="col-12 col-lg-8 mb-4">
</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="d-flex align-items-center mb-3">
<i class="fas fa-calendar-alt me-2 text-primary"></i>
<h6 class="mb-0">Informations</h6>
</div>
<slot name="quote-details"></slot>
</div>
</div>
</div>
<hr class="horizontal dark my-4" />
<hr class="horizontal dark mt-4 mb-4" />
<!-- Product Lines -->
<div class="row">
<div class="col-12">
<h6 class="mb-3">Produits & Services</h6>
<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="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>
</div>
<slot name="product-lines"></slot>
</div>
</div>
<hr class="horizontal dark my-4" />
<!-- Totals -->
<div class="row">
<div class="col-12 col-lg-4 ms-auto">
<div class="col-lg-4 col-12">
<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>
</div>
<slot name="totals"></slot>
</div>
</div>
@ -53,6 +72,7 @@
</div>
</div>
</div>
</div>
</template>
<script setup></script>

View File

@ -1,42 +1,41 @@
<template>
<div class="container-fluid py-4">
<div class="row">
<div class="col-lg-8 mx-auto">
<div class="col-lg-10 mx-auto">
<div class="card mb-4">
<div class="card-header p-3 pb-0">
<slot name="header"></slot>
</div>
<div class="card-body p-3 pt-0">
<hr class="horizontal dark mt-0 mb-4" />
<!-- Product Lines Section (replacing Gold Glasses) -->
<div class="row">
<div class="col-12">
<slot name="lines"></slot>
<div class="col-lg-6 col-md-6 col-12">
<slot name="product"></slot>
</div>
<div class="col-lg-6 col-md-6 col-12 my-auto text-end">
<slot name="cta"></slot>
</div>
</div>
<hr class="horizontal dark mt-4 mb-4" />
<div class="row">
<!-- Tracking/Timeline Section -->
<div class="row g-3">
<div class="col-lg-3 col-md-6 col-12">
<slot name="timeline"></slot>
</div>
<!-- Billing Info Section -->
<div class="col-lg-5 col-md-6 col-12">
<slot name="billing"></slot>
<slot name="payment"></slot>
</div>
<!-- Summary Section -->
<div class="col-lg-3 col-12 ms-auto">
<div class="col-lg-4 col-12 ms-auto">
<slot name="summary"></slot>
</div>
</div>
</div>
<div class="card-footer p-3">
<slot name="actions"></slot>
</div>
</div>
</div>
</div>