Add price list management across the API, store, services, routes, navigation, and sales views. Support quotes for either a client or a client group, including PDF download and nullable client validation for group-based recipients. Extend client groups to manage assigned clients directly from the form and detail views, and refresh supplier, intervention, stock, and order screens with updated interactions and layouts.
804 lines
42 KiB
Vue
804 lines
42 KiB
Vue
<template>
|
|
<div class="intervention-page">
|
|
|
|
<!-- ── Top Navigation Bar ── -->
|
|
<div class="page-topbar">
|
|
<router-link to="/interventions" class="back-btn">
|
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M19 12H5M12 5l-7 7 7 7"/></svg>
|
|
Interventions
|
|
</router-link>
|
|
|
|
<div v-if="intervention" class="topbar-center">
|
|
<span class="topbar-id">#{{ intervention.id }}</span>
|
|
<span class="topbar-divider">·</span>
|
|
<span class="topbar-name">{{ getDeceasedName(intervention) }}</span>
|
|
</div>
|
|
|
|
<div class="topbar-right">
|
|
<div v-if="interventionStore.isLoading" class="topbar-loading">
|
|
<div class="mini-spinner"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ── Loading ── -->
|
|
<div v-if="interventionStore.isLoading && !intervention" class="fullpage-center">
|
|
<div class="loading-orb"></div>
|
|
<p class="loading-text">Chargement de l'intervention…</p>
|
|
</div>
|
|
|
|
<!-- ── Error ── -->
|
|
<div v-else-if="interventionStore.getError && !intervention" class="fullpage-center">
|
|
<div class="state-icon error-icon">
|
|
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/></svg>
|
|
</div>
|
|
<h3 class="state-title">Erreur de chargement</h3>
|
|
<p class="state-desc">{{ interventionStore.getError }}</p>
|
|
<router-link to="/interventions" class="btn-primary">Retour à la liste</router-link>
|
|
</div>
|
|
|
|
<!-- ── Main Layout ── -->
|
|
<div v-else-if="intervention" class="page-layout">
|
|
|
|
<!-- LEFT SIDEBAR -->
|
|
<aside class="sidebar">
|
|
<!-- Hero -->
|
|
<div class="hero-card">
|
|
<div class="hero-avatar">
|
|
<svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
|
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
|
|
<circle cx="9" cy="7" r="4"/>
|
|
<path d="M23 21v-2a4 4 0 0 0-3-3.87"/>
|
|
<path d="M16 3.13a4 4 0 0 1 0 7.75"/>
|
|
</svg>
|
|
</div>
|
|
<h2 class="hero-name">{{ getDeceasedName(intervention) }}</h2>
|
|
<p class="hero-type">{{ getTypeLabel(intervention.type) }}</p>
|
|
<StatusBadge :status="intervention.status" />
|
|
</div>
|
|
|
|
<!-- Quick Stats -->
|
|
<div class="quick-stats">
|
|
<QuickStat color="#4f46e5" :label="'Date'" :value="formatDate(intervention.scheduled_at)">
|
|
<template #icon><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg></template>
|
|
</QuickStat>
|
|
<QuickStat color="#059669" :label="'Lieu'" :value="intervention.location?.name || 'Non défini'">
|
|
<template #icon><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></svg></template>
|
|
</QuickStat>
|
|
<QuickStat color="#d97706" :label="'Durée'" :value="intervention.duration_min ? intervention.duration_min + ' min' : 'Non définie'">
|
|
<template #icon><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg></template>
|
|
</QuickStat>
|
|
</div>
|
|
|
|
<!-- Tab Nav -->
|
|
<nav class="tab-nav">
|
|
<button
|
|
v-for="tab in tabs"
|
|
:key="tab.id"
|
|
class="tab-item"
|
|
:class="{ active: activeTab === tab.id }"
|
|
@click="activeTab = tab.id"
|
|
>
|
|
<span class="tab-icon" v-html="tab.icon"></span>
|
|
<span class="tab-label">{{ tab.label }}</span>
|
|
<span v-if="tab.badge" class="tab-badge">{{ tab.badge }}</span>
|
|
</button>
|
|
</nav>
|
|
|
|
<!-- Assign Button -->
|
|
<button class="assign-cta" @click="openAssignModal">
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="8.5" cy="7" r="4"/><line x1="20" y1="8" x2="20" y2="14"/><line x1="23" y1="11" x2="17" y2="11"/></svg>
|
|
Assigner un praticien
|
|
</button>
|
|
</aside>
|
|
|
|
<!-- MAIN CONTENT -->
|
|
<main class="main-content">
|
|
|
|
<!-- OVERVIEW -->
|
|
<section v-if="activeTab === 'overview'" class="tab-section">
|
|
<SectionHeader title="Vue d'ensemble" />
|
|
<div class="info-grid">
|
|
<InfoCard title="Informations générales" accent="#4f46e5">
|
|
<template #icon><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="8" r="4"/><path d="M6 20v-2a6 6 0 0 1 12 0v2"/></svg></template>
|
|
<DataRow label="Nom du défunt" :value="getDeceasedName(intervention)" />
|
|
<DataRow label="Date prévue" :value="formatDate(intervention.scheduled_at)" />
|
|
<DataRow label="Lieu" :value="intervention.location?.name || 'Non défini'" />
|
|
<DataRow label="Durée" :value="intervention.duration_min ? intervention.duration_min + ' min' : 'Non définie'" />
|
|
</InfoCard>
|
|
|
|
<InfoCard title="Contact & Communication" accent="#10b981">
|
|
<template #icon><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07A19.5 19.5 0 0 1 4.69 13a19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 3.6 2h3a2 2 0 0 1 2 1.72c.127.96.361 1.903.7 2.81a2 2 0 0 1-.45 2.11L7.91 9.91a16 16 0 0 0 6.16 6.16l.91-.91a2 2 0 0 1 2.11-.45c.907.339 1.85.573 2.81.7A2 2 0 0 1 22 17z"/></svg></template>
|
|
<DataRow label="Contact familial" :value="intervention.order_giver || 'Non renseigné'" />
|
|
<DataRow label="Email / Tél." :value="intervention.client ? (intervention.client.email || intervention.client.phone || '-') : '-'" />
|
|
<DataRow label="Type intervention" :value="getTypeLabel(intervention.type)" />
|
|
</InfoCard>
|
|
|
|
<InfoCard title="Notes & Description" accent="#8b5cf6" class="full-col">
|
|
<template #icon><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg></template>
|
|
<p class="notes-text">{{ intervention.notes || 'Aucune description disponible.' }}</p>
|
|
</InfoCard>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- DETAILS -->
|
|
<section v-if="activeTab === 'details'" class="tab-section">
|
|
<SectionHeader title="Détails de l'intervention" />
|
|
<div class="card-wrap">
|
|
<!-- Editable form fields -->
|
|
<div class="edit-form">
|
|
<div class="form-row">
|
|
<div class="form-group">
|
|
<label>Type d'intervention</label>
|
|
<select v-model="editForm.type" class="form-select">
|
|
<option value="thanatopraxie">Thanatopraxie</option>
|
|
<option value="toilette_mortuaire">Toilette mortuaire</option>
|
|
<option value="exhumation">Exhumation</option>
|
|
<option value="retrait_pacemaker">Retrait pacemaker</option>
|
|
<option value="retrait_bijoux">Retrait bijoux</option>
|
|
<option value="autre">Autre</option>
|
|
</select>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Statut</label>
|
|
<select v-model="editForm.status" class="form-select">
|
|
<option value="demande">Demande</option>
|
|
<option value="planifie">Planifié</option>
|
|
<option value="en_cours">En cours</option>
|
|
<option value="termine">Terminé</option>
|
|
<option value="annule">Annulé</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
<div class="form-row">
|
|
<div class="form-group">
|
|
<label>Date prévue</label>
|
|
<input type="datetime-local" v-model="editForm.scheduled_at" class="form-input" />
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Durée (minutes)</label>
|
|
<input type="number" v-model="editForm.duration_min" class="form-input" min="0" />
|
|
</div>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Contact familial (donneur d'ordre)</label>
|
|
<input type="text" v-model="editForm.order_giver" class="form-input" />
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Notes</label>
|
|
<textarea v-model="editForm.notes" class="form-textarea" rows="4" placeholder="Ajouter des notes…"></textarea>
|
|
</div>
|
|
<div class="form-actions">
|
|
<button class="btn-ghost" @click="resetForm">Annuler</button>
|
|
<button class="btn-primary" :disabled="interventionStore.isLoading" @click="submitUpdate">
|
|
<span v-if="interventionStore.isLoading" class="mini-spinner white"></span>
|
|
Enregistrer les modifications
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- TEAM -->
|
|
<section v-if="activeTab === 'team'" class="tab-section">
|
|
<SectionHeader title="Équipe assignée">
|
|
<button class="btn-primary sm" @click="openAssignModal">
|
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
|
Ajouter
|
|
</button>
|
|
</SectionHeader>
|
|
|
|
<div v-if="intervention.practitioners?.length" class="practitioner-grid">
|
|
<div v-for="(p, i) in intervention.practitioners" :key="i" class="practitioner-card">
|
|
<div class="pract-avatar">{{ getInitials(getPractName(p)) }}</div>
|
|
<div class="pract-info">
|
|
<div class="pract-name">{{ getPractName(p) }}</div>
|
|
<span class="role-chip" :class="p.pivot?.role === 'principal' ? 'chip-principal' : 'chip-assistant'">
|
|
{{ p.pivot?.role === 'principal' ? 'Principal' : 'Assistant' }}
|
|
</span>
|
|
</div>
|
|
<button class="unassign-btn" title="Désassigner" @click="handleUnassign(p)">
|
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<EmptyState v-else icon="team" message="Aucun praticien assigné">
|
|
<button class="btn-primary sm" @click="openAssignModal">Assigner maintenant</button>
|
|
</EmptyState>
|
|
</section>
|
|
|
|
<!-- DOCUMENTS -->
|
|
<section v-if="activeTab === 'documents'" class="tab-section">
|
|
<SectionHeader title="Documents" />
|
|
<DocumentManagement
|
|
:documents="documentAttachments"
|
|
:loading="documentStore.isLoading"
|
|
:error="documentStore.getError"
|
|
@files-selected="() => {}"
|
|
@upload-files="handleUploadFiles"
|
|
@delete-document="handleDeleteDocument"
|
|
@delete-documents="handleDeleteDocuments"
|
|
@update-document-label="handleUpdateDocumentLabel"
|
|
@retry="loadDocuments"
|
|
/>
|
|
</section>
|
|
|
|
<!-- QUOTE -->
|
|
<section v-if="activeTab === 'quote'" class="tab-section">
|
|
<SectionHeader title="Devis associé">
|
|
<router-link v-if="intervention.quote?.id" :to="`/ventes/devis/${intervention.quote.id}`" class="btn-primary sm">
|
|
Ouvrir le devis
|
|
</router-link>
|
|
</SectionHeader>
|
|
|
|
<div v-if="intervention.quote">
|
|
<div class="info-grid">
|
|
<InfoCard title="Informations" accent="#3b82f6">
|
|
<template #icon><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg></template>
|
|
<DataRow label="Référence" :value="intervention.quote.reference" />
|
|
<DataRow label="Date" :value="intervention.quote.quote_date" />
|
|
<DataRow label="Validité" :value="intervention.quote.valid_until" />
|
|
<div class="data-row">
|
|
<span class="data-label">Statut</span>
|
|
<span class="status-chip" :class="'sc-' + getQuoteColor(intervention.quote.status)">{{ getQuoteLabel(intervention.quote.status) }}</span>
|
|
</div>
|
|
</InfoCard>
|
|
<InfoCard title="Montants" accent="#10b981">
|
|
<template #icon><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="1" x2="12" y2="23"/><path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/></svg></template>
|
|
<DataRow label="Total HT" :value="fmtCurrency(intervention.quote.total_ht)" />
|
|
<DataRow label="Total TVA" :value="fmtCurrency(intervention.quote.total_tva)" />
|
|
<DataRow label="Total TTC" :value="fmtCurrency(intervention.quote.total_ttc)" :bold="true" />
|
|
</InfoCard>
|
|
</div>
|
|
|
|
<div v-if="intervention.quote.lines?.length" class="quote-lines">
|
|
<div class="lines-title">Lignes du devis</div>
|
|
<div class="table-wrap">
|
|
<table class="data-table">
|
|
<thead><tr><th>Description</th><th class="tc">Qté</th><th class="tr">PU HT</th><th class="tr">Total HT</th></tr></thead>
|
|
<tbody>
|
|
<tr v-for="l in intervention.quote.lines" :key="l.id">
|
|
<td>{{ l.description || '-' }}</td>
|
|
<td class="tc">{{ l.units_qty || 0 }}</td>
|
|
<td class="tr">{{ fmtCurrency(l.unit_price) }}</td>
|
|
<td class="tr fw6">{{ fmtCurrency(l.total_ht) }}</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<EmptyState v-else icon="quote" message="Aucun devis associé à cette intervention" />
|
|
</section>
|
|
|
|
<!-- HISTORY -->
|
|
<section v-if="activeTab === 'history'" class="tab-section">
|
|
<SectionHeader title="Historique" />
|
|
<EmptyState icon="history" message="Historique des modifications">
|
|
<span class="coming-soon-chip">Fonctionnalité à venir</span>
|
|
</EmptyState>
|
|
</section>
|
|
|
|
</main>
|
|
</div>
|
|
|
|
<!-- Assign Modal -->
|
|
<AssignPractitionerModal
|
|
:is-open="isModalOpen"
|
|
@close="closeAssignModal"
|
|
@assign="handleAssignPractitioner"
|
|
/>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, computed, onMounted, watch, defineComponent, h } from 'vue';
|
|
import { useRoute, RouterLink } from 'vue-router';
|
|
import { useInterventionStore } from '@/stores/interventionStore';
|
|
import { useNotificationStore } from '@/stores/notification';
|
|
import { useDocumentAttachmentStore } from '@/stores/documentAttachmentStore';
|
|
import DocumentManagement from '@/components/molecules/Interventions/DocumentManagement.vue';
|
|
import AssignPractitionerModal from '@/components/molecules/intervention/AssignPractitionerModal.vue';
|
|
|
|
// ── Inline sub-components ──────────────────────────────────────────────────
|
|
const StatusBadge = {
|
|
props: { status: String },
|
|
setup(props) {
|
|
const map = { demande: ['warning','Demande'], planifie: ['info','Planifié'], en_cours: ['primary','En cours'], termine: ['success','Terminé'], annule: ['danger','Annulé'] };
|
|
const [color, label] = map[props.status] || ['secondary', props.status || 'En attente'];
|
|
return () => h('span', { class: `status-badge sb-${color}` }, label);
|
|
}
|
|
};
|
|
|
|
const QuickStat = {
|
|
props: { label: String, value: String, color: String },
|
|
template: `
|
|
<div class="qs-item">
|
|
<div class="qs-icon" :style="{ background: color + '18', color }"><slot name="icon"/></div>
|
|
<div><div class="qs-label">{{ label }}</div><div class="qs-value">{{ value || '-' }}</div></div>
|
|
</div>
|
|
`
|
|
};
|
|
|
|
const InfoCard = {
|
|
props: { title: String, accent: String },
|
|
template: `
|
|
<div class="info-card">
|
|
<div class="info-card-header" :style="{ '--a': accent }">
|
|
<span class="ic-icon"><slot name="icon"/></span>
|
|
<span class="ic-title">{{ title }}</span>
|
|
</div>
|
|
<div class="info-card-body"><slot/></div>
|
|
</div>
|
|
`
|
|
};
|
|
|
|
const DataRow = {
|
|
props: { label: String, value: String, bold: Boolean },
|
|
template: `
|
|
<div class="data-row">
|
|
<span class="data-label">{{ label }}</span>
|
|
<span class="data-value" :class="bold ? 'fw6' : ''">{{ value || '-' }}</span>
|
|
</div>
|
|
`
|
|
};
|
|
|
|
const SectionHeader = {
|
|
props: { title: String },
|
|
template: `<div class="section-header"><h3 class="section-title">{{ title }}</h3><slot/></div>`
|
|
};
|
|
|
|
const EmptyState = {
|
|
props: { icon: String, message: String },
|
|
setup(props, { slots }) {
|
|
const icons = {
|
|
team: `<svg width="30" height="30" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><line x1="23" y1="11" x2="17" y2="11"/><line x1="20" y1="8" x2="20" y2="14"/></svg>`,
|
|
quote: `<svg width="30" height="30" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>`,
|
|
history: `<svg width="30" height="30" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 .49-4.5"/></svg>`,
|
|
};
|
|
return () => h('div', { class: 'empty-state' }, [
|
|
h('div', { class: 'empty-icon', innerHTML: icons[props.icon] || icons.team }),
|
|
h('p', { class: 'empty-msg' }, props.message),
|
|
slots.default?.(),
|
|
]);
|
|
}
|
|
};
|
|
|
|
// ── Setup ──────────────────────────────────────────────────────────────────
|
|
const route = useRoute();
|
|
const interventionStore = useInterventionStore();
|
|
const notifStore = useNotificationStore();
|
|
const documentStore = useDocumentAttachmentStore();
|
|
|
|
const intervention = ref(null);
|
|
const activeTab = ref('overview');
|
|
const isModalOpen = ref(false);
|
|
const editForm = ref({});
|
|
|
|
const documentAttachments = computed(() =>
|
|
documentStore.getInterventionAttachments(intervention.value?.id || 0)
|
|
);
|
|
|
|
const tabs = computed(() => [
|
|
{ id: 'overview', label: "Vue d'ensemble", icon: `<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>` },
|
|
{ id: 'details', label: 'Détails', icon: `<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/></svg>` },
|
|
{ id: 'team', label: 'Équipe', icon: `<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>`, badge: intervention.value?.practitioners?.length || null },
|
|
{ id: 'documents', label: 'Documents', icon: `<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>` },
|
|
{ id: 'quote', label: 'Devis', icon: `<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>` },
|
|
{ id: 'history', label: 'Historique', icon: `<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 .49-4.5"/></svg>` },
|
|
]);
|
|
|
|
// ── Helpers ────────────────────────────────────────────────────────────────
|
|
const getDeceasedName = i => i?.deceased
|
|
? `${i.deceased.last_name || ''} ${i.deceased.first_name || ''}`.trim()
|
|
: `Personne ${i?.deceased_id || 'inconnue'}`;
|
|
|
|
const formatDate = v => v ? new Date(v).toLocaleString('fr-FR') : 'Non définie';
|
|
const getTypeLabel = t => ({ thanatopraxie:'Thanatopraxie', toilette_mortuaire:'Toilette mortuaire', exhumation:'Exhumation', retrait_pacemaker:'Retrait pacemaker', retrait_bijoux:'Retrait bijoux', autre:'Autre' }[t] || t || 'Type non défini');
|
|
const getQuoteLabel = s => ({ brouillon:'Brouillon', envoye:'Envoyé', accepte:'Accepté', refuse:'Refusé', expire:'Expiré' }[s] || s || 'Inconnu');
|
|
const getQuoteColor = s => ({ brouillon:'secondary', envoye:'info', accepte:'success', refuse:'danger', expire:'warning' }[s] || 'secondary');
|
|
const fmtCurrency = v => new Intl.NumberFormat('fr-FR', { style:'currency', currency:'EUR' }).format(Number(v || 0));
|
|
const getPractName = p => p.employee ? `${p.employee.first_name || ''} ${p.employee.last_name || ''}`.trim() : `${p.first_name || ''} ${p.last_name || ''}`.trim();
|
|
const getInitials = n => n ? n.split(' ').map(w => w[0]).join('').toUpperCase().substring(0,2) : '?';
|
|
|
|
// ── Edit form ──────────────────────────────────────────────────────────────
|
|
const resetForm = () => {
|
|
if (!intervention.value) return;
|
|
editForm.value = {
|
|
type: intervention.value.type || '',
|
|
status: intervention.value.status || '',
|
|
scheduled_at: intervention.value.scheduled_at ? intervention.value.scheduled_at.substring(0,16) : '',
|
|
duration_min: intervention.value.duration_min || '',
|
|
order_giver: intervention.value.order_giver || '',
|
|
notes: intervention.value.notes || '',
|
|
};
|
|
};
|
|
|
|
const submitUpdate = async () => {
|
|
try {
|
|
const result = await interventionStore.updateIntervention({ id: intervention.value.id, ...editForm.value });
|
|
intervention.value = result;
|
|
notifStore.updated('Intervention');
|
|
} catch (e) {
|
|
notifStore.error('Erreur', 'Impossible de mettre à jour');
|
|
}
|
|
};
|
|
|
|
// ── Data fetch ─────────────────────────────────────────────────────────────
|
|
const fetchIntervention = async () => {
|
|
try {
|
|
const id = parseInt(route.params.id);
|
|
if (id) {
|
|
intervention.value = await interventionStore.fetchInterventionById(id);
|
|
resetForm();
|
|
}
|
|
} catch (e) {
|
|
notifStore.error('Erreur', 'Impossible de charger l\'intervention');
|
|
}
|
|
};
|
|
|
|
// ── Modal & assignment ─────────────────────────────────────────────────────
|
|
const openAssignModal = () => { isModalOpen.value = true; };
|
|
const closeAssignModal = () => { isModalOpen.value = false; };
|
|
|
|
const handleAssignPractitioner = async (data) => {
|
|
try {
|
|
const payload = data.role === 'principal'
|
|
? { principal_practitioner_id: data.practitionerId }
|
|
: { assistant_practitioner_ids: [data.practitionerId] };
|
|
await interventionStore.assignPractitioner(intervention.value.id, payload);
|
|
await fetchIntervention();
|
|
notifStore.created('Praticien assigné');
|
|
closeAssignModal();
|
|
} catch (e) {
|
|
notifStore.error('Erreur', 'Impossible d\'assigner');
|
|
}
|
|
};
|
|
|
|
const handleUnassign = async (p) => {
|
|
try {
|
|
await interventionStore.unassignPractitioner(intervention.value.id, p.id);
|
|
await fetchIntervention();
|
|
notifStore.updated('Praticien désassigné');
|
|
} catch (e) {
|
|
notifStore.error('Erreur', 'Impossible de désassigner');
|
|
}
|
|
};
|
|
|
|
// ── Documents ──────────────────────────────────────────────────────────────
|
|
const loadDocuments = async () => {
|
|
if (!intervention.value?.id) return;
|
|
try { await documentStore.fetchInterventionFiles(intervention.value.id); }
|
|
catch (e) { documentStore.clearError(); }
|
|
};
|
|
const handleUploadFiles = async files => {
|
|
if (!intervention.value?.id || !files.length) return;
|
|
try { await documentStore.uploadAndAttachFiles(files, 'App\\Models\\Intervention', intervention.value.id); }
|
|
catch { documentStore.clearError(); }
|
|
};
|
|
const handleDeleteDocument = async id => { try { await documentStore.detachFile(id); } catch { documentStore.clearError(); } };
|
|
const handleDeleteDocuments = async ids => { try { await documentStore.detachMultipleFiles({ attachment_ids: ids }); } catch { documentStore.clearError(); } };
|
|
const handleUpdateDocumentLabel = async ({ id, label }) => { try { await documentStore.updateAttachmentMetadata(id, { label }); } catch { documentStore.clearError(); } };
|
|
|
|
// ── Watchers & lifecycle ───────────────────────────────────────────────────
|
|
watch(() => interventionStore.currentIntervention, v => { if (v) intervention.value = v; }, { deep: true });
|
|
watch(activeTab, tab => { if (tab === 'documents' && intervention.value?.id) loadDocuments(); });
|
|
onMounted(fetchIntervention);
|
|
</script>
|
|
|
|
<style scoped>
|
|
/* ── Design tokens ─────────────────────────────────────────────────────── */
|
|
*, *::before, *::after { box-sizing: border-box; }
|
|
|
|
.intervention-page {
|
|
--brand: #4f46e5;
|
|
--brand-lt: #eef2ff;
|
|
--brand-dk: #3730a3;
|
|
--surface: #ffffff;
|
|
--surface-2: #f8fafc;
|
|
--surface-3: #f1f5f9;
|
|
--border: #e2e8f0;
|
|
--border-lt: #f1f5f9;
|
|
--text-1: #0f172a;
|
|
--text-2: #64748b;
|
|
--text-3: #94a3b8;
|
|
--r-sm: 8px;
|
|
--r-md: 12px;
|
|
--shadow-sm: 0 1px 3px rgba(0,0,0,.06),0 1px 2px rgba(0,0,0,.04);
|
|
--shadow-md: 0 4px 16px rgba(0,0,0,.08);
|
|
min-height: 100vh;
|
|
|
|
color: var(--text-1);
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
|
|
/* ── Top bar ───────────────────────────────────────────────────────────── */
|
|
.page-topbar {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 16px;
|
|
padding: 0 24px;
|
|
height: 56px;
|
|
background: var(--surface);
|
|
border-bottom: 1px solid var(--border);
|
|
position: sticky;
|
|
top: 0;
|
|
z-index: 100;
|
|
box-shadow: var(--shadow-sm);
|
|
}
|
|
.back-btn {
|
|
display: inline-flex; align-items: center; gap: 6px;
|
|
font-size: 13px; font-weight: 500; color: var(--text-2);
|
|
text-decoration: none; padding: 5px 10px; border-radius: var(--r-sm);
|
|
transition: background .15s, color .15s;
|
|
}
|
|
.back-btn:hover { background: var(--surface-3); color: var(--text-1); }
|
|
.topbar-center { display: flex; align-items: center; gap: 8px; margin: 0 auto; }
|
|
.topbar-id { font-size: 13px; font-weight: 600; color: var(--text-2); }
|
|
.topbar-divider { color: var(--text-3); }
|
|
.topbar-name { font-size: 14px; font-weight: 600; color: var(--text-1); }
|
|
.topbar-right { margin-left: auto; }
|
|
.topbar-loading { display: flex; align-items: center; }
|
|
|
|
/* ── Layout ────────────────────────────────────────────────────────────── */
|
|
.page-layout {
|
|
display: grid;
|
|
grid-template-columns: 272px 1fr;
|
|
flex: 1;
|
|
}
|
|
|
|
/* ── Sidebar ───────────────────────────────────────────────────────────── */
|
|
.sidebar {
|
|
background: var(--surface);
|
|
border-right: 1px solid var(--border);
|
|
display: flex;
|
|
flex-direction: column;
|
|
position: sticky;
|
|
top: 56px;
|
|
height: calc(100vh - 56px);
|
|
overflow-y: auto;
|
|
}
|
|
|
|
/* Hero */
|
|
.hero-card {
|
|
padding: 24px 20px 18px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
gap: 8px;
|
|
text-align: center;
|
|
border-bottom: 1px solid var(--border-lt);
|
|
}
|
|
.hero-avatar {
|
|
width: 60px; height: 60px; border-radius: 50%;
|
|
background: linear-gradient(135deg, #4f46e5, #7c3aed);
|
|
display: flex; align-items: center; justify-content: center;
|
|
color: white; margin-bottom: 4px;
|
|
box-shadow: 0 4px 14px rgba(79,70,229,.3);
|
|
}
|
|
.hero-name { font-size: 15px; font-weight: 700; color: var(--text-1); margin: 0; line-height: 1.3; }
|
|
.hero-type { font-size: 12px; color: var(--text-2); margin: 0; font-weight: 500; }
|
|
|
|
/* Quick stats */
|
|
.quick-stats {
|
|
padding: 14px 18px;
|
|
display: flex; flex-direction: column; gap: 10px;
|
|
border-bottom: 1px solid var(--border-lt);
|
|
}
|
|
.qs-item { display: flex; align-items: flex-start; gap: 10px; }
|
|
.qs-icon { width: 28px; height: 28px; border-radius: 7px; display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
|
|
.qs-label { font-size: 10.5px; text-transform: uppercase; letter-spacing: .5px; color: var(--text-3); font-weight: 600; }
|
|
.qs-value { font-size: 12.5px; color: var(--text-1); font-weight: 500; margin-top: 1px; }
|
|
|
|
/* Tab nav */
|
|
.tab-nav { padding: 10px 10px; display: flex; flex-direction: column; gap: 2px; flex: 1; }
|
|
.tab-item {
|
|
display: flex; align-items: center; gap: 9px; padding: 8px 11px;
|
|
border-radius: var(--r-sm); border: none; background: transparent; cursor: pointer;
|
|
width: 100%; text-align: left; font-size: 13.5px; font-weight: 500; color: var(--text-2);
|
|
transition: all .12s;
|
|
}
|
|
.tab-item:hover { background: var(--surface-3); color: var(--text-1); }
|
|
.tab-item.active { background: var(--brand-lt); color: var(--brand); font-weight: 600; }
|
|
.tab-icon { flex-shrink: 0; display: flex; color: var(--text-3); }
|
|
.tab-item.active .tab-icon { color: var(--brand); }
|
|
.tab-label { flex: 1; }
|
|
.tab-badge {
|
|
min-width: 19px; height: 19px; padding: 0 5px; border-radius: 10px;
|
|
background: var(--brand); color: white; font-size: 10.5px; font-weight: 700;
|
|
display: flex; align-items: center; justify-content: center;
|
|
}
|
|
.tab-item.active .tab-badge { background: var(--brand-dk); }
|
|
|
|
/* Assign CTA */
|
|
.assign-cta {
|
|
margin: 0 10px 14px; display: flex; align-items: center; justify-content: center; gap: 7px;
|
|
padding: 9px; border: 1.5px dashed var(--border); border-radius: var(--r-sm);
|
|
background: transparent; cursor: pointer; font-size: 12.5px; font-weight: 500; color: var(--text-2);
|
|
transition: all .15s;
|
|
}
|
|
.assign-cta:hover { border-color: var(--brand); color: var(--brand); background: var(--brand-lt); }
|
|
|
|
/* ── Main content ──────────────────────────────────────────────────────── */
|
|
.main-content { padding: 24px 28px; overflow-y: auto; }
|
|
.tab-section { animation: fadeUp .2s ease; }
|
|
@keyframes fadeUp { from { opacity:0; transform:translateY(5px); } to { opacity:1; transform:translateY(0); } }
|
|
|
|
.section-header {
|
|
display: flex; align-items: center; justify-content: space-between; margin-bottom: 18px;
|
|
}
|
|
.section-title { font-size: 17px; font-weight: 700; color: var(--text-1); margin: 0; }
|
|
|
|
/* ── Info grid ─────────────────────────────────────────────────────────── */
|
|
.info-grid {
|
|
display: grid;
|
|
grid-template-columns: 1fr 1fr;
|
|
gap: 14px;
|
|
}
|
|
.full-col { grid-column: 1 / -1; }
|
|
|
|
/* Info card */
|
|
.info-card {
|
|
background: var(--surface);
|
|
border: 1px solid var(--border);
|
|
border-radius: var(--r-md);
|
|
overflow: hidden;
|
|
box-shadow: var(--shadow-sm);
|
|
}
|
|
.info-card-header {
|
|
display: flex; align-items: center; gap: 9px;
|
|
padding: 12px 16px; border-bottom: 1px solid var(--border-lt);
|
|
background: var(--surface-2);
|
|
}
|
|
.ic-icon {
|
|
width: 26px; height: 26px; border-radius: 7px; display: flex; align-items: center; justify-content: center;
|
|
background: color-mix(in srgb, var(--a, #4f46e5) 14%, transparent);
|
|
color: var(--a, #4f46e5);
|
|
}
|
|
.ic-title {
|
|
font-size: 11.5px; font-weight: 700; color: var(--text-1);
|
|
text-transform: uppercase; letter-spacing: .6px;
|
|
}
|
|
.info-card-body { padding: 4px 16px 12px; }
|
|
|
|
/* Data row */
|
|
.data-row { display: flex; justify-content: space-between; align-items: center; padding: 9px 0; border-bottom: 1px solid var(--border-lt); gap: 12px; }
|
|
.data-row:last-child { border-bottom: none; }
|
|
.data-label { font-size: 12px; color: var(--text-3); font-weight: 500; flex-shrink: 0; }
|
|
.data-value { font-size: 13px; color: var(--text-1); text-align: right; }
|
|
.fw6 { font-weight: 600; }
|
|
|
|
.notes-text { font-size: 13.5px; color: var(--text-2); line-height: 1.7; margin: 8px 0 0; }
|
|
|
|
/* ── Edit form ─────────────────────────────────────────────────────────── */
|
|
.card-wrap {
|
|
background: var(--surface); border: 1px solid var(--border);
|
|
border-radius: var(--r-md); padding: 24px; box-shadow: var(--shadow-sm);
|
|
}
|
|
.edit-form { display: flex; flex-direction: column; gap: 18px; }
|
|
.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
|
.form-group { display: flex; flex-direction: column; gap: 6px; }
|
|
.form-group label { font-size: 12px; font-weight: 600; color: var(--text-2); text-transform: uppercase; letter-spacing: .4px; }
|
|
.form-input, .form-select, .form-textarea {
|
|
padding: 9px 12px; border: 1px solid var(--border); border-radius: var(--r-sm);
|
|
font-size: 13.5px; color: var(--text-1); background: var(--surface); outline: none;
|
|
transition: border-color .15s, box-shadow .15s; font-family: inherit;
|
|
}
|
|
.form-input:focus, .form-select:focus, .form-textarea:focus {
|
|
border-color: var(--brand); box-shadow: 0 0 0 3px rgba(79,70,229,.1);
|
|
}
|
|
.form-textarea { resize: vertical; }
|
|
.form-actions { display: flex; gap: 10px; justify-content: flex-end; padding-top: 4px; }
|
|
|
|
/* ── Team ──────────────────────────────────────────────────────────────── */
|
|
.practitioner-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 12px; }
|
|
.practitioner-card {
|
|
background: var(--surface); border: 1px solid var(--border); border-radius: var(--r-md);
|
|
padding: 14px 16px; display: flex; align-items: center; gap: 12px;
|
|
box-shadow: var(--shadow-sm); transition: box-shadow .15s;
|
|
}
|
|
.practitioner-card:hover { box-shadow: var(--shadow-md); }
|
|
.pract-avatar {
|
|
width: 42px; height: 42px; border-radius: 50%;
|
|
background: linear-gradient(135deg, #6366f1, #8b5cf6);
|
|
color: white; display: flex; align-items: center; justify-content: center;
|
|
font-size: 13px; font-weight: 700; flex-shrink: 0;
|
|
}
|
|
.pract-info { flex: 1; }
|
|
.pract-name { font-size: 13.5px; font-weight: 600; color: var(--text-1); }
|
|
.role-chip { display: inline-block; padding: 2px 8px; border-radius: 10px; font-size: 10.5px; font-weight: 600; margin-top: 3px; }
|
|
.chip-principal { background: #eef2ff; color: #4f46e5; }
|
|
.chip-assistant { background: #f0fdf4; color: #16a34a; }
|
|
.unassign-btn {
|
|
width: 28px; height: 28px; border-radius: 50%; border: 1px solid var(--border);
|
|
background: var(--surface); cursor: pointer; display: flex; align-items: center; justify-content: center;
|
|
color: var(--text-3); transition: all .15s; flex-shrink: 0;
|
|
}
|
|
.unassign-btn:hover { background: #fee2e2; border-color: #fca5a5; color: #dc2626; }
|
|
|
|
/* ── Status badge ──────────────────────────────────────────────────────── */
|
|
.status-badge {
|
|
display: inline-flex; align-items: center; padding: 3px 10px;
|
|
border-radius: 20px; font-size: 11.5px; font-weight: 600; letter-spacing: .2px;
|
|
}
|
|
.sb-success { background:#dcfce7; color:#16a34a; }
|
|
.sb-warning { background:#fef9c3; color:#ca8a04; }
|
|
.sb-danger { background:#fee2e2; color:#dc2626; }
|
|
.sb-info { background:#dbeafe; color:#2563eb; }
|
|
.sb-primary { background:#eef2ff; color:#4f46e5; }
|
|
.sb-secondary{ background:#f1f5f9; color:#64748b; }
|
|
|
|
/* Status chip (quote) */
|
|
.status-chip { display:inline-block; padding:2px 9px; border-radius:10px; font-size:11.5px; font-weight:600; }
|
|
.sc-success { background:#dcfce7; color:#16a34a; }
|
|
.sc-info { background:#dbeafe; color:#2563eb; }
|
|
.sc-warning { background:#fef9c3; color:#ca8a04; }
|
|
.sc-danger { background:#fee2e2; color:#dc2626; }
|
|
.sc-secondary{ background:#f1f5f9; color:#64748b; }
|
|
|
|
/* ── Buttons ───────────────────────────────────────────────────────────── */
|
|
.btn-primary {
|
|
display: inline-flex; align-items: center; gap: 6px;
|
|
padding: 9px 18px; border-radius: var(--r-sm); border: none; cursor: pointer;
|
|
font-size: 13px; font-weight: 600; color: white; background: var(--brand);
|
|
text-decoration: none; transition: all .15s;
|
|
}
|
|
.btn-primary:hover { background: var(--brand-dk); transform: translateY(-1px); box-shadow: 0 4px 12px rgba(79,70,229,.3); }
|
|
.btn-primary.sm { padding: 6px 13px; font-size: 12px; }
|
|
.btn-primary:disabled { opacity: .6; cursor: not-allowed; transform: none; }
|
|
.btn-ghost {
|
|
display: inline-flex; align-items: center; gap: 6px;
|
|
padding: 9px 18px; border-radius: var(--r-sm); border: 1px solid var(--border);
|
|
cursor: pointer; font-size: 13px; font-weight: 500; color: var(--text-2); background: transparent;
|
|
transition: all .15s;
|
|
}
|
|
.btn-ghost:hover { background: var(--surface-3); color: var(--text-1); }
|
|
|
|
/* ── Quote ─────────────────────────────────────────────────────────────── */
|
|
.quote-lines { margin-top: 18px; }
|
|
.lines-title { font-size: 12px; font-weight: 700; text-transform: uppercase; letter-spacing: .5px; color: var(--text-2); margin-bottom: 10px; }
|
|
.table-wrap { background: var(--surface); border: 1px solid var(--border); border-radius: var(--r-md); overflow: hidden; box-shadow: var(--shadow-sm); }
|
|
.data-table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
|
.data-table thead { background: var(--surface-2); }
|
|
.data-table th { padding: 10px 16px; font-size: 11px; font-weight: 700; color: var(--text-3); text-transform: uppercase; letter-spacing: .5px; border-bottom: 1px solid var(--border); text-align: left; }
|
|
.data-table td { padding: 11px 16px; border-bottom: 1px solid var(--border-lt); color: var(--text-1); }
|
|
.data-table tbody tr:last-child td { border-bottom: none; }
|
|
.data-table tbody tr:hover td { background: var(--surface-2); }
|
|
.tc { text-align: center; }
|
|
.tr { text-align: right; }
|
|
|
|
/* ── Empty state ───────────────────────────────────────────────────────── */
|
|
.empty-state { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 56px 24px; text-align: center; gap: 10px; }
|
|
.empty-icon { width: 60px; height: 60px; border-radius: 50%; background: var(--surface-3); display: flex; align-items: center; justify-content: center; color: var(--text-3); margin-bottom: 4px; }
|
|
.empty-msg { font-size: 14px; color: var(--text-2); margin: 0; font-weight: 500; }
|
|
.coming-soon-chip { font-size: 11px; color: var(--text-3); background: var(--surface-3); padding: 3px 10px; border-radius: 20px; }
|
|
|
|
/* ── Loading / error ───────────────────────────────────────────────────── */
|
|
.fullpage-center { display: flex; flex-direction: column; align-items: center; justify-content: center; flex: 1; gap: 14px; padding: 80px; text-align: center; }
|
|
.loading-orb { width: 40px; height: 40px; border-radius: 50%; border: 3px solid var(--border); border-top-color: var(--brand); animation: spin .75s linear infinite; }
|
|
@keyframes spin { to { transform: rotate(360deg); } }
|
|
.loading-text { font-size: 14px; color: var(--text-2); margin: 0; }
|
|
.state-icon { width: 60px; height: 60px; border-radius: 50%; display: flex; align-items: center; justify-content: center; }
|
|
.error-icon { background: #fee2e2; color: #dc2626; }
|
|
.state-title { font-size: 18px; font-weight: 700; margin: 0; }
|
|
.state-desc { font-size: 14px; color: var(--text-2); margin: 0; }
|
|
|
|
.mini-spinner { width: 14px; height: 14px; border-radius: 50%; border: 2px solid rgba(79,70,229,.3); border-top-color: var(--brand); animation: spin .6s linear infinite; display: inline-block; }
|
|
.mini-spinner.white { border-color: rgba(255,255,255,.3); border-top-color: white; }
|
|
|
|
/* ── Responsive ────────────────────────────────────────────────────────── */
|
|
@media (max-width: 860px) {
|
|
.page-layout { grid-template-columns: 1fr; }
|
|
.sidebar { position: static; height: auto; border-right: none; border-bottom: 1px solid var(--border); }
|
|
.tab-nav { flex-direction: row; flex-wrap: wrap; }
|
|
.tab-item { flex: none; }
|
|
.info-grid { grid-template-columns: 1fr; }
|
|
.full-col { grid-column: 1; }
|
|
.form-row { grid-template-columns: 1fr; }
|
|
.main-content { padding: 16px; }
|
|
}
|
|
</style>
|