Ameloration design
This commit is contained in:
parent
dec87dfdb7
commit
bd04e07f12
2
.gitignore
vendored
2
.gitignore
vendored
@ -20,6 +20,8 @@
|
||||
*.DS_Store
|
||||
*.idea/
|
||||
*.vscode/
|
||||
node_modules/
|
||||
|
||||
*.env.local
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
|
||||
1000
package-lock.json
generated
Normal file
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);
|
||||
}
|
||||
|
||||
// Step 2: Create the client
|
||||
// Step 2: Link existing client or create a new one
|
||||
if (!empty($validated['client_id'])) {
|
||||
$client = $this->clientRepository->find($validated['client_id']);
|
||||
} else {
|
||||
$clientData = $validated['client'];
|
||||
$client = $this->clientRepository->create($clientData);
|
||||
}
|
||||
|
||||
// Step 3: Create the contact (if provided)
|
||||
$contactId = null;
|
||||
@ -226,6 +230,42 @@ class InterventionController extends Controller
|
||||
]);
|
||||
$intervention = $this->interventionRepository->create($interventionData);
|
||||
|
||||
// Step 5a: Assign practitioners if provided
|
||||
if (!empty($validated['intervention']['principal_practitioner_id'])) {
|
||||
$this->interventionPractitionerRepository->createAssignment(
|
||||
$intervention->id,
|
||||
(int) $validated['intervention']['principal_practitioner_id'],
|
||||
'principal'
|
||||
);
|
||||
}
|
||||
|
||||
if (!empty($validated['intervention']['assistant_practitioner_ids']) && is_array($validated['intervention']['assistant_practitioner_ids'])) {
|
||||
foreach ($validated['intervention']['assistant_practitioner_ids'] as $assistantId) {
|
||||
$this->interventionPractitionerRepository->createAssignment(
|
||||
$intervention->id,
|
||||
(int) $assistantId,
|
||||
'assistant'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
empty($validated['intervention']['principal_practitioner_id']) &&
|
||||
!empty($validated['intervention']['practitioners']) &&
|
||||
is_array($validated['intervention']['practitioners'])
|
||||
) {
|
||||
foreach ($validated['intervention']['practitioners'] as $index => $practitionerId) {
|
||||
$role = $index === 0 ? 'principal' : 'assistant';
|
||||
$this->interventionPractitionerRepository->createAssignment(
|
||||
$intervention->id,
|
||||
(int) $practitionerId,
|
||||
$role
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
$intervention->load('practitioners');
|
||||
|
||||
// Step 5b: Create a Quote for this intervention
|
||||
try {
|
||||
$interventionProduct = $this->productRepository->findInterventionProduct();
|
||||
@ -509,7 +549,7 @@ class InterventionController extends Controller
|
||||
'data' => new InterventionResource($intervention),
|
||||
'message' => 'Assignment(s) créé(s) avec succès.',
|
||||
'practitioners_count' => $practitioners->count(),
|
||||
'practitioners' => $practitioners->map(function($p) {
|
||||
'practitioners' => $practitioners->map(function ($p) {
|
||||
return [
|
||||
'id' => $p->id,
|
||||
'full_name' => $p->full_name ?? ($p->first_name . ' ' . $p->last_name),
|
||||
@ -579,7 +619,7 @@ class InterventionController extends Controller
|
||||
'data' => new InterventionResource($intervention),
|
||||
'message' => 'Praticien désassigné avec succès.',
|
||||
'remaining_practitioners_count' => $remainingPractitioners->count(),
|
||||
'remaining_practitioners' => $remainingPractitioners->map(function($p) {
|
||||
'remaining_practitioners' => $remainingPractitioners->map(function ($p) {
|
||||
return [
|
||||
'id' => $p->id,
|
||||
'employee_name' => $p->employee->full_name ?? ($p->employee->first_name . ' ' . $p->employee->last_name),
|
||||
@ -634,7 +674,7 @@ class InterventionController extends Controller
|
||||
'intervention_id' => $id,
|
||||
'database_records' => $dbPractitioners,
|
||||
'eager_loaded_count' => $eagerPractitioners->count(),
|
||||
'eager_loaded_data' => $eagerPractitioners->map(function($p) {
|
||||
'eager_loaded_data' => $eagerPractitioners->map(function ($p) {
|
||||
return [
|
||||
'id' => $p->id,
|
||||
'full_name' => $p->full_name ?? ($p->first_name . ' ' . $p->last_name),
|
||||
|
||||
@ -32,7 +32,8 @@ class StoreInterventionWithAllDataRequest extends FormRequest
|
||||
'deceased.place_of_death' => ['nullable', 'string', 'max:255'],
|
||||
'deceased.notes' => ['nullable', 'string'],
|
||||
|
||||
'client' => 'required|array',
|
||||
'client_id' => ['nullable', 'exists:clients,id'],
|
||||
'client' => 'required_without:client_id|array',
|
||||
'client.name' => ['required', 'string', 'max:255'],
|
||||
'client.vat_number' => ['nullable', 'string', 'max:32'],
|
||||
'client.siret' => ['nullable', 'string', 'max:20'],
|
||||
@ -68,23 +69,29 @@ class StoreInterventionWithAllDataRequest extends FormRequest
|
||||
'documents.*.description' => ['nullable', 'string'],
|
||||
|
||||
'intervention' => 'required|array',
|
||||
'intervention.type' => ['required', Rule::in([
|
||||
'intervention.type' => [
|
||||
'required',
|
||||
Rule::in([
|
||||
'thanatopraxie',
|
||||
'toilette_mortuaire',
|
||||
'exhumation',
|
||||
'retrait_pacemaker',
|
||||
'retrait_bijoux',
|
||||
'autre'
|
||||
])],
|
||||
])
|
||||
],
|
||||
'intervention.scheduled_at' => ['nullable', 'date_format:Y-m-d H:i:s'],
|
||||
'intervention.duration_min' => ['nullable', 'integer', 'min:0'],
|
||||
'intervention.status' => ['sometimes', Rule::in([
|
||||
'intervention.status' => [
|
||||
'sometimes',
|
||||
Rule::in([
|
||||
'demande',
|
||||
'planifie',
|
||||
'en_cours',
|
||||
'termine',
|
||||
'annule'
|
||||
])],
|
||||
])
|
||||
],
|
||||
'intervention.practitioners' => ['nullable', 'array'],
|
||||
'intervention.practitioners.*' => ['exists:thanatopractitioners,id'],
|
||||
'intervention.principal_practitioner_id' => ['nullable', 'exists:thanatopractitioners,id'],
|
||||
|
||||
@ -737,6 +737,18 @@ const handleSubmit = async () => {
|
||||
if (locationForm.value.city) formData.append("location[city]", locationForm.value.city);
|
||||
}
|
||||
if (productForm.value.product_id) formData.append("product_id", productForm.value.product_id);
|
||||
|
||||
if (interventionForm.value.assigned_practitioner_id) {
|
||||
formData.append(
|
||||
"intervention[principal_practitioner_id]",
|
||||
interventionForm.value.assigned_practitioner_id
|
||||
);
|
||||
formData.append(
|
||||
"intervention[practitioners][0]",
|
||||
interventionForm.value.assigned_practitioner_id
|
||||
);
|
||||
}
|
||||
|
||||
Object.keys(interventionForm.value).forEach((key) => {
|
||||
if (interventionForm.value[key] != null) {
|
||||
let value = interventionForm.value[key];
|
||||
|
||||
@ -8,21 +8,23 @@
|
||||
aria-labelledby="planningNewRequestModalLabel"
|
||||
:aria-hidden="!show"
|
||||
>
|
||||
<div class="modal-dialog modal-dialog-centered" role="document">
|
||||
<div class="modal-dialog modal-dialog-centered modal-lg" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<div class="modal-header border-bottom bg-light">
|
||||
<h5 id="planningNewRequestModalLabel" class="modal-title">
|
||||
Nouvelle demande
|
||||
</h5>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-close"
|
||||
class="btn-close d-flex align-items-center justify-content-center"
|
||||
aria-label="Close"
|
||||
@click="$emit('close')"
|
||||
></button>
|
||||
>
|
||||
<i class="fas fa-times text-dark"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body">
|
||||
<div class="modal-body p-4">
|
||||
<p v-if="!creationType" class="text-sm text-muted mb-3">
|
||||
Choisissez le type à créer :
|
||||
</p>
|
||||
@ -108,3 +110,15 @@ defineEmits([
|
||||
"update:event-form",
|
||||
]);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.modal-content {
|
||||
border: 0;
|
||||
box-shadow: 0 0.5rem 1.5rem rgba(15, 23, 42, 0.12);
|
||||
border-radius: 0.75rem;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -28,7 +28,7 @@
|
||||
</soft-button>
|
||||
<soft-button
|
||||
color="info"
|
||||
variant="gradient"
|
||||
variant="fill"
|
||||
size="sm"
|
||||
@click="$emit('new-request')"
|
||||
>
|
||||
@ -166,4 +166,8 @@ const handleUpdateStatus = (payload) => {
|
||||
.text-sm {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.btn.btn-info {
|
||||
box-shadow: 0 0.25rem 0.75rem rgba(17, 205, 239, 0.25);
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -1,75 +1,109 @@
|
||||
<template>
|
||||
<create-quote-template>
|
||||
<!-- ── Actions ── -->
|
||||
<template #actions>
|
||||
<soft-button
|
||||
color="secondary"
|
||||
variant="outline"
|
||||
class="me-2"
|
||||
@click="cancel"
|
||||
>
|
||||
Annuler
|
||||
<soft-button color="secondary" variant="outline" class="me-2" @click="cancel">
|
||||
<i class="fas fa-times me-1"></i> Annuler
|
||||
</soft-button>
|
||||
<soft-button
|
||||
color="primary"
|
||||
variant="gradient"
|
||||
:disabled="loading"
|
||||
@click="saveQuote"
|
||||
>
|
||||
{{ loading ? "Enregistrement..." : "Enregistrer" }}
|
||||
<soft-button color="primary" variant="gradient" :disabled="loading" @click="saveQuote">
|
||||
<i class="fas fa-save me-1"></i>
|
||||
{{ loading ? "Enregistrement..." : "Enregistrer le devis" }}
|
||||
</soft-button>
|
||||
</template>
|
||||
|
||||
<!-- ── Client Selection ── -->
|
||||
<template #client-selection>
|
||||
<label>Client</label>
|
||||
<select v-model="form.client_id" class="form-select">
|
||||
<option value="" disabled selected>Sélectionner un client</option>
|
||||
<div class="field-group">
|
||||
<label class="field-label">Client <span class="text-danger">*</span></label>
|
||||
<select v-model="form.client_id" class="form-select field-select">
|
||||
<option value="" disabled>— Sélectionner un client —</option>
|
||||
<option v-for="client in clients" :key="client.id" :value="client.id">
|
||||
{{ client.name }}
|
||||
</option>
|
||||
</select>
|
||||
<!-- Add client search/autocomplete if list is long -->
|
||||
<p v-if="!form.client_id && attempted" class="field-error">
|
||||
<i class="fas fa-exclamation-circle me-1"></i>Client requis
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- ── Quote Details ── -->
|
||||
<template #quote-details>
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<label>Date du devis</label>
|
||||
<input v-model="form.quote_date" type="date" class="form-control" />
|
||||
<div class="field-group">
|
||||
<label class="field-label">Date du devis</label>
|
||||
<soft-input v-model="form.quote_date" type="date" />
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label>Validité (Date)</label>
|
||||
<input v-model="form.valid_until" type="date" class="form-control" />
|
||||
<div class="field-group mt-3">
|
||||
<label class="field-label">Valide jusqu'au</label>
|
||||
<soft-input v-model="form.valid_until" type="date" />
|
||||
</div>
|
||||
<div class="field-group mt-3">
|
||||
<label class="field-label">Statut</label>
|
||||
<select v-model="form.status" class="form-select field-select">
|
||||
<option v-for="s in statuses" :key="s.value" :value="s.value">
|
||||
{{ s.label }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- ── Product Lines ── -->
|
||||
<template #product-lines>
|
||||
<div v-for="(line, index) in form.lines" :key="index">
|
||||
<!-- Column Headers -->
|
||||
<div class="lines-header">
|
||||
<div class="line-col line-col--product">Produit / Description</div>
|
||||
<div class="line-col line-col--qty">Qté</div>
|
||||
<div class="line-col line-col--price">P.U. HT</div>
|
||||
<div class="line-col line-col--tva">TVA %</div>
|
||||
<div class="line-col line-col--discount">Remise %</div>
|
||||
<div class="line-col line-col--total">Total HT</div>
|
||||
<div class="line-col line-col--action"></div>
|
||||
</div>
|
||||
|
||||
<!-- Lines -->
|
||||
<transition-group name="line-fade" tag="div">
|
||||
<div
|
||||
v-for="(line, index) in form.lines"
|
||||
:key="index"
|
||||
class="line-row"
|
||||
>
|
||||
<product-line-item
|
||||
v-model="form.lines[index]"
|
||||
@remove="removeLine(index)"
|
||||
/>
|
||||
</div>
|
||||
<soft-button color="info" variant="text" size="sm" @click="addLine">
|
||||
<i class="fas fa-plus me-1"></i> Ajouter une ligne
|
||||
</transition-group>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div v-if="form.lines.length === 0" class="lines-empty">
|
||||
<i class="fas fa-box-open mb-2"></i>
|
||||
<p>Aucune ligne. Ajoutez un produit ou service.</p>
|
||||
</div>
|
||||
|
||||
<!-- Add Line -->
|
||||
<div class="lines-footer">
|
||||
<soft-button type="button" color="info" variant="outline" class="add-line-btn" @click="addLine">
|
||||
<i class="fas fa-plus-circle me-2"></i>Ajouter une ligne
|
||||
</soft-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- ── Totals ── -->
|
||||
<template #totals>
|
||||
<ul class="list-group">
|
||||
<li class="list-group-item d-flex justify-content-between">
|
||||
<span>Total HT</span>
|
||||
<strong>{{ formatCurrency(totals.ht) }}</strong>
|
||||
</li>
|
||||
<li class="list-group-item d-flex justify-content-between">
|
||||
<span>TVA</span>
|
||||
<strong>{{ formatCurrency(totals.tva) }}</strong>
|
||||
</li>
|
||||
<li class="list-group-item d-flex justify-content-between bg-gray-100">
|
||||
<span>Total TTC</span>
|
||||
<strong class="text-primary">{{ formatCurrency(totals.ttc) }}</strong>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="totals-list">
|
||||
<div class="totals-row">
|
||||
<span class="totals-label">Sous-total HT</span>
|
||||
<span class="totals-value">{{ formatCurrency(totals.ht) }}</span>
|
||||
</div>
|
||||
<div class="totals-row">
|
||||
<span class="totals-label">TVA</span>
|
||||
<span class="totals-value">{{ formatCurrency(totals.tva) }}</span>
|
||||
</div>
|
||||
<div class="totals-row totals-row--final">
|
||||
<span class="totals-label">Total TTC</span>
|
||||
<span class="totals-value totals-value--final">{{ formatCurrency(totals.ttc) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</create-quote-template>
|
||||
</template>
|
||||
@ -80,6 +114,7 @@ import { useRouter } from "vue-router";
|
||||
import CreateQuoteTemplate from "@/components/templates/Quote/CreateQuoteTemplate.vue";
|
||||
import ProductLineItem from "@/components/molecules/Quote/ProductLineItem.vue";
|
||||
import SoftButton from "@/components/SoftButton.vue";
|
||||
import SoftInput from "@/components/SoftInput.vue";
|
||||
import { useQuoteStore } from "@/stores/quoteStore";
|
||||
import { useClientStore } from "@/stores/clientStore";
|
||||
import { storeToRefs } from "pinia";
|
||||
@ -87,9 +122,25 @@ import { storeToRefs } from "pinia";
|
||||
const router = useRouter();
|
||||
const quoteStore = useQuoteStore();
|
||||
const clientStore = useClientStore();
|
||||
|
||||
const { clients } = storeToRefs(clientStore);
|
||||
|
||||
const loading = ref(false);
|
||||
const attempted = ref(false);
|
||||
|
||||
const statuses = [
|
||||
{ value: "brouillon", label: "Brouillon", icon: "fas fa-pencil-alt", color: "warning" },
|
||||
{ value: "envoye", label: "Envoyé", icon: "fas fa-paper-plane", color: "info" },
|
||||
{ value: "accepte", label: "Accepté", icon: "fas fa-check", color: "success" },
|
||||
];
|
||||
|
||||
const defaultLine = () => ({
|
||||
product_id: null,
|
||||
product_name: "",
|
||||
quantity: 1,
|
||||
unit_price: 0,
|
||||
tva: 20,
|
||||
discount_pct: 0,
|
||||
});
|
||||
|
||||
const form = ref({
|
||||
client_id: "",
|
||||
@ -97,63 +148,29 @@ const form = ref({
|
||||
valid_until: "",
|
||||
status: "brouillon",
|
||||
currency: "EUR",
|
||||
lines: [
|
||||
{
|
||||
product_id: null,
|
||||
product_name: "",
|
||||
quantity: 1,
|
||||
unit_price: 0,
|
||||
tva: 20,
|
||||
discount_pct: 0,
|
||||
},
|
||||
],
|
||||
lines: [defaultLine()],
|
||||
});
|
||||
|
||||
const totals = computed(() => {
|
||||
let ht = 0;
|
||||
let tva = 0;
|
||||
|
||||
form.value.lines.forEach((line) => {
|
||||
const lineHt = line.quantity * line.unit_price;
|
||||
const lineTva = lineHt * (line.tva / 100);
|
||||
ht += lineHt;
|
||||
tva += lineTva;
|
||||
const afterDiscount = line.quantity * line.unit_price * (1 - (line.discount_pct || 0) / 100);
|
||||
ht += afterDiscount;
|
||||
tva += afterDiscount * (line.tva / 100);
|
||||
});
|
||||
|
||||
return {
|
||||
ht,
|
||||
tva,
|
||||
ttc: ht + tva,
|
||||
};
|
||||
return { ht, tva, ttc: ht + tva };
|
||||
});
|
||||
|
||||
const addLine = () => {
|
||||
form.value.lines.push({
|
||||
product_id: null,
|
||||
product_name: "",
|
||||
quantity: 1,
|
||||
unit_price: 0,
|
||||
tva: 20,
|
||||
discount_pct: 0,
|
||||
});
|
||||
};
|
||||
const addLine = () => form.value.lines.push(defaultLine());
|
||||
const removeLine = (index) => form.value.lines.splice(index, 1);
|
||||
|
||||
const removeLine = (index) => {
|
||||
form.value.lines.splice(index, 1);
|
||||
};
|
||||
|
||||
const formatCurrency = (value) => {
|
||||
return new Intl.NumberFormat("fr-FR", {
|
||||
style: "currency",
|
||||
currency: "EUR",
|
||||
}).format(value);
|
||||
};
|
||||
const formatCurrency = (value) =>
|
||||
new Intl.NumberFormat("fr-FR", { style: "currency", currency: "EUR" }).format(value);
|
||||
|
||||
const saveQuote = async () => {
|
||||
if (!form.value.client_id) {
|
||||
alert("Veuillez sélectionner un client");
|
||||
return;
|
||||
}
|
||||
attempted.value = true;
|
||||
if (!form.value.client_id) return;
|
||||
|
||||
loading.value = true;
|
||||
try {
|
||||
@ -166,17 +183,11 @@ const saveQuote = async () => {
|
||||
total_ht: totals.value.ht,
|
||||
total_tva: totals.value.tva,
|
||||
total_ttc: totals.value.ttc,
|
||||
// Assuming backend handles lines separately or we need to pass them
|
||||
// If backend expects lines in payload, we need to add them to interface and store
|
||||
lines: form.value.lines.map((line) => ({
|
||||
...line,
|
||||
discount_pct: line.discount_pct || 0,
|
||||
// Calculate total_ht for the line: qty * unit_price * (1 - discount/100)
|
||||
total_ht:
|
||||
line.quantity *
|
||||
line.unit_price *
|
||||
(1 - (line.discount_pct || 0) / 100),
|
||||
description: line.product_name || "Produit sans nom", // Ensure description is set
|
||||
total_ht: line.quantity * line.unit_price * (1 - (line.discount_pct || 0) / 100),
|
||||
description: line.product_name || "Produit sans nom",
|
||||
})),
|
||||
});
|
||||
router.push("/ventes/devis");
|
||||
@ -188,11 +199,145 @@ const saveQuote = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const cancel = () => {
|
||||
router.back();
|
||||
};
|
||||
const cancel = () => router.back();
|
||||
|
||||
onMounted(() => {
|
||||
clientStore.fetchClients();
|
||||
});
|
||||
onMounted(() => clientStore.fetchClients());
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* ── Field Groups ── */
|
||||
.field-label {
|
||||
display: block;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
color: #8898aa;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
.field-select {
|
||||
border-radius: 8px;
|
||||
border-color: #e9ecef;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.field-error {
|
||||
margin-top: 0.35rem;
|
||||
font-size: 0.75rem;
|
||||
color: #f5365c;
|
||||
}
|
||||
|
||||
/* ── Lines Header ── */
|
||||
.lines-header {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
padding: 0 0.5rem 0.5rem;
|
||||
border-bottom: 2px solid #f0f2f8;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.line-col {
|
||||
font-size: 0.68rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: #adb5bd;
|
||||
}
|
||||
|
||||
.line-col--product { flex: 3; }
|
||||
.line-col--qty { flex: 1; text-align: center; }
|
||||
.line-col--price { flex: 1.5; text-align: right; }
|
||||
.line-col--tva { flex: 1; text-align: center; }
|
||||
.line-col--discount{ flex: 1; text-align: center; }
|
||||
.line-col--total { flex: 1.5; text-align: right; }
|
||||
.line-col--action { width: 36px; }
|
||||
|
||||
/* ── Line Row ── */
|
||||
.line-row {
|
||||
border-radius: 10px;
|
||||
margin-bottom: 0.4rem;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.line-row:hover {
|
||||
background: #fafbff;
|
||||
}
|
||||
|
||||
/* ── Line Transition ── */
|
||||
.line-fade-enter-active,
|
||||
.line-fade-leave-active {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.line-fade-enter-from,
|
||||
.line-fade-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-6px);
|
||||
}
|
||||
|
||||
/* ── Empty State ── */
|
||||
.lines-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 2.5rem 1rem;
|
||||
color: #adb5bd;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.lines-empty i {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
/* ── Add Line ── */
|
||||
.lines-footer {
|
||||
margin-top: 0.75rem;
|
||||
padding-top: 0.75rem;
|
||||
border-top: 1px dashed #e9ecef;
|
||||
}
|
||||
|
||||
.add-line-btn {
|
||||
font-size: 0.82rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* ── Totals ── */
|
||||
.totals-list {
|
||||
padding: 0.75rem 1.25rem;
|
||||
}
|
||||
|
||||
.totals-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.6rem 0;
|
||||
border-bottom: 1px solid #f0f2f8;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.totals-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.totals-label {
|
||||
color: #8898aa;
|
||||
}
|
||||
|
||||
.totals-value {
|
||||
font-weight: 600;
|
||||
color: #1a1f36;
|
||||
}
|
||||
|
||||
.totals-row--final {
|
||||
margin-top: 0.25rem;
|
||||
padding-top: 0.75rem;
|
||||
border-top: 2px solid #e9ecef;
|
||||
}
|
||||
|
||||
.totals-value--final {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 800;
|
||||
color: #5e72e4;
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -1,76 +1,131 @@
|
||||
<template>
|
||||
<div v-if="loading" class="text-center py-5">
|
||||
<div class="spinner-border text-primary" role="status">
|
||||
<span class="visually-hidden">Loading...</span>
|
||||
<!-- Loading -->
|
||||
<div v-if="loading" class="detail-state">
|
||||
<div class="spinner-ring"></div>
|
||||
<p>Chargement du devis…</p>
|
||||
</div>
|
||||
|
||||
<!-- Error -->
|
||||
<div v-else-if="error" class="detail-state detail-state--error">
|
||||
<i class="fas fa-exclamation-triangle"></i>
|
||||
<p>{{ error }}</p>
|
||||
<button class="btn-retry" @click="reload">
|
||||
<i class="fas fa-redo me-2"></i>Réessayer
|
||||
</button>
|
||||
</div>
|
||||
<div v-else-if="error" class="text-center py-5 text-danger">
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<quote-detail-template v-else-if="quote">
|
||||
<template #header>
|
||||
<quote-header
|
||||
:reference="quote.reference"
|
||||
:date="quote.quote_date"
|
||||
:code="quote.reference"
|
||||
/>
|
||||
<div class="d-flex justify-content-between align-items-start flex-wrap gap-3">
|
||||
<div>
|
||||
<h6 class="mb-1">Quote Details</h6>
|
||||
<p class="text-sm mb-0">
|
||||
Quote no.
|
||||
<b>{{ quote.reference || "—" }}</b>
|
||||
from
|
||||
<b>{{ formatDate(quote.quote_date) }}</b>
|
||||
</p>
|
||||
<p class="text-sm mb-0">
|
||||
Valid until:
|
||||
<b>{{ formatDate(quote.valid_until) }}</b>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="d-flex align-items-center flex-wrap gap-2">
|
||||
<soft-badge :color="statusBadgeColor(quote.status)" variant="gradient">
|
||||
<i :class="statusIcon(quote.status) + ' me-1'"></i>
|
||||
{{ getStatusLabel(quote.status) }}
|
||||
</soft-badge>
|
||||
<soft-button color="secondary" variant="gradient" class="mb-0">
|
||||
Export PDF
|
||||
</soft-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #lines>
|
||||
<quote-lines-table :lines="quote.lines" />
|
||||
<template #product>
|
||||
<div class="d-flex">
|
||||
<div class="qd-visual me-3">
|
||||
<i class="fas fa-file-signature"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h6 class="text-lg mb-0 mt-1">{{ quote.client?.name || "Client inconnu" }}</h6>
|
||||
<p class="text-sm mb-3">{{ quote.lines?.length || 0 }} ligne(s) dans ce devis.</p>
|
||||
<soft-badge :color="statusBadgeColor(quote.status)" variant="gradient" size="sm">
|
||||
{{ getStatusLabel(quote.status) }}
|
||||
</soft-badge>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #cta>
|
||||
<div class="d-flex justify-content-end">
|
||||
<div class="qd-status-select-wrap">
|
||||
<label class="form-label text-xs mb-1">Change status</label>
|
||||
<select
|
||||
class="form-select qd-status-select"
|
||||
:value="selectedStatus"
|
||||
:disabled="updating"
|
||||
@change="onStatusSelect"
|
||||
>
|
||||
<option v-for="s in availableStatuses" :key="s" :value="s">
|
||||
{{ getStatusLabel(s) }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-sm mt-2 mb-0 text-end">
|
||||
Change quote status directly from this section.
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<template #timeline>
|
||||
<h6 class="mb-3">Track quote</h6>
|
||||
<quote-timeline :history="quote.history" />
|
||||
</template>
|
||||
|
||||
<template #billing>
|
||||
<quote-billing-info
|
||||
:client-name="quote.client ? quote.client.name : 'Client inconnu'"
|
||||
:client-email="quote.client ? quote.client.email : ''"
|
||||
:client-phone="quote.client ? quote.client.phone : ''"
|
||||
/>
|
||||
<template #payment>
|
||||
<h6 class="mb-3">Quote lines</h6>
|
||||
<quote-lines-table :lines="quote.lines" />
|
||||
|
||||
|
||||
|
||||
<h6 class="mb-3 mt-4">Billing Information</h6>
|
||||
<ul class="list-group">
|
||||
<li class="list-group-item border-0 d-flex p-4 mb-2 bg-gray-100 border-radius-lg">
|
||||
<div class="d-flex flex-column">
|
||||
<h6 class="mb-3 text-sm">{{ quote.client?.name || "Client inconnu" }}</h6>
|
||||
<span class="mb-2 text-xs">
|
||||
Email Address:
|
||||
<span class="text-dark ms-2 font-weight-bold">{{ quote.client?.email || "—" }}</span>
|
||||
</span>
|
||||
<span class="mb-2 text-xs">
|
||||
Phone:
|
||||
<span class="text-dark ms-2 font-weight-bold">{{ quote.client?.phone || "—" }}</span>
|
||||
</span>
|
||||
<span class="text-xs">
|
||||
Quote Reference:
|
||||
<span class="text-dark ms-2 font-weight-bold">{{ quote.reference || "—" }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
<template #summary>
|
||||
<quote-summary
|
||||
:ht="quote.total_ht"
|
||||
:tva="quote.total_tva"
|
||||
:ttc="quote.total_ttc"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #actions>
|
||||
<div class="d-flex justify-content-end">
|
||||
<div class="position-relative d-inline-block me-2">
|
||||
<soft-button
|
||||
color="secondary"
|
||||
variant="gradient"
|
||||
@click="dropdownOpen = !dropdownOpen"
|
||||
>
|
||||
{{ getStatusLabel(quote.status) }}
|
||||
<i class="fas fa-chevron-down ms-2"></i>
|
||||
</soft-button>
|
||||
<ul
|
||||
v-if="dropdownOpen"
|
||||
class="dropdown-menu show position-absolute"
|
||||
style="top: 100%; left: 0; z-index: 1000"
|
||||
>
|
||||
<li v-for="status in availableStatuses" :key="status">
|
||||
<a
|
||||
class="dropdown-item"
|
||||
:class="{ active: status === quote.status }"
|
||||
href="javascript:;"
|
||||
@click="
|
||||
changeStatus(status);
|
||||
dropdownOpen = false;
|
||||
"
|
||||
>
|
||||
{{ getStatusLabel(status) }}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
<h6 class="mb-3">Quote Summary</h6>
|
||||
<div class="d-flex justify-content-between">
|
||||
<span class="mb-2 text-sm">Total HT:</span>
|
||||
<span class="text-dark font-weight-bold ms-2">{{ formatCurrency(quote.total_ht) }}</span>
|
||||
</div>
|
||||
<div class="d-flex justify-content-between">
|
||||
<span class="mb-2 text-sm">TVA:</span>
|
||||
<span class="text-dark ms-2 font-weight-bold">{{ formatCurrency(quote.total_tva) }}</span>
|
||||
</div>
|
||||
<div class="d-flex justify-content-between mt-4">
|
||||
<span class="mb-2 text-lg">Total TTC:</span>
|
||||
<span class="text-dark text-lg ms-2 font-weight-bold">{{ formatCurrency(quote.total_ttc) }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</quote-detail-template>
|
||||
@ -78,109 +133,192 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, defineProps } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { useQuoteStore } from "@/stores/quoteStore";
|
||||
import { useNotificationStore } from "@/stores/notification";
|
||||
import QuoteDetailTemplate from "@/components/templates/Quote/QuoteDetailTemplate.vue";
|
||||
import QuoteHeader from "@/components/molecules/Quote/QuoteHeader.vue";
|
||||
import QuoteTimeline from "@/components/molecules/Quote/QuoteTimeline.vue";
|
||||
import QuoteBillingInfo from "@/components/molecules/Quote/QuoteBillingInfo.vue";
|
||||
import QuoteSummary from "@/components/molecules/Quote/QuoteSummary.vue";
|
||||
import QuoteLinesTable from "@/components/molecules/Quote/QuoteLinesTable.vue";
|
||||
import SoftButton from "@/components/SoftButton.vue";
|
||||
import SoftBadge from "@/components/SoftBadge.vue";
|
||||
|
||||
const props = defineProps({
|
||||
quoteId: {
|
||||
type: [String, Number],
|
||||
required: true,
|
||||
},
|
||||
quoteId: { type: [String, Number], required: true },
|
||||
});
|
||||
|
||||
const router = useRouter();
|
||||
const quoteStore = useQuoteStore();
|
||||
const notificationStore = useNotificationStore();
|
||||
|
||||
const quote = ref(null);
|
||||
const loading = ref(true);
|
||||
const updating = ref(false);
|
||||
const error = ref(null);
|
||||
const dropdownOpen = ref(false);
|
||||
const selectedStatus = ref("brouillon");
|
||||
|
||||
onMounted(async () => {
|
||||
const load = async () => {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
const fetchedQuote = await quoteStore.fetchQuote(props.quoteId);
|
||||
quote.value = fetchedQuote;
|
||||
quote.value = await quoteStore.fetchQuote(props.quoteId);
|
||||
selectedStatus.value = quote.value?.status || "brouillon";
|
||||
} catch (e) {
|
||||
error.value = "Impossible de charger le devis.";
|
||||
console.error(e);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
const goBack = () => {
|
||||
router.back();
|
||||
};
|
||||
|
||||
const formatDate = (dateString) => {
|
||||
if (!dateString) return "-";
|
||||
return new Date(dateString).toLocaleDateString("fr-FR");
|
||||
const reload = () => load();
|
||||
onMounted(load);
|
||||
|
||||
/* ── Helpers ── */
|
||||
const formatDate = (d) =>
|
||||
d
|
||||
? new Date(d).toLocaleDateString("fr-FR", {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
})
|
||||
: "—";
|
||||
|
||||
const formatCurrency = (value) => {
|
||||
const amount = Number(value || 0);
|
||||
return new Intl.NumberFormat("fr-FR", {
|
||||
style: "currency",
|
||||
currency: "EUR",
|
||||
minimumFractionDigits: 2,
|
||||
}).format(amount);
|
||||
};
|
||||
|
||||
const availableStatuses = [
|
||||
"brouillon",
|
||||
"envoye",
|
||||
"accepte",
|
||||
"refuse",
|
||||
"expire",
|
||||
"annule",
|
||||
];
|
||||
const availableStatuses = ["brouillon", "envoye", "accepte", "refuse", "expire", "annule"];
|
||||
|
||||
const getStatusLabel = (status) => {
|
||||
const labels = {
|
||||
const statusLabels = {
|
||||
brouillon: "Brouillon",
|
||||
envoye: "Envoyé",
|
||||
accepte: "Accepté",
|
||||
refuse: "Refusé",
|
||||
expire: "Expiré",
|
||||
annule: "Annulé",
|
||||
};
|
||||
return labels[status] || status;
|
||||
};
|
||||
|
||||
/* eslint-disable require-atomic-updates */
|
||||
const changeStatus = async (newStatus) => {
|
||||
if (!quote.value?.id) return;
|
||||
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",
|
||||
};
|
||||
|
||||
// Capture the current quote ID to prevent race conditions
|
||||
const currentQuoteId = quote.value.id;
|
||||
const statusBadgeColor = (status) => {
|
||||
const map = {
|
||||
brouillon: "warning",
|
||||
envoye: "info",
|
||||
accepte: "success",
|
||||
refuse: "danger",
|
||||
expire: "secondary",
|
||||
annule: "dark",
|
||||
};
|
||||
return map[status] || "secondary";
|
||||
};
|
||||
|
||||
try {
|
||||
loading.value = true;
|
||||
const updated = await quoteStore.updateQuote({
|
||||
id: currentQuoteId,
|
||||
status: newStatus,
|
||||
});
|
||||
const getStatusLabel = (s) => statusLabels[s] || s;
|
||||
const statusIcon = (s) => statusIcons[s] || "fas fa-circle";
|
||||
|
||||
// Only update if we're still viewing the same quote
|
||||
if (quote.value?.id === currentQuoteId) {
|
||||
const onStatusSelect = (event) => {
|
||||
const newStatus = event.target.value;
|
||||
selectedStatus.value = newStatus;
|
||||
if (!quote.value?.id || newStatus === quote.value.status) return;
|
||||
changeStatus(quote.value.id, newStatus);
|
||||
};
|
||||
|
||||
/* ── Status Update ── */
|
||||
const changeStatus = (id, newStatus) => {
|
||||
if (!id || updating.value) return;
|
||||
updating.value = true;
|
||||
quoteStore
|
||||
.updateQuote({ id, status: newStatus })
|
||||
.then((updated) => {
|
||||
if (`${props.quoteId}` !== `${id}`) return;
|
||||
quote.value = updated;
|
||||
|
||||
// Show success notification
|
||||
selectedStatus.value = updated?.status || newStatus;
|
||||
notificationStore.success(
|
||||
"Statut mis à jour",
|
||||
`Le devis est maintenant "${getStatusLabel(newStatus)}"`,
|
||||
3000
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to update status", e);
|
||||
notificationStore.error(
|
||||
"Erreur",
|
||||
"Impossible de mettre à jour le statut",
|
||||
3000
|
||||
);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
notificationStore.error("Erreur", "Impossible de mettre à jour le statut", 3000);
|
||||
})
|
||||
.finally(() => {
|
||||
updating.value = false;
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* ── States ── */
|
||||
.detail-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 60vh;
|
||||
color: #8898aa;
|
||||
font-size: .9rem;
|
||||
gap: .6rem;
|
||||
}
|
||||
|
||||
.detail-state--error i {
|
||||
font-size: 2.5rem;
|
||||
color: #f5365c;
|
||||
}
|
||||
|
||||
.spinner-ring {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border: 3px solid #e9ecef;
|
||||
border-top-color: #5e72e4;
|
||||
border-radius: 50%;
|
||||
animation: spin .8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
.btn-retry {
|
||||
margin-top: .25rem;
|
||||
padding: .4rem 1.1rem;
|
||||
border-radius: 8px;
|
||||
border: 1.5px solid #f5365c;
|
||||
background: none;
|
||||
color: #f5365c;
|
||||
font-size: .8rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: background .15s;
|
||||
}
|
||||
.btn-retry:hover { background: #fff0f3; }
|
||||
|
||||
.qd-visual {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(135deg, #5e72e4, #825ee4);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
font-size: 1.3rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.qd-status-select-wrap {
|
||||
min-width: 220px;
|
||||
}
|
||||
|
||||
.qd-status-select {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -24,7 +24,7 @@
|
||||
selectedItem.email || "Pas d'email"
|
||||
}})
|
||||
</div>
|
||||
<div v-if="fieldErrors.client_id" class="invalid-feedback">
|
||||
<div v-if="fieldErrors.client_id" class="invalid-feedback small-error">
|
||||
{{ fieldErrors.client_id }}
|
||||
</div>
|
||||
</div>
|
||||
@ -47,7 +47,7 @@
|
||||
Sélectionné: {{ selectedDeceased.last_name }}
|
||||
{{ selectedDeceased.first_name || "" }}
|
||||
</div>
|
||||
<div v-if="fieldErrors.deceased_id" class="invalid-feedback">
|
||||
<div v-if="fieldErrors.deceased_id" class="invalid-feedback small-error">
|
||||
{{ fieldErrors.deceased_id }}
|
||||
</div>
|
||||
</div>
|
||||
@ -72,7 +72,7 @@
|
||||
<option value="retrait_bijoux">Retrait bijoux</option>
|
||||
<option value="autre">Autre</option>
|
||||
</select>
|
||||
<div v-if="fieldErrors.type" class="invalid-feedback">
|
||||
<div v-if="fieldErrors.type" class="invalid-feedback small-error">
|
||||
{{ fieldErrors.type }}
|
||||
</div>
|
||||
</div>
|
||||
@ -88,7 +88,7 @@
|
||||
:class="{ 'is-invalid': fieldErrors.scheduled_at }"
|
||||
type="date"
|
||||
/>
|
||||
<div v-if="fieldErrors.scheduled_at" class="invalid-feedback">
|
||||
<div v-if="fieldErrors.scheduled_at" class="invalid-feedback small-error">
|
||||
{{ fieldErrors.scheduled_at }}
|
||||
</div>
|
||||
</div>
|
||||
@ -100,7 +100,7 @@
|
||||
:class="{ 'is-invalid': fieldErrors.scheduled_at }"
|
||||
type="time"
|
||||
/>
|
||||
<div v-if="fieldErrors.scheduled_at" class="invalid-feedback">
|
||||
<div v-if="fieldErrors.scheduled_at" class="invalid-feedback small-error">
|
||||
{{ fieldErrors.scheduled_at }}
|
||||
</div>
|
||||
</div>
|
||||
@ -118,7 +118,7 @@
|
||||
min="1"
|
||||
placeholder="ex. 90"
|
||||
/>
|
||||
<div v-if="fieldErrors.duration_min" class="invalid-feedback">
|
||||
<div v-if="fieldErrors.duration_min" class="invalid-feedback small-error">
|
||||
{{ fieldErrors.duration_min }}
|
||||
</div>
|
||||
</div>
|
||||
@ -136,7 +136,7 @@
|
||||
<option value="termine">Terminé</option>
|
||||
<option value="annule">Annulé</option>
|
||||
</select>
|
||||
<div v-if="fieldErrors.status" class="invalid-feedback">
|
||||
<div v-if="fieldErrors.status" class="invalid-feedback small-error">
|
||||
{{ fieldErrors.status }}
|
||||
</div>
|
||||
</div>
|
||||
@ -153,7 +153,7 @@
|
||||
type="text"
|
||||
placeholder="Nom du donneur d'ordre"
|
||||
/>
|
||||
<div v-if="fieldErrors.order_giver" class="invalid-feedback">
|
||||
<div v-if="fieldErrors.order_giver" class="invalid-feedback small-error">
|
||||
{{ fieldErrors.order_giver }}
|
||||
</div>
|
||||
</div>
|
||||
@ -171,22 +171,12 @@
|
||||
placeholder="Informations complémentaires, instructions spéciales..."
|
||||
maxlength="2000"
|
||||
></textarea>
|
||||
<div v-if="fieldErrors.notes" class="invalid-feedback">
|
||||
<div v-if="fieldErrors.notes" class="invalid-feedback small-error">
|
||||
{{ fieldErrors.notes }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Validation errors -->
|
||||
<div v-if="formValidationError" class="row mt-3">
|
||||
<div class="col-12">
|
||||
<div class="alert alert-warning" role="alert">
|
||||
<i class="fas fa-exclamation-triangle me-2"></i>
|
||||
{{ formValidationError }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Boutons -->
|
||||
<div class="button-row d-flex mt-4">
|
||||
<soft-button
|
||||
@ -203,7 +193,7 @@
|
||||
color="dark"
|
||||
variant="gradient"
|
||||
class="ms-auto mb-0"
|
||||
:disabled="props.loading || !!formValidationError"
|
||||
:disabled="props.loading"
|
||||
@click="submitForm"
|
||||
>
|
||||
<span
|
||||
@ -219,7 +209,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, defineProps, defineEmits, watch, computed } from "vue";
|
||||
import { ref, defineProps, defineEmits, watch } from "vue";
|
||||
import SoftInput from "@/components/SoftInput.vue";
|
||||
import SoftButton from "@/components/SoftButton.vue";
|
||||
import SearchInput from "@/components/atoms/input/SearchInput.vue";
|
||||
@ -336,19 +326,6 @@ const form = ref({
|
||||
notes: "",
|
||||
});
|
||||
|
||||
// Computed property to validate form
|
||||
const formValidationError = computed(() => {
|
||||
if (!form.value.client_id) {
|
||||
return "Le client est obligatoire";
|
||||
}
|
||||
|
||||
if (!form.value.type || form.value.type === "") {
|
||||
return "Le type d'intervention est obligatoire";
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
|
||||
// Watch for validation errors from parent
|
||||
watch(
|
||||
() => props.validationErrors,
|
||||
@ -372,6 +349,9 @@ watch(
|
||||
watch(
|
||||
() => form.value.client_id,
|
||||
(newClientId) => {
|
||||
if (newClientId && fieldErrors.value.client_id) {
|
||||
delete fieldErrors.value.client_id;
|
||||
}
|
||||
if (!newClientId) {
|
||||
selectedItem.value = null;
|
||||
}
|
||||
@ -382,31 +362,43 @@ watch(
|
||||
watch(
|
||||
() => form.value.deceased_id,
|
||||
(newDeceasedId) => {
|
||||
if (newDeceasedId && fieldErrors.value.deceased_id) {
|
||||
delete fieldErrors.value.deceased_id;
|
||||
}
|
||||
if (!newDeceasedId) {
|
||||
selectedDeceased.value = null;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
watch(
|
||||
() => form.value.type,
|
||||
(newType) => {
|
||||
if (newType && fieldErrors.value.type) {
|
||||
delete fieldErrors.value.type;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const submitForm = async () => {
|
||||
// Clear errors before submitting
|
||||
fieldErrors.value = {};
|
||||
errors.value = [];
|
||||
|
||||
// Check for form validation errors
|
||||
if (formValidationError.value) {
|
||||
errors.value.push(formValidationError.value);
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate required fields
|
||||
let hasErrors = false;
|
||||
|
||||
if (!form.value.client_id || form.value.client_id === "") {
|
||||
fieldErrors.value.client_id = "Le client est obligatoire";
|
||||
return;
|
||||
hasErrors = true;
|
||||
}
|
||||
|
||||
if (!form.value.type || form.value.type === "") {
|
||||
fieldErrors.value.type = "Le type d'intervention est obligatoire";
|
||||
hasErrors = true;
|
||||
}
|
||||
|
||||
if (hasErrors) {
|
||||
return;
|
||||
}
|
||||
|
||||
@ -487,6 +479,11 @@ const clearErrors = () => {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.small-error {
|
||||
font-size: 0.75rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.spinner-border-sm {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
|
||||
@ -43,36 +43,7 @@ const interventions = computed(() => {
|
||||
return props.interventionData.length > 0
|
||||
? props.interventionData
|
||||
: [
|
||||
{
|
||||
id: 1, // Add required id for navigation
|
||||
title: "Cérémonie religieuse",
|
||||
status: {
|
||||
label: "Confirmé",
|
||||
color: "success",
|
||||
variant: "fill",
|
||||
size: "md",
|
||||
},
|
||||
date: "15 Décembre 2024 - 14:00",
|
||||
defuntName: "Jean Dupont",
|
||||
lieux: "Église Saint-Pierre, Paris",
|
||||
duree: "1h30",
|
||||
description:
|
||||
"Cérémonie religieuse traditionnelle suivie d'une bénédiction.",
|
||||
action: {
|
||||
label: "Voir détails",
|
||||
color: "primary",
|
||||
},
|
||||
members: [
|
||||
{
|
||||
image: "/images/avatar1.jpg",
|
||||
name: "Marie Curie",
|
||||
},
|
||||
{
|
||||
image: "/images/avatar2.jpg",
|
||||
name: "Pierre Durand",
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
];
|
||||
});
|
||||
</script>
|
||||
|
||||
@ -106,6 +106,21 @@ const typeIcons = {
|
||||
|
||||
const getTypeIcon = (type) => typeIcons[type] || 'fas fa-briefcase-medical';
|
||||
|
||||
const parseInterventionDate = (value) => {
|
||||
if (!value) return null;
|
||||
if (value instanceof Date) return Number.isNaN(value.getTime()) ? null : value;
|
||||
|
||||
if (typeof value === 'string') {
|
||||
// Support backend format "YYYY-MM-DD HH:mm:ss" (without timezone)
|
||||
const normalized = value.includes(' ') ? value.replace(' ', 'T') : value;
|
||||
const parsed = new Date(normalized);
|
||||
if (!Number.isNaN(parsed.getTime())) return parsed;
|
||||
}
|
||||
|
||||
const fallback = new Date(value);
|
||||
return Number.isNaN(fallback.getTime()) ? null : fallback;
|
||||
};
|
||||
|
||||
const weekRangeLabel = computed(() => {
|
||||
if (!calendar) return '';
|
||||
try {
|
||||
@ -120,8 +135,12 @@ const weekRangeLabel = computed(() => {
|
||||
|
||||
const mapEvents = (interventions) => interventions.map((i) => {
|
||||
const color = typeColors[i.type] || '#6b7280';
|
||||
const start = new Date(i.date);
|
||||
const end = i.end ? new Date(i.end) : new Date(start.getTime() + 60 * 60 * 1000);
|
||||
const start = parseInterventionDate(i.date);
|
||||
if (!start) return null;
|
||||
|
||||
const parsedEnd = parseInterventionDate(i.end);
|
||||
const end = parsedEnd || new Date(start.getTime() + 60 * 60 * 1000);
|
||||
|
||||
return {
|
||||
id: String(i.id),
|
||||
title: i.deceased || i.type || 'Intervention',
|
||||
@ -131,7 +150,7 @@ const mapEvents = (interventions) => interventions.map((i) => {
|
||||
textColor: '#fff',
|
||||
extendedProps: { originalData: i, color },
|
||||
};
|
||||
});
|
||||
}).filter(Boolean);
|
||||
|
||||
const showPopover = (jsEvent, originalData, color) => {
|
||||
const rect = jsEvent.target.closest('.fc-event')?.getBoundingClientRect() || jsEvent.target.getBoundingClientRect();
|
||||
@ -194,8 +213,8 @@ const initCalendar = () => {
|
||||
headerToolbar: false,
|
||||
initialDate: props.startDate,
|
||||
allDaySlot: false,
|
||||
slotMinTime: '07:00:00',
|
||||
slotMaxTime: '21:00:00',
|
||||
slotMinTime: '00:00:00',
|
||||
slotMaxTime: '24:00:00',
|
||||
height: 'auto',
|
||||
expandRows: true,
|
||||
stickyHeaderDates: true,
|
||||
|
||||
@ -109,7 +109,7 @@
|
||||
<td class="font-weight-bold">
|
||||
<div class="d-flex align-items-center">
|
||||
<soft-avatar
|
||||
:img="product.media.photo_url || getRandomAvatar()"
|
||||
:img="product.media?.photo_url || getRandomAvatar()"
|
||||
size="xs"
|
||||
class="me-2"
|
||||
alt="product image"
|
||||
|
||||
@ -1,67 +1,41 @@
|
||||
<template>
|
||||
<div class="contact-form-root">
|
||||
<div class="card p-3 border-radius-xl bg-white" data-animation="FadeIn">
|
||||
<h5 class="font-weight-bolder mb-0">Nouveau Contact</h5>
|
||||
<p class="mb-0 text-sm">Associez un contact à un client existant</p>
|
||||
|
||||
<!-- Header -->
|
||||
<div class="form-header">
|
||||
<div class="header-icon-wrap">
|
||||
<i class="fas fa-user-plus"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h5 class="form-title">Nouveau Contact</h5>
|
||||
<p class="form-subtitle">Associez un contact à un client existant</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Success Banner -->
|
||||
<transition name="banner-fade">
|
||||
<div v-if="props.success" class="status-banner success-banner">
|
||||
<i class="fas fa-check-circle banner-icon"></i>
|
||||
<span><strong>Contact créé avec succès !</strong> Il est maintenant lié au client.</span>
|
||||
<div v-if="props.success" class="alert alert-success text-white mt-3 mb-0 py-2" role="alert">
|
||||
<span class="text-sm"><strong>Contact créé avec succès !</strong> Il est maintenant lié au client.</span>
|
||||
</div>
|
||||
</transition>
|
||||
|
||||
<!-- Error Banner -->
|
||||
<transition name="banner-fade">
|
||||
<div v-if="fieldErrors.general" class="status-banner error-banner">
|
||||
<i class="fas fa-exclamation-circle banner-icon"></i>
|
||||
<span>{{ fieldErrors.general }}</span>
|
||||
<div v-if="fieldErrors.general" class="alert alert-danger text-white mt-3 mb-0 py-2" role="alert">
|
||||
<span class="text-sm">{{ fieldErrors.general }}</span>
|
||||
</div>
|
||||
</transition>
|
||||
|
||||
<div class="form-body">
|
||||
<div class="multisteps-form__content">
|
||||
<div class="row mt-3">
|
||||
<div class="col-12">
|
||||
<label class="form-label">Client associé <span class="text-danger">*</span></label>
|
||||
|
||||
<!-- ── SECTION 1: Client ── -->
|
||||
<div class="form-section">
|
||||
<div class="section-label">
|
||||
<span>Client associé</span>
|
||||
</div>
|
||||
|
||||
<!-- Search -->
|
||||
<div class="field-group" v-if="!selectedClient">
|
||||
<label class="field-label">
|
||||
Client <span class="req">*</span>
|
||||
</label>
|
||||
<div v-if="!selectedClient" class="position-relative">
|
||||
<div class="search-wrap" :class="{ 'has-error': fieldErrors.client_id }">
|
||||
<i class="fas fa-search search-pfx"></i>
|
||||
<input
|
||||
:value="searchQuery"
|
||||
type="text"
|
||||
class="field-input search-input"
|
||||
class="form-control search-input"
|
||||
placeholder="Rechercher un client par nom ou email..."
|
||||
@input="handleSearchInput($event.target.value)"
|
||||
@focus="showDropdown = true"
|
||||
@blur="onInputBlur"
|
||||
/>
|
||||
<span v-if="searchQuery" class="search-loader">
|
||||
<i class="fas fa-circle-notch fa-spin" style="color:#9ca3af;font-size:0.75rem"></i>
|
||||
<i class="fas fa-circle-notch fa-spin text-secondary text-xs"></i>
|
||||
</span>
|
||||
</div>
|
||||
<span v-if="fieldErrors.client_id" class="field-error">
|
||||
<i class="fas fa-exclamation-circle"></i> {{ fieldErrors.client_id }}
|
||||
</span>
|
||||
<p class="hint-text"><i class="fas fa-info-circle me-1"></i>Le contact sera rattaché à ce client</p>
|
||||
|
||||
<!-- Dropdown -->
|
||||
<transition name="dropdown-pop">
|
||||
<div v-if="showDropdown && props.searchResults && props.searchResults.length > 0" class="search-dropdown">
|
||||
<button
|
||||
@ -71,218 +45,158 @@
|
||||
class="dropdown-row"
|
||||
@mousedown="selectClient(client)"
|
||||
>
|
||||
<div class="dr-avatar">{{ (client.name || '?')[0] }}</div>
|
||||
<div class="dr-info">
|
||||
<span class="dr-avatar">{{ (client.name || "?")[0] }}</span>
|
||||
<span class="dr-info">
|
||||
<span class="dr-name">{{ client.name }}</span>
|
||||
<span class="dr-meta" v-if="client.email">{{ client.email }}</span>
|
||||
</div>
|
||||
<i class="fas fa-arrow-right dr-arrow"></i>
|
||||
<span v-if="client.email" class="dr-meta">{{ client.email }}</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<div v-else-if="showDropdown && searchQuery && props.searchResults && props.searchResults.length === 0" class="search-dropdown">
|
||||
<div class="dropdown-empty">
|
||||
<i class="fas fa-search empty-icon"></i>
|
||||
<span>Aucun résultat pour <strong>« {{ searchQuery }} »</strong></span>
|
||||
</div>
|
||||
<div v-else-if="showDropdown && searchQuery && props.searchResults && props.searchResults.length === 0" class="search-dropdown py-3 text-center text-sm text-secondary">
|
||||
Aucun résultat pour <strong>« {{ searchQuery }} »</strong>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
|
||||
<!-- Selected Client Card -->
|
||||
<transition name="banner-fade">
|
||||
<div v-if="selectedClient" class="selected-client-card">
|
||||
<div class="sc-avatar">{{ (selectedClient.name || '?')[0] }}</div>
|
||||
<div class="sc-info">
|
||||
<div v-else class="selected-client-card mt-2">
|
||||
<span class="sc-avatar">{{ (selectedClient.name || "?")[0] }}</span>
|
||||
<span class="sc-info">
|
||||
<span class="sc-label">Client sélectionné</span>
|
||||
<span class="sc-name">{{ selectedClient.name }}</span>
|
||||
<span class="sc-meta" v-if="selectedClient.email">{{ selectedClient.email }}</span>
|
||||
</div>
|
||||
<button type="button" class="sc-change-btn" @click="clearSelection">
|
||||
<i class="fas fa-exchange-alt me-1"></i>Changer
|
||||
</button>
|
||||
</div>
|
||||
</transition>
|
||||
<span v-if="selectedClient.email" class="sc-meta">{{ selectedClient.email }}</span>
|
||||
</span>
|
||||
<soft-button type="button" color="secondary" variant="outline" size="sm" @click="clearSelection">
|
||||
Changer
|
||||
</soft-button>
|
||||
</div>
|
||||
|
||||
<div class="section-divider"></div>
|
||||
|
||||
<!-- ── SECTION 2: Identity ── -->
|
||||
<div class="form-section">
|
||||
<div class="section-label">
|
||||
<span>Identité</span>
|
||||
<div v-if="fieldErrors.client_id" class="invalid-feedback d-block">{{ fieldErrors.client_id }}</div>
|
||||
<p class="text-xs text-secondary mb-0 mt-1">Le contact sera rattaché à ce client</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="two-col">
|
||||
<div class="field-group">
|
||||
<label class="field-label">Prénom</label>
|
||||
<input
|
||||
:value="form.first_name"
|
||||
<div class="row mt-3">
|
||||
<div class="col-12 col-sm-6">
|
||||
<label class="form-label">Prénom</label>
|
||||
<soft-input
|
||||
v-model="form.first_name"
|
||||
class="multisteps-form__input"
|
||||
:error="!!fieldErrors.first_name"
|
||||
type="text"
|
||||
class="field-input"
|
||||
:class="{ 'is-invalid': fieldErrors.first_name }"
|
||||
placeholder="Jean"
|
||||
maxlength="191"
|
||||
@input="form.first_name = $event.target.value"
|
||||
/>
|
||||
<span v-if="fieldErrors.first_name" class="field-error">
|
||||
<i class="fas fa-exclamation-circle"></i> {{ fieldErrors.first_name }}
|
||||
</span>
|
||||
<div v-if="fieldErrors.first_name" class="invalid-feedback d-block">{{ fieldErrors.first_name }}</div>
|
||||
</div>
|
||||
<div class="field-group">
|
||||
<label class="field-label">Nom</label>
|
||||
<input
|
||||
:value="form.last_name"
|
||||
<div class="col-12 col-sm-6 mt-3 mt-sm-0">
|
||||
<label class="form-label">Nom</label>
|
||||
<soft-input
|
||||
v-model="form.last_name"
|
||||
class="multisteps-form__input"
|
||||
:error="!!fieldErrors.last_name"
|
||||
type="text"
|
||||
class="field-input"
|
||||
:class="{ 'is-invalid': fieldErrors.last_name }"
|
||||
placeholder="Dupont"
|
||||
maxlength="191"
|
||||
@input="form.last_name = $event.target.value"
|
||||
/>
|
||||
<span v-if="fieldErrors.last_name" class="field-error">
|
||||
<i class="fas fa-exclamation-circle"></i> {{ fieldErrors.last_name }}
|
||||
</span>
|
||||
<div v-if="fieldErrors.last_name" class="invalid-feedback d-block">{{ fieldErrors.last_name }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field-group mt-field">
|
||||
<label class="field-label">
|
||||
<i class="fas fa-briefcase field-pfx-icon"></i>Rôle / Poste
|
||||
</label>
|
||||
<input
|
||||
:value="form.role"
|
||||
<div class="row mt-3">
|
||||
<div class="col-12">
|
||||
<label class="form-label">Rôle / Poste</label>
|
||||
<soft-input
|
||||
v-model="form.role"
|
||||
class="multisteps-form__input"
|
||||
:error="!!fieldErrors.role"
|
||||
type="text"
|
||||
class="field-input"
|
||||
:class="{ 'is-invalid': fieldErrors.role }"
|
||||
placeholder="ex. Directeur Commercial, Responsable RH..."
|
||||
maxlength="191"
|
||||
@input="form.role = $event.target.value"
|
||||
/>
|
||||
<span v-if="fieldErrors.role" class="field-error">
|
||||
<i class="fas fa-exclamation-circle"></i> {{ fieldErrors.role }}
|
||||
</span>
|
||||
<div v-if="fieldErrors.role" class="invalid-feedback d-block">{{ fieldErrors.role }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section-divider"></div>
|
||||
|
||||
<!-- ── SECTION 3: Coordonnées ── -->
|
||||
<div class="form-section">
|
||||
<div class="section-label">
|
||||
<span>Coordonnées</span>
|
||||
</div>
|
||||
|
||||
<div class="field-group">
|
||||
<label class="field-label">
|
||||
<i class="fas fa-envelope field-pfx-icon"></i>Email
|
||||
</label>
|
||||
<input
|
||||
:value="form.email"
|
||||
<div class="row mt-3">
|
||||
<div class="col-12">
|
||||
<label class="form-label">Email</label>
|
||||
<soft-input
|
||||
v-model="form.email"
|
||||
class="multisteps-form__input"
|
||||
:error="!!fieldErrors.email"
|
||||
type="email"
|
||||
class="field-input"
|
||||
:class="{ 'is-invalid': fieldErrors.email }"
|
||||
placeholder="jean.dupont@entreprise.com"
|
||||
maxlength="191"
|
||||
@input="form.email = $event.target.value"
|
||||
/>
|
||||
<span v-if="fieldErrors.email" class="field-error">
|
||||
<i class="fas fa-exclamation-circle"></i> {{ fieldErrors.email }}
|
||||
</span>
|
||||
<div v-if="fieldErrors.email" class="invalid-feedback d-block">{{ fieldErrors.email }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="two-col mt-field">
|
||||
<div class="field-group">
|
||||
<label class="field-label">
|
||||
<i class="fas fa-phone field-pfx-icon"></i>Téléphone
|
||||
</label>
|
||||
<input
|
||||
:value="form.phone"
|
||||
<div class="row mt-3">
|
||||
<div class="col-12 col-sm-6">
|
||||
<label class="form-label">Téléphone</label>
|
||||
<soft-input
|
||||
v-model="form.phone"
|
||||
class="multisteps-form__input"
|
||||
:error="!!fieldErrors.phone"
|
||||
type="text"
|
||||
class="field-input"
|
||||
:class="{ 'is-invalid': fieldErrors.phone }"
|
||||
placeholder="+33 1 23 45 67 89"
|
||||
maxlength="50"
|
||||
@input="form.phone = $event.target.value"
|
||||
/>
|
||||
<span v-if="fieldErrors.phone" class="field-error">
|
||||
<i class="fas fa-exclamation-circle"></i> {{ fieldErrors.phone }}
|
||||
</span>
|
||||
<div v-if="fieldErrors.phone" class="invalid-feedback d-block">{{ fieldErrors.phone }}</div>
|
||||
</div>
|
||||
<div class="field-group">
|
||||
<label class="field-label">
|
||||
<i class="fas fa-mobile-alt field-pfx-icon"></i>Mobile
|
||||
</label>
|
||||
<input
|
||||
:value="form.mobile"
|
||||
<div class="col-12 col-sm-6 mt-3 mt-sm-0">
|
||||
<label class="form-label">Mobile</label>
|
||||
<soft-input
|
||||
v-model="form.mobile"
|
||||
class="multisteps-form__input"
|
||||
:error="!!fieldErrors.mobile"
|
||||
type="text"
|
||||
class="field-input"
|
||||
:class="{ 'is-invalid': fieldErrors.mobile }"
|
||||
placeholder="+33 6 12 34 56 78"
|
||||
maxlength="50"
|
||||
@input="form.mobile = $event.target.value"
|
||||
/>
|
||||
<span v-if="fieldErrors.mobile" class="field-error">
|
||||
<i class="fas fa-exclamation-circle"></i> {{ fieldErrors.mobile }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="fieldErrors.mobile" class="invalid-feedback d-block">{{ fieldErrors.mobile }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section-divider"></div>
|
||||
|
||||
<!-- ── SECTION 4: Notes ── -->
|
||||
<div class="form-section">
|
||||
<div class="section-label">
|
||||
<span>Notes</span>
|
||||
</div>
|
||||
<div class="field-group">
|
||||
<label class="field-label">
|
||||
<i class="fas fa-sticky-note field-pfx-icon"></i>Observations
|
||||
</label>
|
||||
<div class="row mt-3">
|
||||
<div class="col-12">
|
||||
<label class="form-label">Observations</label>
|
||||
<textarea
|
||||
:value="form.notes"
|
||||
class="field-textarea"
|
||||
class="form-control multisteps-form__input"
|
||||
rows="3"
|
||||
placeholder="Informations complémentaires sur ce contact..."
|
||||
maxlength="1000"
|
||||
@input="form.notes = $event.target.value"
|
||||
></textarea>
|
||||
<span class="char-count">{{ (form.notes || '').length }}/1000</span>
|
||||
<div class="text-end text-xs text-secondary mt-1">{{ (form.notes || "").length }}/1000</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Validation Warning -->
|
||||
<transition name="banner-fade">
|
||||
<div v-if="showValidationWarning && form.client_id" class="warn-banner">
|
||||
<i class="fas fa-exclamation-triangle warn-icon"></i>
|
||||
<span>Renseignez au moins un champ parmi : prénom, nom, email ou téléphone.</span>
|
||||
<div v-if="showValidationWarning && form.client_id" class="alert alert-warning text-dark mt-3 py-2 mb-0" role="alert">
|
||||
<span class="text-sm">Renseignez au moins un champ parmi : prénom, nom, email ou téléphone.</span>
|
||||
</div>
|
||||
</transition>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Footer Actions -->
|
||||
<div class="form-footer">
|
||||
<button type="button" class="btn-reset" @click="resetForm">
|
||||
<i class="fas fa-undo me-2"></i>Réinitialiser
|
||||
</button>
|
||||
<button
|
||||
<div class="d-flex mt-4 justify-content-between gap-2 flex-wrap">
|
||||
<soft-button type="button" color="secondary" variant="outline" @click="resetForm">
|
||||
Réinitialiser
|
||||
</soft-button>
|
||||
<soft-button
|
||||
type="button"
|
||||
class="btn-submit"
|
||||
color="info"
|
||||
variant="gradient"
|
||||
:disabled="props.loading || !isFormValid"
|
||||
@click="submitForm"
|
||||
>
|
||||
<span v-if="props.loading" class="spinner-border spinner-border-sm me-2" role="status"></span>
|
||||
<i v-else class="fas fa-user-check me-2"></i>
|
||||
{{ props.loading ? "Création en cours…" : "Créer le contact" }}
|
||||
</button>
|
||||
<span>{{ props.loading ? "Création en cours…" : "Créer le contact" }}</span>
|
||||
</soft-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, defineProps, defineEmits, watch, computed } from "vue";
|
||||
import SoftInput from "@/components/SoftInput.vue";
|
||||
import SoftButton from "@/components/SoftButton.vue";
|
||||
|
||||
const props = defineProps({
|
||||
loading: { type: Boolean, default: false },
|
||||
@ -293,7 +207,6 @@ const props = defineProps({
|
||||
|
||||
const emit = defineEmits(["createContact", "searchClient", "clientSelected"]);
|
||||
|
||||
const errors = ref([]);
|
||||
const fieldErrors = ref({});
|
||||
const searchQuery = ref("");
|
||||
const selectedClient = ref(null);
|
||||
@ -370,285 +283,52 @@ const resetForm = () => {
|
||||
searchQuery.value = "";
|
||||
showDropdown.value = false;
|
||||
fieldErrors.value = {};
|
||||
errors.value = [];
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* ─── Root ─── */
|
||||
.contact-form-root {
|
||||
background: #fff;
|
||||
border-radius: 16px;
|
||||
border: 1px solid #e5e7eb;
|
||||
box-shadow: 0 4px 24px rgba(0,0,0,0.07);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* ─── Header ─── */
|
||||
.form-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 20px 24px 16px;
|
||||
border-bottom: 1px solid #f0f2f5;
|
||||
background: linear-gradient(135deg, #1a2e4a 0%, #2d4a6e 100%);
|
||||
}
|
||||
|
||||
.header-icon-wrap {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 12px;
|
||||
background: rgba(255,255,255,0.15);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 18px;
|
||||
color: #fff;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.form-title {
|
||||
color: #fff;
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
margin: 0 0 2px;
|
||||
}
|
||||
|
||||
.form-subtitle {
|
||||
color: rgba(255,255,255,0.6);
|
||||
font-size: 0.78rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ─── Banners ─── */
|
||||
.status-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 12px 24px;
|
||||
font-size: 0.84rem;
|
||||
}
|
||||
|
||||
.success-banner {
|
||||
background: #f0fdf4;
|
||||
border-bottom: 1px solid #bbf7d0;
|
||||
color: #166534;
|
||||
}
|
||||
|
||||
.error-banner {
|
||||
background: #fff1f2;
|
||||
border-bottom: 1px solid #fecdd3;
|
||||
color: #9f1239;
|
||||
}
|
||||
|
||||
.banner-icon { font-size: 1rem; flex-shrink: 0; }
|
||||
|
||||
.warn-banner {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
background: #fffbeb;
|
||||
border: 1px solid #fde68a;
|
||||
border-radius: 9px;
|
||||
padding: 10px 14px;
|
||||
font-size: 0.8rem;
|
||||
color: #92400e;
|
||||
margin: 0 24px;
|
||||
}
|
||||
|
||||
.warn-icon { color: #f59e0b; flex-shrink: 0; margin-top: 1px; }
|
||||
|
||||
/* ─── Body ─── */
|
||||
.form-body {
|
||||
flex: 1;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
/* ─── Sections ─── */
|
||||
.form-section {
|
||||
padding: 18px 24px;
|
||||
}
|
||||
|
||||
.section-divider {
|
||||
height: 1px;
|
||||
background: #f0f2f5;
|
||||
margin: 0 24px;
|
||||
}
|
||||
|
||||
.section-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.07em;
|
||||
color: #8c9ab0;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.section-num {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
background: #e8eef7;
|
||||
color: #2d4a6e;
|
||||
font-size: 0.68rem;
|
||||
font-weight: 800;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ─── Grid ─── */
|
||||
.two-col {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
@media (max-width: 540px) {
|
||||
.two-col { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
.mt-field { margin-top: 12px; }
|
||||
|
||||
/* ─── Field Group ─── */
|
||||
.field-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.field-label {
|
||||
font-size: 0.79rem;
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.field-pfx-icon {
|
||||
font-size: 0.72rem;
|
||||
color: #9ca3af;
|
||||
width: 14px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.req { color: #ef4444; }
|
||||
|
||||
/* ─── Inputs ─── */
|
||||
.field-input {
|
||||
border: 1.5px solid #e5e7eb;
|
||||
border-radius: 9px;
|
||||
padding: 9px 12px;
|
||||
font-size: 0.875rem;
|
||||
color: #111827;
|
||||
outline: none;
|
||||
width: 100%;
|
||||
background: #fff;
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
font-family: inherit;
|
||||
}
|
||||
.field-input:focus {
|
||||
border-color: #2d4a6e;
|
||||
box-shadow: 0 0 0 3px rgba(45,74,110,0.08);
|
||||
}
|
||||
.field-input.is-invalid { border-color: #ef4444; }
|
||||
.field-input::placeholder { color: #9ca3af; }
|
||||
|
||||
.field-textarea {
|
||||
border: 1.5px solid #e5e7eb;
|
||||
border-radius: 9px;
|
||||
padding: 9px 12px;
|
||||
font-size: 0.875rem;
|
||||
color: #111827;
|
||||
outline: none;
|
||||
width: 100%;
|
||||
background: #fff;
|
||||
resize: vertical;
|
||||
min-height: 80px;
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
font-family: inherit;
|
||||
}
|
||||
.field-textarea:focus {
|
||||
border-color: #2d4a6e;
|
||||
box-shadow: 0 0 0 3px rgba(45,74,110,0.08);
|
||||
}
|
||||
.field-textarea::placeholder { color: #9ca3af; }
|
||||
|
||||
.char-count {
|
||||
font-size: 0.68rem;
|
||||
color: #9ca3af;
|
||||
text-align: right;
|
||||
margin-top: -2px;
|
||||
}
|
||||
|
||||
/* ─── Field Error ─── */
|
||||
.field-error {
|
||||
font-size: 0.74rem;
|
||||
color: #ef4444;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.hint-text {
|
||||
font-size: 0.73rem;
|
||||
color: #9ca3af;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ─── Search ─── */
|
||||
.search-wrap {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border: 1.5px solid #e5e7eb;
|
||||
border-radius: 9px;
|
||||
border: 1px solid #d2d6da;
|
||||
border-radius: 0.5rem;
|
||||
background: #fff;
|
||||
overflow: visible;
|
||||
overflow: hidden;
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
.search-wrap:focus-within {
|
||||
border-color: #2d4a6e;
|
||||
box-shadow: 0 0 0 3px rgba(45,74,110,0.08);
|
||||
border-color: #8392ab;
|
||||
box-shadow: 0 0 0 2px rgba(131,146,171,0.25);
|
||||
}
|
||||
.search-wrap.has-error { border-color: #ef4444; }
|
||||
.search-wrap.has-error { border-color: #fd5c70; }
|
||||
|
||||
.search-pfx {
|
||||
padding: 0 10px;
|
||||
padding: 0 0.75rem;
|
||||
color: #9ca3af;
|
||||
font-size: 0.8rem;
|
||||
font-size: 0.75rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
flex: 1;
|
||||
border: none !important;
|
||||
border-radius: 0 !important;
|
||||
box-shadow: none !important;
|
||||
padding: 9px 4px !important;
|
||||
border: none;
|
||||
padding: 0.55rem 0;
|
||||
min-width: 0;
|
||||
outline: none;
|
||||
}
|
||||
.search-input:focus { box-shadow: none !important; border: none !important; }
|
||||
.search-input:focus { box-shadow: none; }
|
||||
|
||||
.search-loader { padding: 0 10px; flex-shrink: 0; }
|
||||
.search-loader { padding: 0 0.75rem; flex-shrink: 0; }
|
||||
|
||||
/* ─── Dropdown ─── */
|
||||
.search-dropdown {
|
||||
position: absolute;
|
||||
top: calc(100% + 5px);
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: #fff;
|
||||
border: 1.5px solid #e5e7eb;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #e9ecef;
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: 0 12px 32px rgba(0,0,0,0.12);
|
||||
z-index: 1050;
|
||||
overflow: hidden;
|
||||
@ -663,24 +343,23 @@ const resetForm = () => {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 14px;
|
||||
gap: 0.625rem;
|
||||
padding: 0.625rem 0.875rem;
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #f9fafb;
|
||||
border-bottom: 1px solid #f3f4f6;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.dropdown-row:last-child { border-bottom: none; }
|
||||
.dropdown-row:hover { background: #f5f8ff; }
|
||||
.dropdown-row:hover .dr-arrow { opacity: 1; transform: translateX(0); }
|
||||
|
||||
.dr-avatar {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 9px;
|
||||
background: linear-gradient(135deg, #1a2e4a, #2d4a6e);
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border-radius: 0.5rem;
|
||||
background: linear-gradient(310deg, #2152ff, #21d4fd);
|
||||
color: #fff;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
@ -701,7 +380,7 @@ const resetForm = () => {
|
||||
|
||||
.dr-name {
|
||||
font-size: 0.84rem;
|
||||
font-weight: 600;
|
||||
font-weight: 700;
|
||||
color: #111827;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
@ -716,46 +395,23 @@ const resetForm = () => {
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.dr-arrow {
|
||||
font-size: 0.72rem;
|
||||
color: #2d4a6e;
|
||||
opacity: 0;
|
||||
transform: translateX(-4px);
|
||||
transition: opacity 0.15s, transform 0.15s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.dropdown-empty {
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
color: #9ca3af;
|
||||
font-size: 0.82rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.empty-icon { font-size: 1.4rem; opacity: 0.4; }
|
||||
|
||||
/* ─── Selected Client Card ─── */
|
||||
.selected-client-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
background: #f0fdf4;
|
||||
border: 1.5px solid #bbf7d0;
|
||||
border-radius: 10px;
|
||||
padding: 12px 14px;
|
||||
gap: 0.75rem;
|
||||
background: #f8f9fa;
|
||||
border: 1px solid #e9ecef;
|
||||
border-radius: 0.75rem;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.sc-avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 10px;
|
||||
background: linear-gradient(135deg, #059669, #10b981);
|
||||
width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
border-radius: 0.5rem;
|
||||
background: linear-gradient(310deg, #17ad37, #98ec2d);
|
||||
color: #fff;
|
||||
font-size: 1rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@ -773,90 +429,25 @@ const resetForm = () => {
|
||||
}
|
||||
|
||||
.sc-label {
|
||||
font-size: 0.68rem;
|
||||
font-size: 0.65rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: #059669;
|
||||
color: #8392ab;
|
||||
}
|
||||
|
||||
.sc-name {
|
||||
font-size: 0.88rem;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
color: #064e3b;
|
||||
color: #344767;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.sc-meta {
|
||||
font-size: 0.73rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.sc-change-btn {
|
||||
background: #fff;
|
||||
border: 1.5px solid #bbf7d0;
|
||||
border-radius: 8px;
|
||||
color: #059669;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
padding: 5px 12px;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition: all 0.15s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.sc-change-btn:hover { background: #dcfce7; border-color: #86efac; }
|
||||
|
||||
/* ─── Footer ─── */
|
||||
.form-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 24px;
|
||||
background: #f8fafc;
|
||||
border-top: 1px solid #f0f2f5;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.btn-reset {
|
||||
font-size: 0.82rem;
|
||||
color: #6b7280;
|
||||
background: #fff;
|
||||
border: 1.5px solid #e5e7eb;
|
||||
border-radius: 9px;
|
||||
padding: 8px 16px;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.btn-reset:hover { background: #f3f4f6; border-color: #d1d5db; }
|
||||
|
||||
.btn-submit {
|
||||
font-size: 0.875rem;
|
||||
color: #fff;
|
||||
background: linear-gradient(135deg, #1a2e4a 0%, #2d4a6e 100%);
|
||||
border: none;
|
||||
border-radius: 9px;
|
||||
padding: 9px 22px;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
transition: all 0.2s;
|
||||
box-shadow: 0 2px 8px rgba(45,74,110,0.25);
|
||||
margin-left: auto;
|
||||
}
|
||||
.btn-submit:hover:not(:disabled) {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 14px rgba(45,74,110,0.35);
|
||||
}
|
||||
.btn-submit:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
font-size: 0.72rem;
|
||||
color: #8392ab;
|
||||
}
|
||||
|
||||
/* ─── Animations ─── */
|
||||
|
||||
@ -1,17 +1,23 @@
|
||||
<template>
|
||||
<div class="planning-container p-2 p-md-4">
|
||||
<div class="planning-container container-fluid py-4">
|
||||
<div class="container-max mx-auto h-100">
|
||||
<!-- Header Section -->
|
||||
<div class="card border-0 shadow-sm mb-4">
|
||||
<div
|
||||
class="d-flex flex-column flex-md-row justify-content-between align-items-start align-items-md-center mb-4 gap-3"
|
||||
class="card-body p-3 p-md-4 d-flex flex-column flex-md-row justify-content-between align-items-start align-items-md-center gap-3"
|
||||
>
|
||||
<slot name="header"></slot>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- View Toggles -->
|
||||
<div class="d-flex gap-2 mb-4 overflow-auto pb-2">
|
||||
<div class="card border-0 shadow-sm mb-4">
|
||||
<div class="card-body p-2 p-md-3">
|
||||
<div class="d-flex gap-2 overflow-auto pb-1">
|
||||
<slot name="view-toggles"></slot>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="content-wrapper h-100">
|
||||
<div class="row g-4">
|
||||
@ -27,12 +33,16 @@
|
||||
|
||||
<!-- Calendar Grid Column (Right on desktop) -->
|
||||
<div class="col-12 col-xl-12 order-1 order-xl-2">
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-body p-2 p-md-3 p-lg-4">
|
||||
<slot name="calendar-grid"></slot>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
@ -42,7 +52,7 @@
|
||||
<style scoped>
|
||||
.planning-container {
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(135deg, #f0f7ff 0%, #eef2ff 50%, #f5f3ff 100%);
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
|
||||
.container-max {
|
||||
@ -56,10 +66,6 @@
|
||||
min-height: calc(100vh - 12rem);
|
||||
}
|
||||
|
||||
.gap-3 {
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
/* Custom scrollbar for view toggles on mobile */
|
||||
.overflow-auto::-webkit-scrollbar {
|
||||
height: 4px;
|
||||
|
||||
@ -1,50 +1,69 @@
|
||||
<template>
|
||||
<div class="container-fluid py-4">
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-header pb-0">
|
||||
<div class="d-lg-flex">
|
||||
<div>
|
||||
<h5 class="mb-0">Nouveau Devis</h5>
|
||||
<p class="text-sm mb-0">
|
||||
Créer un nouveau devis pour un client.
|
||||
</p>
|
||||
<div class="col-lg-10 mx-auto">
|
||||
<div class="card mb-4">
|
||||
<div class="card-header p-3 pb-0">
|
||||
<div class="d-flex flex-wrap align-items-center justify-content-between gap-3">
|
||||
<div class="d-flex align-items-center gap-3">
|
||||
<div class="icon icon-shape icon-sm bg-gradient-primary shadow text-center border-radius-md">
|
||||
<i class="fas fa-file-invoice text-white text-sm"></i>
|
||||
</div>
|
||||
<div class="ms-auto my-auto mt-lg-0 mt-4">
|
||||
<div class="ms-auto my-auto">
|
||||
<div>
|
||||
<h6 class="mb-0">Nouveau Devis</h6>
|
||||
<p class="text-sm text-muted mb-0">Créer un devis pour un client</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex flex-wrap align-items-center gap-2">
|
||||
<slot name="actions"></slot>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-body p-3 pt-0">
|
||||
<hr class="horizontal dark mt-0 mb-4" />
|
||||
|
||||
<div class="row g-3">
|
||||
<div class="col-lg-6 col-md-6 col-12">
|
||||
<div class="card card-body border card-plain border-radius-lg h-100">
|
||||
<div class="d-flex align-items-center mb-3">
|
||||
<i class="fas fa-user-circle me-2 text-primary"></i>
|
||||
<h6 class="mb-0">Client</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<!-- Client Selection -->
|
||||
<div class="col-12 col-lg-4 mb-4">
|
||||
<slot name="client-selection"></slot>
|
||||
</div>
|
||||
<!-- Quote Details -->
|
||||
<div class="col-12 col-lg-8 mb-4">
|
||||
</div>
|
||||
|
||||
<div class="col-lg-6 col-md-6 col-12">
|
||||
<div class="card card-body border card-plain border-radius-lg h-100">
|
||||
<div class="d-flex align-items-center mb-3">
|
||||
<i class="fas fa-calendar-alt me-2 text-primary"></i>
|
||||
<h6 class="mb-0">Informations</h6>
|
||||
</div>
|
||||
<slot name="quote-details"></slot>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr class="horizontal dark my-4" />
|
||||
<hr class="horizontal dark mt-4 mb-4" />
|
||||
|
||||
<!-- Product Lines -->
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<h6 class="mb-3">Produits & Services</h6>
|
||||
<div class="row g-3">
|
||||
<div class="col-lg-8 col-12">
|
||||
<div class="card card-body border card-plain border-radius-lg h-100">
|
||||
<div class="d-flex align-items-center mb-3">
|
||||
<i class="fas fa-boxes me-2 text-primary"></i>
|
||||
<h6 class="mb-0">Produits & Services</h6>
|
||||
</div>
|
||||
<slot name="product-lines"></slot>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr class="horizontal dark my-4" />
|
||||
|
||||
<!-- Totals -->
|
||||
<div class="row">
|
||||
<div class="col-12 col-lg-4 ms-auto">
|
||||
<div class="col-lg-4 col-12">
|
||||
<div class="card card-body border card-plain border-radius-lg h-100">
|
||||
<div class="d-flex align-items-center mb-3">
|
||||
<i class="fas fa-calculator me-2 text-primary"></i>
|
||||
<h6 class="mb-0">Récapitulatif</h6>
|
||||
</div>
|
||||
<slot name="totals"></slot>
|
||||
</div>
|
||||
</div>
|
||||
@ -53,6 +72,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup></script>
|
||||
|
||||
@ -1,42 +1,41 @@
|
||||
<template>
|
||||
<div class="container-fluid py-4">
|
||||
<div class="row">
|
||||
<div class="col-lg-8 mx-auto">
|
||||
<div class="col-lg-10 mx-auto">
|
||||
<div class="card mb-4">
|
||||
<div class="card-header p-3 pb-0">
|
||||
<slot name="header"></slot>
|
||||
</div>
|
||||
|
||||
<div class="card-body p-3 pt-0">
|
||||
<hr class="horizontal dark mt-0 mb-4" />
|
||||
|
||||
<!-- Product Lines Section (replacing Gold Glasses) -->
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<slot name="lines"></slot>
|
||||
<div class="col-lg-6 col-md-6 col-12">
|
||||
<slot name="product"></slot>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-6 col-md-6 col-12 my-auto text-end">
|
||||
<slot name="cta"></slot>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr class="horizontal dark mt-4 mb-4" />
|
||||
|
||||
<div class="row">
|
||||
<!-- Tracking/Timeline Section -->
|
||||
<div class="row g-3">
|
||||
<div class="col-lg-3 col-md-6 col-12">
|
||||
<slot name="timeline"></slot>
|
||||
</div>
|
||||
|
||||
<!-- Billing Info Section -->
|
||||
<div class="col-lg-5 col-md-6 col-12">
|
||||
<slot name="billing"></slot>
|
||||
<slot name="payment"></slot>
|
||||
</div>
|
||||
|
||||
<!-- Summary Section -->
|
||||
<div class="col-lg-3 col-12 ms-auto">
|
||||
<div class="col-lg-4 col-12 ms-auto">
|
||||
<slot name="summary"></slot>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer p-3">
|
||||
<slot name="actions"></slot>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user