/** * My Presence Page * Personal calendar for marking daily presence */ let currentUser = null; let currentDate = new Date(); let presenceData = {}; let parkingData = {}; let currentAssignmentId = null; let weeklyClosingDays = []; let specificClosingDays = []; let statusDate = new Date(); let statusViewMode = 'daily'; document.addEventListener('DOMContentLoaded', async () => { currentUser = await api.requireAuth(); if (!currentUser) return; await Promise.all([loadPresences(), loadParkingAssignments(), loadClosingDays()]); // Initialize Modal Logic ModalLogic.init({ onMarkPresence: handleMarkPresence, onClearPresence: handleClearPresence, onReleaseParking: handleReleaseParking, onReassignParking: handleReassignParking }); renderCalendar(); setupEventListeners(); // Initialize Parking Status initParkingStatus(); setupStatusListeners(); // Initialize Exclusion Logic initExclusionLogic(); }); async function loadPresences() { const year = currentDate.getFullYear(); const month = currentDate.getMonth(); const firstDay = new Date(year, month, 1); const lastDay = new Date(year, month + 1, 0); const startDate = utils.formatDate(firstDay); const endDate = utils.formatDate(lastDay); const response = await api.get(`/api/presence/my-presences?start_date=${startDate}&end_date=${endDate}`); if (response && response.ok) { const presences = await response.json(); presenceData = {}; presences.forEach(p => { presenceData[p.date] = p; }); } } async function loadParkingAssignments() { const year = currentDate.getFullYear(); const month = currentDate.getMonth(); const firstDay = new Date(year, month, 1); const lastDay = new Date(year, month + 1, 0); const startDate = utils.formatDate(firstDay); const endDate = utils.formatDate(lastDay); const response = await api.get(`/api/parking/my-assignments?start_date=${startDate}&end_date=${endDate}`); if (response && response.ok) { const assignments = await response.json(); parkingData = {}; assignments.forEach(a => { parkingData[a.date] = a; }); } } async function loadClosingDays() { if (!currentUser.office_id) return; try { const [weeklyRes, specificRes] = await Promise.all([ api.get(`/api/offices/${currentUser.office_id}/weekly-closing-days`), api.get(`/api/offices/${currentUser.office_id}/closing-days`) ]); if (weeklyRes && weeklyRes.ok) { const days = await weeklyRes.json(); weeklyClosingDays = days.map(d => d.weekday); } if (specificRes && specificRes.ok) { specificClosingDays = await specificRes.json(); } } catch (e) { console.error('Error loading closing days:', e); } } function renderCalendar() { const year = currentDate.getFullYear(); const month = currentDate.getMonth(); const weekStartDay = currentUser.week_start_day || 1; // 0=Sunday, 1=Monday (default to Monday) // Update month header document.getElementById('currentMonth').textContent = `${utils.getMonthName(month)} ${year}`; // Get calendar info const daysInMonth = utils.getDaysInMonth(year, month); const firstDayOfMonth = new Date(year, month, 1).getDay(); // 0=Sunday const today = new Date(); // Calculate offset based on week start preference let firstDayOffset = firstDayOfMonth - weekStartDay; if (firstDayOffset < 0) firstDayOffset += 7; // Build calendar grid const grid = document.getElementById('calendarGrid'); grid.innerHTML = ''; // Day headers - reorder based on week start day const allDayNames = ['Dom', 'Lun', 'Mar', 'Mer', 'Gio', 'Ven', 'Sab']; const dayNames = []; for (let i = 0; i < 7; i++) { dayNames.push(allDayNames[(weekStartDay + i) % 7]); } dayNames.forEach(name => { const header = document.createElement('div'); header.className = 'calendar-day'; header.style.cursor = 'default'; header.style.fontWeight = '600'; header.style.fontSize = '0.75rem'; header.textContent = name; grid.appendChild(header); }); // Empty cells before first day for (let i = 0; i < firstDayOffset; i++) { const empty = document.createElement('div'); empty.className = 'calendar-day'; empty.style.visibility = 'hidden'; grid.appendChild(empty); } // Day cells for (let day = 1; day <= daysInMonth; day++) { const date = new Date(year, month, day); const dateStr = utils.formatDate(date); const dayOfWeek = date.getDay(); const isWeekend = dayOfWeek === 0 || dayOfWeek === 6; const isHoliday = utils.isItalianHoliday(date); const isToday = date.toDateString() === today.toDateString(); const presence = presenceData[dateStr]; const parking = parkingData[dateStr]; const cell = document.createElement('div'); cell.className = 'calendar-day'; cell.dataset.date = dateStr; if (isWeekend) cell.classList.add('weekend'); if (isHoliday) cell.classList.add('holiday'); if (isToday) cell.classList.add('today'); // Check closing days // Note: JS getDay(): 0=Sunday, 1=Monday... // DB WeekDay: 0=Sunday, etc. (They match) const isWeeklyClosed = weeklyClosingDays.includes(dayOfWeek); const isSpecificClosed = specificClosingDays.some(d => { const start = new Date(d.date); const end = d.end_date ? new Date(d.end_date) : start; // Reset times for strict date comparison start.setHours(0, 0, 0, 0); end.setHours(0, 0, 0, 0); return date >= start && date <= end; }); const isClosed = isWeeklyClosed || isSpecificClosed; if (isClosed) { cell.classList.add('closed'); cell.title = "Ufficio Chiuso"; } else if (presence) { cell.classList.add(`status-${presence.status}`); } // Show parking badge if assigned const parkingBadge = parking ? `${parking.spot_display_name || parking.spot_id}` : ''; cell.innerHTML = `
${day}
${parkingBadge} `; if (!isClosed) { cell.addEventListener('click', () => openDayModal(dateStr, presence, parking)); } grid.appendChild(cell); } } function openDayModal(dateStr, presence, parking) { ModalLogic.openModal({ dateStr, presence, parking }); } async function handleMarkPresence(status, date) { const response = await api.post('/api/presence/mark', { date, status }); if (response && response.ok) { await Promise.all([loadPresences(), loadParkingAssignments()]); renderCalendar(); ModalLogic.closeModal(); } else { const error = await response.json(); alert(error.detail || 'Impossibile segnare la presenza'); } } async function handleClearPresence(date) { const response = await api.delete(`/api/presence/${date}`); if (response && response.ok) { await Promise.all([loadPresences(), loadParkingAssignments()]); renderCalendar(); ModalLogic.closeModal(); } } async function handleReleaseParking(assignmentId) { if (!confirm('Rilasciare il parcheggio per questa data?')) return; const response = await api.post(`/api/parking/release-my-spot/${assignmentId}`); if (response && response.ok) { await loadParkingAssignments(); renderCalendar(); ModalLogic.closeModal(); } else { const error = await response.json(); alert(error.detail || 'Impossibile rilasciare il parcheggio'); } } async function handleReassignParking(assignmentId, newUserId) { // Basic validation handled by select; confirm if (!assignmentId || !newUserId) { alert('Seleziona un utente'); return; } const response = await api.post('/api/parking/reassign-spot', { assignment_id: assignmentId, new_user_id: newUserId }); if (response && response.ok) { await loadParkingAssignments(); renderCalendar(); ModalLogic.closeModal(); } else { const error = await response.json(); alert(error.detail || 'Impossibile riassegnare il parcheggio'); } } function setupEventListeners() { // Month navigation document.getElementById('prevMonth').addEventListener('click', async () => { currentDate.setMonth(currentDate.getMonth() - 1); await Promise.all([loadPresences(), loadParkingAssignments()]); renderCalendar(); }); document.getElementById('nextMonth').addEventListener('click', async () => { currentDate.setMonth(currentDate.getMonth() + 1); await Promise.all([loadPresences(), loadParkingAssignments()]); renderCalendar(); }); // Quick Entry Logic const quickEntryModal = document.getElementById('quickEntryModal'); const quickEntryBtn = document.getElementById('quickEntryBtn'); const closeQuickEntryBtn = document.getElementById('closeQuickEntryModal'); const cancelQuickEntryBtn = document.getElementById('cancelQuickEntry'); const quickEntryForm = document.getElementById('quickEntryForm'); if (quickEntryBtn) { quickEntryBtn.addEventListener('click', () => { // Default dates: tomorrow const tomorrow = new Date(); tomorrow.setDate(tomorrow.getDate() + 1); document.getElementById('qeStartDate').valueAsDate = tomorrow; document.getElementById('qeEndDate').valueAsDate = tomorrow; document.getElementById('qeStatus').value = ''; // Clear selections document.querySelectorAll('.qe-status-btn').forEach(btn => btn.classList.remove('active')); quickEntryModal.style.display = 'flex'; }); } if (closeQuickEntryBtn) closeQuickEntryBtn.addEventListener('click', () => quickEntryModal.style.display = 'none'); if (cancelQuickEntryBtn) cancelQuickEntryBtn.addEventListener('click', () => quickEntryModal.style.display = 'none'); // Status selection in QE document.querySelectorAll('.qe-status-btn').forEach(btn => { btn.addEventListener('click', () => { document.querySelectorAll('.qe-status-btn').forEach(b => b.classList.remove('active')); btn.classList.add('active'); document.getElementById('qeStatus').value = btn.dataset.status; }); }); if (quickEntryForm) { quickEntryForm.addEventListener('submit', async (e) => { e.preventDefault(); const startStr = document.getElementById('qeStartDate').value; const endStr = document.getElementById('qeEndDate').value; const status = document.getElementById('qeStatus').value; if (!status) return utils.showMessage('Seleziona uno stato', 'error'); if (!startStr || !endStr) return utils.showMessage('Seleziona le date', 'error'); const startDate = new Date(startStr); const endDate = new Date(endStr); if (endDate < startDate) return utils.showMessage('La data di fine non può essere precedente alla data di inizio', 'error'); quickEntryModal.style.display = 'none'; utils.showMessage('Inserimento in corso...', 'warning'); const promises = []; let current = new Date(startDate); // Validate filtering let skippedCount = 0; while (current <= endDate) { const dStr = current.toISOString().split('T')[0]; // Create local date for rules check (matches renderCalendar logic) const localCurrent = new Date(dStr + 'T00:00:00'); const dayOfWeek = localCurrent.getDay(); // 0-6 // Check closing days // Only enforce rules if we are not clearing (or should we enforce for clearing too? // Usually clearing is allowed always, but "Inserimento" implies adding. // Ensuring we don't ADD presence on closed days is the main goal.) let isClosed = false; if (status !== 'clear') { const isWeeklyClosed = weeklyClosingDays.includes(dayOfWeek); const isSpecificClosed = specificClosingDays.some(d => { const start = new Date(d.date); const end = d.end_date ? new Date(d.end_date) : start; start.setHours(0, 0, 0, 0); end.setHours(0, 0, 0, 0); // localCurrent is already set to 00:00:00 local return localCurrent >= start && localCurrent <= end; }); if (isWeeklyClosed || isSpecificClosed) isClosed = true; } if (isClosed) { skippedCount++; } else { if (status === 'clear') { promises.push(api.delete(`/api/presence/${dStr}`)); } else { promises.push(api.post('/api/presence/mark', { date: dStr, status: status })); } } current.setDate(current.getDate() + 1); } try { await Promise.all(promises); if (skippedCount > 0) { utils.showMessage(`Inserimento completato! (${skippedCount} giorni chiusi ignorati)`, 'warning'); } else { utils.showMessage('Inserimento completato!', 'success'); } await Promise.all([loadPresences(), loadParkingAssignments()]); renderCalendar(); } catch (err) { console.error(err); utils.showMessage('Errore durante l\'inserimento. Alcuni giorni potrebbero non essere stati aggiornati.', 'error'); } }); } } // ---------------------------------------------------------------------------- // Parking Status Logic // ---------------------------------------------------------------------------- function initParkingStatus() { updateStatusHeader(); loadDailyStatus(); // Update office name if available if (currentUser && currentUser.office_name) { const nameDisplay = document.getElementById('statusOfficeName'); if (nameDisplay) nameDisplay.textContent = currentUser.office_name; const headerDisplay = document.getElementById('currentOfficeDisplay'); if (headerDisplay) headerDisplay.textContent = currentUser.office_name; } else { const nameDisplay = document.getElementById('statusOfficeName'); if (nameDisplay) nameDisplay.textContent = 'Tuo Ufficio'; const headerDisplay = document.getElementById('currentOfficeDisplay'); if (headerDisplay) headerDisplay.textContent = 'Tuo Ufficio'; } } function updateStatusHeader() { const options = { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' }; const dateStr = statusDate.toLocaleDateString('it-IT', options); const capitalizedDate = dateStr.charAt(0).toUpperCase() + dateStr.slice(1); const statusDateDisplay = document.getElementById('statusDateDisplay'); if (statusDateDisplay) statusDateDisplay.textContent = capitalizedDate; const pickerDateDisplay = document.getElementById('pickerDateDisplay'); if (pickerDateDisplay) pickerDateDisplay.textContent = utils.formatDate(statusDate); const summaryDateDisplay = document.getElementById('summaryDateDisplay'); if (summaryDateDisplay) summaryDateDisplay.textContent = dateStr; const picker = document.getElementById('statusDatePicker'); if (picker) { const yyyy = statusDate.getFullYear(); const mm = String(statusDate.getMonth() + 1).padStart(2, '0'); const dd = String(statusDate.getDate()).padStart(2, '0'); picker.value = `${yyyy}-${mm}-${dd}`; } } async function loadDailyStatus() { if (!currentUser || !currentUser.office_id) return; const dateStr = utils.formatDate(statusDate); const officeId = currentUser.office_id; const grid = document.getElementById('spotsGrid'); // Keep grid height to avoid jump if possible, or just loading styling if (grid) grid.innerHTML = '
Caricamento...
'; try { const response = await api.get(`/api/parking/assignments/${dateStr}?office_id=${officeId}`); if (response && response.ok) { const assignments = await response.json(); renderParkingStatus(assignments); } else { if (grid) grid.innerHTML = '
Impossibile caricare i dati.
'; } } catch (e) { console.error("Error loading parking status", e); if (grid) grid.innerHTML = '
Errore di caricamento.
'; } } function renderParkingStatus(assignments) { const grid = document.getElementById('spotsGrid'); if (!grid) return; grid.innerHTML = ''; if (!assignments || assignments.length === 0) { grid.innerHTML = '
Nessun posto configurato o disponibile.
'; const badge = document.getElementById('spotsCountBadge'); if (badge) badge.textContent = `Liberi: 0/0`; return; } // Sort assignments.sort((a, b) => { const nameA = a.spot_display_name || a.spot_id; const nameB = b.spot_display_name || b.spot_id; return nameA.localeCompare(nameB, undefined, { numeric: true, sensitivity: 'base' }); }); let total = assignments.length; let free = 0; assignments.forEach(a => { const isFree = !a.user_id; if (isFree) free++; const spotName = a.spot_display_name || a.spot_id; const statusText = isFree ? 'Libero' : (a.user_name || 'Occupato'); // Colors: Free = Green (default), Occupied = Yellow (requested) // Yellow palette: Border #eab308, bg #fefce8, text #a16207, icon #eab308 const borderColor = isFree ? '#22c55e' : '#eab308'; const bgColor = isFree ? '#f0fdf4' : '#fefce8'; const textColor = isFree ? '#15803d' : '#a16207'; const iconColor = isFree ? '#22c55e' : '#eab308'; const el = document.createElement('div'); el.className = 'spot-card'; el.style.cssText = ` border: 1px solid ${borderColor}; background: ${bgColor}; border-radius: 8px; padding: 1rem; width: 140px; min-width: 120px; text-align: center; display: flex; flex-direction: column; align-items: center; gap: 0.5rem; transition: all 0.2s; `; // New Car Icon (Front Facing Sedan style or similar simple shape) // Using a cleaner SVG path el.innerHTML = `
${spotName}
${statusText}
`; grid.appendChild(el); }); const badge = document.getElementById('spotsCountBadge'); if (badge) badge.textContent = `Liberi: ${free}/${total}`; } function setupStatusListeners() { const prevDay = document.getElementById('statusPrevDay'); if (prevDay) prevDay.addEventListener('click', () => { statusDate.setDate(statusDate.getDate() - 1); updateStatusHeader(); loadDailyStatus(); }); const nextDay = document.getElementById('statusNextDay'); if (nextDay) nextDay.addEventListener('click', () => { statusDate.setDate(statusDate.getDate() + 1); updateStatusHeader(); loadDailyStatus(); }); const datePicker = document.getElementById('statusDatePicker'); if (datePicker) datePicker.addEventListener('change', (e) => { if (e.target.value) { statusDate = new Date(e.target.value); updateStatusHeader(); loadDailyStatus(); } }); } // ---------------------------------------------------------------------------- // Exclusion Logic // ---------------------------------------------------------------------------- async function initExclusionLogic() { await loadExclusionStatus(); setupExclusionListeners(); } async function loadExclusionStatus() { try { const response = await api.get('/api/users/me/exclusion'); if (response && response.ok) { const data = await response.json(); updateExclusionUI(data); // data is now a list } } catch (e) { console.error("Error loading exclusion status", e); } } function updateExclusionUI(exclusions) { const statusDiv = document.getElementById('exclusionStatusDisplay'); const manageBtn = document.getElementById('manageExclusionBtn'); // Always show manage button as "Aggiungi Esclusione" manageBtn.textContent = 'Aggiungi Esclusione'; // Clear previous binding to avoid duplicates or simply use a new function // But specific listeners are set in setupExclusionListeners. // Actually, manageBtn logic was resetting UI. if (exclusions && exclusions.length > 0) { statusDiv.style.display = 'block'; let html = '
'; exclusions.forEach(ex => { let period = 'Tempo Indeterminato'; if (ex.start_date && ex.end_date) { period = `${utils.formatDate(new Date(ex.start_date))} - ${utils.formatDate(new Date(ex.end_date))}`; } else if (ex.start_date) { period = `Dal ${utils.formatDate(new Date(ex.start_date))}`; } html += `
${period}
${ex.notes ? `
${ex.notes}
` : ''}
`; }); html += '
'; statusDiv.innerHTML = html; // Update container style for list statusDiv.style.backgroundColor = '#f9fafb'; statusDiv.style.color = 'inherit'; statusDiv.style.border = 'none'; // remove border from container, items have border statusDiv.style.padding = '0'; // reset padding } else { statusDiv.style.display = 'none'; statusDiv.innerHTML = ''; } } // Global for edit let myEditingExclusionId = null; function openEditMyExclusion(id, data) { myEditingExclusionId = id; const modal = document.getElementById('userExclusionModal'); const radioForever = document.querySelector('input[name="exclusionType"][value="forever"]'); const radioRange = document.querySelector('input[name="exclusionType"][value="range"]'); const rangeDiv = document.getElementById('exclusionDateRange'); const deleteBtn = document.getElementById('deleteExclusionBtn'); // Hide in edit mode (we have icon) or keep? // User requested "matita a destra per la modifica ed eliminazione". // I added trash icon to the list. So modal "Rimuovi" is redundant but harmless. // I'll hide it for clarity. if (deleteBtn) deleteBtn.style.display = 'none'; if (data.start_date || data.end_date) { radioRange.checked = true; rangeDiv.style.display = 'block'; if (data.start_date) document.getElementById('ueStartDate').value = data.start_date; if (data.end_date) document.getElementById('ueEndDate').value = data.end_date; } else { radioForever.checked = true; rangeDiv.style.display = 'none'; document.getElementById('ueStartDate').value = ''; document.getElementById('ueEndDate').value = ''; } document.getElementById('ueNotes').value = data.notes || ''; document.querySelector('#userExclusionModal h3').textContent = 'Modifica Esclusione'; modal.style.display = 'flex'; } async function deleteMyExclusion(id) { if (!confirm('Rimuovere questa esclusione?')) return; const response = await api.delete(`/api/users/me/exclusion/${id}`); if (response && response.ok) { utils.showMessage('Esclusione rimossa con successo', 'success'); loadExclusionStatus(); } else { const err = await response.json(); utils.showMessage(err.detail || 'Errore rimozione', 'error'); } } function resetMyExclusionForm() { document.getElementById('userExclusionForm').reset(); myEditingExclusionId = null; document.querySelector('#userExclusionModal h3').textContent = 'Nuova Esclusione'; const rangeDiv = document.getElementById('exclusionDateRange'); rangeDiv.style.display = 'none'; document.querySelector('input[name="exclusionType"][value="forever"]').checked = true; // Hide delete btn in modal (using list icon instead) const deleteBtn = document.getElementById('deleteExclusionBtn'); if (deleteBtn) deleteBtn.style.display = 'none'; } function setupExclusionListeners() { const modal = document.getElementById('userExclusionModal'); const manageBtn = document.getElementById('manageExclusionBtn'); const closeBtn = document.getElementById('closeUserExclusionModal'); const cancelBtn = document.getElementById('cancelUserExclusion'); const form = document.getElementById('userExclusionForm'); const radioForever = document.querySelector('input[name="exclusionType"][value="forever"]'); const radioRange = document.querySelector('input[name="exclusionType"][value="range"]'); const rangeDiv = document.getElementById('exclusionDateRange'); if (manageBtn) { manageBtn.addEventListener('click', () => { resetMyExclusionForm(); modal.style.display = 'flex'; }); } if (closeBtn) closeBtn.addEventListener('click', () => modal.style.display = 'none'); if (cancelBtn) cancelBtn.addEventListener('click', () => modal.style.display = 'none'); // Radio logic radioForever.addEventListener('change', () => rangeDiv.style.display = 'none'); radioRange.addEventListener('change', () => rangeDiv.style.display = 'block'); // Save if (form) { form.addEventListener('submit', async (e) => { e.preventDefault(); const type = document.querySelector('input[name="exclusionType"]:checked').value; const payload = { notes: document.getElementById('ueNotes').value }; if (type === 'range') { const start = document.getElementById('ueStartDate').value; const end = document.getElementById('ueEndDate').value; if (start) payload.start_date = start; if (end) payload.end_date = end; if (start && end && new Date(end) < new Date(start)) { return utils.showMessage('La data di fine deve essere dopo la data di inizio', 'error'); } } else { payload.start_date = null; payload.end_date = null; } let response; if (myEditingExclusionId) { response = await api.put(`/api/users/me/exclusion/${myEditingExclusionId}`, payload); } else { response = await api.post('/api/users/me/exclusion', payload); } if (response && response.ok) { utils.showMessage('Esclusione salvata', 'success'); modal.style.display = 'none'; loadExclusionStatus(); } else { const err = await response.json(); utils.showMessage(err.detail || 'Errore salvataggio', 'error'); } }); } } // Globals window.openEditMyExclusion = openEditMyExclusion; window.deleteMyExclusion = deleteMyExclusion;