Ameloration design
This commit is contained in:
parent
dec87dfdb7
commit
bd04e07f12
2
.gitignore
vendored
2
.gitignore
vendored
@ -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
1000
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
5
package.json
Normal file
5
package.json
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"dependencies": {
|
||||||
|
"exceljs": "^4.4.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
70
thanas
70
thanas
@ -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>
|
|
||||||
60
thanasoft
60
thanasoft
@ -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>
|
|
||||||
@ -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
|
||||||
|
if (!empty($validated['client_id'])) {
|
||||||
|
$client = $this->clientRepository->find($validated['client_id']);
|
||||||
|
} else {
|
||||||
$clientData = $validated['client'];
|
$clientData = $validated['client'];
|
||||||
$client = $this->clientRepository->create($clientData);
|
$client = $this->clientRepository->create($clientData);
|
||||||
|
}
|
||||||
|
|
||||||
// Step 3: Create the contact (if provided)
|
// Step 3: Create the contact (if provided)
|
||||||
$contactId = null;
|
$contactId = null;
|
||||||
@ -226,6 +230,42 @@ 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();
|
||||||
|
|||||||
@ -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' => [
|
||||||
|
'required',
|
||||||
|
Rule::in([
|
||||||
'thanatopraxie',
|
'thanatopraxie',
|
||||||
'toilette_mortuaire',
|
'toilette_mortuaire',
|
||||||
'exhumation',
|
'exhumation',
|
||||||
'retrait_pacemaker',
|
'retrait_pacemaker',
|
||||||
'retrait_bijoux',
|
'retrait_bijoux',
|
||||||
'autre'
|
'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' => [
|
||||||
|
'sometimes',
|
||||||
|
Rule::in([
|
||||||
'demande',
|
'demande',
|
||||||
'planifie',
|
'planifie',
|
||||||
'en_cours',
|
'en_cours',
|
||||||
'termine',
|
'termine',
|
||||||
'annule'
|
'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'],
|
||||||
|
|||||||
@ -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];
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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 value="" disabled>— Sélectionner un client —</option>
|
||||||
<option v-for="client in clients" :key="client.id" :value="client.id">
|
<option v-for="client in clients" :key="client.id" :value="client.id">
|
||||||
{{ client.name }}
|
{{ client.name }}
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</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>
|
</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="col-md-6">
|
<div class="field-group mt-3">
|
||||||
<label>Validité (Date)</label>
|
<label class="field-label">Valide jusqu'au</label>
|
||||||
<input v-model="form.valid_until" type="date" class="form-control" />
|
<soft-input v-model="form.valid_until" type="date" />
|
||||||
</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 -->
|
||||||
|
<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
|
<product-line-item
|
||||||
v-model="form.lines[index]"
|
v-model="form.lines[index]"
|
||||||
@remove="removeLine(index)"
|
@remove="removeLine(index)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<soft-button color="info" variant="text" size="sm" @click="addLine">
|
</transition-group>
|
||||||
<i class="fas fa-plus me-1"></i> Ajouter une ligne
|
|
||||||
|
<!-- 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>
|
</soft-button>
|
||||||
|
</div>
|
||||||
</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 {
|
const addLine = () => form.value.lines.push(defaultLine());
|
||||||
ht,
|
const removeLine = (index) => form.value.lines.splice(index, 1);
|
||||||
tva,
|
|
||||||
ttc: ht + tva,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const addLine = () => {
|
const formatCurrency = (value) =>
|
||||||
form.value.lines.push({
|
new Intl.NumberFormat("fr-FR", { style: "currency", currency: "EUR" }).format(value);
|
||||||
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 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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
<p>Chargement du devis…</p>
|
||||||
</div>
|
</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>
|
||||||
<div v-else-if="error" class="text-center py-5 text-danger">
|
|
||||||
{{ error }}
|
<!-- Content -->
|
||||||
</div>
|
|
||||||
<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>
|
||||||
/>
|
|
||||||
</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>
|
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</quote-detail-template>
|
</quote-detail-template>
|
||||||
@ -78,65 +133,66 @@
|
|||||||
|
|
||||||
<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é",
|
||||||
@ -144,43 +200,125 @@ const getStatusLabel = (status) => {
|
|||||||
expire: "Expiré",
|
expire: "Expiré",
|
||||||
annule: "Annulé",
|
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 statusBadgeColor = (status) => {
|
||||||
const changeStatus = async (newStatus) => {
|
const map = {
|
||||||
if (!quote.value?.id) return;
|
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 getStatusLabel = (s) => statusLabels[s] || s;
|
||||||
const currentQuoteId = quote.value.id;
|
const statusIcon = (s) => statusIcons[s] || "fas fa-circle";
|
||||||
|
|
||||||
try {
|
const onStatusSelect = (event) => {
|
||||||
loading.value = true;
|
const newStatus = event.target.value;
|
||||||
const updated = await quoteStore.updateQuote({
|
selectedStatus.value = newStatus;
|
||||||
id: currentQuoteId,
|
if (!quote.value?.id || newStatus === quote.value.status) return;
|
||||||
status: newStatus,
|
changeStatus(quote.value.id, newStatus);
|
||||||
});
|
};
|
||||||
|
|
||||||
// Only update if we're still viewing the same quote
|
/* ── Status Update ── */
|
||||||
if (quote.value?.id === currentQuoteId) {
|
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;
|
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>
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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();
|
||||||
@ -194,8 +213,8 @@ const initCalendar = () => {
|
|||||||
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,
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -1,67 +1,41 @@
|
|||||||
<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="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 class="search-wrap" :class="{ 'has-error': fieldErrors.client_id }">
|
<div class="search-wrap" :class="{ 'has-error': fieldErrors.client_id }">
|
||||||
<i class="fas fa-search search-pfx"></i>
|
<i class="fas fa-search search-pfx"></i>
|
||||||
<input
|
<input
|
||||||
:value="searchQuery"
|
:value="searchQuery"
|
||||||
type="text"
|
type="text"
|
||||||
class="field-input search-input"
|
class="form-control search-input"
|
||||||
placeholder="Rechercher un client par nom ou email..."
|
placeholder="Rechercher un client par nom ou email..."
|
||||||
@input="handleSearchInput($event.target.value)"
|
@input="handleSearchInput($event.target.value)"
|
||||||
@focus="showDropdown = true"
|
|
||||||
@blur="onInputBlur"
|
@blur="onInputBlur"
|
||||||
/>
|
/>
|
||||||
<span v-if="searchQuery" class="search-loader">
|
<span v-if="searchQuery" class="search-loader">
|
||||||
<i class="fas fa-circle-notch fa-spin" style="color:#9ca3af;font-size:0.75rem"></i>
|
<i class="fas fa-circle-notch fa-spin text-secondary text-xs"></i>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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">
|
<transition name="dropdown-pop">
|
||||||
<div v-if="showDropdown && props.searchResults && props.searchResults.length > 0" class="search-dropdown">
|
<div v-if="showDropdown && props.searchResults && props.searchResults.length > 0" class="search-dropdown">
|
||||||
<button
|
<button
|
||||||
@ -71,218 +45,158 @@
|
|||||||
class="dropdown-row"
|
class="dropdown-row"
|
||||||
@mousedown="selectClient(client)"
|
@mousedown="selectClient(client)"
|
||||||
>
|
>
|
||||||
<div class="dr-avatar">{{ (client.name || '?')[0] }}</div>
|
<span class="dr-avatar">{{ (client.name || "?")[0] }}</span>
|
||||||
<div class="dr-info">
|
<span class="dr-info">
|
||||||
<span class="dr-name">{{ client.name }}</span>
|
<span class="dr-name">{{ client.name }}</span>
|
||||||
<span class="dr-meta" v-if="client.email">{{ client.email }}</span>
|
<span v-if="client.email" class="dr-meta">{{ client.email }}</span>
|
||||||
</div>
|
</span>
|
||||||
<i class="fas fa-arrow-right dr-arrow"></i>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="showDropdown && searchQuery && props.searchResults && props.searchResults.length === 0" class="search-dropdown">
|
<div v-else-if="showDropdown && searchQuery && props.searchResults && props.searchResults.length === 0" class="search-dropdown py-3 text-center text-sm text-secondary">
|
||||||
<div class="dropdown-empty">
|
Aucun résultat pour <strong>« {{ searchQuery }} »</strong>
|
||||||
<i class="fas fa-search empty-icon"></i>
|
|
||||||
<span>Aucun résultat pour <strong>« {{ searchQuery }} »</strong></span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</transition>
|
</transition>
|
||||||
</div>
|
</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>
|
|
||||||
</transition>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="section-divider"></div>
|
<div v-if="fieldErrors.client_id" class="invalid-feedback d-block">{{ fieldErrors.client_id }}</div>
|
||||||
|
<p class="text-xs text-secondary mb-0 mt-1">Le contact sera rattaché à ce client</p>
|
||||||
<!-- ── SECTION 2: Identity ── -->
|
</div>
|
||||||
<div class="form-section">
|
|
||||||
<div class="section-label">
|
|
||||||
<span>Identité</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="two-col">
|
<div class="row mt-3">
|
||||||
<div class="field-group">
|
<div class="col-12 col-sm-6">
|
||||||
<label class="field-label">Prénom</label>
|
<label class="form-label">Prénom</label>
|
||||||
<input
|
<soft-input
|
||||||
:value="form.first_name"
|
v-model="form.first_name"
|
||||||
|
class="multisteps-form__input"
|
||||||
|
:error="!!fieldErrors.first_name"
|
||||||
type="text"
|
type="text"
|
||||||
class="field-input"
|
|
||||||
:class="{ 'is-invalid': fieldErrors.first_name }"
|
|
||||||
placeholder="Jean"
|
placeholder="Jean"
|
||||||
maxlength="191"
|
|
||||||
@input="form.first_name = $event.target.value"
|
|
||||||
/>
|
/>
|
||||||
<span v-if="fieldErrors.first_name" class="field-error">
|
<div v-if="fieldErrors.first_name" class="invalid-feedback d-block">{{ fieldErrors.first_name }}</div>
|
||||||
<i class="fas fa-exclamation-circle"></i> {{ fieldErrors.first_name }}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="field-group">
|
<div class="col-12 col-sm-6 mt-3 mt-sm-0">
|
||||||
<label class="field-label">Nom</label>
|
<label class="form-label">Nom</label>
|
||||||
<input
|
<soft-input
|
||||||
:value="form.last_name"
|
v-model="form.last_name"
|
||||||
|
class="multisteps-form__input"
|
||||||
|
:error="!!fieldErrors.last_name"
|
||||||
type="text"
|
type="text"
|
||||||
class="field-input"
|
|
||||||
:class="{ 'is-invalid': fieldErrors.last_name }"
|
|
||||||
placeholder="Dupont"
|
placeholder="Dupont"
|
||||||
maxlength="191"
|
|
||||||
@input="form.last_name = $event.target.value"
|
|
||||||
/>
|
/>
|
||||||
<span v-if="fieldErrors.last_name" class="field-error">
|
<div v-if="fieldErrors.last_name" class="invalid-feedback d-block">{{ fieldErrors.last_name }}</div>
|
||||||
<i class="fas fa-exclamation-circle"></i> {{ fieldErrors.last_name }}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field-group mt-field">
|
<div class="row mt-3">
|
||||||
<label class="field-label">
|
<div class="col-12">
|
||||||
<i class="fas fa-briefcase field-pfx-icon"></i>Rôle / Poste
|
<label class="form-label">Rôle / Poste</label>
|
||||||
</label>
|
<soft-input
|
||||||
<input
|
v-model="form.role"
|
||||||
:value="form.role"
|
class="multisteps-form__input"
|
||||||
|
:error="!!fieldErrors.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 }}
|
</div>
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="two-col mt-field">
|
<div class="row mt-3">
|
||||||
<div class="field-group">
|
<div class="col-12 col-sm-6">
|
||||||
<label class="field-label">
|
<label class="form-label">Téléphone</label>
|
||||||
<i class="fas fa-phone field-pfx-icon"></i>Téléphone
|
<soft-input
|
||||||
</label>
|
v-model="form.phone"
|
||||||
<input
|
class="multisteps-form__input"
|
||||||
:value="form.phone"
|
:error="!!fieldErrors.phone"
|
||||||
type="text"
|
type="text"
|
||||||
class="field-input"
|
|
||||||
:class="{ 'is-invalid': fieldErrors.phone }"
|
|
||||||
placeholder="+33 1 23 45 67 89"
|
placeholder="+33 1 23 45 67 89"
|
||||||
maxlength="50"
|
|
||||||
@input="form.phone = $event.target.value"
|
|
||||||
/>
|
/>
|
||||||
<span v-if="fieldErrors.phone" class="field-error">
|
<div v-if="fieldErrors.phone" class="invalid-feedback d-block">{{ fieldErrors.phone }}</div>
|
||||||
<i class="fas fa-exclamation-circle"></i> {{ fieldErrors.phone }}
|
|
||||||
</span>
|
|
||||||
</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-mobile-alt field-pfx-icon"></i>Mobile
|
<soft-input
|
||||||
</label>
|
v-model="form.mobile"
|
||||||
<input
|
class="multisteps-form__input"
|
||||||
:value="form.mobile"
|
:error="!!fieldErrors.mobile"
|
||||||
type="text"
|
type="text"
|
||||||
class="field-input"
|
|
||||||
:class="{ 'is-invalid': fieldErrors.mobile }"
|
|
||||||
placeholder="+33 6 12 34 56 78"
|
placeholder="+33 6 12 34 56 78"
|
||||||
maxlength="50"
|
|
||||||
@input="form.mobile = $event.target.value"
|
|
||||||
/>
|
/>
|
||||||
<span v-if="fieldErrors.mobile" class="field-error">
|
<div v-if="fieldErrors.mobile" class="invalid-feedback d-block">{{ fieldErrors.mobile }}</div>
|
||||||
<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">
|
||||||
<!-- ── SECTION 4: Notes ── -->
|
<label class="form-label">Observations</label>
|
||||||
<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>
|
|
||||||
<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>
|
<div class="d-flex mt-4 justify-content-between gap-2 flex-wrap">
|
||||||
|
<soft-button type="button" color="secondary" variant="outline" @click="resetForm">
|
||||||
<!-- Footer Actions -->
|
Réinitialiser
|
||||||
<div class="form-footer">
|
</soft-button>
|
||||||
<button type="button" class="btn-reset" @click="resetForm">
|
<soft-button
|
||||||
<i class="fas fa-undo me-2"></i>Réinitialiser
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
type="button"
|
||||||
class="btn-submit"
|
color="info"
|
||||||
|
variant="gradient"
|
||||||
:disabled="props.loading || !isFormValid"
|
:disabled="props.loading || !isFormValid"
|
||||||
@click="submitForm"
|
@click="submitForm"
|
||||||
>
|
>
|
||||||
<span v-if="props.loading" class="spinner-border spinner-border-sm me-2" role="status"></span>
|
<span 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>
|
<span>{{ props.loading ? "Création en cours…" : "Créer le contact" }}</span>
|
||||||
{{ props.loading ? "Création en cours…" : "Créer le contact" }}
|
</soft-button>
|
||||||
</button>
|
</div>
|
||||||
</div>
|
</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 ─── */
|
||||||
|
|||||||
@ -1,17 +1,23 @@
|
|||||||
<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 class="card border-0 shadow-sm mb-4">
|
||||||
<div
|
<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>
|
<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">
|
||||||
|
<div class="card-body p-2 p-md-3">
|
||||||
|
<div class="d-flex gap-2 overflow-auto pb-1">
|
||||||
<slot name="view-toggles"></slot>
|
<slot name="view-toggles"></slot>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="content-wrapper h-100">
|
<div class="content-wrapper h-100">
|
||||||
<div class="row g-4">
|
<div class="row g-4">
|
||||||
@ -27,12 +33,16 @@
|
|||||||
|
|
||||||
<!-- 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">
|
||||||
|
<div class="card border-0 shadow-sm">
|
||||||
|
<div class="card-body p-2 p-md-3 p-lg-4">
|
||||||
<slot name="calendar-grid"></slot>
|
<slot name="calendar-grid"></slot>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
@ -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;
|
||||||
|
|||||||
@ -1,50 +1,69 @@
|
|||||||
<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>
|
||||||
<div class="ms-auto my-auto mt-lg-0 mt-4">
|
<div>
|
||||||
<div class="ms-auto my-auto">
|
<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>
|
<slot name="actions"></slot>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
||||||
<div class="card-body">
|
|
||||||
<div class="row">
|
|
||||||
<!-- Client Selection -->
|
|
||||||
<div class="col-12 col-lg-4 mb-4">
|
|
||||||
<slot name="client-selection"></slot>
|
<slot name="client-selection"></slot>
|
||||||
</div>
|
</div>
|
||||||
<!-- Quote Details -->
|
</div>
|
||||||
<div class="col-12 col-lg-8 mb-4">
|
|
||||||
|
<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>
|
<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">
|
||||||
|
<i class="fas fa-boxes me-2 text-primary"></i>
|
||||||
|
<h6 class="mb-0">Produits & Services</h6>
|
||||||
|
</div>
|
||||||
<slot name="product-lines"></slot>
|
<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>
|
||||||
|
</div>
|
||||||
<slot name="totals"></slot>
|
<slot name="totals"></slot>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -53,6 +72,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup></script>
|
<script setup></script>
|
||||||
|
|||||||
@ -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">
|
||||||
|
<div class="card-header p-3 pb-0">
|
||||||
<slot name="header"></slot>
|
<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>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user