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 *.DS_Store
*.idea/ *.idea/
*.vscode/ *.vscode/
node_modules/
*.env.local *.env.local
npm-debug.log* npm-debug.log*
yarn-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); $deceased = $this->deceasedRepository->create($deceasedData);
} }
// Step 2: Create the client // Step 2: Link existing client or create a new one
$clientData = $validated['client']; if (!empty($validated['client_id'])) {
$client = $this->clientRepository->create($clientData); $client = $this->clientRepository->find($validated['client_id']);
} else {
$clientData = $validated['client'];
$client = $this->clientRepository->create($clientData);
}
// Step 3: Create the contact (if provided) // Step 3: Create the contact (if provided)
$contactId = null; $contactId = null;
@ -183,18 +187,18 @@ class InterventionController extends Controller
// Create new location for the client // Create new location for the client
$locData = array_merge($locationData, [ $locData = array_merge($locationData, [
'client_id' => $client->id, 'client_id' => $client->id,
'is_default' => false 'is_default' => false
]); ]);
$newLocation = $this->clientLocationRepository->create($locData); $newLocation = $this->clientLocationRepository->create($locData);
$locationId = $newLocation->id; $locationId = $newLocation->id;
} }
if ($locationId) { if ($locationId) {
// Fetch location to add details to notes if needed, or just rely on relation. // Fetch location to add details to notes if needed, or just rely on relation.
// For now, let's keep the legacy behavior of adding text to notes for quick reference, // For now, let's keep the legacy behavior of adding text to notes for quick reference,
// but also link the ID. Use the provided data or fetch? // but also link the ID. Use the provided data or fetch?
// If we have an ID, we might not have the text data in $locationData if it came from search. // If we have an ID, we might not have the text data in $locationData if it came from search.
// So we only append text notes if we have $locationData (Create mode or if frontend sends it). // So we only append text notes if we have $locationData (Create mode or if frontend sends it).
} }
if (!empty($locationData)) { if (!empty($locationData)) {
@ -226,23 +230,59 @@ class InterventionController extends Controller
]); ]);
$intervention = $this->interventionRepository->create($interventionData); $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 // Step 5b: Create a Quote for this intervention
try { try {
$interventionProduct = $this->productRepository->findInterventionProduct(); $interventionProduct = $this->productRepository->findInterventionProduct();
if ($interventionProduct) {
// Calculate totals
$quantity = 1;
// Ideally fetch TVA rate from product, default to 20% if not set
// Assuming product has tva_rate relationship or simple logic
$tvaRateValue = 20;
$unitPrice = $interventionProduct->prix_unitaire;
$totalHt = $unitPrice * $quantity;
$totalTva = $totalHt * ($tvaRateValue / 100);
$totalTtc = $totalHt + $totalTva;
$quoteData = [ if ($interventionProduct) {
// Calculate totals
$quantity = 1;
// Ideally fetch TVA rate from product, default to 20% if not set
// Assuming product has tva_rate relationship or simple logic
$tvaRateValue = 20;
$unitPrice = $interventionProduct->prix_unitaire;
$totalHt = $unitPrice * $quantity;
$totalTva = $totalHt * ($tvaRateValue / 100);
$totalTtc = $totalHt + $totalTva;
$quoteData = [
'client_id' => $client->id, 'client_id' => $client->id,
'status' => 'brouillon', 'status' => 'brouillon',
'quote_date' => now()->toDateString(), 'quote_date' => now()->toDateString(),
@ -509,7 +549,7 @@ class InterventionController extends Controller
'data' => new InterventionResource($intervention), 'data' => new InterventionResource($intervention),
'message' => 'Assignment(s) créé(s) avec succès.', 'message' => 'Assignment(s) créé(s) avec succès.',
'practitioners_count' => $practitioners->count(), 'practitioners_count' => $practitioners->count(),
'practitioners' => $practitioners->map(function($p) { 'practitioners' => $practitioners->map(function ($p) {
return [ return [
'id' => $p->id, 'id' => $p->id,
'full_name' => $p->full_name ?? ($p->first_name . ' ' . $p->last_name), 'full_name' => $p->full_name ?? ($p->first_name . ' ' . $p->last_name),
@ -579,7 +619,7 @@ class InterventionController extends Controller
'data' => new InterventionResource($intervention), 'data' => new InterventionResource($intervention),
'message' => 'Praticien désassigné avec succès.', 'message' => 'Praticien désassigné avec succès.',
'remaining_practitioners_count' => $remainingPractitioners->count(), 'remaining_practitioners_count' => $remainingPractitioners->count(),
'remaining_practitioners' => $remainingPractitioners->map(function($p) { 'remaining_practitioners' => $remainingPractitioners->map(function ($p) {
return [ return [
'id' => $p->id, 'id' => $p->id,
'employee_name' => $p->employee->full_name ?? ($p->employee->first_name . ' ' . $p->employee->last_name), 'employee_name' => $p->employee->full_name ?? ($p->employee->first_name . ' ' . $p->employee->last_name),
@ -634,7 +674,7 @@ class InterventionController extends Controller
'intervention_id' => $id, 'intervention_id' => $id,
'database_records' => $dbPractitioners, 'database_records' => $dbPractitioners,
'eager_loaded_count' => $eagerPractitioners->count(), 'eager_loaded_count' => $eagerPractitioners->count(),
'eager_loaded_data' => $eagerPractitioners->map(function($p) { 'eager_loaded_data' => $eagerPractitioners->map(function ($p) {
return [ return [
'id' => $p->id, 'id' => $p->id,
'full_name' => $p->full_name ?? ($p->first_name . ' ' . $p->last_name), 'full_name' => $p->full_name ?? ($p->first_name . ' ' . $p->last_name),

View File

@ -32,7 +32,8 @@ class StoreInterventionWithAllDataRequest extends FormRequest
'deceased.place_of_death' => ['nullable', 'string', 'max:255'], 'deceased.place_of_death' => ['nullable', 'string', 'max:255'],
'deceased.notes' => ['nullable', 'string'], '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.name' => ['required', 'string', 'max:255'],
'client.vat_number' => ['nullable', 'string', 'max:32'], 'client.vat_number' => ['nullable', 'string', 'max:32'],
'client.siret' => ['nullable', 'string', 'max:20'], 'client.siret' => ['nullable', 'string', 'max:20'],
@ -68,23 +69,29 @@ class StoreInterventionWithAllDataRequest extends FormRequest
'documents.*.description' => ['nullable', 'string'], 'documents.*.description' => ['nullable', 'string'],
'intervention' => 'required|array', 'intervention' => 'required|array',
'intervention.type' => ['required', Rule::in([ 'intervention.type' => [
'thanatopraxie', 'required',
'toilette_mortuaire', Rule::in([
'exhumation', 'thanatopraxie',
'retrait_pacemaker', 'toilette_mortuaire',
'retrait_bijoux', 'exhumation',
'autre' 'retrait_pacemaker',
])], 'retrait_bijoux',
'autre'
])
],
'intervention.scheduled_at' => ['nullable', 'date_format:Y-m-d H:i:s'], 'intervention.scheduled_at' => ['nullable', 'date_format:Y-m-d H:i:s'],
'intervention.duration_min' => ['nullable', 'integer', 'min:0'], 'intervention.duration_min' => ['nullable', 'integer', 'min:0'],
'intervention.status' => ['sometimes', Rule::in([ 'intervention.status' => [
'demande', 'sometimes',
'planifie', Rule::in([
'en_cours', 'demande',
'termine', 'planifie',
'annule' 'en_cours',
])], 'termine',
'annule'
])
],
'intervention.practitioners' => ['nullable', 'array'], 'intervention.practitioners' => ['nullable', 'array'],
'intervention.practitioners.*' => ['exists:thanatopractitioners,id'], 'intervention.practitioners.*' => ['exists:thanatopractitioners,id'],
'intervention.principal_practitioner_id' => ['nullable', '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 (locationForm.value.city) formData.append("location[city]", locationForm.value.city);
} }
if (productForm.value.product_id) formData.append("product_id", productForm.value.product_id); 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) => { Object.keys(interventionForm.value).forEach((key) => {
if (interventionForm.value[key] != null) { if (interventionForm.value[key] != null) {
let value = interventionForm.value[key]; let value = interventionForm.value[key];

View File

@ -8,21 +8,23 @@
aria-labelledby="planningNewRequestModalLabel" aria-labelledby="planningNewRequestModalLabel"
:aria-hidden="!show" :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-content">
<div class="modal-header"> <div class="modal-header border-bottom bg-light">
<h5 id="planningNewRequestModalLabel" class="modal-title"> <h5 id="planningNewRequestModalLabel" class="modal-title">
Nouvelle demande Nouvelle demande
</h5> </h5>
<button <button
type="button" type="button"
class="btn-close" class="btn-close d-flex align-items-center justify-content-center"
aria-label="Close" aria-label="Close"
@click="$emit('close')" @click="$emit('close')"
></button> >
<i class="fas fa-times text-dark"></i>
</button>
</div> </div>
<div class="modal-body"> <div class="modal-body p-4">
<p v-if="!creationType" class="text-sm text-muted mb-3"> <p v-if="!creationType" class="text-sm text-muted mb-3">
Choisissez le type à créer : Choisissez le type à créer :
</p> </p>
@ -108,3 +110,15 @@ defineEmits([
"update:event-form", "update:event-form",
]); ]);
</script> </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>
<soft-button <soft-button
color="info" color="info"
variant="gradient" variant="fill"
size="sm" size="sm"
@click="$emit('new-request')" @click="$emit('new-request')"
> >
@ -166,4 +166,8 @@ const handleUpdateStatus = (payload) => {
.text-sm { .text-sm {
font-size: 0.875rem; font-size: 0.875rem;
} }
.btn.btn-info {
box-shadow: 0 0.25rem 0.75rem rgba(17, 205, 239, 0.25);
}
</style> </style>

View File

@ -1,75 +1,109 @@
<template> <template>
<create-quote-template> <create-quote-template>
<!-- Actions -->
<template #actions> <template #actions>
<soft-button <soft-button color="secondary" variant="outline" class="me-2" @click="cancel">
color="secondary" <i class="fas fa-times me-1"></i> Annuler
variant="outline"
class="me-2"
@click="cancel"
>
Annuler
</soft-button> </soft-button>
<soft-button <soft-button color="primary" variant="gradient" :disabled="loading" @click="saveQuote">
color="primary" <i class="fas fa-save me-1"></i>
variant="gradient" {{ loading ? "Enregistrement..." : "Enregistrer le devis" }}
:disabled="loading"
@click="saveQuote"
>
{{ loading ? "Enregistrement..." : "Enregistrer" }}
</soft-button> </soft-button>
</template> </template>
<!-- Client Selection -->
<template #client-selection> <template #client-selection>
<label>Client</label> <div class="field-group">
<select v-model="form.client_id" class="form-select"> <label class="field-label">Client <span class="text-danger">*</span></label>
<option value="" disabled selected>Sélectionner un client</option> <select v-model="form.client_id" class="form-select field-select">
<option v-for="client in clients" :key="client.id" :value="client.id"> <option value="" disabled> Sélectionner un client </option>
{{ client.name }} <option v-for="client in clients" :key="client.id" :value="client.id">
</option> {{ client.name }}
</select> </option>
<!-- Add client search/autocomplete if list is long --> </select>
<p v-if="!form.client_id && attempted" class="field-error">
<i class="fas fa-exclamation-circle me-1"></i>Client requis
</p>
</div>
</template> </template>
<!-- Quote Details -->
<template #quote-details> <template #quote-details>
<div class="row"> <div class="field-group">
<div class="col-md-6"> <label class="field-label">Date du devis</label>
<label>Date du devis</label> <soft-input v-model="form.quote_date" type="date" />
<input v-model="form.quote_date" type="date" class="form-control" /> </div>
</div> <div class="field-group mt-3">
<div class="col-md-6"> <label class="field-label">Valide jusqu'au</label>
<label>Validité (Date)</label> <soft-input v-model="form.valid_until" type="date" />
<input v-model="form.valid_until" type="date" class="form-control" /> </div>
</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> </div>
</template> </template>
<!-- Product Lines -->
<template #product-lines> <template #product-lines>
<div v-for="(line, index) in form.lines" :key="index"> <!-- Column Headers -->
<product-line-item <div class="lines-header">
v-model="form.lines[index]" <div class="line-col line-col--product">Produit / Description</div>
@remove="removeLine(index)" <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>
</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> </div>
<soft-button color="info" variant="text" size="sm" @click="addLine">
<i class="fas fa-plus me-1"></i> Ajouter une ligne
</soft-button>
</template> </template>
<!-- Totals -->
<template #totals> <template #totals>
<ul class="list-group"> <div class="totals-list">
<li class="list-group-item d-flex justify-content-between"> <div class="totals-row">
<span>Total HT</span> <span class="totals-label">Sous-total HT</span>
<strong>{{ formatCurrency(totals.ht) }}</strong> <span class="totals-value">{{ formatCurrency(totals.ht) }}</span>
</li> </div>
<li class="list-group-item d-flex justify-content-between"> <div class="totals-row">
<span>TVA</span> <span class="totals-label">TVA</span>
<strong>{{ formatCurrency(totals.tva) }}</strong> <span class="totals-value">{{ formatCurrency(totals.tva) }}</span>
</li> </div>
<li class="list-group-item d-flex justify-content-between bg-gray-100"> <div class="totals-row totals-row--final">
<span>Total TTC</span> <span class="totals-label">Total TTC</span>
<strong class="text-primary">{{ formatCurrency(totals.ttc) }}</strong> <span class="totals-value totals-value--final">{{ formatCurrency(totals.ttc) }}</span>
</li> </div>
</ul> </div>
</template> </template>
</create-quote-template> </create-quote-template>
</template> </template>
@ -80,6 +114,7 @@ import { useRouter } from "vue-router";
import CreateQuoteTemplate from "@/components/templates/Quote/CreateQuoteTemplate.vue"; import CreateQuoteTemplate from "@/components/templates/Quote/CreateQuoteTemplate.vue";
import ProductLineItem from "@/components/molecules/Quote/ProductLineItem.vue"; import ProductLineItem from "@/components/molecules/Quote/ProductLineItem.vue";
import SoftButton from "@/components/SoftButton.vue"; import SoftButton from "@/components/SoftButton.vue";
import SoftInput from "@/components/SoftInput.vue";
import { useQuoteStore } from "@/stores/quoteStore"; import { useQuoteStore } from "@/stores/quoteStore";
import { useClientStore } from "@/stores/clientStore"; import { useClientStore } from "@/stores/clientStore";
import { storeToRefs } from "pinia"; import { storeToRefs } from "pinia";
@ -87,9 +122,25 @@ import { storeToRefs } from "pinia";
const router = useRouter(); const router = useRouter();
const quoteStore = useQuoteStore(); const quoteStore = useQuoteStore();
const clientStore = useClientStore(); const clientStore = useClientStore();
const { clients } = storeToRefs(clientStore); const { clients } = storeToRefs(clientStore);
const loading = ref(false); 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({ const form = ref({
client_id: "", client_id: "",
@ -97,63 +148,29 @@ const form = ref({
valid_until: "", valid_until: "",
status: "brouillon", status: "brouillon",
currency: "EUR", currency: "EUR",
lines: [ lines: [defaultLine()],
{
product_id: null,
product_name: "",
quantity: 1,
unit_price: 0,
tva: 20,
discount_pct: 0,
},
],
}); });
const totals = computed(() => { const totals = computed(() => {
let ht = 0; let ht = 0;
let tva = 0; let tva = 0;
form.value.lines.forEach((line) => { form.value.lines.forEach((line) => {
const lineHt = line.quantity * line.unit_price; const afterDiscount = line.quantity * line.unit_price * (1 - (line.discount_pct || 0) / 100);
const lineTva = lineHt * (line.tva / 100); ht += afterDiscount;
ht += lineHt; tva += afterDiscount * (line.tva / 100);
tva += lineTva;
}); });
return { ht, tva, ttc: ht + tva };
return {
ht,
tva,
ttc: ht + tva,
};
}); });
const addLine = () => { const addLine = () => form.value.lines.push(defaultLine());
form.value.lines.push({ const removeLine = (index) => form.value.lines.splice(index, 1);
product_id: null,
product_name: "",
quantity: 1,
unit_price: 0,
tva: 20,
discount_pct: 0,
});
};
const removeLine = (index) => { const formatCurrency = (value) =>
form.value.lines.splice(index, 1); new Intl.NumberFormat("fr-FR", { style: "currency", currency: "EUR" }).format(value);
};
const formatCurrency = (value) => {
return new Intl.NumberFormat("fr-FR", {
style: "currency",
currency: "EUR",
}).format(value);
};
const saveQuote = async () => { const saveQuote = async () => {
if (!form.value.client_id) { attempted.value = true;
alert("Veuillez sélectionner un client"); if (!form.value.client_id) return;
return;
}
loading.value = true; loading.value = true;
try { try {
@ -166,17 +183,11 @@ const saveQuote = async () => {
total_ht: totals.value.ht, total_ht: totals.value.ht,
total_tva: totals.value.tva, total_tva: totals.value.tva,
total_ttc: totals.value.ttc, 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) => ({ lines: form.value.lines.map((line) => ({
...line, ...line,
discount_pct: line.discount_pct || 0, 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),
total_ht: description: line.product_name || "Produit sans nom",
line.quantity *
line.unit_price *
(1 - (line.discount_pct || 0) / 100),
description: line.product_name || "Produit sans nom", // Ensure description is set
})), })),
}); });
router.push("/ventes/devis"); router.push("/ventes/devis");
@ -188,11 +199,145 @@ const saveQuote = async () => {
} }
}; };
const cancel = () => { const cancel = () => router.back();
router.back();
};
onMounted(() => { onMounted(() => clientStore.fetchClients());
clientStore.fetchClients();
});
</script> </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> <template>
<div v-if="loading" class="text-center py-5"> <!-- Loading -->
<div class="spinner-border text-primary" role="status"> <div v-if="loading" class="detail-state">
<span class="visually-hidden">Loading...</span> <div class="spinner-ring"></div>
</div> <p>Chargement du devis</p>
</div> </div>
<div v-else-if="error" class="text-center py-5 text-danger">
{{ error }} <!-- 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>
<!-- Content -->
<quote-detail-template v-else-if="quote"> <quote-detail-template v-else-if="quote">
<template #header> <template #header>
<quote-header <div class="d-flex justify-content-between align-items-start flex-wrap gap-3">
:reference="quote.reference" <div>
:date="quote.quote_date" <h6 class="mb-1">Quote Details</h6>
:code="quote.reference" <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>
<template #lines> <template #product>
<quote-lines-table :lines="quote.lines" /> <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>
<template #timeline> <template #timeline>
<h6 class="mb-3">Track quote</h6>
<quote-timeline :history="quote.history" /> <quote-timeline :history="quote.history" />
</template> </template>
<template #billing> <template #payment>
<quote-billing-info <h6 class="mb-3">Quote lines</h6>
:client-name="quote.client ? quote.client.name : 'Client inconnu'" <quote-lines-table :lines="quote.lines" />
:client-email="quote.client ? quote.client.email : ''"
:client-phone="quote.client ? quote.client.phone : ''"
/>
<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>
<template #summary> <template #summary>
<quote-summary <h6 class="mb-3">Quote Summary</h6>
:ht="quote.total_ht" <div class="d-flex justify-content-between">
:tva="quote.total_tva" <span class="mb-2 text-sm">Total HT:</span>
:ttc="quote.total_ttc" <span class="text-dark font-weight-bold ms-2">{{ formatCurrency(quote.total_ht) }}</span>
/> </div>
</template> <div class="d-flex justify-content-between">
<span class="mb-2 text-sm">TVA:</span>
<template #actions> <span class="text-dark ms-2 font-weight-bold">{{ formatCurrency(quote.total_tva) }}</span>
<div class="d-flex justify-content-end"> </div>
<div class="position-relative d-inline-block me-2"> <div class="d-flex justify-content-between mt-4">
<soft-button <span class="mb-2 text-lg">Total TTC:</span>
color="secondary" <span class="text-dark text-lg ms-2 font-weight-bold">{{ formatCurrency(quote.total_ttc) }}</span>
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>
</div>
</div> </div>
</template> </template>
</quote-detail-template> </quote-detail-template>
@ -78,109 +133,192 @@
<script setup> <script setup>
import { ref, onMounted, defineProps } from "vue"; import { ref, onMounted, defineProps } from "vue";
import { useRouter } from "vue-router";
import { useQuoteStore } from "@/stores/quoteStore"; import { useQuoteStore } from "@/stores/quoteStore";
import { useNotificationStore } from "@/stores/notification"; import { useNotificationStore } from "@/stores/notification";
import QuoteDetailTemplate from "@/components/templates/Quote/QuoteDetailTemplate.vue"; 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 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 QuoteLinesTable from "@/components/molecules/Quote/QuoteLinesTable.vue";
import SoftButton from "@/components/SoftButton.vue"; import SoftButton from "@/components/SoftButton.vue";
import SoftBadge from "@/components/SoftBadge.vue";
const props = defineProps({ const props = defineProps({
quoteId: { quoteId: { type: [String, Number], required: true },
type: [String, Number],
required: true,
},
}); });
const router = useRouter();
const quoteStore = useQuoteStore(); const quoteStore = useQuoteStore();
const notificationStore = useNotificationStore(); const notificationStore = useNotificationStore();
const quote = ref(null); const quote = ref(null);
const loading = ref(true); const loading = ref(true);
const updating = ref(false);
const error = ref(null); const error = ref(null);
const dropdownOpen = ref(false); const selectedStatus = ref("brouillon");
onMounted(async () => { const load = async () => {
loading.value = true; loading.value = true;
error.value = null;
try { try {
const fetchedQuote = await quoteStore.fetchQuote(props.quoteId); quote.value = await quoteStore.fetchQuote(props.quoteId);
quote.value = fetchedQuote; selectedStatus.value = quote.value?.status || "brouillon";
} catch (e) { } catch (e) {
error.value = "Impossible de charger le devis."; error.value = "Impossible de charger le devis.";
console.error(e); console.error(e);
} finally { } finally {
loading.value = false; loading.value = false;
} }
});
const goBack = () => {
router.back();
}; };
const formatDate = (dateString) => { const reload = () => load();
if (!dateString) return "-"; onMounted(load);
return new Date(dateString).toLocaleDateString("fr-FR");
/* ── 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 = [ const availableStatuses = ["brouillon", "envoye", "accepte", "refuse", "expire", "annule"];
"brouillon",
"envoye",
"accepte",
"refuse",
"expire",
"annule",
];
const getStatusLabel = (status) => { const statusLabels = {
const labels = { brouillon: "Brouillon",
brouillon: "Brouillon", envoye: "Envoyé",
envoye: "Envoyé", accepte: "Accepté",
accepte: "Accepté", refuse: "Refusé",
refuse: "Refusé", expire: "Expiré",
expire: "Expiré", annule: "Annulé",
annule: "Annulé", };
const statusIcons = {
brouillon: "fas fa-pencil-alt",
envoye: "fas fa-paper-plane",
accepte: "fas fa-check-circle",
refuse: "fas fa-times-circle",
expire: "fas fa-clock",
annule: "fas fa-ban",
};
const statusBadgeColor = (status) => {
const map = {
brouillon: "warning",
envoye: "info",
accepte: "success",
refuse: "danger",
expire: "secondary",
annule: "dark",
}; };
return labels[status] || status; return map[status] || "secondary";
}; };
/* eslint-disable require-atomic-updates */ const getStatusLabel = (s) => statusLabels[s] || s;
const changeStatus = async (newStatus) => { const statusIcon = (s) => statusIcons[s] || "fas fa-circle";
if (!quote.value?.id) return;
// Capture the current quote ID to prevent race conditions const onStatusSelect = (event) => {
const currentQuoteId = quote.value.id; const newStatus = event.target.value;
selectedStatus.value = newStatus;
if (!quote.value?.id || newStatus === quote.value.status) return;
changeStatus(quote.value.id, newStatus);
};
try { /* ── Status Update ── */
loading.value = true; const changeStatus = (id, newStatus) => {
const updated = await quoteStore.updateQuote({ if (!id || updating.value) return;
id: currentQuoteId, updating.value = true;
status: newStatus, quoteStore
}); .updateQuote({ id, status: newStatus })
.then((updated) => {
// Only update if we're still viewing the same quote if (`${props.quoteId}` !== `${id}`) return;
if (quote.value?.id === currentQuoteId) {
quote.value = updated; quote.value = updated;
selectedStatus.value = updated?.status || newStatus;
// Show success notification
notificationStore.success( notificationStore.success(
"Statut mis à jour", "Statut mis à jour",
`Le devis est maintenant "${getStatusLabel(newStatus)}"`, `Le devis est maintenant "${getStatusLabel(newStatus)}"`,
3000 3000
); );
} })
} catch (e) { .catch((e) => {
console.error("Failed to update status", e); console.error(e);
notificationStore.error( notificationStore.error("Erreur", "Impossible de mettre à jour le statut", 3000);
"Erreur", })
"Impossible de mettre à jour le statut", .finally(() => {
3000 updating.value = false;
); });
} finally {
loading.value = false;
}
}; };
</script> </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" selectedItem.email || "Pas d'email"
}}) }})
</div> </div>
<div v-if="fieldErrors.client_id" class="invalid-feedback"> <div v-if="fieldErrors.client_id" class="invalid-feedback small-error">
{{ fieldErrors.client_id }} {{ fieldErrors.client_id }}
</div> </div>
</div> </div>
@ -47,7 +47,7 @@
Sélectionné: {{ selectedDeceased.last_name }} Sélectionné: {{ selectedDeceased.last_name }}
{{ selectedDeceased.first_name || "" }} {{ selectedDeceased.first_name || "" }}
</div> </div>
<div v-if="fieldErrors.deceased_id" class="invalid-feedback"> <div v-if="fieldErrors.deceased_id" class="invalid-feedback small-error">
{{ fieldErrors.deceased_id }} {{ fieldErrors.deceased_id }}
</div> </div>
</div> </div>
@ -72,7 +72,7 @@
<option value="retrait_bijoux">Retrait bijoux</option> <option value="retrait_bijoux">Retrait bijoux</option>
<option value="autre">Autre</option> <option value="autre">Autre</option>
</select> </select>
<div v-if="fieldErrors.type" class="invalid-feedback"> <div v-if="fieldErrors.type" class="invalid-feedback small-error">
{{ fieldErrors.type }} {{ fieldErrors.type }}
</div> </div>
</div> </div>
@ -88,7 +88,7 @@
:class="{ 'is-invalid': fieldErrors.scheduled_at }" :class="{ 'is-invalid': fieldErrors.scheduled_at }"
type="date" type="date"
/> />
<div v-if="fieldErrors.scheduled_at" class="invalid-feedback"> <div v-if="fieldErrors.scheduled_at" class="invalid-feedback small-error">
{{ fieldErrors.scheduled_at }} {{ fieldErrors.scheduled_at }}
</div> </div>
</div> </div>
@ -100,7 +100,7 @@
:class="{ 'is-invalid': fieldErrors.scheduled_at }" :class="{ 'is-invalid': fieldErrors.scheduled_at }"
type="time" type="time"
/> />
<div v-if="fieldErrors.scheduled_at" class="invalid-feedback"> <div v-if="fieldErrors.scheduled_at" class="invalid-feedback small-error">
{{ fieldErrors.scheduled_at }} {{ fieldErrors.scheduled_at }}
</div> </div>
</div> </div>
@ -118,7 +118,7 @@
min="1" min="1"
placeholder="ex. 90" 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 }} {{ fieldErrors.duration_min }}
</div> </div>
</div> </div>
@ -136,7 +136,7 @@
<option value="termine">Terminé</option> <option value="termine">Terminé</option>
<option value="annule">Annulé</option> <option value="annule">Annulé</option>
</select> </select>
<div v-if="fieldErrors.status" class="invalid-feedback"> <div v-if="fieldErrors.status" class="invalid-feedback small-error">
{{ fieldErrors.status }} {{ fieldErrors.status }}
</div> </div>
</div> </div>
@ -153,7 +153,7 @@
type="text" type="text"
placeholder="Nom du donneur d'ordre" placeholder="Nom du donneur d'ordre"
/> />
<div v-if="fieldErrors.order_giver" class="invalid-feedback"> <div v-if="fieldErrors.order_giver" class="invalid-feedback small-error">
{{ fieldErrors.order_giver }} {{ fieldErrors.order_giver }}
</div> </div>
</div> </div>
@ -171,22 +171,12 @@
placeholder="Informations complémentaires, instructions spéciales..." placeholder="Informations complémentaires, instructions spéciales..."
maxlength="2000" maxlength="2000"
></textarea> ></textarea>
<div v-if="fieldErrors.notes" class="invalid-feedback"> <div v-if="fieldErrors.notes" class="invalid-feedback small-error">
{{ fieldErrors.notes }} {{ fieldErrors.notes }}
</div> </div>
</div> </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 --> <!-- Boutons -->
<div class="button-row d-flex mt-4"> <div class="button-row d-flex mt-4">
<soft-button <soft-button
@ -203,7 +193,7 @@
color="dark" color="dark"
variant="gradient" variant="gradient"
class="ms-auto mb-0" class="ms-auto mb-0"
:disabled="props.loading || !!formValidationError" :disabled="props.loading"
@click="submitForm" @click="submitForm"
> >
<span <span
@ -219,7 +209,7 @@
</template> </template>
<script setup> <script setup>
import { ref, defineProps, defineEmits, watch, computed } from "vue"; import { ref, defineProps, defineEmits, watch } from "vue";
import SoftInput from "@/components/SoftInput.vue"; import SoftInput from "@/components/SoftInput.vue";
import SoftButton from "@/components/SoftButton.vue"; import SoftButton from "@/components/SoftButton.vue";
import SearchInput from "@/components/atoms/input/SearchInput.vue"; import SearchInput from "@/components/atoms/input/SearchInput.vue";
@ -336,19 +326,6 @@ const form = ref({
notes: "", 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 for validation errors from parent
watch( watch(
() => props.validationErrors, () => props.validationErrors,
@ -372,6 +349,9 @@ watch(
watch( watch(
() => form.value.client_id, () => form.value.client_id,
(newClientId) => { (newClientId) => {
if (newClientId && fieldErrors.value.client_id) {
delete fieldErrors.value.client_id;
}
if (!newClientId) { if (!newClientId) {
selectedItem.value = null; selectedItem.value = null;
} }
@ -382,31 +362,43 @@ watch(
watch( watch(
() => form.value.deceased_id, () => form.value.deceased_id,
(newDeceasedId) => { (newDeceasedId) => {
if (newDeceasedId && fieldErrors.value.deceased_id) {
delete fieldErrors.value.deceased_id;
}
if (!newDeceasedId) { if (!newDeceasedId) {
selectedDeceased.value = null; selectedDeceased.value = null;
} }
} }
); );
watch(
() => form.value.type,
(newType) => {
if (newType && fieldErrors.value.type) {
delete fieldErrors.value.type;
}
}
);
const submitForm = async () => { const submitForm = async () => {
// Clear errors before submitting // Clear errors before submitting
fieldErrors.value = {}; fieldErrors.value = {};
errors.value = []; errors.value = [];
// Check for form validation errors
if (formValidationError.value) {
errors.value.push(formValidationError.value);
return;
}
// Validate required fields // Validate required fields
let hasErrors = false;
if (!form.value.client_id || form.value.client_id === "") { if (!form.value.client_id || form.value.client_id === "") {
fieldErrors.value.client_id = "Le client est obligatoire"; fieldErrors.value.client_id = "Le client est obligatoire";
return; hasErrors = true;
} }
if (!form.value.type || form.value.type === "") { if (!form.value.type || form.value.type === "") {
fieldErrors.value.type = "Le type d'intervention est obligatoire"; fieldErrors.value.type = "Le type d'intervention est obligatoire";
hasErrors = true;
}
if (hasErrors) {
return; return;
} }
@ -487,6 +479,11 @@ const clearErrors = () => {
display: block; display: block;
} }
.small-error {
font-size: 0.75rem;
margin-top: 0.25rem;
}
.spinner-border-sm { .spinner-border-sm {
width: 1rem; width: 1rem;
height: 1rem; height: 1rem;

View File

@ -43,36 +43,7 @@ const interventions = computed(() => {
return props.interventionData.length > 0 return props.interventionData.length > 0
? props.interventionData ? 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> </script>

View File

@ -106,6 +106,21 @@ const typeIcons = {
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 (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(() => { const weekRangeLabel = computed(() => {
if (!calendar) return ''; if (!calendar) return '';
try { try {
@ -120,8 +135,12 @@ const weekRangeLabel = computed(() => {
const mapEvents = (interventions) => interventions.map((i) => { const mapEvents = (interventions) => interventions.map((i) => {
const color = typeColors[i.type] || '#6b7280'; const color = typeColors[i.type] || '#6b7280';
const start = new Date(i.date); const start = parseInterventionDate(i.date);
const end = i.end ? new Date(i.end) : new Date(start.getTime() + 60 * 60 * 1000); if (!start) return null;
const parsedEnd = parseInterventionDate(i.end);
const end = parsedEnd || new Date(start.getTime() + 60 * 60 * 1000);
return { return {
id: String(i.id), id: String(i.id),
title: i.deceased || i.type || 'Intervention', title: i.deceased || i.type || 'Intervention',
@ -131,7 +150,7 @@ const mapEvents = (interventions) => interventions.map((i) => {
textColor: '#fff', textColor: '#fff',
extendedProps: { originalData: i, color }, extendedProps: { originalData: i, color },
}; };
}); }).filter(Boolean);
const showPopover = (jsEvent, originalData, color) => { const showPopover = (jsEvent, originalData, color) => {
const rect = jsEvent.target.closest('.fc-event')?.getBoundingClientRect() || jsEvent.target.getBoundingClientRect(); const rect = jsEvent.target.closest('.fc-event')?.getBoundingClientRect() || jsEvent.target.getBoundingClientRect();
@ -190,12 +209,12 @@ const initCalendar = () => {
calendar = new Calendar(calendarEl.value, { calendar = new Calendar(calendarEl.value, {
plugins: [timeGridPlugin, interactionPlugin], plugins: [timeGridPlugin, interactionPlugin],
initialView: 'timeGridWeek', initialView: 'timeGridWeek',
locale: frLocale, locale: frLocale,
headerToolbar: false, headerToolbar: false,
initialDate: props.startDate, initialDate: props.startDate,
allDaySlot: false, allDaySlot: false,
slotMinTime: '07:00:00', slotMinTime: '00:00:00',
slotMaxTime: '21:00:00', slotMaxTime: '24:00:00',
height: 'auto', height: 'auto',
expandRows: true, expandRows: true,
stickyHeaderDates: true, stickyHeaderDates: true,
@ -640,4 +659,4 @@ watch(currentDate, () => {}); // just triggers computed re-eval
opacity: 0; opacity: 0;
transform: scale(0.96); transform: scale(0.96);
} }
</style> </style>

View File

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

View File

@ -1,288 +1,202 @@
<template> <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"> <transition name="banner-fade">
<div v-if="props.success" class="status-banner success-banner"> <div v-if="props.success" class="alert alert-success text-white mt-3 mb-0 py-2" role="alert">
<i class="fas fa-check-circle banner-icon"></i> <span class="text-sm"><strong>Contact créé avec succès !</strong> Il est maintenant lié au client.</span>
<span><strong>Contact créé avec succès !</strong> Il est maintenant lié au client.</span>
</div> </div>
</transition> </transition>
<!-- Error Banner -->
<transition name="banner-fade"> <transition name="banner-fade">
<div v-if="fieldErrors.general" class="status-banner error-banner"> <div v-if="fieldErrors.general" class="alert alert-danger text-white mt-3 mb-0 py-2" role="alert">
<i class="fas fa-exclamation-circle banner-icon"></i> <span class="text-sm">{{ fieldErrors.general }}</span>
<span>{{ fieldErrors.general }}</span>
</div> </div>
</transition> </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 v-if="!selectedClient" class="position-relative">
<div class="form-section"> <div class="search-wrap" :class="{ 'has-error': fieldErrors.client_id }">
<div class="section-label"> <i class="fas fa-search search-pfx"></i>
<span>Client associé</span> <input
</div> :value="searchQuery"
type="text"
<!-- Search --> class="form-control search-input"
<div class="field-group" v-if="!selectedClient"> placeholder="Rechercher un client par nom ou email..."
<label class="field-label"> @input="handleSearchInput($event.target.value)"
Client <span class="req">*</span> @blur="onInputBlur"
</label> />
<div class="search-wrap" :class="{ 'has-error': fieldErrors.client_id }"> <span v-if="searchQuery" class="search-loader">
<i class="fas fa-search search-pfx"></i> <i class="fas fa-circle-notch fa-spin text-secondary text-xs"></i>
<input </span>
:value="searchQuery"
type="text"
class="field-input 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>
</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
v-for="client in props.searchResults"
:key="client.id"
type="button"
class="dropdown-row"
@mousedown="selectClient(client)"
>
<div class="dr-avatar">{{ (client.name || '?')[0] }}</div>
<div 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>
</button>
</div> </div>
<div v-else-if="showDropdown && searchQuery && props.searchResults && props.searchResults.length === 0" class="search-dropdown">
<div class="dropdown-empty"> <transition name="dropdown-pop">
<i class="fas fa-search empty-icon"></i> <div v-if="showDropdown && props.searchResults && props.searchResults.length > 0" class="search-dropdown">
<span>Aucun résultat pour <strong>« {{ searchQuery }} »</strong></span> <button
v-for="client in props.searchResults"
:key="client.id"
type="button"
class="dropdown-row"
@mousedown="selectClient(client)"
>
<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>
</button>
</div> </div>
</div> <div v-else-if="showDropdown && searchQuery && props.searchResults && props.searchResults.length === 0" class="search-dropdown py-3 text-center text-sm text-secondary">
</transition> Aucun résultat pour <strong>« {{ searchQuery }} »</strong>
</div> </div>
</transition>
</div>
<!-- Selected Client Card --> <div v-else class="selected-client-card mt-2">
<transition name="banner-fade"> <span class="sc-avatar">{{ (selectedClient.name || "?")[0] }}</span>
<div v-if="selectedClient" class="selected-client-card"> <span class="sc-info">
<div class="sc-avatar">{{ (selectedClient.name || '?')[0] }}</div>
<div class="sc-info">
<span class="sc-label">Client sélectionné</span> <span class="sc-label">Client sélectionné</span>
<span class="sc-name">{{ selectedClient.name }}</span> <span class="sc-name">{{ selectedClient.name }}</span>
<span class="sc-meta" v-if="selectedClient.email">{{ selectedClient.email }}</span> <span v-if="selectedClient.email" class="sc-meta">{{ selectedClient.email }}</span>
</div> </span>
<button type="button" class="sc-change-btn" @click="clearSelection"> <soft-button type="button" color="secondary" variant="outline" size="sm" @click="clearSelection">
<i class="fas fa-exchange-alt me-1"></i>Changer Changer
</button> </soft-button>
</div> </div>
</transition>
<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>
<div class="section-divider"></div> <div class="row mt-3">
<div class="col-12 col-sm-6">
<!-- SECTION 2: Identity --> <label class="form-label">Prénom</label>
<div class="form-section"> <soft-input
<div class="section-label"> v-model="form.first_name"
<span>Identité</span> class="multisteps-form__input"
</div> :error="!!fieldErrors.first_name"
type="text"
<div class="two-col"> placeholder="Jean"
<div class="field-group"> />
<label class="field-label">Prénom</label> <div v-if="fieldErrors.first_name" class="invalid-feedback d-block">{{ fieldErrors.first_name }}</div>
<input </div>
:value="form.first_name" <div class="col-12 col-sm-6 mt-3 mt-sm-0">
type="text" <label class="form-label">Nom</label>
class="field-input" <soft-input
:class="{ 'is-invalid': fieldErrors.first_name }" v-model="form.last_name"
placeholder="Jean" class="multisteps-form__input"
maxlength="191" :error="!!fieldErrors.last_name"
@input="form.first_name = $event.target.value" type="text"
/> placeholder="Dupont"
<span v-if="fieldErrors.first_name" class="field-error"> />
<i class="fas fa-exclamation-circle"></i> {{ fieldErrors.first_name }} <div v-if="fieldErrors.last_name" class="invalid-feedback d-block">{{ fieldErrors.last_name }}</div>
</span> </div>
</div> </div>
<div class="field-group">
<label class="field-label">Nom</label> <div class="row mt-3">
<input <div class="col-12">
:value="form.last_name" <label class="form-label">Rôle / Poste</label>
type="text" <soft-input
class="field-input" v-model="form.role"
:class="{ 'is-invalid': fieldErrors.last_name }" class="multisteps-form__input"
placeholder="Dupont" :error="!!fieldErrors.role"
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>
</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"
type="text" type="text"
class="field-input"
:class="{ 'is-invalid': fieldErrors.role }"
placeholder="ex. Directeur Commercial, Responsable RH..." placeholder="ex. Directeur Commercial, Responsable RH..."
maxlength="191"
@input="form.role = $event.target.value"
/> />
<span v-if="fieldErrors.role" class="field-error"> <div v-if="fieldErrors.role" class="invalid-feedback d-block">{{ fieldErrors.role }}</div>
<i class="fas fa-exclamation-circle"></i> {{ fieldErrors.role }}
</span>
</div> </div>
</div> </div>
<div class="section-divider"></div> <div class="row mt-3">
<div class="col-12">
<!-- SECTION 3: Coordonnées --> <label class="form-label">Email</label>
<div class="form-section"> <soft-input
<div class="section-label"> v-model="form.email"
<span>Coordonnées</span> class="multisteps-form__input"
</div> :error="!!fieldErrors.email"
<div class="field-group">
<label class="field-label">
<i class="fas fa-envelope field-pfx-icon"></i>Email
</label>
<input
:value="form.email"
type="email" type="email"
class="field-input"
:class="{ 'is-invalid': fieldErrors.email }"
placeholder="jean.dupont@entreprise.com" placeholder="jean.dupont@entreprise.com"
maxlength="191"
@input="form.email = $event.target.value"
/> />
<span v-if="fieldErrors.email" class="field-error"> <div v-if="fieldErrors.email" class="invalid-feedback d-block">{{ fieldErrors.email }}</div>
<i class="fas fa-exclamation-circle"></i> {{ fieldErrors.email }}
</span>
</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"
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>
<div class="field-group">
<label class="field-label">
<i class="fas fa-mobile-alt field-pfx-icon"></i>Mobile
</label>
<input
:value="form.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> </div>
</div> </div>
<div class="section-divider"></div> <div class="row mt-3">
<div class="col-12 col-sm-6">
<!-- SECTION 4: Notes --> <label class="form-label">Téléphone</label>
<div class="form-section"> <soft-input
<div class="section-label"> v-model="form.phone"
<span>Notes</span> class="multisteps-form__input"
:error="!!fieldErrors.phone"
type="text"
placeholder="+33 1 23 45 67 89"
/>
<div v-if="fieldErrors.phone" class="invalid-feedback d-block">{{ fieldErrors.phone }}</div>
</div> </div>
<div class="field-group"> <div class="col-12 col-sm-6 mt-3 mt-sm-0">
<label class="field-label"> <label class="form-label">Mobile</label>
<i class="fas fa-sticky-note field-pfx-icon"></i>Observations <soft-input
</label> v-model="form.mobile"
class="multisteps-form__input"
:error="!!fieldErrors.mobile"
type="text"
placeholder="+33 6 12 34 56 78"
/>
<div v-if="fieldErrors.mobile" class="invalid-feedback d-block">{{ fieldErrors.mobile }}</div>
</div>
</div>
<div class="row mt-3">
<div class="col-12">
<label class="form-label">Observations</label>
<textarea <textarea
:value="form.notes" :value="form.notes"
class="field-textarea" class="form-control multisteps-form__input"
rows="3" rows="3"
placeholder="Informations complémentaires sur ce contact..." placeholder="Informations complémentaires sur ce contact..."
maxlength="1000" maxlength="1000"
@input="form.notes = $event.target.value" @input="form.notes = $event.target.value"
></textarea> ></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>
</div> </div>
<!-- Validation Warning -->
<transition name="banner-fade"> <transition name="banner-fade">
<div v-if="showValidationWarning && form.client_id" class="warn-banner"> <div v-if="showValidationWarning && form.client_id" class="alert alert-warning text-dark mt-3 py-2 mb-0" role="alert">
<i class="fas fa-exclamation-triangle warn-icon"></i> <span class="text-sm">Renseignez au moins un champ parmi : prénom, nom, email ou téléphone.</span>
<span>Renseignez au moins un champ parmi : prénom, nom, email ou téléphone.</span>
</div> </div>
</transition> </transition>
<div class="d-flex mt-4 justify-content-between gap-2 flex-wrap">
<soft-button type="button" color="secondary" variant="outline" @click="resetForm">
Réinitialiser
</soft-button>
<soft-button
type="button"
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>
<span>{{ props.loading ? "Création en cours…" : "Créer le contact" }}</span>
</soft-button>
</div>
</div> </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
type="button"
class="btn-submit"
: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>
</div>
</div> </div>
</template> </template>
<script setup> <script setup>
import { ref, defineProps, defineEmits, watch, computed } from "vue"; import { ref, defineProps, defineEmits, watch, computed } from "vue";
import SoftInput from "@/components/SoftInput.vue";
import SoftButton from "@/components/SoftButton.vue";
const props = defineProps({ const props = defineProps({
loading: { type: Boolean, default: false }, loading: { type: Boolean, default: false },
@ -293,7 +207,6 @@ const props = defineProps({
const emit = defineEmits(["createContact", "searchClient", "clientSelected"]); const emit = defineEmits(["createContact", "searchClient", "clientSelected"]);
const errors = ref([]);
const fieldErrors = ref({}); const fieldErrors = ref({});
const searchQuery = ref(""); const searchQuery = ref("");
const selectedClient = ref(null); const selectedClient = ref(null);
@ -370,285 +283,52 @@ const resetForm = () => {
searchQuery.value = ""; searchQuery.value = "";
showDropdown.value = false; showDropdown.value = false;
fieldErrors.value = {}; fieldErrors.value = {};
errors.value = [];
}; };
</script> </script>
<style scoped> <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 { .search-wrap {
position: relative; position: relative;
display: flex; display: flex;
align-items: center; align-items: center;
border: 1.5px solid #e5e7eb; border: 1px solid #d2d6da;
border-radius: 9px; border-radius: 0.5rem;
background: #fff; background: #fff;
overflow: visible; overflow: hidden;
transition: border-color 0.2s, box-shadow 0.2s; transition: border-color 0.2s, box-shadow 0.2s;
} }
.search-wrap:focus-within { .search-wrap:focus-within {
border-color: #2d4a6e; border-color: #8392ab;
box-shadow: 0 0 0 3px rgba(45,74,110,0.08); 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 { .search-pfx {
padding: 0 10px; padding: 0 0.75rem;
color: #9ca3af; color: #9ca3af;
font-size: 0.8rem; font-size: 0.75rem;
flex-shrink: 0; flex-shrink: 0;
} }
.search-input { .search-input {
flex: 1; flex: 1;
border: none !important; border: none;
border-radius: 0 !important; padding: 0.55rem 0;
box-shadow: none !important;
padding: 9px 4px !important;
min-width: 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 { .search-dropdown {
position: absolute; position: absolute;
top: calc(100% + 5px); top: calc(100% + 5px);
left: 0; left: 0;
right: 0; right: 0;
background: #fff; background: #fff;
border: 1.5px solid #e5e7eb; border: 1px solid #e9ecef;
border-radius: 10px; border-radius: 0.5rem;
box-shadow: 0 12px 32px rgba(0,0,0,0.12); box-shadow: 0 12px 32px rgba(0,0,0,0.12);
z-index: 1050; z-index: 1050;
overflow: hidden; overflow: hidden;
@ -663,24 +343,23 @@ const resetForm = () => {
width: 100%; width: 100%;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 10px; gap: 0.625rem;
padding: 10px 14px; padding: 0.625rem 0.875rem;
border: none; border: none;
background: none; background: none;
cursor: pointer; cursor: pointer;
text-align: left; text-align: left;
border-bottom: 1px solid #f9fafb; border-bottom: 1px solid #f3f4f6;
transition: background 0.15s; transition: background 0.15s;
} }
.dropdown-row:last-child { border-bottom: none; } .dropdown-row:last-child { border-bottom: none; }
.dropdown-row:hover { background: #f5f8ff; } .dropdown-row:hover { background: #f5f8ff; }
.dropdown-row:hover .dr-arrow { opacity: 1; transform: translateX(0); }
.dr-avatar { .dr-avatar {
width: 32px; width: 2rem;
height: 32px; height: 2rem;
border-radius: 9px; border-radius: 0.5rem;
background: linear-gradient(135deg, #1a2e4a, #2d4a6e); background: linear-gradient(310deg, #2152ff, #21d4fd);
color: #fff; color: #fff;
font-size: 0.8rem; font-size: 0.8rem;
font-weight: 700; font-weight: 700;
@ -701,7 +380,7 @@ const resetForm = () => {
.dr-name { .dr-name {
font-size: 0.84rem; font-size: 0.84rem;
font-weight: 600; font-weight: 700;
color: #111827; color: #111827;
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
@ -716,46 +395,23 @@ const resetForm = () => {
text-overflow: ellipsis; 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 { .selected-client-card {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 12px; gap: 0.75rem;
background: #f0fdf4; background: #f8f9fa;
border: 1.5px solid #bbf7d0; border: 1px solid #e9ecef;
border-radius: 10px; border-radius: 0.75rem;
padding: 12px 14px; padding: 0.75rem;
} }
.sc-avatar { .sc-avatar {
width: 40px; width: 2.25rem;
height: 40px; height: 2.25rem;
border-radius: 10px; border-radius: 0.5rem;
background: linear-gradient(135deg, #059669, #10b981); background: linear-gradient(310deg, #17ad37, #98ec2d);
color: #fff; color: #fff;
font-size: 1rem; font-size: 0.875rem;
font-weight: 700; font-weight: 700;
display: flex; display: flex;
align-items: center; align-items: center;
@ -773,90 +429,25 @@ const resetForm = () => {
} }
.sc-label { .sc-label {
font-size: 0.68rem; font-size: 0.65rem;
font-weight: 700; font-weight: 700;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.05em; letter-spacing: 0.05em;
color: #059669; color: #8392ab;
} }
.sc-name { .sc-name {
font-size: 0.88rem; font-size: 0.85rem;
font-weight: 700; font-weight: 700;
color: #064e3b; color: #344767;
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
} }
.sc-meta { .sc-meta {
font-size: 0.73rem; font-size: 0.72rem;
color: #6b7280; color: #8392ab;
}
.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;
} }
/* ─── Animations ─── */ /* ─── Animations ─── */
@ -869,4 +460,4 @@ const resetForm = () => {
.dropdown-pop-leave-active { transition: opacity 0.18s ease, transform 0.18s ease; } .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-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-to { opacity: 0; transform: translateY(-4px) scale(0.98); }
</style> </style>

View File

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

View File

@ -1,51 +1,71 @@
<template> <template>
<div class="container-fluid py-4"> <div class="container-fluid py-4">
<div class="row"> <div class="row">
<div class="col-12"> <div class="col-lg-10 mx-auto">
<div class="card"> <div class="card mb-4">
<div class="card-header pb-0"> <div class="card-header p-3 pb-0">
<div class="d-lg-flex"> <div class="d-flex flex-wrap align-items-center justify-content-between gap-3">
<div> <div class="d-flex align-items-center gap-3">
<h5 class="mb-0">Nouveau Devis</h5> <div class="icon icon-shape icon-sm bg-gradient-primary shadow text-center border-radius-md">
<p class="text-sm mb-0"> <i class="fas fa-file-invoice text-white text-sm"></i>
Créer un nouveau devis pour un client.
</p>
</div>
<div class="ms-auto my-auto mt-lg-0 mt-4">
<div class="ms-auto my-auto">
<slot name="actions"></slot>
</div> </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>
</div>
</div>
<div class="d-flex flex-wrap align-items-center gap-2">
<slot name="actions"></slot>
</div> </div>
</div> </div>
</div> </div>
<div class="card-body">
<div class="row"> <div class="card-body p-3 pt-0">
<!-- Client Selection --> <hr class="horizontal dark mt-0 mb-4" />
<div class="col-12 col-lg-4 mb-4">
<slot name="client-selection"></slot> <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>
<slot name="client-selection"></slot>
</div>
</div> </div>
<!-- Quote Details -->
<div class="col-12 col-lg-8 mb-4"> <div class="col-lg-6 col-md-6 col-12">
<slot name="quote-details"></slot> <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>
</div> </div>
<hr class="horizontal dark my-4" /> <hr class="horizontal dark mt-4 mb-4" />
<!-- Product Lines --> <div class="row g-3">
<div class="row"> <div class="col-lg-8 col-12">
<div class="col-12"> <div class="card card-body border card-plain border-radius-lg h-100">
<h6 class="mb-3">Produits & Services</h6> <div class="d-flex align-items-center mb-3">
<slot name="product-lines"></slot> <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> </div>
</div>
<hr class="horizontal dark my-4" /> <div class="col-lg-4 col-12">
<div class="card card-body border card-plain border-radius-lg h-100">
<!-- Totals --> <div class="d-flex align-items-center mb-3">
<div class="row"> <i class="fas fa-calculator me-2 text-primary"></i>
<div class="col-12 col-lg-4 ms-auto"> <h6 class="mb-0">Récapitulatif</h6>
<slot name="totals"></slot> </div>
<slot name="totals"></slot>
</div>
</div> </div>
</div> </div>
</div> </div>

View File

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