663 lines
15 KiB
Vue
663 lines
15 KiB
Vue
<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>
|