Features: - Manager-centric parking spot management - Fair assignment algorithm (parking/presence ratio) - Presence tracking calendar - Closing days (specific & weekly recurring) - Guarantees and exclusions - Authelia/LLDAP integration for SSO Stack: - FastAPI backend - SQLite database - Vanilla JS frontend - Docker deployment
371 lines
13 KiB
JavaScript
371 lines
13 KiB
JavaScript
/**
|
|
* My Presence Page
|
|
* Personal calendar for marking daily presence
|
|
*/
|
|
|
|
let currentUser = null;
|
|
let currentDate = new Date();
|
|
let presenceData = {};
|
|
let parkingData = {};
|
|
let currentAssignmentId = null;
|
|
|
|
document.addEventListener('DOMContentLoaded', async () => {
|
|
if (!api.requireAuth()) return;
|
|
|
|
currentUser = await api.getCurrentUser();
|
|
if (!currentUser) return;
|
|
|
|
await Promise.all([loadPresences(), loadParkingAssignments()]);
|
|
renderCalendar();
|
|
setupEventListeners();
|
|
});
|
|
|
|
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;
|
|
});
|
|
}
|
|
}
|
|
|
|
function renderCalendar() {
|
|
const year = currentDate.getFullYear();
|
|
const month = currentDate.getMonth();
|
|
const weekStartDay = currentUser.week_start_day || 0; // 0=Sunday, 1=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 = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
|
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');
|
|
|
|
if (presence) {
|
|
cell.classList.add(`status-${presence.status}`);
|
|
}
|
|
|
|
// Show parking badge if assigned
|
|
const parkingBadge = parking
|
|
? `<span class="parking-badge">${parking.spot_display_name || parking.spot_id}</span>`
|
|
: '';
|
|
|
|
cell.innerHTML = `
|
|
<div class="day-number">${day}</div>
|
|
${parkingBadge}
|
|
`;
|
|
|
|
cell.addEventListener('click', () => openDayModal(dateStr, presence, parking));
|
|
grid.appendChild(cell);
|
|
}
|
|
}
|
|
|
|
function openDayModal(dateStr, presence, parking) {
|
|
const modal = document.getElementById('dayModal');
|
|
const title = document.getElementById('dayModalTitle');
|
|
|
|
title.textContent = utils.formatDateDisplay(dateStr);
|
|
|
|
// Highlight current status
|
|
document.querySelectorAll('.status-btn').forEach(btn => {
|
|
const status = btn.dataset.status;
|
|
if (presence && presence.status === status) {
|
|
btn.classList.add('active');
|
|
} else {
|
|
btn.classList.remove('active');
|
|
}
|
|
});
|
|
|
|
// Update parking section
|
|
const parkingSection = document.getElementById('parkingSection');
|
|
const parkingInfo = document.getElementById('parkingInfo');
|
|
const releaseBtn = document.getElementById('releaseParkingBtn');
|
|
|
|
if (parking) {
|
|
parkingSection.style.display = 'block';
|
|
const spotName = parking.spot_display_name || parking.spot_id;
|
|
parkingInfo.innerHTML = `<strong>Parking:</strong> Spot ${spotName}`;
|
|
releaseBtn.dataset.assignmentId = parking.id;
|
|
document.getElementById('reassignParkingBtn').dataset.assignmentId = parking.id;
|
|
currentAssignmentId = parking.id;
|
|
} else {
|
|
parkingSection.style.display = 'none';
|
|
}
|
|
|
|
modal.dataset.date = dateStr;
|
|
modal.style.display = 'flex';
|
|
}
|
|
|
|
async function markPresence(status) {
|
|
const modal = document.getElementById('dayModal');
|
|
const date = modal.dataset.date;
|
|
|
|
const response = await api.post('/api/presence/mark', { date, status });
|
|
if (response && response.ok) {
|
|
await Promise.all([loadPresences(), loadParkingAssignments()]);
|
|
renderCalendar();
|
|
modal.style.display = 'none';
|
|
} else {
|
|
const error = await response.json();
|
|
alert(error.detail || 'Failed to mark presence');
|
|
}
|
|
}
|
|
|
|
async function clearPresence() {
|
|
const modal = document.getElementById('dayModal');
|
|
const date = modal.dataset.date;
|
|
|
|
if (!confirm('Clear presence for this date?')) return;
|
|
|
|
const response = await api.delete(`/api/presence/${date}`);
|
|
if (response && response.ok) {
|
|
await Promise.all([loadPresences(), loadParkingAssignments()]);
|
|
renderCalendar();
|
|
modal.style.display = 'none';
|
|
}
|
|
}
|
|
|
|
async function releaseParking() {
|
|
const modal = document.getElementById('dayModal');
|
|
const releaseBtn = document.getElementById('releaseParkingBtn');
|
|
const assignmentId = releaseBtn.dataset.assignmentId;
|
|
|
|
if (!assignmentId) return;
|
|
if (!confirm('Release your parking spot for this date?')) return;
|
|
|
|
const response = await api.post(`/api/parking/release-my-spot/${assignmentId}`);
|
|
|
|
if (response && response.ok) {
|
|
await loadParkingAssignments();
|
|
renderCalendar();
|
|
modal.style.display = 'none';
|
|
} else {
|
|
const error = await response.json();
|
|
alert(error.detail || 'Failed to release parking spot');
|
|
}
|
|
}
|
|
|
|
async function openReassignModal() {
|
|
const assignmentId = currentAssignmentId;
|
|
if (!assignmentId) return;
|
|
|
|
// Load eligible users
|
|
const response = await api.get(`/api/parking/eligible-users/${assignmentId}`);
|
|
if (!response || !response.ok) {
|
|
const error = await response.json();
|
|
alert(error.detail || 'Failed to load eligible users');
|
|
return;
|
|
}
|
|
|
|
const users = await response.json();
|
|
const select = document.getElementById('reassignUser');
|
|
select.innerHTML = '<option value="">Select user...</option>';
|
|
|
|
if (users.length === 0) {
|
|
select.innerHTML = '<option value="">No eligible users available</option>';
|
|
} else {
|
|
users.forEach(user => {
|
|
const option = document.createElement('option');
|
|
option.value = user.id;
|
|
option.textContent = user.name;
|
|
select.appendChild(option);
|
|
});
|
|
}
|
|
|
|
// Get spot info from parking data
|
|
const parking = Object.values(parkingData).find(p => p.id === assignmentId);
|
|
if (parking) {
|
|
const spotName = parking.spot_display_name || parking.spot_id;
|
|
document.getElementById('reassignSpotInfo').textContent = `Spot ${spotName}`;
|
|
}
|
|
|
|
document.getElementById('dayModal').style.display = 'none';
|
|
document.getElementById('reassignModal').style.display = 'flex';
|
|
}
|
|
|
|
async function confirmReassign() {
|
|
const assignmentId = currentAssignmentId;
|
|
const newUserId = document.getElementById('reassignUser').value;
|
|
|
|
if (!assignmentId || !newUserId) {
|
|
alert('Please select a user');
|
|
return;
|
|
}
|
|
|
|
const response = await api.post('/api/parking/reassign-spot', {
|
|
assignment_id: assignmentId,
|
|
new_user_id: newUserId
|
|
});
|
|
|
|
if (response && response.ok) {
|
|
await loadParkingAssignments();
|
|
renderCalendar();
|
|
document.getElementById('reassignModal').style.display = 'none';
|
|
} else {
|
|
const error = await response.json();
|
|
alert(error.detail || 'Failed to reassign parking spot');
|
|
}
|
|
}
|
|
|
|
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();
|
|
});
|
|
|
|
// Day modal
|
|
document.getElementById('closeDayModal').addEventListener('click', () => {
|
|
document.getElementById('dayModal').style.display = 'none';
|
|
});
|
|
|
|
document.querySelectorAll('.status-btn').forEach(btn => {
|
|
btn.addEventListener('click', () => markPresence(btn.dataset.status));
|
|
});
|
|
|
|
document.getElementById('clearDayBtn').addEventListener('click', clearPresence);
|
|
document.getElementById('releaseParkingBtn').addEventListener('click', releaseParking);
|
|
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');
|
|
|
|
// Bulk mark
|
|
document.getElementById('bulkMarkBtn').addEventListener('click', () => {
|
|
document.getElementById('bulkMarkModal').style.display = 'flex';
|
|
});
|
|
|
|
document.getElementById('closeBulkModal').addEventListener('click', () => {
|
|
document.getElementById('bulkMarkModal').style.display = 'none';
|
|
});
|
|
|
|
document.getElementById('cancelBulk').addEventListener('click', () => {
|
|
document.getElementById('bulkMarkModal').style.display = 'none';
|
|
});
|
|
|
|
utils.setupModalClose('bulkMarkModal');
|
|
|
|
document.getElementById('bulkMarkForm').addEventListener('submit', async (e) => {
|
|
e.preventDefault();
|
|
|
|
const startDate = document.getElementById('startDate').value;
|
|
const endDate = document.getElementById('endDate').value;
|
|
const status = document.getElementById('bulkStatus').value;
|
|
const weekdaysOnly = document.getElementById('weekdaysOnly').checked;
|
|
|
|
const data = { start_date: startDate, end_date: endDate, status };
|
|
if (weekdaysOnly) {
|
|
data.days = [1, 2, 3, 4, 5]; // Mon-Fri (JS weekday)
|
|
}
|
|
|
|
const response = await api.post('/api/presence/mark-bulk', data);
|
|
if (response && response.ok) {
|
|
const results = await response.json();
|
|
alert(`Marked ${results.length} dates`);
|
|
document.getElementById('bulkMarkModal').style.display = 'none';
|
|
await Promise.all([loadPresences(), loadParkingAssignments()]);
|
|
renderCalendar();
|
|
} else {
|
|
const error = await response.json();
|
|
alert(error.detail || 'Failed to bulk mark');
|
|
}
|
|
});
|
|
}
|