nyavokevin 9cbc1bcbdb feat(ui): add price lists and group-based quote flows
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.
2026-04-02 12:07:11 +03:00

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>