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
377 lines
14 KiB
JavaScript
377 lines
14 KiB
JavaScript
/**
|
|
* Office Rules Page
|
|
* Manage closing days, parking guarantees, and exclusions
|
|
*
|
|
* Rules are set at manager level and apply to all offices managed by that manager.
|
|
*/
|
|
|
|
let currentUser = null;
|
|
let selectedManagerId = null;
|
|
let managerUsers = [];
|
|
|
|
document.addEventListener('DOMContentLoaded', async () => {
|
|
if (!api.requireAuth()) return;
|
|
|
|
currentUser = await api.getCurrentUser();
|
|
if (!currentUser) return;
|
|
|
|
// Only managers and admins can access
|
|
if (currentUser.role === 'employee') {
|
|
window.location.href = '/presence';
|
|
return;
|
|
}
|
|
|
|
await loadManagers();
|
|
setupEventListeners();
|
|
});
|
|
|
|
async function loadManagers() {
|
|
const response = await api.get('/api/offices/managers/list');
|
|
if (response && response.ok) {
|
|
const managers = await response.json();
|
|
const select = document.getElementById('officeSelect');
|
|
|
|
// Filter to managers this user can see
|
|
let filteredManagers = managers;
|
|
if (currentUser.role === 'manager') {
|
|
// Manager only sees themselves
|
|
filteredManagers = managers.filter(m => m.id === currentUser.id);
|
|
}
|
|
|
|
// Show managers in dropdown
|
|
let totalManagers = 0;
|
|
let firstManagerId = null;
|
|
|
|
filteredManagers.forEach(manager => {
|
|
const option = document.createElement('option');
|
|
option.value = manager.id;
|
|
// Show manager name and count of offices
|
|
const officeCount = manager.offices.length;
|
|
const officeNames = manager.offices.map(o => o.name).join(', ');
|
|
if (officeCount > 0) {
|
|
option.textContent = `${manager.name} (${officeNames})`;
|
|
} else {
|
|
option.textContent = `${manager.name} (no offices)`;
|
|
}
|
|
select.appendChild(option);
|
|
totalManagers++;
|
|
if (!firstManagerId) firstManagerId = manager.id;
|
|
});
|
|
|
|
// Auto-select if only one manager
|
|
if (totalManagers === 1 && firstManagerId) {
|
|
select.value = firstManagerId;
|
|
await selectManager(firstManagerId);
|
|
}
|
|
}
|
|
}
|
|
|
|
async function selectManager(managerId) {
|
|
selectedManagerId = managerId;
|
|
|
|
if (!managerId) {
|
|
document.getElementById('rulesContent').style.display = 'none';
|
|
document.getElementById('noOfficeMessage').style.display = 'block';
|
|
return;
|
|
}
|
|
|
|
document.getElementById('rulesContent').style.display = 'block';
|
|
document.getElementById('noOfficeMessage').style.display = 'none';
|
|
|
|
await Promise.all([
|
|
loadWeeklyClosingDays(),
|
|
loadClosingDays(),
|
|
loadGuarantees(),
|
|
loadExclusions(),
|
|
loadManagerUsers()
|
|
]);
|
|
}
|
|
|
|
async function loadWeeklyClosingDays() {
|
|
const response = await api.get(`/api/offices/managers/${selectedManagerId}/weekly-closing-days`);
|
|
if (response && response.ok) {
|
|
const days = await response.json();
|
|
const weekdays = days.map(d => d.weekday);
|
|
|
|
// Update checkboxes
|
|
document.querySelectorAll('#weeklyClosingDays input[type="checkbox"]').forEach(cb => {
|
|
const weekday = parseInt(cb.dataset.weekday);
|
|
cb.checked = weekdays.includes(weekday);
|
|
});
|
|
}
|
|
}
|
|
|
|
async function loadManagerUsers() {
|
|
const response = await api.get(`/api/offices/managers/${selectedManagerId}/users`);
|
|
if (response && response.ok) {
|
|
managerUsers = await response.json();
|
|
updateUserSelects();
|
|
}
|
|
}
|
|
|
|
function updateUserSelects() {
|
|
['guaranteeUser', 'exclusionUser'].forEach(selectId => {
|
|
const select = document.getElementById(selectId);
|
|
select.innerHTML = '<option value="">Select user...</option>';
|
|
managerUsers.forEach(user => {
|
|
const option = document.createElement('option');
|
|
option.value = user.id;
|
|
option.textContent = user.name;
|
|
select.appendChild(option);
|
|
});
|
|
});
|
|
}
|
|
|
|
async function loadClosingDays() {
|
|
const response = await api.get(`/api/offices/managers/${selectedManagerId}/closing-days`);
|
|
const container = document.getElementById('closingDaysList');
|
|
|
|
if (response && response.ok) {
|
|
const days = await response.json();
|
|
if (days.length === 0) {
|
|
container.innerHTML = '';
|
|
return;
|
|
}
|
|
|
|
container.innerHTML = days.map(day => `
|
|
<div class="rule-item">
|
|
<div class="rule-info">
|
|
<span class="rule-date">${utils.formatDateDisplay(day.date)}</span>
|
|
${day.reason ? `<span class="rule-reason">${day.reason}</span>` : ''}
|
|
</div>
|
|
<button class="btn-icon btn-danger" onclick="deleteClosingDay('${day.id}')">
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<polyline points="3 6 5 6 21 6"></polyline>
|
|
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
`).join('');
|
|
}
|
|
}
|
|
|
|
function formatDateRange(startDate, endDate) {
|
|
if (!startDate && !endDate) return '';
|
|
if (startDate && !endDate) return `From ${utils.formatDateDisplay(startDate)}`;
|
|
if (!startDate && endDate) return `Until ${utils.formatDateDisplay(endDate)}`;
|
|
return `${utils.formatDateDisplay(startDate)} - ${utils.formatDateDisplay(endDate)}`;
|
|
}
|
|
|
|
async function loadGuarantees() {
|
|
const response = await api.get(`/api/offices/managers/${selectedManagerId}/guarantees`);
|
|
const container = document.getElementById('guaranteesList');
|
|
|
|
if (response && response.ok) {
|
|
const guarantees = await response.json();
|
|
if (guarantees.length === 0) {
|
|
container.innerHTML = '';
|
|
return;
|
|
}
|
|
|
|
container.innerHTML = guarantees.map(g => {
|
|
const dateRange = formatDateRange(g.start_date, g.end_date);
|
|
return `
|
|
<div class="rule-item">
|
|
<div class="rule-info">
|
|
<span class="rule-name">${g.user_name}</span>
|
|
${dateRange ? `<span class="rule-date-range">${dateRange}</span>` : ''}
|
|
</div>
|
|
<button class="btn-icon btn-danger" onclick="deleteGuarantee('${g.id}')">
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<polyline points="3 6 5 6 21 6"></polyline>
|
|
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
`}).join('');
|
|
}
|
|
}
|
|
|
|
async function loadExclusions() {
|
|
const response = await api.get(`/api/offices/managers/${selectedManagerId}/exclusions`);
|
|
const container = document.getElementById('exclusionsList');
|
|
|
|
if (response && response.ok) {
|
|
const exclusions = await response.json();
|
|
if (exclusions.length === 0) {
|
|
container.innerHTML = '';
|
|
return;
|
|
}
|
|
|
|
container.innerHTML = exclusions.map(e => {
|
|
const dateRange = formatDateRange(e.start_date, e.end_date);
|
|
return `
|
|
<div class="rule-item">
|
|
<div class="rule-info">
|
|
<span class="rule-name">${e.user_name}</span>
|
|
${dateRange ? `<span class="rule-date-range">${dateRange}</span>` : ''}
|
|
</div>
|
|
<button class="btn-icon btn-danger" onclick="deleteExclusion('${e.id}')">
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<polyline points="3 6 5 6 21 6"></polyline>
|
|
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
`}).join('');
|
|
}
|
|
}
|
|
|
|
// Delete functions
|
|
async function deleteClosingDay(id) {
|
|
if (!confirm('Delete this closing day?')) return;
|
|
const response = await api.delete(`/api/offices/managers/${selectedManagerId}/closing-days/${id}`);
|
|
if (response && response.ok) {
|
|
await loadClosingDays();
|
|
}
|
|
}
|
|
|
|
async function deleteGuarantee(id) {
|
|
if (!confirm('Remove this parking guarantee?')) return;
|
|
const response = await api.delete(`/api/offices/managers/${selectedManagerId}/guarantees/${id}`);
|
|
if (response && response.ok) {
|
|
await loadGuarantees();
|
|
}
|
|
}
|
|
|
|
async function deleteExclusion(id) {
|
|
if (!confirm('Remove this parking exclusion?')) return;
|
|
const response = await api.delete(`/api/offices/managers/${selectedManagerId}/exclusions/${id}`);
|
|
if (response && response.ok) {
|
|
await loadExclusions();
|
|
}
|
|
}
|
|
|
|
function setupEventListeners() {
|
|
// Manager selection
|
|
document.getElementById('officeSelect').addEventListener('change', (e) => {
|
|
selectManager(e.target.value);
|
|
});
|
|
|
|
// Weekly closing day checkboxes
|
|
document.querySelectorAll('#weeklyClosingDays input[type="checkbox"]').forEach(cb => {
|
|
cb.addEventListener('change', async (e) => {
|
|
const weekday = parseInt(e.target.dataset.weekday);
|
|
|
|
if (e.target.checked) {
|
|
// Add weekly closing day
|
|
const response = await api.post(`/api/offices/managers/${selectedManagerId}/weekly-closing-days`, { weekday });
|
|
if (!response || !response.ok) {
|
|
e.target.checked = false;
|
|
const error = await response.json();
|
|
alert(error.detail || 'Failed to add weekly closing day');
|
|
}
|
|
} else {
|
|
// Remove weekly closing day - need to find the ID first
|
|
const getResponse = await api.get(`/api/offices/managers/${selectedManagerId}/weekly-closing-days`);
|
|
if (getResponse && getResponse.ok) {
|
|
const days = await getResponse.json();
|
|
const day = days.find(d => d.weekday === weekday);
|
|
if (day) {
|
|
const deleteResponse = await api.delete(`/api/offices/managers/${selectedManagerId}/weekly-closing-days/${day.id}`);
|
|
if (!deleteResponse || !deleteResponse.ok) {
|
|
e.target.checked = true;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
// Modal openers
|
|
document.getElementById('addClosingDayBtn').addEventListener('click', () => {
|
|
document.getElementById('closingDayForm').reset();
|
|
document.getElementById('closingDayModal').style.display = 'flex';
|
|
});
|
|
|
|
document.getElementById('addGuaranteeBtn').addEventListener('click', () => {
|
|
document.getElementById('guaranteeForm').reset();
|
|
document.getElementById('guaranteeModal').style.display = 'flex';
|
|
});
|
|
|
|
document.getElementById('addExclusionBtn').addEventListener('click', () => {
|
|
document.getElementById('exclusionForm').reset();
|
|
document.getElementById('exclusionModal').style.display = 'flex';
|
|
});
|
|
|
|
// Modal closers
|
|
['closeClosingDayModal', 'cancelClosingDay'].forEach(id => {
|
|
document.getElementById(id).addEventListener('click', () => {
|
|
document.getElementById('closingDayModal').style.display = 'none';
|
|
});
|
|
});
|
|
|
|
['closeGuaranteeModal', 'cancelGuarantee'].forEach(id => {
|
|
document.getElementById(id).addEventListener('click', () => {
|
|
document.getElementById('guaranteeModal').style.display = 'none';
|
|
});
|
|
});
|
|
|
|
['closeExclusionModal', 'cancelExclusion'].forEach(id => {
|
|
document.getElementById(id).addEventListener('click', () => {
|
|
document.getElementById('exclusionModal').style.display = 'none';
|
|
});
|
|
});
|
|
|
|
// Form submissions
|
|
document.getElementById('closingDayForm').addEventListener('submit', async (e) => {
|
|
e.preventDefault();
|
|
const data = {
|
|
date: document.getElementById('closingDate').value,
|
|
reason: document.getElementById('closingReason').value || null
|
|
};
|
|
const response = await api.post(`/api/offices/managers/${selectedManagerId}/closing-days`, data);
|
|
if (response && response.ok) {
|
|
document.getElementById('closingDayModal').style.display = 'none';
|
|
await loadClosingDays();
|
|
} else {
|
|
const error = await response.json();
|
|
alert(error.detail || 'Failed to add closing day');
|
|
}
|
|
});
|
|
|
|
document.getElementById('guaranteeForm').addEventListener('submit', async (e) => {
|
|
e.preventDefault();
|
|
const data = {
|
|
user_id: document.getElementById('guaranteeUser').value,
|
|
start_date: document.getElementById('guaranteeStartDate').value || null,
|
|
end_date: document.getElementById('guaranteeEndDate').value || null
|
|
};
|
|
const response = await api.post(`/api/offices/managers/${selectedManagerId}/guarantees`, data);
|
|
if (response && response.ok) {
|
|
document.getElementById('guaranteeModal').style.display = 'none';
|
|
await loadGuarantees();
|
|
} else {
|
|
const error = await response.json();
|
|
alert(error.detail || 'Failed to add guarantee');
|
|
}
|
|
});
|
|
|
|
document.getElementById('exclusionForm').addEventListener('submit', async (e) => {
|
|
e.preventDefault();
|
|
const data = {
|
|
user_id: document.getElementById('exclusionUser').value,
|
|
start_date: document.getElementById('exclusionStartDate').value || null,
|
|
end_date: document.getElementById('exclusionEndDate').value || null
|
|
};
|
|
const response = await api.post(`/api/offices/managers/${selectedManagerId}/exclusions`, data);
|
|
if (response && response.ok) {
|
|
document.getElementById('exclusionModal').style.display = 'none';
|
|
await loadExclusions();
|
|
} else {
|
|
const error = await response.json();
|
|
alert(error.detail || 'Failed to add exclusion');
|
|
}
|
|
});
|
|
|
|
// Modal background clicks
|
|
utils.setupModalClose('closingDayModal');
|
|
utils.setupModalClose('guaranteeModal');
|
|
utils.setupModalClose('exclusionModal');
|
|
}
|
|
|
|
// Make delete functions globally accessible
|
|
window.deleteClosingDay = deleteClosingDay;
|
|
window.deleteGuarantee = deleteGuarantee;
|
|
window.deleteExclusion = deleteExclusion;
|