Primo commit

This commit is contained in:
2026-01-13 11:20:12 +01:00
parent ce9e2fdf2a
commit 17453f5d13
51 changed files with 3883 additions and 2508 deletions

View File

@@ -1,72 +1,118 @@
/**
* Team Calendar Page
* Shows presence and parking for all team members
* Filtered by manager (manager-centric model)
* Filtered by office (office-centric model)
*/
let currentUser = null;
let currentStartDate = null;
let viewMode = 'week'; // 'week' or 'month'
let managers = [];
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 || 0;
const weekStartDay = currentUser.week_start_day || 1;
currentStartDate = utils.getWeekStart(new Date(), weekStartDay);
await loadManagers();
await loadOffices();
await loadTeamData();
await loadTeamData();
// Initialize Modal Logic
ModalLogic.init({
onMarkPresence: handleMarkPresence,
onClearPresence: handleClearPresence,
onReleaseParking: handleReleaseParking,
onReassignParking: handleReassignParking
});
renderCalendar();
setupEventListeners();
});
async function loadManagers() {
const response = await api.get('/api/managers');
if (response && response.ok) {
managers = await response.json();
const select = document.getElementById('managerFilter');
function updateOfficeDisplay() {
const display = document.getElementById('currentOfficeNameDisplay');
if (!display) return;
// Filter managers based on user role
let filteredManagers = managers;
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 themselves
filteredManagers = managers.filter(m => m.id === currentUser.id);
} else if (currentUser.role === 'employee') {
// Employee only sees their own manager
if (currentUser.manager_id) {
filteredManagers = managers.filter(m => m.id === currentUser.manager_id);
// 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 {
filteredManagers = [];
filteredOffices = [];
}
}
filteredManagers.forEach(manager => {
filteredOffices.forEach(office => {
const option = document.createElement('option');
option.value = manager.id;
const userCount = manager.managed_user_count || 0;
option.textContent = `${manager.name} (${userCount} users)`;
option.value = office.id;
option.textContent = `${office.name} (${office.user_count || 0} utenti)`;
select.appendChild(option);
});
// Auto-select for managers and employees (they only see their team)
if (filteredManagers.length === 1) {
select.value = filteredManagers[0].id;
}
// Hide manager filter for employees (they can only see their team)
if (currentUser.role === 'employee') {
select.style.display = 'none';
// Auto-select for managers
if (currentUser.role === 'manager' && filteredOffices.length === 1) {
select.value = filteredOffices[0].id;
}
}
// Initial update of office display
updateOfficeDisplay();
}
function getDateRange() {
@@ -85,15 +131,16 @@ function getDateRange() {
}
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 managerFilter = document.getElementById('managerFilter').value;
if (managerFilter) {
url += `&manager_id=${managerFilter}`;
const officeFilter = document.getElementById('officeFilter').value;
if (officeFilter) {
url += `&office_id=${officeFilter}`;
}
const response = await api.get(url);
@@ -114,6 +161,68 @@ async function loadTeamData() {
}
}
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');
@@ -132,8 +241,8 @@ function renderCalendar() {
const dayCount = Math.round((endDate - startDate) / (1000 * 60 * 60 * 24)) + 1;
// Build header row
const dayNames = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
let headerHtml = '<th>Name</th><th>Manager</th>';
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);
@@ -141,13 +250,19 @@ function renderCalendar() {
const dayOfWeek = date.getDay();
const isWeekend = dayOfWeek === 0 || dayOfWeek === 6;
const isHoliday = utils.isItalianHoliday(date);
const isToday = date.toDateString() === new Date().toDateString();
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>
@@ -157,7 +272,7 @@ function renderCalendar() {
// Build body rows
if (teamData.length === 0) {
body.innerHTML = `<tr><td colspan="${dayCount + 2}" class="text-center">No team members found</td></tr>`;
body.innerHTML = `<tr><td colspan="${dayCount + 2}" class="text-center">Nessun membro del team trovato</td></tr>`;
return;
}
@@ -165,7 +280,7 @@ function renderCalendar() {
teamData.forEach(member => {
bodyHtml += `<tr>
<td class="member-name">${member.name || 'Unknown'}</td>
<td class="member-manager">${member.manager_name || '-'}</td>`;
<td class="member-manager">${member.office_name || '-'}</td>`;
for (let i = 0; i < dayCount; i++) {
const date = new Date(startDate);
@@ -179,7 +294,7 @@ function renderCalendar() {
const parkingKey = `${member.id}_${dateStr}`;
const parkingSpot = parkingDataLookup[parkingKey];
const hasParking = member.parking_dates && member.parking_dates.includes(dateStr);
const isToday = date.toDateString() === new Date().toDateString();
const isToday = dateStr === utils.formatDate(new Date());
let cellClasses = ['calendar-cell'];
if (isWeekend) cellClasses.push('weekend');
@@ -187,6 +302,47 @@ function renderCalendar() {
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) {
@@ -194,7 +350,7 @@ function renderCalendar() {
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 || ''}">${parkingBadge}</td>`;
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>';
@@ -205,12 +361,14 @@ function renderCalendar() {
if (currentUser.role === 'admin' || currentUser.role === 'manager') {
body.querySelectorAll('.calendar-cell').forEach(cell => {
cell.style.cursor = 'pointer';
cell.addEventListener('click', () => {
const userId = cell.dataset.userId;
const date = cell.dataset.date;
const userName = cell.dataset.userName;
openDayModal(userId, date, userName);
});
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);
});
}
});
}
}
@@ -219,129 +377,107 @@ function openDayModal(userId, dateStr, userName) {
selectedUserId = userId;
selectedDate = dateStr;
const modal = document.getElementById('dayModal');
document.getElementById('dayModalTitle').textContent = utils.formatDateDisplay(dateStr);
document.getElementById('dayModalUser').textContent = userName;
// 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
// Highlight current status
document.querySelectorAll('#dayModal .status-btn').forEach(btn => {
const status = btn.dataset.status;
if (presence && presence.status === status) {
btn.classList.add('active');
} else {
btn.classList.remove('active');
}
const parkingObj = assignmentId ? {
id: assignmentId,
spot_display_name: parkingSpot,
spot_id: parkingSpot
} : null;
ModalLogic.openModal({
dateStr,
userName,
presence,
parking: parkingObj,
userId
});
// Update parking section
const parkingSection = document.getElementById('parkingSection');
const parkingInfo = document.getElementById('parkingInfo');
if (parkingSpot) {
parkingSection.style.display = 'block';
parkingInfo.innerHTML = `<strong>Parking:</strong> Spot ${parkingSpot}`;
currentAssignmentId = assignmentId;
} else {
parkingSection.style.display = 'none';
currentAssignmentId = null;
}
modal.style.display = 'flex';
}
async function markPresence(status) {
if (!selectedUserId || !selectedDate) return;
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: selectedUserId,
date: selectedDate,
user_id: targetUserId,
date: date,
status: status
});
if (response && response.ok) {
document.getElementById('dayModal').style.display = 'none';
ModalLogic.closeModal();
await loadTeamData();
renderCalendar();
} else {
const error = await response.json();
alert(error.detail || 'Failed to mark presence');
alert(error.detail || 'Impossibile segnare la presenza');
}
}
async function clearPresence() {
if (!selectedUserId || !selectedDate) return;
if (!confirm('Clear presence for this date?')) return;
async function handleClearPresence(date, userId) {
const targetUserId = userId || selectedUserId;
if (!targetUserId) return;
const response = await api.delete(`/api/presence/admin/${selectedUserId}/${selectedDate}`);
// 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) {
document.getElementById('dayModal').style.display = 'none';
ModalLogic.closeModal();
await loadTeamData();
renderCalendar();
}
}
async function openReassignModal() {
if (!currentAssignmentId) return;
async function handleReleaseParking(assignmentId) {
if (!confirm('Rilasciare il parcheggio per questa data?')) return;
// Load eligible users
const response = await api.get(`/api/parking/eligible-users/${currentAssignmentId}`);
if (!response || !response.ok) {
const error = await response.json();
alert(error.detail || 'Failed to load eligible users');
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 users = await response.json();
const select = document.getElementById('reassignUser');
select.innerHTML = '<option value="">Select user...</option>';
const response = await api.post('/api/parking/reassign-spot', {
assignment_id: assignmentId,
new_user_id: null // Release
});
if (users.length === 0) {
select.innerHTML = '<option value="">No eligible users available</option>';
if (response && response.ok) {
ModalLogic.closeModal();
await loadTeamData();
renderCalendar();
} else {
users.forEach(user => {
const option = document.createElement('option');
option.value = user.id;
option.textContent = user.name;
select.appendChild(option);
});
const error = await response.json();
alert(error.detail || 'Impossibile rilasciare il parcheggio');
}
// Get spot info
const parkingKey = `${selectedUserId}_${selectedDate}`;
const spotName = parkingDataLookup[parkingKey] || 'Unknown';
document.getElementById('reassignSpotInfo').textContent = `Spot ${spotName}`;
document.getElementById('dayModal').style.display = 'none';
document.getElementById('reassignModal').style.display = 'flex';
}
async function confirmReassign() {
const newUserId = document.getElementById('reassignUser').value;
if (!currentAssignmentId || !newUserId) {
alert('Please select a user');
async function handleReassignParking(assignmentId, newUserId) {
if (!assignmentId || !newUserId) {
alert('Seleziona un utente');
return;
}
const response = await api.post('/api/parking/reassign-spot', {
assignment_id: currentAssignmentId,
assignment_id: assignmentId,
new_user_id: newUserId
});
if (response && response.ok) {
await loadTeamData();
renderCalendar();
document.getElementById('reassignModal').style.display = 'none';
ModalLogic.closeModal();
} else {
const error = await response.json();
alert(error.detail || 'Failed to reassign parking spot');
alert(error.detail || 'Impossibile riassegnare il parcheggio');
}
}
@@ -375,40 +511,20 @@ function setupEventListeners() {
currentStartDate = new Date(currentStartDate.getFullYear(), currentStartDate.getMonth(), 1);
} else {
// Set to current week start
const weekStartDay = currentUser.week_start_day || 0;
const weekStartDay = currentUser.week_start_day || 1;
currentStartDate = utils.getWeekStart(new Date(), weekStartDay);
}
await loadTeamData();
renderCalendar();
});
// Manager filter
document.getElementById('managerFilter').addEventListener('change', async () => {
// Office filter
document.getElementById('officeFilter').addEventListener('change', async () => {
updateOfficeDisplay(); // Update label on change
await loadTeamData();
renderCalendar();
});
// Day modal
document.getElementById('closeDayModal').addEventListener('click', () => {
document.getElementById('dayModal').style.display = 'none';
});
document.querySelectorAll('#dayModal .status-btn').forEach(btn => {
btn.addEventListener('click', () => markPresence(btn.dataset.status));
});
document.getElementById('clearDayBtn').addEventListener('click', clearPresence);
document.getElementById('reassignParkingBtn').addEventListener('click', openReassignModal);
utils.setupModalClose('dayModal');
// Reassign modal
document.getElementById('closeReassignModal').addEventListener('click', () => {
document.getElementById('reassignModal').style.display = 'none';
});
document.getElementById('cancelReassign').addEventListener('click', () => {
document.getElementById('reassignModal').style.display = 'none';
});
document.getElementById('confirmReassign').addEventListener('click', confirmReassign);
utils.setupModalClose('reassignModal');
}