2026-03-13 16:13:49 +03:00

663 lines
15 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="calendar-root">
<!-- Custom Header / Navigation -->
<div class="calendar-header">
<div class="header-left">
<div class="week-label">
<i class="fas fa-calendar-week week-icon"></i>
<span class="week-range">{{ weekRangeLabel }}</span>
</div>
<div class="legend-row">
<span v-for="(color, type) in typeColors" :key="type" class="legend-item">
<span class="legend-dot" :style="{ background: color }"></span>
{{ type }}
</span>
</div>
</div>
<div class="header-right">
<button class="nav-btn" @click="navigate(-1)" title="Semaine précédente">
<i class="fas fa-chevron-left"></i>
</button>
<button class="today-btn" @click="goToday">Aujourd'hui</button>
<button class="nav-btn" @click="navigate(1)" title="Semaine suivante">
<i class="fas fa-chevron-right"></i>
</button>
</div>
</div>
<!-- Calendar Wrapper -->
<div class="calendar-body">
<div ref="calendarEl" class="fc-wrapper"></div>
</div>
<!-- Event Detail Popover -->
<transition name="popover-fade">
<div
v-if="activePopover"
class="event-popover"
:style="popoverStyle"
@click.stop
>
<div class="popover-strip" :style="{ background: activePopover.color }"></div>
<div class="popover-body">
<div class="popover-top">
<span class="popover-type" :style="{ color: activePopover.color, background: activePopover.color + '18' }">
<i :class="getTypeIcon(activePopover.type)" class="me-1"></i>
{{ activePopover.type }}
</span>
<button class="popover-close" @click="closePopover"><i class="fas fa-times"></i></button>
</div>
<div class="popover-deceased">{{ activePopover.deceased || 'Non spécifié' }}</div>
<div class="popover-meta">
<span><i class="fas fa-user me-1"></i>{{ activePopover.client || '' }}</span>
<span><i class="far fa-clock me-1"></i>{{ activePopover.timeLabel }}</span>
</div>
<div class="popover-status">
<span class="status-pill" :style="{ background: activePopover.color + '20', color: activePopover.color }">
{{ activePopover.status || 'Planifié' }}
</span>
</div>
<button class="popover-edit-btn" @click="editFromPopover">
<i class="fas fa-pen me-2"></i>Modifier
</button>
</div>
</div>
</transition>
<div v-if="activePopover" class="popover-backdrop" @click="closePopover"></div>
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted, watch, computed, defineProps, defineEmits } from "vue";
import { Calendar } from "@fullcalendar/core";
import timeGridPlugin from "@fullcalendar/timegrid";
import interactionPlugin from "@fullcalendar/interaction";
import frLocale from "@fullcalendar/core/locales/fr";
const props = defineProps({
startDate: { type: Date, default: () => new Date() },
interventions: { type: Array, default: () => [] },
});
const emit = defineEmits(["cell-click", "edit"]);
const calendarEl = ref(null);
let calendar = null;
const activePopover = ref(null);
const popoverStyle = ref({});
const currentDate = ref(props.startDate);
const typeColors = {
'Soin': '#3b82f6',
'Transport': '#10b981',
'Mise en bière': '#f59e0b',
'Cérémonie': '#8b5cf6',
};
const typeIcons = {
'Soin': 'fas fa-heartbeat',
'Transport': 'fas fa-car',
'Mise en bière': 'fas fa-box',
'Cérémonie': 'fas fa-dove',
};
const getTypeIcon = (type) => typeIcons[type] || 'fas fa-briefcase-medical';
const parseInterventionDate = (value) => {
if (!value) return null;
if (value instanceof Date) return Number.isNaN(value.getTime()) ? null : value;
if (typeof value === 'string') {
// Support backend format "YYYY-MM-DD HH:mm:ss" (without timezone)
const normalized = value.includes(' ') ? value.replace(' ', 'T') : value;
const parsed = new Date(normalized);
if (!Number.isNaN(parsed.getTime())) return parsed;
}
const fallback = new Date(value);
return Number.isNaN(fallback.getTime()) ? null : fallback;
};
const weekRangeLabel = computed(() => {
if (!calendar) return '';
try {
const view = calendar.view;
const start = view.currentStart;
const end = new Date(view.currentEnd);
end.setDate(end.getDate() - 1);
const fmt = (d) => d.toLocaleDateString('fr-FR', { day: 'numeric', month: 'short' });
return `${fmt(start)} ${fmt(end)} ${start.getFullYear()}`;
} catch { return ''; }
});
const mapEvents = (interventions) => interventions.map((i) => {
const color = typeColors[i.type] || '#6b7280';
const start = parseInterventionDate(i.date);
if (!start) return null;
const parsedEnd = parseInterventionDate(i.end);
const end = parsedEnd || new Date(start.getTime() + 60 * 60 * 1000);
return {
id: String(i.id),
title: i.deceased || i.type || 'Intervention',
start, end,
backgroundColor: color + 'dd',
borderColor: color,
textColor: '#fff',
extendedProps: { originalData: i, color },
};
}).filter(Boolean);
const showPopover = (jsEvent, originalData, color) => {
const rect = jsEvent.target.closest('.fc-event')?.getBoundingClientRect() || jsEvent.target.getBoundingClientRect();
const rootRect = calendarEl.value.getBoundingClientRect();
const fmt = (d) => new Date(d).toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' });
activePopover.value = {
...originalData,
color,
timeLabel: fmt(originalData.date),
};
// Position popover near the event
let left = rect.right - rootRect.left + 8;
let top = rect.top - rootRect.top;
// Clamp right edge
const popW = 260;
if (left + popW > rootRect.width) {
left = rect.left - rootRect.left - popW - 8;
}
if (left < 0) left = 8;
if (top + 200 > rootRect.height) top = rootRect.height - 210;
if (top < 0) top = 8;
popoverStyle.value = { left: left + 'px', top: top + 'px' };
};
const closePopover = () => { activePopover.value = null; };
const editFromPopover = () => {
if (activePopover.value) {
emit('edit', activePopover.value);
closePopover();
}
};
const navigate = (dir) => {
if (!calendar) return;
dir === 1 ? calendar.next() : calendar.prev();
// Force reactive update of label
currentDate.value = calendar.getDate();
};
const goToday = () => {
if (!calendar) return;
calendar.today();
currentDate.value = calendar.getDate();
};
const initCalendar = () => {
if (calendar) calendar.destroy();
if (!calendarEl.value) return;
calendar = new Calendar(calendarEl.value, {
plugins: [timeGridPlugin, interactionPlugin],
initialView: 'timeGridWeek',
locale: frLocale,
headerToolbar: false,
initialDate: props.startDate,
allDaySlot: false,
slotMinTime: '00:00:00',
slotMaxTime: '24:00:00',
height: 'auto',
expandRows: true,
stickyHeaderDates: true,
nowIndicator: true,
slotDuration: '00:30:00',
dayHeaderFormat: { weekday: 'short', day: 'numeric', month: 'short' },
events: mapEvents(props.interventions),
eventClick: (info) => {
info.jsEvent.stopPropagation();
const orig = info.event.extendedProps.originalData;
const color = info.event.extendedProps.color;
showPopover(info.jsEvent, orig, color);
},
dateClick: (info) => {
closePopover();
emit('cell-click', { date: info.date });
},
eventContent: (arg) => {
const data = arg.event.extendedProps.originalData;
const color = arg.event.extendedProps.color;
const icon = typeIcons[data?.type] || 'fas fa-briefcase-medical';
const fmt = (d) => new Date(d).toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' });
return {
html: `
<div class="fc-event-custom">
<div class="fce-header">
<i class="${icon} fce-icon"></i>
<span class="fce-time">${fmt(arg.event.start)}</span>
</div>
<div class="fce-title">${arg.event.title}</div>
${data?.client ? `<div class="fce-sub">${data.client}</div>` : ''}
</div>
`
};
},
viewDidMount: () => {
currentDate.value = calendar.getDate();
},
});
calendar.render();
currentDate.value = calendar.getDate();
};
onMounted(() => initCalendar());
onUnmounted(() => { if (calendar) calendar.destroy(); });
watch(() => props.startDate, (d) => { if (calendar) { calendar.gotoDate(d); currentDate.value = d; } });
watch(() => props.interventions, (v) => {
if (calendar) {
calendar.removeAllEvents();
calendar.addEventSource(mapEvents(v));
}
}, { deep: true });
// Force label recompute when currentDate changes
watch(currentDate, () => {}); // just triggers computed re-eval
</script>
<style scoped>
/* ─── Root ─── */
.calendar-root {
position: relative;
display: flex;
flex-direction: column;
background: #fff;
border-radius: 16px;
border: 1px solid #e5e7eb;
box-shadow: 0 4px 20px rgba(0,0,0,0.06);
overflow: hidden;
min-height: 620px;
}
/* ─── Header ─── */
.calendar-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px 12px;
border-bottom: 1px solid #f0f2f5;
background: #fff;
flex-wrap: wrap;
gap: 10px;
flex-shrink: 0;
}
.header-left {
display: flex;
flex-direction: column;
gap: 6px;
}
.week-label {
display: flex;
align-items: center;
gap: 8px;
}
.week-icon {
color: #2d4a6e;
font-size: 0.9rem;
}
.week-range {
font-size: 0.95rem;
font-weight: 700;
color: #1f2937;
letter-spacing: -0.01em;
}
.legend-row {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.legend-item {
display: flex;
align-items: center;
gap: 5px;
font-size: 0.72rem;
color: #6b7280;
font-weight: 500;
}
.legend-dot {
width: 7px;
height: 7px;
border-radius: 50%;
flex-shrink: 0;
}
.header-right {
display: flex;
align-items: center;
gap: 6px;
}
.nav-btn {
width: 34px;
height: 34px;
border-radius: 9px;
border: 1.5px solid #e5e7eb;
background: #fff;
color: #374151;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.8rem;
transition: all 0.15s;
}
.nav-btn:hover {
background: #f3f4f6;
border-color: #d1d5db;
}
.today-btn {
height: 34px;
padding: 0 14px;
border-radius: 9px;
border: 1.5px solid #2d4a6e;
background: #2d4a6e;
color: #fff;
font-size: 0.8rem;
font-weight: 600;
cursor: pointer;
transition: all 0.15s;
}
.today-btn:hover {
background: #1a2e4a;
border-color: #1a2e4a;
}
/* ─── Calendar Body ─── */
.calendar-body {
flex: 1;
padding: 0 4px 4px;
overflow: hidden;
}
.fc-wrapper {
height: 100%;
}
/* ─── FullCalendar Overrides ─── */
:deep(.fc) {
font-family: 'Segoe UI', system-ui, sans-serif;
}
:deep(.fc-scrollgrid) {
border: none !important;
}
:deep(.fc td),
:deep(.fc th) {
border-color: #f0f2f5 !important;
}
:deep(.fc-col-header) {
background: #f8fafc;
}
:deep(.fc-col-header-cell) {
padding: 10px 4px !important;
border-bottom: 2px solid #e5e7eb !important;
}
:deep(.fc-col-header-cell-cushion) {
font-size: 0.78rem;
font-weight: 700;
color: #374151;
text-transform: capitalize;
text-decoration: none !important;
letter-spacing: 0.01em;
}
/* Today column highlight */
:deep(.fc-day-today) {
background: #fafbff !important;
}
:deep(.fc-day-today .fc-col-header-cell-cushion) {
color: #2d4a6e;
}
:deep(.fc-day-today .fc-col-header-cell) {
border-bottom-color: #2d4a6e !important;
}
/* Time labels */
:deep(.fc-timegrid-slot-label-cushion) {
font-size: 0.7rem;
font-weight: 600;
color: #9ca3af;
padding-right: 10px;
}
:deep(.fc-timegrid-slot) {
height: 3rem !important;
}
:deep(.fc-timegrid-slot-minor) {
border-top-style: dashed !important;
border-color: #f3f4f6 !important;
}
/* Now indicator */
:deep(.fc-timegrid-now-indicator-line) {
border-color: #ef4444 !important;
border-width: 2px !important;
}
:deep(.fc-timegrid-now-indicator-arrow) {
border-top-color: #ef4444 !important;
border-bottom-color: #ef4444 !important;
}
/* Events */
:deep(.fc-event) {
border-radius: 7px !important;
border-width: 0 0 0 3px !important;
cursor: pointer;
transition: transform 0.15s, box-shadow 0.15s !important;
padding: 0 !important;
margin: 1px 2px !important;
box-shadow: 0 1px 4px rgba(0,0,0,0.1) !important;
}
:deep(.fc-event:hover) {
transform: scale(1.02);
box-shadow: 0 4px 12px rgba(0,0,0,0.15) !important;
z-index: 10 !important;
}
/* Custom event content */
:deep(.fc-event-custom) {
padding: 5px 7px;
height: 100%;
display: flex;
flex-direction: column;
gap: 2px;
overflow: hidden;
}
:deep(.fce-header) {
display: flex;
align-items: center;
gap: 5px;
}
:deep(.fce-icon) {
font-size: 0.62rem;
opacity: 0.85;
}
:deep(.fce-time) {
font-size: 0.68rem;
font-weight: 700;
opacity: 0.9;
letter-spacing: 0.02em;
}
:deep(.fce-title) {
font-size: 0.75rem;
font-weight: 700;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
line-height: 1.2;
}
:deep(.fce-sub) {
font-size: 0.65rem;
opacity: 0.8;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* ─── Popover ─── */
.event-popover {
position: absolute;
width: 260px;
background: #fff;
border-radius: 12px;
box-shadow: 0 16px 40px rgba(0,0,0,0.14), 0 4px 12px rgba(0,0,0,0.08);
z-index: 200;
overflow: hidden;
border: 1px solid #e5e7eb;
}
.popover-backdrop {
position: absolute;
inset: 0;
z-index: 199;
}
.popover-strip {
height: 4px;
width: 100%;
}
.popover-body {
padding: 14px 16px;
display: flex;
flex-direction: column;
gap: 8px;
}
.popover-top {
display: flex;
align-items: center;
justify-content: space-between;
}
.popover-type {
font-size: 0.72rem;
font-weight: 700;
border-radius: 5px;
padding: 2px 8px;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.popover-close {
background: none;
border: none;
font-size: 0.8rem;
color: #9ca3af;
cursor: pointer;
padding: 2px 4px;
border-radius: 5px;
transition: color 0.15s;
}
.popover-close:hover { color: #374151; }
.popover-deceased {
font-size: 0.9rem;
font-weight: 700;
color: #111827;
line-height: 1.3;
}
.popover-meta {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 0.77rem;
color: #6b7280;
}
.popover-meta i {
font-size: 0.7rem;
width: 14px;
}
.popover-status {
display: flex;
}
.status-pill {
font-size: 0.72rem;
font-weight: 600;
border-radius: 20px;
padding: 2px 10px;
}
.popover-edit-btn {
background: #1a2e4a;
color: #fff;
border: none;
border-radius: 8px;
padding: 8px 14px;
font-size: 0.8rem;
font-weight: 600;
cursor: pointer;
width: 100%;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.15s;
margin-top: 2px;
}
.popover-edit-btn:hover { background: #2d4a6e; }
/* ─── Popover animation ─── */
.popover-fade-enter-active,
.popover-fade-leave-active {
transition: opacity 0.18s ease, transform 0.18s ease;
}
.popover-fade-enter-from {
opacity: 0;
transform: scale(0.94) translateY(-6px);
}
.popover-fade-leave-to {
opacity: 0;
transform: scale(0.96);
}
</style>