New-Thanasoft/thanasoft-front/refont_interview/InterventionDetailPresentation.vue
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

1057 lines
39 KiB
Vue

<template>
<div class="intervention-page">
<!-- Top Navigation Bar -->
<div class="page-topbar">
<router-link to="/interventions" class="back-btn">
<svg width="16" height="16" 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 class="topbar-center" v-if="mappedIntervention">
<span class="topbar-id">#{{ mappedIntervention.id }}</span>
<StatusPill :status="mappedIntervention.status" />
</div>
<div class="topbar-actions">
<button class="action-btn secondary" @click="$emit('cancel')">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M18 6L6 18M6 6l12 12"/></svg>
Annuler
</button>
</div>
</div>
<!-- Loading -->
<div v-if="loading" class="fullpage-loading">
<div class="loading-orb"></div>
<p>Chargement de l'intervention…</p>
</div>
<!-- Error -->
<div v-else-if="error" class="fullpage-error">
<div class="error-icon">
<svg width="32" height="32" 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>Erreur de chargement</h3>
<p>{{ error }}</p>
<button class="action-btn primary" @click="$emit('cancel')">Retour</button>
</div>
<!-- Main Layout -->
<div v-else-if="mappedIntervention" class="page-layout">
<!-- LEFT SIDEBAR -->
<aside class="sidebar">
<!-- Hero Card -->
<div class="hero-card">
<div class="hero-avatar">
<svg width="28" height="28" 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>
<div class="hero-info">
<h2 class="hero-name">{{ mappedIntervention.defuntName }}</h2>
<p class="hero-type">{{ mappedIntervention.title }}</p>
</div>
<StatusPill :status="mappedIntervention.status" large />
</div>
<!-- Quick Stats -->
<div class="quick-stats">
<div class="stat-item">
<div class="stat-icon date-icon">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="18" rx="2" ry="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>
</div>
<div>
<div class="stat-label">Date</div>
<div class="stat-value">{{ mappedIntervention.date }}</div>
</div>
</div>
<div class="stat-item">
<div class="stat-icon loc-icon">
<svg width="14" height="14" 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>
</div>
<div>
<div class="stat-label">Lieu</div>
<div class="stat-value">{{ mappedIntervention.lieux }}</div>
</div>
</div>
<div class="stat-item">
<div class="stat-icon dur-icon">
<svg width="14" height="14" 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>
</div>
<div>
<div class="stat-label">Durée</div>
<div class="stat-value">{{ mappedIntervention.duree }}</div>
</div>
</div>
</div>
<!-- Tab Navigation -->
<nav class="sidebar-nav">
<button
v-for="tab in tabs"
:key="tab.id"
class="nav-item"
:class="{ active: activeTab === tab.id }"
@click="$emit('change-tab', tab.id)"
>
<span class="nav-icon" v-html="tab.icon"></span>
<span class="nav-label">{{ tab.label }}</span>
<span v-if="tab.badge" class="nav-badge">{{ tab.badge }}</span>
</button>
</nav>
<!-- Assign Button -->
<button class="assign-btn" @click="$emit('assign-practitioner')">
<svg width="15" height="15" 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 TAB ── -->
<div v-if="activeTab === 'overview'" class="tab-content">
<div class="section-header">
<h3>Vue d'ensemble</h3>
</div>
<div class="info-grid">
<InfoSection title="Informations générales" icon-color="#6366f1">
<template #icon>
<svg width="16" height="16" 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="mappedIntervention.defuntName" />
<DataRow label="Date prévue" :value="mappedIntervention.date" />
<DataRow label="Lieu" :value="mappedIntervention.lieux" />
<DataRow label="Durée" :value="mappedIntervention.duree" />
</InfoSection>
<InfoSection title="Contact & Communication" icon-color="#10b981">
<template #icon>
<svg width="16" height="16" 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="mappedIntervention.contactFamilial" />
<DataRow label="Coordonnées" :value="mappedIntervention.coordonneesContact" />
<DataRow label="Type d'intervention" :value="mappedIntervention.title" />
</InfoSection>
<InfoSection title="Informations supplémentaires" icon-color="#f59e0b" class="full-width">
<template #icon>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
</template>
<div class="row-2col">
<DataRow label="Personnes attendues" :value="String(mappedIntervention.nombrePersonnes || '-')" />
<DataRow label="Prestations supp." :value="mappedIntervention.prestationsSupplementaires" />
</div>
</InfoSection>
<InfoSection title="Description" icon-color="#8b5cf6" class="full-width">
<template #icon>
<svg width="16" height="16" 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"/><polyline points="10 9 9 9 8 9"/></svg>
</template>
<p class="description-text">{{ mappedIntervention.description || 'Aucune description disponible.' }}</p>
</InfoSection>
</div>
</div>
<!-- DETAILS TAB -->
<div v-if="activeTab === 'details'" class="tab-content">
<div class="section-header">
<h3>Détails</h3>
</div>
<InterventionDetails
:intervention="mappedIntervention"
:loading="loading"
:error="error"
@update="$emit('update-intervention', $event)"
@cancel="$emit('cancel')"
/>
</div>
<!-- TEAM TAB -->
<div v-if="activeTab === 'team'" class="tab-content">
<div class="section-header">
<h3>Équipe assignée</h3>
<button class="action-btn primary sm" @click="$emit('assign-practitioner')">
<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>
</div>
<div v-if="mappedIntervention.practitioners?.length" class="team-grid">
<div
v-for="(p, i) in mappedIntervention.practitioners"
:key="i"
class="practitioner-card"
>
<div class="pract-avatar">{{ getInitials(p.name) }}</div>
<div class="pract-info">
<div class="pract-name">{{ p.name }}</div>
<div class="pract-role">
<span class="role-badge" :class="p.role === 'principal' ? 'role-principal' : 'role-assistant'">
{{ p.role === 'principal' ? 'Principal' : 'Assistant' }}
</span>
</div>
</div>
<button class="unassign-btn" @click="$emit('unassign-practitioner', { practitionerId: p.id, practitionerName: p.name })">
<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>
<div v-else class="empty-state">
<div class="empty-icon">
<svg width="32" height="32" 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>
</div>
<p>Aucun praticien assigné</p>
<button class="action-btn primary sm" @click="$emit('assign-practitioner')">Assigner maintenant</button>
</div>
</div>
<!-- DOCUMENTS TAB -->
<div v-if="activeTab === 'documents'" class="tab-content">
<div class="section-header">
<h3>Documents</h3>
</div>
<DocumentManagement
:documents="documentAttachments"
:loading="documentStore.isLoading"
:error="documentStore.getError"
@files-selected="handleFilesSelected"
@upload-files="handleUploadFiles"
@delete-document="handleDeleteDocument"
@delete-documents="handleDeleteDocuments"
@update-document-label="handleUpdateDocumentLabel"
@retry="loadDocumentAttachments"
/>
</div>
<!-- QUOTE TAB -->
<div v-if="activeTab === 'quote'" class="tab-content">
<div class="section-header">
<h3>Devis</h3>
<router-link
v-if="mappedIntervention.quote?.id"
:to="`/ventes/devis/${mappedIntervention.quote.id}`"
class="action-btn primary sm"
>Ouvrir le devis</router-link>
</div>
<div v-if="mappedIntervention.quote">
<div class="info-grid">
<InfoSection title="Informations du devis" icon-color="#3b82f6">
<template #icon><svg width="16" height="16" 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="mappedIntervention.quote.reference" />
<DataRow label="Date" :value="mappedIntervention.quote.quote_date" />
<DataRow label="Validité" :value="mappedIntervention.quote.valid_until" />
<div class="data-row">
<span class="data-label">Statut</span>
<span class="quote-status-badge" :class="`qs-${getQuoteStatusColor(mappedIntervention.quote.status)}`">
{{ getQuoteStatusLabel(mappedIntervention.quote.status) }}
</span>
</div>
</InfoSection>
<InfoSection title="Montants" icon-color="#10b981">
<template #icon><svg width="16" height="16" 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="formatCurrency(mappedIntervention.quote.total_ht)" />
<DataRow label="Total TVA" :value="formatCurrency(mappedIntervention.quote.total_tva)" />
<DataRow label="Total TTC" :value="formatCurrency(mappedIntervention.quote.total_ttc)" bold />
</InfoSection>
</div>
<div class="quote-lines-section" v-if="mappedIntervention.quote.lines?.length">
<div class="quote-table-header">Lignes du devis</div>
<div class="quote-table-wrapper">
<table class="quote-table">
<thead>
<tr>
<th>Description</th>
<th class="text-center">Qté</th>
<th class="text-right">Prix unitaire</th>
<th class="text-right">Total HT</th>
</tr>
</thead>
<tbody>
<tr v-for="line in mappedIntervention.quote.lines" :key="line.id">
<td>{{ line.description || '-' }}</td>
<td class="text-center">{{ line.units_qty || 0 }}</td>
<td class="text-right">{{ formatCurrency(line.unit_price) }}</td>
<td class="text-right fw-semibold">{{ formatCurrency(line.total_ht) }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<div v-else class="empty-state">
<div class="empty-icon">
<svg width="32" height="32" 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>
</div>
<p>Aucun devis associé à cette intervention</p>
</div>
</div>
<!-- HISTORY TAB -->
<div v-if="activeTab === 'history'" class="tab-content">
<div class="section-header"><h3>Historique</h3></div>
<div class="empty-state">
<div class="empty-icon">
<svg width="32" height="32" 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>
</div>
<p>Historique des modifications</p>
<span class="coming-soon">Fonctionnalité à venir</span>
</div>
</div>
</main>
</div>
<!-- Assign Modal -->
<AssignPractitionerModal
:is-open="isModalOpen"
@close="$emit('close-modal')"
@assign="$emit('assign-practitioner-confirmed', $event)"
/>
</div>
</template>
<script setup>
import { defineProps, defineEmits, ref, computed, watch, onMounted } from 'vue';
import { RouterLink } from 'vue-router';
import InterventionDetails from '@/components/molecules/Interventions/interventionDetails.vue';
import DocumentManagement from '@/components/molecules/Interventions/DocumentManagement.vue';
import AssignPractitionerModal from '@/components/molecules/intervention/AssignPractitionerModal.vue';
import { useDocumentAttachmentStore } from '@/stores/documentAttachmentStore';
// ── Sub-components (inline for self-containment) ──
const StatusPill = {
props: { status: Object, large: Boolean },
template: `
<span class="status-pill" :class="['sp-' + (status?.color || 'secondary'), large ? 'sp-lg' : '']">
{{ status?.label || 'En attente' }}
</span>
`
};
const InfoSection = {
props: { title: String, iconColor: String },
template: `
<div class="info-section">
<div class="info-section-header" :style="{ '--accent': iconColor }">
<span class="info-section-icon"><slot name="icon" /></span>
<span class="info-section-title">{{ title }}</span>
</div>
<div class="info-section-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="{ 'fw-semibold': bold }">{{ value || '-' }}</span>
</div>
`
};
const props = defineProps({
intervention: { type: Object, required: true },
loading: { type: Boolean, default: false },
error: { type: String, default: null },
activeTab: { type: String, default: 'overview' },
practitioners: { type: Array, default: () => [] },
isModalOpen: { type: Boolean, default: false },
});
const emit = defineEmits([
'update-intervention', 'cancel', 'assign-practitioner',
'assign-practitioner-confirmed', 'change-tab', 'close-modal',
'unassign-practitioner',
]);
const documentStore = useDocumentAttachmentStore();
const documentAttachments = computed(() =>
documentStore.getInterventionAttachments(props.intervention?.id || 0)
);
// ── Mapped intervention ──
const mappedIntervention = computed(() => {
if (!props.intervention) return null;
const i = props.intervention;
return {
...i,
defuntName: i.deceased
? `${i.deceased.last_name || ''} ${i.deceased.first_name || ''}`.trim()
: `Personne ${i.deceased_id || 'inconnue'}`,
date: i.scheduled_at ? new Date(i.scheduled_at).toLocaleString('fr-FR') : 'Non définie',
lieux: i.location?.name || 'Lieu non défini',
duree: i.duration_min ? `${i.duration_min} min` : 'Non définie',
title: i.type ? getTypeLabel(i.type) : 'Type non défini',
contactFamilial: i.order_giver || 'Non renseigné',
description: i.notes || '',
nombrePersonnes: i.attachments_count || 0,
coordonneesContact: i.client ? (i.client.email || i.client.phone || 'Non disponible') : 'Non disponible',
prestationsSupplementaires: 'À définir',
practitioners: (i.practitioners || []).map(p => ({
id: p.id,
name: p.employee
? `${p.employee.first_name || ''} ${p.employee.last_name || ''}`.trim()
: `${p.first_name || ''} ${p.last_name || ''}`.trim(),
role: p.pivot?.role || 'assistant',
})),
status: i.status
? { label: getStatusLabel(i.status), color: getStatusColor(i.status) }
: { label: 'En attente', color: 'warning' },
quote: i.quote || null,
};
});
const tabs = computed(() => [
{ id: 'overview', label: 'Vue d\'ensemble', icon: eyeIcon },
{ id: 'details', label: 'Détails', icon: listIcon },
{ id: 'team', label: 'Équipe', icon: teamIcon, badge: mappedIntervention.value?.practitioners?.length || null },
{ id: 'documents', label: 'Documents', icon: docIcon },
{ id: 'quote', label: 'Devis', icon: quoteIcon },
{ id: 'history', label: 'Historique', icon: histIcon },
]);
// SVG icons as strings for tab nav
const eyeIcon = `<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>`;
const listIcon = `<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>`;
const teamIcon = `<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>`;
const docIcon = `<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>`;
const quoteIcon = `<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"/><polyline points="10 9 9 9 8 9"/></svg>`;
const histIcon = `<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 getStatusLabel = s => ({ demande:'Demande', planifie:'Planifié', en_cours:'En cours', termine:'Terminé', annule:'Annulé' }[s] || s);
const getStatusColor = s => ({ demande:'warning', planifie:'info', en_cours:'primary', termine:'success', annule:'danger' }[s] || 'secondary');
const getTypeLabel = t => ({ thanatopraxie:'Thanatopraxie', toilette_mortuaire:'Toilette mortuaire', exhumation:'Exhumation', retrait_pacemaker:'Retrait pacemaker', retrait_bijoux:'Retrait bijoux', autre:'Autre' }[t] || t);
const getQuoteStatusLabel = s => ({ brouillon:'Brouillon', envoye:'Envoyé', accepte:'Accepté', refuse:'Refusé', expire:'Expiré' }[s] || s || 'Inconnu');
const getQuoteStatusColor = s => ({ brouillon:'secondary', envoye:'info', accepte:'success', refuse:'danger', expire:'warning' }[s] || 'secondary');
const formatCurrency = v => new Intl.NumberFormat('fr-FR', { style:'currency', currency:'EUR', minimumFractionDigits:2 }).format(Number(v || 0));
const getInitials = name => name ? name.split(' ').map(w => w[0]).join('').toUpperCase().substring(0, 2) : '?';
// ── Document handlers ──
const handleFilesSelected = files => console.log('Files selected:', files.length);
const loadDocumentAttachments = async () => {
if (!props.intervention?.id) return;
try { await documentStore.fetchInterventionFiles(props.intervention.id); }
catch (e) { documentStore.clearError(); }
};
const handleUploadFiles = async files => {
if (!props.intervention?.id || !files.length) return;
try { await documentStore.uploadAndAttachFiles(files, 'App\\Models\\Intervention', props.intervention.id); }
catch (e) { documentStore.clearError(); }
};
const handleDeleteDocument = async id => {
try { await documentStore.detachFile(id); } catch (e) { documentStore.clearError(); }
};
const handleDeleteDocuments = async ids => {
try { await documentStore.detachMultipleFiles({ attachment_ids: ids }); } catch (e) { documentStore.clearError(); }
};
const handleUpdateDocumentLabel = async ({ id, label }) => {
try { await documentStore.updateAttachmentMetadata(id, { label }); } catch (e) { documentStore.clearError(); }
};
watch(() => props.activeTab, tab => {
if (tab === 'documents' && props.intervention?.id) loadDocumentAttachments();
});
watch(() => props.intervention?.id, id => {
if (id && props.activeTab === 'documents') loadDocumentAttachments();
});
onMounted(() => {
if (props.activeTab === 'documents' && props.intervention?.id) loadDocumentAttachments();
});
</script>
<style scoped>
/* ─── Design System ─────────────────────────────────────────────── */
:root {
--brand: #4f46e5;
--brand-light: #eef2ff;
--brand-dark: #3730a3;
--surface: #ffffff;
--surface-2: #f8fafc;
--surface-3: #f1f5f9;
--border: #e2e8f0;
--border-light: #f1f5f9;
--text-primary: #0f172a;
--text-secondary: #64748b;
--text-muted: #94a3b8;
--radius-sm: 8px;
--radius-md: 12px;
--radius-lg: 16px;
--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);
--shadow-lg: 0 8px 32px rgba(0,0,0,.1);
}
/* ─── Page Shell ────────────────────────────────────────────────── */
.intervention-page {
min-height: 100vh;
background: var(--surface-2);
font-family: 'Inter', system-ui, -apple-system, sans-serif;
color: var(--text-primary);
display: flex;
flex-direction: column;
}
/* ─── Top Bar ───────────────────────────────────────────────────── */
.page-topbar {
display: flex;
align-items: center;
gap: 16px;
padding: 14px 28px;
background: var(--surface);
border-bottom: 1px solid var(--border);
position: sticky;
top: 0;
z-index: 50;
box-shadow: var(--shadow-sm);
}
.back-btn {
display: inline-flex;
align-items: center;
gap: 7px;
font-size: 13px;
font-weight: 500;
color: var(--text-secondary);
text-decoration: none;
padding: 6px 12px;
border-radius: var(--radius-sm);
transition: background .15s, color .15s;
}
.back-btn:hover { background: var(--surface-3); color: var(--text-primary); }
.topbar-center {
display: flex;
align-items: center;
gap: 10px;
margin: 0 auto;
}
.topbar-id {
font-size: 13px;
font-weight: 600;
color: var(--text-secondary);
letter-spacing: .3px;
}
.topbar-actions { display: flex; gap: 8px; }
/* ─── Layout ────────────────────────────────────────────────────── */
.page-layout {
display: grid;
grid-template-columns: 280px 1fr;
gap: 0;
flex: 1;
min-height: 0;
}
/* ─── Sidebar ───────────────────────────────────────────────────── */
.sidebar {
background: var(--surface);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
gap: 0;
position: sticky;
top: 57px;
height: calc(100vh - 57px);
overflow-y: auto;
}
.hero-card {
padding: 24px 20px 20px;
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
text-align: center;
border-bottom: 1px solid var(--border-light);
}
.hero-avatar {
width: 64px;
height: 64px;
border-radius: 50%;
background: linear-gradient(135deg, #4f46e5 0%, #7c3aed 100%);
display: flex;
align-items: center;
justify-content: center;
color: white;
margin-bottom: 4px;
box-shadow: 0 4px 16px rgba(79,70,229,.3);
}
.hero-name {
font-size: 16px;
font-weight: 700;
color: var(--text-primary);
margin: 0;
line-height: 1.3;
}
.hero-type {
font-size: 12px;
color: var(--text-secondary);
margin: 0;
font-weight: 500;
}
/* Quick Stats */
.quick-stats {
padding: 16px 20px;
display: flex;
flex-direction: column;
gap: 12px;
border-bottom: 1px solid var(--border-light);
}
.stat-item {
display: flex;
align-items: flex-start;
gap: 10px;
}
.stat-icon {
width: 30px;
height: 30px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.date-icon { background: #eef2ff; color: #4f46e5; }
.loc-icon { background: #ecfdf5; color: #059669; }
.dur-icon { background: #fff7ed; color: #d97706; }
.stat-label { font-size: 11px; color: var(--text-muted); font-weight: 500; text-transform: uppercase; letter-spacing: .4px; }
.stat-value { font-size: 13px; color: var(--text-primary); font-weight: 500; margin-top: 1px; }
/* Sidebar Nav */
.sidebar-nav {
padding: 12px 12px;
display: flex;
flex-direction: column;
gap: 2px;
flex: 1;
}
.nav-item {
display: flex;
align-items: center;
gap: 10px;
padding: 9px 12px;
border-radius: var(--radius-sm);
border: none;
background: transparent;
cursor: pointer;
width: 100%;
text-align: left;
font-size: 13.5px;
font-weight: 500;
color: var(--text-secondary);
transition: all .15s;
}
.nav-item:hover { background: var(--surface-3); color: var(--text-primary); }
.nav-item.active { background: var(--brand-light); color: var(--brand); font-weight: 600; }
.nav-item.active .nav-icon { color: var(--brand); }
.nav-icon { flex-shrink: 0; display: flex; color: var(--text-muted); transition: color .15s; }
.nav-label { flex: 1; }
.nav-badge {
min-width: 20px;
height: 20px;
padding: 0 6px;
background: var(--brand);
color: white;
border-radius: 10px;
font-size: 11px;
font-weight: 600;
display: flex;
align-items: center;
justify-content: center;
}
.nav-item.active .nav-badge { background: var(--brand-dark); }
/* Assign Button */
.assign-btn {
margin: 0 12px 16px;
display: flex;
align-items: center;
justify-content: center;
gap: 7px;
padding: 10px;
border: 1.5px dashed var(--border);
border-radius: var(--radius-sm);
background: transparent;
cursor: pointer;
font-size: 13px;
font-weight: 500;
color: var(--text-secondary);
transition: all .15s;
}
.assign-btn:hover { border-color: var(--brand); color: var(--brand); background: var(--brand-light); }
/* ─── Main Content ──────────────────────────────────────────────── */
.main-content {
padding: 28px;
overflow-y: auto;
}
.tab-content {
animation: fadeIn .2s ease;
}
@keyframes fadeIn { from { opacity:0; transform: translateY(4px); } to { opacity:1; transform:translateY(0); } }
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
}
.section-header h3 {
font-size: 18px;
font-weight: 700;
color: var(--text-primary);
margin: 0;
}
/* ─── Info Grid ─────────────────────────────────────────────────── */
.info-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
.info-grid .full-width { grid-column: 1 / -1; }
/* Info Section Card */
.info-section {
background: var(--surface);
border-radius: var(--radius-md);
border: 1px solid var(--border);
overflow: hidden;
box-shadow: var(--shadow-sm);
}
.info-section-header {
display: flex;
align-items: center;
gap: 10px;
padding: 14px 18px;
border-bottom: 1px solid var(--border-light);
background: var(--surface-2);
}
.info-section-icon {
width: 28px;
height: 28px;
border-radius: 7px;
background: var(--accent, #6366f1);
opacity: 1;
display: flex;
align-items: center;
justify-content: center;
color: white;
background: color-mix(in srgb, var(--accent, #6366f1) 15%, transparent);
color: var(--accent, #6366f1);
}
.info-section-title {
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
text-transform: uppercase;
letter-spacing: .5px;
}
.info-section-body {
padding: 6px 18px 14px;
}
/* Data Row */
.data-row {
display: flex;
justify-content: space-between;
align-items: flex-start;
padding: 9px 0;
border-bottom: 1px solid var(--border-light);
gap: 12px;
}
.data-row:last-child { border-bottom: none; }
.data-label {
font-size: 12.5px;
color: var(--text-muted);
font-weight: 500;
flex-shrink: 0;
}
.data-value {
font-size: 13px;
color: var(--text-primary);
text-align: right;
font-weight: 400;
}
.fw-semibold { font-weight: 600; }
.row-2col {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0 24px;
}
.description-text {
font-size: 13.5px;
color: var(--text-secondary);
line-height: 1.7;
margin: 4px 0 0;
}
/* ─── Status Pills ──────────────────────────────────────────────── */
.status-pill {
display: inline-flex;
align-items: center;
padding: 3px 10px;
border-radius: 20px;
font-size: 11.5px;
font-weight: 600;
letter-spacing: .2px;
}
.status-pill.sp-lg { padding: 5px 14px; font-size: 12.5px; }
.sp-success { background: #dcfce7; color: #16a34a; }
.sp-warning { background: #fef9c3; color: #ca8a04; }
.sp-danger { background: #fee2e2; color: #dc2626; }
.sp-info { background: #dbeafe; color: #2563eb; }
.sp-primary { background: #eef2ff; color: #4f46e5; }
.sp-secondary { background: #f1f5f9; color: #64748b; }
/* ─── Buttons ───────────────────────────────────────────────────── */
.action-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 16px;
border-radius: var(--radius-sm);
border: none;
cursor: pointer;
font-size: 13px;
font-weight: 500;
text-decoration: none;
transition: all .15s;
}
.action-btn.primary {
background: var(--brand);
color: white;
}
.action-btn.primary:hover { background: var(--brand-dark); transform: translateY(-1px); box-shadow: 0 4px 12px rgba(79,70,229,.3); }
.action-btn.secondary {
background: var(--surface-3);
color: var(--text-secondary);
}
.action-btn.secondary:hover { background: var(--border); color: var(--text-primary); }
.action-btn.sm { padding: 6px 12px; font-size: 12px; }
/* ─── Team Grid ─────────────────────────────────────────────────── */
.team-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 12px;
}
.practitioner-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-md);
padding: 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: 44px;
height: 44px;
border-radius: 50%;
background: linear-gradient(135deg, #6366f1, #8b5cf6);
color: white;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
font-weight: 700;
flex-shrink: 0;
}
.pract-info { flex: 1; }
.pract-name { font-size: 14px; font-weight: 600; color: var(--text-primary); }
.pract-role { margin-top: 3px; }
.role-badge {
display: inline-block;
padding: 2px 8px;
border-radius: 10px;
font-size: 11px;
font-weight: 600;
}
.role-principal { background: #eef2ff; color: #4f46e5; }
.role-assistant { background: #f0fdf4; color: #16a34a; }
.unassign-btn {
width: 30px;
height: 30px;
border-radius: 50%;
border: 1px solid var(--border);
background: var(--surface);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-muted);
transition: all .15s;
}
.unassign-btn:hover { background: #fee2e2; border-color: #fca5a5; color: #dc2626; }
/* ─── Quote ─────────────────────────────────────────────────────── */
.quote-status-badge {
display: inline-block;
padding: 2px 10px;
border-radius: 12px;
font-size: 12px;
font-weight: 600;
}
.qs-success { background: #dcfce7; color: #16a34a; }
.qs-info { background: #dbeafe; color: #2563eb; }
.qs-warning { background: #fef9c3; color: #ca8a04; }
.qs-danger { background: #fee2e2; color: #dc2626; }
.qs-secondary { background: #f1f5f9; color: #64748b; }
.quote-lines-section { margin-top: 20px; }
.quote-table-header {
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 10px;
text-transform: uppercase;
letter-spacing: .5px;
}
.quote-table-wrapper {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-md);
overflow: hidden;
box-shadow: var(--shadow-sm);
}
.quote-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.quote-table thead { background: var(--surface-2); }
.quote-table th {
padding: 11px 16px;
font-size: 11.5px;
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: .4px;
border-bottom: 1px solid var(--border);
text-align: left;
}
.quote-table td {
padding: 12px 16px;
border-bottom: 1px solid var(--border-light);
color: var(--text-primary);
}
.quote-table tbody tr:last-child td { border-bottom: none; }
.quote-table tbody tr:hover td { background: var(--surface-2); }
.text-center { text-align: center; }
.text-right { text-align: right; }
/* ─── Empty / Loading States ────────────────────────────────────── */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 64px 24px;
text-align: center;
gap: 12px;
}
.empty-icon {
width: 64px;
height: 64px;
border-radius: 50%;
background: var(--surface-3);
display: flex;
align-items: center;
justify-content: center;
color: var(--text-muted);
margin-bottom: 4px;
}
.empty-state p { font-size: 14px; color: var(--text-secondary); margin: 0; font-weight: 500; }
.coming-soon {
font-size: 11.5px;
color: var(--text-muted);
background: var(--surface-3);
padding: 3px 10px;
border-radius: 20px;
}
.fullpage-loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
flex: 1;
gap: 16px;
padding: 80px;
}
.loading-orb {
width: 44px;
height: 44px;
border-radius: 50%;
border: 3px solid var(--border);
border-top-color: var(--brand);
animation: spin .8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
.fullpage-loading p { font-size: 14px; color: var(--text-secondary); }
.fullpage-error {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
flex: 1;
gap: 12px;
padding: 80px;
text-align: center;
}
.error-icon {
width: 64px;
height: 64px;
border-radius: 50%;
background: #fee2e2;
color: #dc2626;
display: flex;
align-items: center;
justify-content: center;
}
.fullpage-error h3 { font-size: 18px; font-weight: 700; color: var(--text-primary); margin: 0; }
.fullpage-error p { font-size: 14px; color: var(--text-secondary); margin: 0; }
/* ─── Responsive ────────────────────────────────────────────────── */
@media (max-width: 900px) {
.page-layout { grid-template-columns: 1fr; }
.sidebar {
position: static;
height: auto;
border-right: none;
border-bottom: 1px solid var(--border);
}
.sidebar-nav { flex-direction: row; flex-wrap: wrap; padding: 8px; }
.nav-item { flex: 0 1 auto; }
.info-grid { grid-template-columns: 1fr; }
.info-grid .full-width { grid-column: 1; }
.main-content { padding: 16px; }
}
</style>