2026-07-08 15:53:25 +02:00
|
|
|
|
let state = {
|
|
|
|
|
|
people: [],
|
|
|
|
|
|
slots: [],
|
|
|
|
|
|
personIds: [],
|
2026-07-10 14:07:30 +02:00
|
|
|
|
rehearsals: [],
|
2026-07-10 15:27:07 +02:00
|
|
|
|
upcomingConcerts: [],
|
|
|
|
|
|
concertHours: { start: '19:00', end: '22:00' },
|
2026-07-08 15:53:25 +02:00
|
|
|
|
};
|
2026-07-10 14:07:30 +02:00
|
|
|
|
let me = null;
|
|
|
|
|
|
let currentModalDate = null;
|
2026-07-08 15:53:25 +02:00
|
|
|
|
|
|
|
|
|
|
async function loadPeople() {
|
|
|
|
|
|
state.people = await api.get('/api/calendar/people');
|
|
|
|
|
|
state.personIds = state.people.map((p) => p.id);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function loadSlots() {
|
|
|
|
|
|
state.slots = await api.get('/api/calendar/slots?weeks=3');
|
|
|
|
|
|
renderCalendar();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-10 14:07:30 +02:00
|
|
|
|
async function loadRehearsals() {
|
|
|
|
|
|
state.rehearsals = await api.get('/api/rehearsals');
|
|
|
|
|
|
renderRehearsals();
|
|
|
|
|
|
renderCalendar();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-10 15:27:07 +02:00
|
|
|
|
async function loadUpcomingConcerts() {
|
|
|
|
|
|
state.upcomingConcerts = await api.get('/api/setlists/upcoming');
|
2026-07-10 14:22:01 +02:00
|
|
|
|
renderCalendar();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-10 15:27:07 +02:00
|
|
|
|
async function loadConcertHours() {
|
|
|
|
|
|
state.concertHours = await api.get('/api/calendar/concert-hours');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// concert_date has no time-of-day (a plain DATE column) — synthesize a
|
|
|
|
|
|
// start/end using the admin-configured default concert hours, assuming the
|
|
|
|
|
|
// browser's local timezone is close enough to Europe/Paris (same convention
|
|
|
|
|
|
// used elsewhere on this page for matching concert_date to grid cells).
|
|
|
|
|
|
function concertTimesFor(concertDateStr) {
|
|
|
|
|
|
const d = new Date(concertDateStr);
|
|
|
|
|
|
const [startHour, startMinute] = state.concertHours.start.split(':').map(Number);
|
|
|
|
|
|
const [endHour, endMinute] = state.concertHours.end.split(':').map(Number);
|
|
|
|
|
|
const start = new Date(d.getFullYear(), d.getMonth(), d.getDate(), startHour, startMinute);
|
|
|
|
|
|
const end = new Date(d.getFullYear(), d.getMonth(), d.getDate(), endHour, endMinute);
|
|
|
|
|
|
return { startISO: start.toISOString(), endISO: end.toISOString() };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function concertLinksHtml(concert) {
|
|
|
|
|
|
const { startISO, endISO } = concertTimesFor(concert.concert_date);
|
|
|
|
|
|
const linkArgs = { uid: `concert-${concert.id}`, title: concert.name || 'Concert Octane', startISO, endISO, location: concert.venue };
|
|
|
|
|
|
return `
|
|
|
|
|
|
<div class="modal-section">
|
|
|
|
|
|
<div class="modal-section-title">Concert${concert.name ? ' : ' + escapeHtml(concert.name) : ''}</div>
|
|
|
|
|
|
<p class="note" style="margin:0.25rem 0 0.5rem">${concert.venue ? escapeHtml(concert.venue) + ' · ' : ''}${formatTime(startISO)} – ${formatTime(endISO)}</p>
|
|
|
|
|
|
<div class="rehearsal-actions">
|
2026-07-15 17:46:00 +02:00
|
|
|
|
${calendarLinksMenuHtml(linkArgs, 'concert.ics')}
|
2026-07-10 15:27:07 +02:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-20 14:54:57 +02:00
|
|
|
|
function rehearsalInfoHtml(r) {
|
|
|
|
|
|
const linkArgs = { uid: `rehearsal-${r.id}`, title: 'Répétition Octane', startISO: r.startsAt, endISO: r.endsAt, location: r.location };
|
|
|
|
|
|
return `
|
|
|
|
|
|
<div class="modal-section">
|
|
|
|
|
|
<div class="modal-section-title">Répétition proposée</div>
|
|
|
|
|
|
<p class="note" style="margin:0.25rem 0 0.5rem">${r.location ? escapeHtml(r.location) + ' · ' : ''}${formatTime(r.startsAt)} – ${formatTime(r.endsAt)} · Proposée par ${escapeHtml(r.proposedByName)}</p>
|
|
|
|
|
|
${rehearsalAcceptedByHtml(r)}
|
|
|
|
|
|
<div class="rehearsal-actions">
|
|
|
|
|
|
${calendarLinksMenuHtml(linkArgs, 'repetition.ics')}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-08 15:53:25 +02:00
|
|
|
|
async function loadLastChecked() {
|
|
|
|
|
|
const { last_checked } = await api.get('/api/calendar/last-checked');
|
|
|
|
|
|
const el = document.getElementById('last-checked');
|
|
|
|
|
|
el.textContent = last_checked
|
|
|
|
|
|
? 'Dernière mise à jour : ' + formatDatetime(last_checked)
|
|
|
|
|
|
: 'Aucune donnée pour le moment — cliquez sur Actualiser';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function isoDate(d) {
|
|
|
|
|
|
return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function shortMonth(d) {
|
|
|
|
|
|
return d.toLocaleDateString('fr-FR', { month: 'short' });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function formatTime(iso) {
|
|
|
|
|
|
return new Date(iso).toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit', timeZone: 'Europe/Paris' });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function formatDatetime(iso) {
|
|
|
|
|
|
return new Date(iso).toLocaleString('fr-FR', {
|
|
|
|
|
|
day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit',
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function renderCalendar() {
|
|
|
|
|
|
const grid = document.getElementById('calendar-grid');
|
|
|
|
|
|
grid.innerHTML = '';
|
|
|
|
|
|
|
|
|
|
|
|
const today = new Date();
|
|
|
|
|
|
today.setHours(0, 0, 0, 0);
|
|
|
|
|
|
|
|
|
|
|
|
const selectedSet = new Set(state.personIds);
|
|
|
|
|
|
|
|
|
|
|
|
const slotMap = new Map();
|
|
|
|
|
|
for (const slot of state.slots) slotMap.set(slot.slot_date, slot);
|
|
|
|
|
|
|
2026-07-10 14:07:30 +02:00
|
|
|
|
const rehearsalsByDate = new Map();
|
|
|
|
|
|
for (const r of state.rehearsals) rehearsalsByDate.set(isoDate(new Date(r.startsAt)), r);
|
|
|
|
|
|
|
2026-07-10 15:27:07 +02:00
|
|
|
|
const concertsByDate = new Map();
|
|
|
|
|
|
for (const c of state.upcomingConcerts) concertsByDate.set(isoDate(new Date(c.concert_date)), c);
|
2026-07-10 14:22:01 +02:00
|
|
|
|
|
2026-07-08 15:53:25 +02:00
|
|
|
|
// Grid starts on the Monday of the current week so columns align Mon->Sun.
|
|
|
|
|
|
const start = new Date(today);
|
|
|
|
|
|
const dow = start.getDay();
|
|
|
|
|
|
start.setDate(start.getDate() + (dow === 0 ? -6 : 1 - dow));
|
|
|
|
|
|
|
|
|
|
|
|
const daysBeforeToday = Math.round((today - start) / 86400000);
|
|
|
|
|
|
const totalDays = Math.ceil((daysBeforeToday + 21) / 7) * 7;
|
|
|
|
|
|
|
|
|
|
|
|
for (let i = 0; i < totalDays; i++) {
|
|
|
|
|
|
const date = new Date(start);
|
|
|
|
|
|
date.setDate(start.getDate() + i);
|
|
|
|
|
|
|
|
|
|
|
|
const isPastDay = date < today;
|
|
|
|
|
|
const isBeyond21 = i >= daysBeforeToday + 21;
|
|
|
|
|
|
const isToday = date.getTime() === today.getTime();
|
|
|
|
|
|
const slot = slotMap.get(isoDate(date));
|
|
|
|
|
|
|
|
|
|
|
|
const cell = document.createElement('div');
|
|
|
|
|
|
cell.className = 'cal-cell' + (isToday ? ' today' : '');
|
|
|
|
|
|
|
|
|
|
|
|
if (isBeyond21) {
|
|
|
|
|
|
cell.classList.add('empty');
|
|
|
|
|
|
grid.appendChild(cell);
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const dateLabel = document.createElement('div');
|
|
|
|
|
|
dateLabel.className = 'cell-date';
|
|
|
|
|
|
dateLabel.innerHTML = `<span class="day-num">${date.getDate()}</span>${shortMonth(date)}`;
|
|
|
|
|
|
cell.appendChild(dateLabel);
|
|
|
|
|
|
|
2026-07-10 14:22:01 +02:00
|
|
|
|
const badges = document.createElement('div');
|
|
|
|
|
|
badges.className = 'cell-badges';
|
|
|
|
|
|
|
2026-07-10 14:07:30 +02:00
|
|
|
|
const rehearsal = rehearsalsByDate.get(isoDate(date));
|
|
|
|
|
|
if (rehearsal) {
|
|
|
|
|
|
cell.classList.add('has-rehearsal');
|
2026-07-10 14:22:01 +02:00
|
|
|
|
const badge = document.createElement('span');
|
2026-07-10 14:07:30 +02:00
|
|
|
|
badge.title = 'Répétition proposée';
|
|
|
|
|
|
badge.textContent = '🎸';
|
2026-07-10 14:22:01 +02:00
|
|
|
|
badges.appendChild(badge);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-10 15:27:07 +02:00
|
|
|
|
const concert = concertsByDate.get(isoDate(date));
|
|
|
|
|
|
if (concert) {
|
2026-07-10 14:22:01 +02:00
|
|
|
|
cell.classList.add('has-concert');
|
|
|
|
|
|
const badge = document.createElement('span');
|
2026-07-10 15:27:07 +02:00
|
|
|
|
badge.title = `Concert${concert.name ? ' : ' + concert.name : ''}${concert.venue ? ' (' + concert.venue + ')' : ''}`;
|
2026-07-10 14:22:01 +02:00
|
|
|
|
badge.textContent = '🎤';
|
|
|
|
|
|
badges.appendChild(badge);
|
2026-07-10 14:07:30 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-10 14:22:01 +02:00
|
|
|
|
if (badges.childElementCount) cell.appendChild(badges);
|
|
|
|
|
|
|
2026-07-08 15:53:25 +02:00
|
|
|
|
if (isPastDay) {
|
|
|
|
|
|
cell.classList.add('empty');
|
|
|
|
|
|
const lbl = document.createElement('div');
|
|
|
|
|
|
lbl.className = 'no-slot-label';
|
|
|
|
|
|
lbl.textContent = 'passé';
|
|
|
|
|
|
cell.appendChild(lbl);
|
|
|
|
|
|
} else if (slot) {
|
|
|
|
|
|
const visible = slot.people.filter((p) => selectedSet.has(p.id));
|
|
|
|
|
|
const avail = visible.filter((p) => p.is_available);
|
|
|
|
|
|
|
|
|
|
|
|
cell.classList.add('has-slot');
|
|
|
|
|
|
if (visible.length > 0) {
|
|
|
|
|
|
if (avail.length === visible.length) {
|
|
|
|
|
|
cell.classList.add('all-available');
|
|
|
|
|
|
} else {
|
|
|
|
|
|
const unavailRatio = (visible.length - avail.length) / visible.length;
|
|
|
|
|
|
if (unavailRatio <= 0.2) cell.classList.add('heat-1');
|
|
|
|
|
|
else if (unavailRatio <= 0.4) cell.classList.add('heat-2');
|
|
|
|
|
|
else if (unavailRatio <= 0.6) cell.classList.add('heat-3');
|
|
|
|
|
|
else if (unavailRatio <= 0.8) cell.classList.add('heat-4');
|
|
|
|
|
|
else cell.classList.add('heat-5');
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const timeEl = document.createElement('div');
|
|
|
|
|
|
timeEl.className = 'cell-time';
|
|
|
|
|
|
timeEl.textContent = formatTime(slot.lower) + '–' + formatTime(slot.upper);
|
|
|
|
|
|
cell.appendChild(timeEl);
|
|
|
|
|
|
|
|
|
|
|
|
const dotsEl = document.createElement('div');
|
|
|
|
|
|
dotsEl.className = 'cell-dots';
|
|
|
|
|
|
for (const person of visible) {
|
|
|
|
|
|
const dot = document.createElement('div');
|
|
|
|
|
|
dot.className = 'cell-dot' + (person.is_available ? '' : ' busy');
|
|
|
|
|
|
dot.style.backgroundColor = person.color;
|
|
|
|
|
|
dot.title = person.name + (person.is_available ? ' ✓' : ' ✗');
|
|
|
|
|
|
dotsEl.appendChild(dot);
|
|
|
|
|
|
}
|
|
|
|
|
|
cell.appendChild(dotsEl);
|
|
|
|
|
|
|
2026-07-20 14:54:57 +02:00
|
|
|
|
cell.addEventListener('click', () => openModal(date, slot, visible, concert, rehearsal));
|
2026-07-08 15:53:25 +02:00
|
|
|
|
|
|
|
|
|
|
const countEl = document.createElement('div');
|
|
|
|
|
|
countEl.className = 'cell-count';
|
|
|
|
|
|
countEl.textContent = avail.length + '/' + visible.length;
|
|
|
|
|
|
cell.appendChild(countEl);
|
2026-07-10 15:27:07 +02:00
|
|
|
|
} else if (concert) {
|
2026-07-20 14:54:57 +02:00
|
|
|
|
cell.addEventListener('click', () => openModal(date, null, [], concert, rehearsal));
|
|
|
|
|
|
} else if (rehearsal) {
|
|
|
|
|
|
cell.addEventListener('click', () => openModal(date, null, [], null, rehearsal));
|
2026-07-08 15:53:25 +02:00
|
|
|
|
} else {
|
|
|
|
|
|
cell.classList.add('empty');
|
|
|
|
|
|
const lbl = document.createElement('div');
|
|
|
|
|
|
lbl.className = 'no-slot-label';
|
|
|
|
|
|
lbl.textContent = 'aucune donnée';
|
|
|
|
|
|
cell.appendChild(lbl);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
grid.appendChild(cell);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function buildFilters() {
|
|
|
|
|
|
const listEl = document.getElementById('people-list');
|
2026-07-09 18:26:25 +02:00
|
|
|
|
listEl.innerHTML = '';
|
2026-07-08 15:53:25 +02:00
|
|
|
|
for (const person of state.people) {
|
|
|
|
|
|
const row = document.createElement('label');
|
|
|
|
|
|
row.className = 'person-toggle';
|
|
|
|
|
|
row.dataset.id = person.id;
|
|
|
|
|
|
|
|
|
|
|
|
const dot = document.createElement('span');
|
|
|
|
|
|
dot.className = 'person-dot';
|
|
|
|
|
|
dot.style.backgroundColor = person.color;
|
|
|
|
|
|
|
|
|
|
|
|
row.appendChild(dot);
|
|
|
|
|
|
row.appendChild(document.createTextNode(person.name));
|
|
|
|
|
|
|
|
|
|
|
|
row.addEventListener('click', () => {
|
|
|
|
|
|
const id = person.id;
|
|
|
|
|
|
if (state.personIds.includes(id)) {
|
|
|
|
|
|
if (state.personIds.length === 1) return;
|
|
|
|
|
|
state.personIds = state.personIds.filter((x) => x !== id);
|
|
|
|
|
|
row.classList.add('excluded');
|
|
|
|
|
|
} else {
|
|
|
|
|
|
state.personIds.push(id);
|
|
|
|
|
|
row.classList.remove('excluded');
|
|
|
|
|
|
}
|
|
|
|
|
|
renderCalendar();
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
listEl.appendChild(row);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
buildLegend();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function resetRefreshButton(btn) {
|
|
|
|
|
|
btn.disabled = false;
|
|
|
|
|
|
btn.textContent = 'Actualiser les disponibilités';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function pollWorkflowStatus(btn, maxMs = 180000, intervalMs = 4000) {
|
|
|
|
|
|
const started = Date.now();
|
|
|
|
|
|
const timer = setInterval(async () => {
|
|
|
|
|
|
if (Date.now() - started > maxMs) {
|
|
|
|
|
|
clearInterval(timer);
|
2026-07-09 17:52:08 +02:00
|
|
|
|
showToast('La synchronisation a expiré — aucun résultat après 3 minutes', true);
|
2026-07-08 15:53:25 +02:00
|
|
|
|
resetRefreshButton(btn);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
try {
|
|
|
|
|
|
const data = await api.get('/api/calendar/workflow-status');
|
|
|
|
|
|
if (data.status === 'success') {
|
|
|
|
|
|
clearInterval(timer);
|
2026-07-08 16:16:51 +02:00
|
|
|
|
showToast('Calendrier mis à jour !' + (data.message ? ' ' + data.message : ''), false, 10000);
|
2026-07-08 15:53:25 +02:00
|
|
|
|
resetRefreshButton(btn);
|
|
|
|
|
|
await loadSlots();
|
|
|
|
|
|
await loadLastChecked();
|
|
|
|
|
|
} else if (data.status === 'error') {
|
|
|
|
|
|
clearInterval(timer);
|
2026-07-09 17:52:08 +02:00
|
|
|
|
showToast('Erreur lors de la synchronisation : ' + (data.message || 'inconnue'), true);
|
2026-07-08 15:53:25 +02:00
|
|
|
|
resetRefreshButton(btn);
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
/* transient, keep polling */
|
|
|
|
|
|
}
|
|
|
|
|
|
}, intervalMs);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-20 14:54:57 +02:00
|
|
|
|
function openModal(date, slot, visible, concert, rehearsal) {
|
2026-07-08 15:53:25 +02:00
|
|
|
|
const avail = visible.filter((p) => p.is_available);
|
|
|
|
|
|
const busy = visible.filter((p) => !p.is_available);
|
|
|
|
|
|
|
2026-07-10 14:07:30 +02:00
|
|
|
|
currentModalDate = { date, slot };
|
|
|
|
|
|
const proposeBtn = document.getElementById('modal-propose-rehearsal-btn');
|
2026-07-10 15:27:07 +02:00
|
|
|
|
const availabilitySection = document.getElementById('modal-availability-section');
|
|
|
|
|
|
if (slot) {
|
|
|
|
|
|
availabilitySection.style.display = '';
|
|
|
|
|
|
proposeBtn.style.display = '';
|
|
|
|
|
|
const alreadyProposed = state.rehearsals.some((r) => isoDate(new Date(r.startsAt)) === isoDate(date));
|
|
|
|
|
|
proposeBtn.disabled = alreadyProposed;
|
|
|
|
|
|
proposeBtn.textContent = alreadyProposed
|
|
|
|
|
|
? 'Déjà proposée pour cette date'
|
|
|
|
|
|
: 'Proposer cette date pour la prochaine répétition';
|
|
|
|
|
|
document.getElementById('modal-time').textContent = formatTime(slot.lower) + ' – ' + formatTime(slot.upper);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
availabilitySection.style.display = 'none';
|
|
|
|
|
|
proposeBtn.style.display = 'none';
|
|
|
|
|
|
}
|
2026-07-10 14:07:30 +02:00
|
|
|
|
|
2026-07-08 15:53:25 +02:00
|
|
|
|
document.getElementById('modal-date').textContent = date.toLocaleDateString('fr-FR', {
|
|
|
|
|
|
weekday: 'long', day: 'numeric', month: 'long', year: 'numeric', timeZone: 'Europe/Paris',
|
|
|
|
|
|
});
|
2026-07-10 15:27:07 +02:00
|
|
|
|
|
|
|
|
|
|
const concertSection = document.getElementById('modal-concert-section');
|
|
|
|
|
|
concertSection.innerHTML = concert ? concertLinksHtml(concert) : '';
|
2026-07-08 15:53:25 +02:00
|
|
|
|
|
2026-07-20 14:54:57 +02:00
|
|
|
|
const rehearsalSection = document.getElementById('modal-rehearsal-section');
|
|
|
|
|
|
rehearsalSection.innerHTML = rehearsal ? rehearsalInfoHtml(rehearsal) : '';
|
|
|
|
|
|
|
2026-07-08 15:53:25 +02:00
|
|
|
|
const renderPeople = (list, containerId) => {
|
|
|
|
|
|
const el = document.getElementById(containerId);
|
|
|
|
|
|
el.innerHTML = '';
|
|
|
|
|
|
if (!list.length) {
|
|
|
|
|
|
const em = document.createElement('div');
|
|
|
|
|
|
em.className = 'modal-empty empty';
|
|
|
|
|
|
em.textContent = 'Aucune';
|
|
|
|
|
|
el.appendChild(em);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
for (const person of list) {
|
|
|
|
|
|
const row = document.createElement('div');
|
|
|
|
|
|
row.className = 'modal-person';
|
|
|
|
|
|
const dot = document.createElement('span');
|
|
|
|
|
|
dot.className = 'person-dot';
|
|
|
|
|
|
dot.style.backgroundColor = person.color;
|
|
|
|
|
|
row.appendChild(dot);
|
|
|
|
|
|
row.appendChild(document.createTextNode(person.name));
|
|
|
|
|
|
el.appendChild(row);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-07-10 15:27:07 +02:00
|
|
|
|
if (slot) {
|
|
|
|
|
|
renderPeople(avail, 'modal-available');
|
|
|
|
|
|
renderPeople(busy, 'modal-busy');
|
|
|
|
|
|
}
|
2026-07-08 15:53:25 +02:00
|
|
|
|
|
|
|
|
|
|
document.getElementById('modal-overlay').classList.remove('hidden');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function closeModal() {
|
|
|
|
|
|
document.getElementById('modal-overlay').classList.add('hidden');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function openFilters() {
|
|
|
|
|
|
document.getElementById('filters-panel').classList.add('open');
|
|
|
|
|
|
document.getElementById('filters-backdrop').classList.add('open');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function closeFilters() {
|
|
|
|
|
|
document.getElementById('filters-panel').classList.remove('open');
|
|
|
|
|
|
document.getElementById('filters-backdrop').classList.remove('open');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function buildLegend() {
|
|
|
|
|
|
const el = document.getElementById('legend');
|
2026-07-09 18:26:25 +02:00
|
|
|
|
el.innerHTML = '';
|
2026-07-08 15:53:25 +02:00
|
|
|
|
for (const person of state.people) {
|
|
|
|
|
|
const item = document.createElement('div');
|
|
|
|
|
|
item.className = 'legend-item';
|
|
|
|
|
|
const dot = document.createElement('span');
|
|
|
|
|
|
dot.className = 'legend-dot';
|
|
|
|
|
|
dot.style.backgroundColor = person.color;
|
|
|
|
|
|
item.appendChild(dot);
|
|
|
|
|
|
item.appendChild(document.createTextNode(person.name));
|
|
|
|
|
|
el.appendChild(item);
|
|
|
|
|
|
}
|
|
|
|
|
|
const allItem = document.createElement('div');
|
|
|
|
|
|
allItem.className = 'legend-item';
|
|
|
|
|
|
allItem.innerHTML = '<span class="legend-dot legend-dot-all"></span> Tout le monde libre';
|
|
|
|
|
|
el.appendChild(allItem);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function showToast(msg, isError = false, duration = 5000) {
|
|
|
|
|
|
const el = document.getElementById('toast');
|
|
|
|
|
|
el.textContent = msg;
|
|
|
|
|
|
el.classList.remove('hidden', 'error');
|
|
|
|
|
|
if (isError) el.classList.add('error');
|
|
|
|
|
|
clearTimeout(el._timer);
|
|
|
|
|
|
el._timer = setTimeout(() => el.classList.add('hidden'), duration);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function showError(message) {
|
|
|
|
|
|
document.getElementById('error').innerHTML = `<div class="error-banner">${escapeHtml(message)}</div>`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-10 14:07:30 +02:00
|
|
|
|
// ---------- Répétitions proposées ----------
|
|
|
|
|
|
|
2026-07-15 17:25:32 +02:00
|
|
|
|
function rehearsalVoteActionsHtml(r) {
|
|
|
|
|
|
const myVote = me ? r.votes.find((v) => v.userId === me.id) : null;
|
|
|
|
|
|
if (!myVote) {
|
|
|
|
|
|
return `
|
|
|
|
|
|
<button type="button" class="secondary accept-rehearsal-btn" data-rehearsal-id="${r.id}">✔ Accepter</button>
|
|
|
|
|
|
<button type="button" class="secondary reject-rehearsal-btn" data-rehearsal-id="${r.id}">✘ Refuser</button>
|
|
|
|
|
|
`;
|
|
|
|
|
|
}
|
|
|
|
|
|
const label = myVote.vote === 'accept' ? '✔ Vous avez accepté' : '✘ Vous avez refusé';
|
|
|
|
|
|
return `
|
|
|
|
|
|
<span class="vote-badge ${myVote.vote}">${label}</span>
|
|
|
|
|
|
<button type="button" class="secondary undo-rehearsal-vote-btn" data-rehearsal-id="${r.id}">Annuler</button>
|
|
|
|
|
|
`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function rehearsalAcceptedByHtml(r) {
|
|
|
|
|
|
const accepted = r.votes.filter((v) => v.vote === 'accept');
|
|
|
|
|
|
if (!accepted.length) return '';
|
|
|
|
|
|
return `
|
|
|
|
|
|
<div class="rehearsal-accepted-by">
|
|
|
|
|
|
${accepted.map((v) => `${avatarHtml({ name: v.name, avatarUrl: v.avatarUrl }, 'avatar-sm')}<span>${escapeHtml(v.name)}</span>`).join('')}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-10 14:07:30 +02:00
|
|
|
|
function rehearsalRowTemplate(r) {
|
|
|
|
|
|
const title = 'Répétition Octane';
|
2026-07-10 15:27:07 +02:00
|
|
|
|
const linkArgs = { uid: `rehearsal-${r.id}`, title, startISO: r.startsAt, endISO: r.endsAt, location: r.location };
|
2026-07-10 14:07:30 +02:00
|
|
|
|
const canRemove = me && (me.id === r.proposedBy || me.isAdmin);
|
|
|
|
|
|
return `
|
|
|
|
|
|
<div class="rehearsal-row" data-rehearsal-id="${r.id}">
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<div class="card-title">${formatDatetime(r.startsAt)} – ${formatTime(r.endsAt)}</div>
|
|
|
|
|
|
<div class="card-subtitle">${r.location ? `${escapeHtml(r.location)} · ` : ''}Proposée par ${escapeHtml(r.proposedByName)}</div>
|
2026-07-15 17:25:32 +02:00
|
|
|
|
${rehearsalAcceptedByHtml(r)}
|
2026-07-10 14:07:30 +02:00
|
|
|
|
</div>
|
|
|
|
|
|
<div class="rehearsal-actions">
|
2026-07-15 17:25:32 +02:00
|
|
|
|
${rehearsalVoteActionsHtml(r)}
|
2026-07-15 17:46:00 +02:00
|
|
|
|
${calendarLinksMenuHtml(linkArgs, 'repetition.ics')}
|
2026-07-10 14:07:30 +02:00
|
|
|
|
${canRemove ? `<button type="button" class="danger icon-btn remove-rehearsal-btn" data-rehearsal-id="${r.id}">Retirer</button>` : ''}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-15 17:46:00 +02:00
|
|
|
|
function wireRehearsalActions(container) {
|
2026-07-10 14:07:30 +02:00
|
|
|
|
container.querySelectorAll('.remove-rehearsal-btn').forEach((btn) => {
|
|
|
|
|
|
btn.addEventListener('click', () => onRemoveRehearsal(parseInt(btn.dataset.rehearsalId, 10)));
|
|
|
|
|
|
});
|
2026-07-15 17:25:32 +02:00
|
|
|
|
container.querySelectorAll('.accept-rehearsal-btn').forEach((btn) => {
|
|
|
|
|
|
btn.addEventListener('click', () => onVoteRehearsal(parseInt(btn.dataset.rehearsalId, 10), 'accept'));
|
|
|
|
|
|
});
|
|
|
|
|
|
container.querySelectorAll('.reject-rehearsal-btn').forEach((btn) => {
|
|
|
|
|
|
btn.addEventListener('click', () => onVoteRehearsal(parseInt(btn.dataset.rehearsalId, 10), 'reject'));
|
|
|
|
|
|
});
|
|
|
|
|
|
container.querySelectorAll('.undo-rehearsal-vote-btn').forEach((btn) => {
|
|
|
|
|
|
btn.addEventListener('click', () => onUndoRehearsalVote(parseInt(btn.dataset.rehearsalId, 10)));
|
|
|
|
|
|
});
|
2026-07-10 14:07:30 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-15 17:46:00 +02:00
|
|
|
|
function renderRehearsals() {
|
|
|
|
|
|
const section = document.getElementById('rehearsals-section');
|
2026-07-15 17:53:01 +02:00
|
|
|
|
const title = document.getElementById('rehearsals-title');
|
2026-07-15 17:46:00 +02:00
|
|
|
|
const container = document.getElementById('rehearsals-list');
|
|
|
|
|
|
const votedSection = document.getElementById('rehearsals-voted-section');
|
|
|
|
|
|
const votedSummary = document.getElementById('rehearsals-voted-summary');
|
|
|
|
|
|
const votedContainer = document.getElementById('rehearsals-voted-list');
|
|
|
|
|
|
|
|
|
|
|
|
if (!state.rehearsals.length) {
|
|
|
|
|
|
section.style.display = 'none';
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
section.style.display = '';
|
|
|
|
|
|
|
|
|
|
|
|
const pending = state.rehearsals.filter(
|
|
|
|
|
|
(r) => !me || !r.votes.some((v) => v.userId === me.id)
|
|
|
|
|
|
);
|
|
|
|
|
|
const voted = state.rehearsals.filter(
|
|
|
|
|
|
(r) => me && r.votes.some((v) => v.userId === me.id)
|
|
|
|
|
|
);
|
|
|
|
|
|
|
2026-07-15 17:53:01 +02:00
|
|
|
|
title.style.display = pending.length ? '' : 'none';
|
|
|
|
|
|
container.style.display = pending.length ? '' : 'none';
|
2026-07-15 17:46:00 +02:00
|
|
|
|
container.innerHTML = pending.map(rehearsalRowTemplate).join('');
|
|
|
|
|
|
wireRehearsalActions(container);
|
|
|
|
|
|
|
|
|
|
|
|
if (voted.length) {
|
|
|
|
|
|
votedSection.style.display = '';
|
|
|
|
|
|
votedSummary.textContent = `Répétitions traitées (${voted.length})`;
|
|
|
|
|
|
votedContainer.innerHTML = voted.map(rehearsalRowTemplate).join('');
|
|
|
|
|
|
wireRehearsalActions(votedContainer);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
votedSection.style.display = 'none';
|
|
|
|
|
|
votedContainer.innerHTML = '';
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-10 14:07:30 +02:00
|
|
|
|
async function onProposeRehearsal() {
|
|
|
|
|
|
if (!currentModalDate) return;
|
|
|
|
|
|
const { slot } = currentModalDate;
|
|
|
|
|
|
try {
|
|
|
|
|
|
await api.post('/api/rehearsals', { startsAt: slot.lower, endsAt: slot.upper });
|
|
|
|
|
|
closeModal();
|
|
|
|
|
|
await loadRehearsals();
|
|
|
|
|
|
showToast('Répétition proposée.');
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
showError(err.message);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function onRemoveRehearsal(id) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
await api.del(`/api/rehearsals/${id}`);
|
|
|
|
|
|
await loadRehearsals();
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
showError(err.message);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-15 17:25:32 +02:00
|
|
|
|
async function onVoteRehearsal(id, vote) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
await api.post(`/api/rehearsals/${id}/vote`, { vote });
|
|
|
|
|
|
await loadRehearsals();
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
showError(err.message);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function onUndoRehearsalVote(id) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
await api.del(`/api/rehearsals/${id}/vote`);
|
|
|
|
|
|
await loadRehearsals();
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
showError(err.message);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-09 18:26:25 +02:00
|
|
|
|
// ---------- Mes calendriers (self-service ICS feeds) ----------
|
|
|
|
|
|
|
|
|
|
|
|
let myFeeds = [];
|
|
|
|
|
|
|
|
|
|
|
|
// Shown for a registered feed instead of its full URL — consistent with the
|
|
|
|
|
|
// admin/profile calendar-management panels.
|
|
|
|
|
|
function maskIcsUrl(url) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const u = new URL(url);
|
|
|
|
|
|
return `${u.hostname}/••••`;
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
return '••••';
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function myFeedRowTemplate(feed) {
|
|
|
|
|
|
return `
|
|
|
|
|
|
<div class="feed-row" data-feed-id="${feed.id}">
|
|
|
|
|
|
<span class="feed-label">${feed.label ? `${escapeHtml(feed.label)} — ` : ''}${escapeHtml(maskIcsUrl(feed.icsUrl))}</span>
|
|
|
|
|
|
<button type="button" class="secondary icon-btn remove-my-feed-btn" data-feed-id="${feed.id}">Supprimer</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function loadMyFeeds() {
|
|
|
|
|
|
myFeeds = await api.get('/api/calendar/my-feeds');
|
|
|
|
|
|
renderMyFeedBanner();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function renderMyFeedBanner() {
|
|
|
|
|
|
document.getElementById('my-feed-banner').style.display = myFeeds.length ? 'none' : '';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function renderMyCalendarsModal() {
|
|
|
|
|
|
const container = document.getElementById('my-calendars-list');
|
|
|
|
|
|
container.innerHTML = myFeeds.length
|
|
|
|
|
|
? myFeeds.map(myFeedRowTemplate).join('')
|
|
|
|
|
|
: '<p class="empty">Aucun calendrier configuré.</p>';
|
|
|
|
|
|
container.querySelectorAll('.remove-my-feed-btn').forEach((btn) => {
|
|
|
|
|
|
btn.addEventListener('click', () => onRemoveMyFeed(parseInt(btn.dataset.feedId, 10)));
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function openMyCalendarsModal() {
|
|
|
|
|
|
renderMyCalendarsModal();
|
|
|
|
|
|
document.getElementById('my-calendars-modal-overlay').classList.remove('hidden');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function closeMyCalendarsModal() {
|
|
|
|
|
|
document.getElementById('my-calendars-modal-overlay').classList.add('hidden');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function onAddMyFeed(e) {
|
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
|
const form = e.target;
|
|
|
|
|
|
const label = form.label.value.trim();
|
|
|
|
|
|
const icsUrl = form.icsUrl.value.trim();
|
2026-07-10 11:27:08 +02:00
|
|
|
|
const submitBtn = form.querySelector('button[type="submit"]');
|
|
|
|
|
|
submitBtn.disabled = true;
|
|
|
|
|
|
submitBtn.textContent = 'Vérification…';
|
2026-07-09 18:26:25 +02:00
|
|
|
|
try {
|
|
|
|
|
|
await api.post('/api/calendar/my-feeds', { label, icsUrl });
|
|
|
|
|
|
form.reset();
|
|
|
|
|
|
await loadMyFeeds();
|
|
|
|
|
|
renderMyCalendarsModal();
|
|
|
|
|
|
await loadPeople();
|
|
|
|
|
|
buildFilters();
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
showError(err.message);
|
2026-07-10 11:27:08 +02:00
|
|
|
|
} finally {
|
|
|
|
|
|
submitBtn.disabled = false;
|
|
|
|
|
|
submitBtn.textContent = '+ Ajouter';
|
2026-07-09 18:26:25 +02:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function onRemoveMyFeed(feedId) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
await api.del(`/api/calendar/my-feeds/${feedId}`);
|
|
|
|
|
|
await loadMyFeeds();
|
|
|
|
|
|
renderMyCalendarsModal();
|
|
|
|
|
|
await loadPeople();
|
|
|
|
|
|
buildFilters();
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
showError(err.message);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-08 15:53:25 +02:00
|
|
|
|
(async function init() {
|
2026-07-10 14:07:30 +02:00
|
|
|
|
me = await initNav('calendar');
|
2026-07-08 15:53:25 +02:00
|
|
|
|
|
|
|
|
|
|
document.getElementById('modal-close').addEventListener('click', closeModal);
|
|
|
|
|
|
document.getElementById('modal-overlay').addEventListener('click', (e) => {
|
|
|
|
|
|
if (e.target === e.currentTarget) closeModal();
|
|
|
|
|
|
});
|
2026-07-10 14:07:30 +02:00
|
|
|
|
document.getElementById('modal-propose-rehearsal-btn').addEventListener('click', onProposeRehearsal);
|
2026-07-09 18:26:25 +02:00
|
|
|
|
document.getElementById('my-calendars-modal-close').addEventListener('click', closeMyCalendarsModal);
|
|
|
|
|
|
document.getElementById('my-calendars-modal-overlay').addEventListener('click', (e) => {
|
|
|
|
|
|
if (e.target === e.currentTarget) closeMyCalendarsModal();
|
|
|
|
|
|
});
|
|
|
|
|
|
document.getElementById('my-calendars-add-form').addEventListener('submit', onAddMyFeed);
|
2026-07-08 15:53:25 +02:00
|
|
|
|
document.addEventListener('keydown', (e) => {
|
|
|
|
|
|
if (e.key === 'Escape') {
|
|
|
|
|
|
closeModal();
|
|
|
|
|
|
closeFilters();
|
2026-07-09 18:26:25 +02:00
|
|
|
|
closeMyCalendarsModal();
|
2026-07-08 15:53:25 +02:00
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
document.getElementById('btn-filters-toggle').addEventListener('click', openFilters);
|
|
|
|
|
|
document.getElementById('btn-filters-close').addEventListener('click', closeFilters);
|
|
|
|
|
|
document.getElementById('filters-backdrop').addEventListener('click', closeFilters);
|
2026-07-09 18:26:25 +02:00
|
|
|
|
document.getElementById('btn-my-calendars').addEventListener('click', openMyCalendarsModal);
|
|
|
|
|
|
document.getElementById('btn-my-feed-banner-add').addEventListener('click', openMyCalendarsModal);
|
|
|
|
|
|
|
|
|
|
|
|
document.getElementById('btn-refresh').addEventListener('click', async () => {
|
|
|
|
|
|
const btn = document.getElementById('btn-refresh');
|
|
|
|
|
|
btn.disabled = true;
|
|
|
|
|
|
btn.textContent = 'Actualisation…';
|
|
|
|
|
|
showToast('Synchronisation des calendriers…');
|
|
|
|
|
|
try {
|
|
|
|
|
|
await api.post('/api/calendar/refresh', {});
|
|
|
|
|
|
showToast('Synchronisation en cours — en attente des résultats…');
|
|
|
|
|
|
pollWorkflowStatus(btn);
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
const message = err.message === 'no_feeds_configured'
|
|
|
|
|
|
? "Aucun calendrier n'est configuré — voir la page Administration."
|
|
|
|
|
|
: err.message;
|
|
|
|
|
|
showToast(message, true);
|
|
|
|
|
|
resetRefreshButton(btn);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
2026-07-08 15:53:25 +02:00
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
await loadPeople();
|
|
|
|
|
|
buildFilters();
|
2026-07-10 15:27:07 +02:00
|
|
|
|
await loadConcertHours();
|
2026-07-10 14:07:30 +02:00
|
|
|
|
await loadRehearsals();
|
2026-07-10 15:27:07 +02:00
|
|
|
|
await loadUpcomingConcerts();
|
2026-07-08 15:53:25 +02:00
|
|
|
|
await loadSlots();
|
|
|
|
|
|
await loadLastChecked();
|
2026-07-09 18:26:25 +02:00
|
|
|
|
await loadMyFeeds();
|
2026-07-08 15:53:25 +02:00
|
|
|
|
} catch (err) {
|
|
|
|
|
|
showError(err.message);
|
|
|
|
|
|
}
|
|
|
|
|
|
})();
|