531 lines
19 KiB
JavaScript
531 lines
19 KiB
JavaScript
/**
|
|
* Team Calendar Page
|
|
* Shows presence and parking for all team members
|
|
* Filtered by office (office-centric model)
|
|
*/
|
|
|
|
let currentUser = null;
|
|
let currentStartDate = null;
|
|
let viewMode = 'week'; // 'week' or 'month'
|
|
let offices = [];
|
|
let teamData = [];
|
|
let parkingDataLookup = {};
|
|
let parkingAssignmentLookup = {};
|
|
let selectedUserId = null;
|
|
let selectedDate = null;
|
|
let currentAssignmentId = null;
|
|
let officeClosingRules = {}; // { officeId: { weekly: [], specific: [] } }
|
|
|
|
document.addEventListener('DOMContentLoaded', async () => {
|
|
currentUser = await api.requireAuth();
|
|
if (!currentUser) return;
|
|
|
|
// Initialize start date based on week start preference
|
|
const weekStartDay = currentUser.week_start_day || 1;
|
|
currentStartDate = utils.getWeekStart(new Date(), weekStartDay);
|
|
|
|
await loadOffices();
|
|
await loadTeamData();
|
|
await loadTeamData();
|
|
|
|
// Initialize Modal Logic
|
|
ModalLogic.init({
|
|
onMarkPresence: handleMarkPresence,
|
|
onClearPresence: handleClearPresence,
|
|
onReleaseParking: handleReleaseParking,
|
|
onReassignParking: handleReassignParking
|
|
});
|
|
|
|
renderCalendar();
|
|
setupEventListeners();
|
|
});
|
|
|
|
function updateOfficeDisplay() {
|
|
const display = document.getElementById('currentOfficeNameDisplay');
|
|
if (!display) return;
|
|
|
|
const select = document.getElementById('officeFilter');
|
|
|
|
// If user is employee, show their office name directly
|
|
if (currentUser.role === 'employee') {
|
|
display.textContent = currentUser.office_name || "Mio Ufficio";
|
|
return;
|
|
}
|
|
|
|
// For admin/manager, show selected
|
|
if (select && select.value) {
|
|
// Find name in options
|
|
const option = select.options[select.selectedIndex];
|
|
if (option) {
|
|
// Remove the count (xx utenti) part if desired, or keep it.
|
|
// User requested "nome del'ufficio", let's keep it simple.
|
|
// Option text is "Name (Count users)"
|
|
// let text = option.textContent.split('(')[0].trim();
|
|
display.textContent = option.textContent;
|
|
} else {
|
|
display.textContent = "Tutti gli Uffici";
|
|
}
|
|
} else {
|
|
display.textContent = "Tutti gli Uffici";
|
|
}
|
|
}
|
|
|
|
async function loadOffices() {
|
|
const select = document.getElementById('officeFilter');
|
|
|
|
// Only Admins and Managers can list offices
|
|
// Employees will just see their own office logic handled in loadTeamData
|
|
// Only Admins can see the office selector
|
|
if (currentUser.role !== 'admin') {
|
|
select.style.display = 'none';
|
|
// Employees stop here, Managers continue to allow auto-selection logic below
|
|
if (currentUser.role === 'employee') return;
|
|
}
|
|
|
|
const response = await api.get('/api/offices');
|
|
if (response && response.ok) {
|
|
offices = await response.json();
|
|
|
|
let filteredOffices = offices;
|
|
if (currentUser.role === 'manager') {
|
|
// Manager only sees their own office in the filter?
|
|
// Actually managers might want to filter if they (hypothetically) managed multiple,
|
|
// but currently User has 1 office.
|
|
if (currentUser.office_id) {
|
|
filteredOffices = offices.filter(o => o.id === currentUser.office_id);
|
|
} else {
|
|
filteredOffices = [];
|
|
}
|
|
}
|
|
|
|
filteredOffices.forEach(office => {
|
|
const option = document.createElement('option');
|
|
option.value = office.id;
|
|
option.textContent = `${office.name} (${office.user_count || 0} utenti)`;
|
|
select.appendChild(option);
|
|
});
|
|
|
|
// Auto-select for managers
|
|
if (currentUser.role === 'manager' && filteredOffices.length === 1) {
|
|
select.value = filteredOffices[0].id;
|
|
}
|
|
}
|
|
|
|
// Initial update of office display
|
|
updateOfficeDisplay();
|
|
}
|
|
|
|
function getDateRange() {
|
|
let startDate = new Date(currentStartDate);
|
|
let endDate = new Date(currentStartDate);
|
|
|
|
if (viewMode === 'week') {
|
|
endDate.setDate(endDate.getDate() + 6);
|
|
} else {
|
|
// Month view - start from first day of month
|
|
startDate = new Date(currentStartDate.getFullYear(), currentStartDate.getMonth(), 1);
|
|
endDate = new Date(currentStartDate.getFullYear(), currentStartDate.getMonth() + 1, 0);
|
|
}
|
|
|
|
return { startDate, endDate };
|
|
}
|
|
|
|
async function loadTeamData() {
|
|
await loadClosingData();
|
|
const { startDate, endDate } = getDateRange();
|
|
const startStr = utils.formatDate(startDate);
|
|
const endStr = utils.formatDate(endDate);
|
|
|
|
let url = `/api/presence/team?start_date=${startStr}&end_date=${endStr}`;
|
|
|
|
const officeFilter = document.getElementById('officeFilter').value;
|
|
if (officeFilter) {
|
|
url += `&office_id=${officeFilter}`;
|
|
}
|
|
|
|
const response = await api.get(url);
|
|
if (response && response.ok) {
|
|
teamData = await response.json();
|
|
// Build parking lookup with spot names and assignment IDs
|
|
parkingDataLookup = {};
|
|
parkingAssignmentLookup = {};
|
|
teamData.forEach(member => {
|
|
if (member.parking_info) {
|
|
member.parking_info.forEach(p => {
|
|
const key = `${member.id}_${p.date}`;
|
|
parkingDataLookup[key] = p.spot_display_name || p.spot_id;
|
|
parkingAssignmentLookup[key] = p.id;
|
|
});
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
|
|
async function loadClosingData() {
|
|
officeClosingRules = {};
|
|
let officeIdsToLoad = [];
|
|
|
|
const selectedOfficeId = document.getElementById('officeFilter').value;
|
|
|
|
if (selectedOfficeId) {
|
|
officeIdsToLoad = [selectedOfficeId];
|
|
} else if (currentUser.role === 'employee' || (currentUser.role === 'manager' && currentUser.office_id)) {
|
|
officeIdsToLoad = [currentUser.office_id];
|
|
} else if (offices.length > 0) {
|
|
// Admin viewing all or Manager with access to list
|
|
officeIdsToLoad = offices.map(o => o.id);
|
|
}
|
|
|
|
if (officeIdsToLoad.length === 0) return;
|
|
|
|
// Fetch in parallel
|
|
const promises = officeIdsToLoad.map(async (oid) => {
|
|
try {
|
|
const [weeklyRes, specificRes] = await Promise.all([
|
|
api.get(`/api/offices/${oid}/weekly-closing-days`),
|
|
api.get(`/api/offices/${oid}/closing-days`)
|
|
]);
|
|
|
|
officeClosingRules[oid] = { weekly: [], specific: [] };
|
|
|
|
if (weeklyRes && weeklyRes.ok) {
|
|
const days = await weeklyRes.json();
|
|
officeClosingRules[oid].weekly = days.map(d => d.weekday);
|
|
}
|
|
|
|
if (specificRes && specificRes.ok) {
|
|
officeClosingRules[oid].specific = await specificRes.json();
|
|
|
|
// OPTIMIZATION: Pre-calculate all specific closed dates into a Set
|
|
const closedSet = new Set();
|
|
officeClosingRules[oid].specific.forEach(range => {
|
|
let start = new Date(range.date);
|
|
let end = range.end_date ? new Date(range.end_date) : new Date(range.date);
|
|
|
|
// Normalize to noon to avoid timezone issues when stepping
|
|
start.setHours(12, 0, 0, 0);
|
|
end.setHours(12, 0, 0, 0);
|
|
|
|
let current = new Date(start);
|
|
while (current <= end) {
|
|
closedSet.add(utils.formatDate(current));
|
|
current.setDate(current.getDate() + 1);
|
|
}
|
|
});
|
|
officeClosingRules[oid].closedDatesSet = closedSet;
|
|
}
|
|
} catch (e) {
|
|
console.error(`Error loading closing days for office ${oid}:`, e);
|
|
}
|
|
});
|
|
|
|
await Promise.all(promises);
|
|
}
|
|
|
|
function renderCalendar() {
|
|
const header = document.getElementById('calendarHeader');
|
|
const body = document.getElementById('calendarBody');
|
|
const { startDate, endDate } = getDateRange();
|
|
|
|
// Update header text
|
|
if (viewMode === 'week') {
|
|
document.getElementById('currentWeek').textContent =
|
|
`${utils.formatDateShort(startDate)} - ${utils.formatDateShort(endDate)}`;
|
|
} else {
|
|
document.getElementById('currentWeek').textContent =
|
|
`${utils.getMonthName(startDate.getMonth())} ${startDate.getFullYear()}`;
|
|
}
|
|
|
|
// Calculate number of days
|
|
const dayCount = Math.round((endDate - startDate) / (1000 * 60 * 60 * 24)) + 1;
|
|
|
|
// Build header row
|
|
const dayNames = ['Dom', 'Lun', 'Mar', 'Mer', 'Gio', 'Ven', 'Sab'];
|
|
let headerHtml = '<th>Nome</th><th>Ufficio</th>';
|
|
|
|
for (let i = 0; i < dayCount; i++) {
|
|
const date = new Date(startDate);
|
|
date.setDate(date.getDate() + i);
|
|
const dayOfWeek = date.getDay();
|
|
const isWeekend = dayOfWeek === 0 || dayOfWeek === 6;
|
|
const isHoliday = utils.isItalianHoliday(date);
|
|
const isToday = utils.formatDate(date) === utils.formatDate(new Date());
|
|
|
|
let classes = [];
|
|
if (isWeekend) classes.push('weekend');
|
|
if (isHoliday) classes.push('holiday');
|
|
if (isToday) classes.push('today');
|
|
|
|
if (isToday) classes.push('today');
|
|
|
|
// Header doesn't show closed status in multi-office view
|
|
// unless we want to check if ALL are closed?
|
|
// For now, simpler to leave header clean.
|
|
|
|
headerHtml += `<th class="${classes.join(' ')}">
|
|
<div>${dayNames[dayOfWeek].charAt(0)}</div>
|
|
<div class="day-number">${date.getDate()}</div>
|
|
</th>`;
|
|
}
|
|
header.innerHTML = headerHtml;
|
|
|
|
// Build body rows
|
|
if (teamData.length === 0) {
|
|
body.innerHTML = `<tr><td colspan="${dayCount + 2}" class="text-center">Nessun membro del team trovato</td></tr>`;
|
|
return;
|
|
}
|
|
|
|
let bodyHtml = '';
|
|
teamData.forEach(member => {
|
|
bodyHtml += `<tr>
|
|
<td class="member-name">${member.name || 'Unknown'}</td>
|
|
<td class="member-manager">${member.office_name || '-'}</td>`;
|
|
|
|
for (let i = 0; i < dayCount; i++) {
|
|
const date = new Date(startDate);
|
|
date.setDate(date.getDate() + i);
|
|
const dateStr = utils.formatDate(date);
|
|
const dayOfWeek = date.getDay();
|
|
const isWeekend = dayOfWeek === 0 || dayOfWeek === 6;
|
|
const isHoliday = utils.isItalianHoliday(date);
|
|
|
|
const presence = member.presences.find(p => p.date === dateStr);
|
|
const parkingKey = `${member.id}_${dateStr}`;
|
|
const parkingSpot = parkingDataLookup[parkingKey];
|
|
const hasParking = member.parking_dates && member.parking_dates.includes(dateStr);
|
|
const isToday = dateStr === utils.formatDate(new Date());
|
|
|
|
let cellClasses = ['calendar-cell'];
|
|
if (isWeekend) cellClasses.push('weekend');
|
|
if (isHoliday) cellClasses.push('holiday');
|
|
if (isToday) cellClasses.push('today');
|
|
if (presence) cellClasses.push(`status-${presence.status}`);
|
|
|
|
if (isToday) cellClasses.push('today');
|
|
if (presence) cellClasses.push(`status-${presence.status}`);
|
|
|
|
// Optimized closing day check
|
|
// Pre-calculate loop-invariant sets outside if not already done, but here we do it per-cell because of date dependency?
|
|
// BETTER: We should pre-calculate a "closedMap" for the viewed range for each office?
|
|
// OR: Just optimize the inner check.
|
|
|
|
// Optimization: Create a lookup string for the current date once
|
|
// (Already have dateStr)
|
|
|
|
const memberRules = officeClosingRules[member.office_id];
|
|
let isClosed = false;
|
|
|
|
if (memberRules) {
|
|
// Check weekly
|
|
if (memberRules.weekly.includes(dayOfWeek)) {
|
|
isClosed = true;
|
|
} else if (memberRules.specific && memberRules.specific.length > 0) {
|
|
// Check specific
|
|
// Optimization: Use the string date lookup if we had a Set, but we have ranges.
|
|
// We can optimize by converting ranges to Sets ONCE when loading data,
|
|
// OR just stick to this check if N is small.
|
|
// Given the "optimization" task, let's just make sure we don't do new Date() inside.
|
|
// The `specific` array contains objects with `date` and `end_date` strings.
|
|
// We can compare strings directly if format is YYYY-MM-DD and we are careful.
|
|
|
|
// Optimization: check if dateStr is in a Set of closed dates for this office?
|
|
// Let's implement the Set lookup logic in `loadClosingData` or `renderCalendar` start.
|
|
// For now, let's assume `memberRules.closedDatesSet` exists.
|
|
|
|
if (memberRules.closedDatesSet && memberRules.closedDatesSet.has(dateStr)) {
|
|
isClosed = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (isClosed) {
|
|
cellClasses.push('closed');
|
|
}
|
|
|
|
// Show parking badge instead of just 'P'
|
|
let parkingBadge = '';
|
|
if (hasParking) {
|
|
const spotName = parkingSpot || 'P';
|
|
parkingBadge = `<span class="parking-badge-sm">${spotName}</span>`;
|
|
}
|
|
|
|
bodyHtml += `<td class="${cellClasses.join(' ')}" data-user-id="${member.id}" data-date="${dateStr}" data-user-name="${member.name || ''}" ${isClosed ? 'data-closed="true"' : ''}>${parkingBadge}</td>`;
|
|
}
|
|
|
|
bodyHtml += '</tr>';
|
|
});
|
|
body.innerHTML = bodyHtml;
|
|
|
|
// Add click handlers to cells (only for admins and managers)
|
|
if (currentUser.role === 'admin' || currentUser.role === 'manager') {
|
|
body.querySelectorAll('.calendar-cell').forEach(cell => {
|
|
cell.style.cursor = 'pointer';
|
|
if (cell.dataset.closed !== 'true') {
|
|
cell.addEventListener('click', () => {
|
|
const userId = cell.dataset.userId;
|
|
const date = cell.dataset.date;
|
|
const userName = cell.dataset.userName;
|
|
openDayModal(userId, date, userName);
|
|
});
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
function openDayModal(userId, dateStr, userName) {
|
|
selectedUserId = userId;
|
|
selectedDate = dateStr;
|
|
|
|
// Find current status and parking
|
|
const member = teamData.find(m => m.id === userId);
|
|
const presence = member?.presences.find(p => p.date === dateStr);
|
|
const parkingKey = `${userId}_${dateStr}`;
|
|
const parkingSpot = parkingDataLookup[parkingKey];
|
|
const assignmentId = parkingAssignmentLookup[parkingKey];
|
|
currentAssignmentId = assignmentId; // Ensure this is set for modal logic
|
|
|
|
const parkingObj = assignmentId ? {
|
|
id: assignmentId,
|
|
spot_display_name: parkingSpot,
|
|
spot_id: parkingSpot
|
|
} : null;
|
|
|
|
ModalLogic.openModal({
|
|
dateStr,
|
|
userName,
|
|
presence,
|
|
parking: parkingObj,
|
|
userId
|
|
});
|
|
}
|
|
|
|
async function handleMarkPresence(status, date, userId) {
|
|
// userId passed from ModalLogic if provided, or use selectedUserId
|
|
const targetUserId = userId || selectedUserId;
|
|
if (!targetUserId) return;
|
|
|
|
const response = await api.post('/api/presence/admin/mark', {
|
|
user_id: targetUserId,
|
|
date: date,
|
|
status: status
|
|
});
|
|
|
|
if (response && response.ok) {
|
|
ModalLogic.closeModal();
|
|
await loadTeamData();
|
|
renderCalendar();
|
|
} else {
|
|
const error = await response.json();
|
|
alert(error.detail || 'Impossibile segnare la presenza');
|
|
}
|
|
}
|
|
|
|
async function handleClearPresence(date, userId) {
|
|
const targetUserId = userId || selectedUserId;
|
|
if (!targetUserId) return;
|
|
|
|
// confirm is not needed here if ModalLogic doesn't mandate it, but keeping logic
|
|
// ModalLogic buttons usually trigger this directly.
|
|
|
|
const response = await api.delete(`/api/presence/admin/${targetUserId}/${date}`);
|
|
if (response && response.ok) {
|
|
ModalLogic.closeModal();
|
|
await loadTeamData();
|
|
renderCalendar();
|
|
}
|
|
}
|
|
|
|
async function handleReleaseParking(assignmentId) {
|
|
if (!confirm('Rilasciare il parcheggio per questa data?')) return;
|
|
|
|
// Note: Admin endpoint for releasing ANY spot vs "my spot"
|
|
// Since we are admin/manager here, we might need a general release endpoint or use reassign with null?
|
|
// The current 'release_my_spot' is only for self.
|
|
// 'reassign_spot' with null user_id is the way for admins.
|
|
|
|
const response = await api.post('/api/parking/reassign-spot', {
|
|
assignment_id: assignmentId,
|
|
new_user_id: null // Release
|
|
});
|
|
|
|
if (response && response.ok) {
|
|
ModalLogic.closeModal();
|
|
await loadTeamData();
|
|
renderCalendar();
|
|
} else {
|
|
const error = await response.json();
|
|
alert(error.detail || 'Impossibile rilasciare il parcheggio');
|
|
}
|
|
}
|
|
|
|
|
|
async function handleReassignParking(assignmentId, newUserId) {
|
|
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 loadTeamData();
|
|
renderCalendar();
|
|
ModalLogic.closeModal();
|
|
} else {
|
|
const error = await response.json();
|
|
alert(error.detail || 'Impossibile riassegnare il parcheggio');
|
|
}
|
|
}
|
|
|
|
function setupEventListeners() {
|
|
// Navigation (prev/next)
|
|
document.getElementById('prevWeek').addEventListener('click', async () => {
|
|
if (viewMode === 'week') {
|
|
currentStartDate.setDate(currentStartDate.getDate() - 7);
|
|
} else {
|
|
currentStartDate.setMonth(currentStartDate.getMonth() - 1);
|
|
}
|
|
await loadTeamData();
|
|
renderCalendar();
|
|
});
|
|
|
|
document.getElementById('nextWeek').addEventListener('click', async () => {
|
|
if (viewMode === 'week') {
|
|
currentStartDate.setDate(currentStartDate.getDate() + 7);
|
|
} else {
|
|
currentStartDate.setMonth(currentStartDate.getMonth() + 1);
|
|
}
|
|
await loadTeamData();
|
|
renderCalendar();
|
|
});
|
|
|
|
// View toggle (week/month)
|
|
document.getElementById('viewToggle').addEventListener('change', async (e) => {
|
|
viewMode = e.target.value;
|
|
if (viewMode === 'month') {
|
|
// Set to first day of current month
|
|
currentStartDate = new Date(currentStartDate.getFullYear(), currentStartDate.getMonth(), 1);
|
|
} else {
|
|
// Set to current week start
|
|
const weekStartDay = currentUser.week_start_day || 1;
|
|
currentStartDate = utils.getWeekStart(new Date(), weekStartDay);
|
|
}
|
|
await loadTeamData();
|
|
renderCalendar();
|
|
});
|
|
|
|
|
|
// Office filter
|
|
document.getElementById('officeFilter').addEventListener('change', async () => {
|
|
updateOfficeDisplay(); // Update label on change
|
|
await loadTeamData();
|
|
renderCalendar();
|
|
});
|
|
|
|
utils.setupModalClose('dayModal');
|
|
}
|