Initial commit: Parking Manager
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
This commit is contained in:
1749
frontend/css/styles.css
Normal file
1749
frontend/css/styles.css
Normal file
File diff suppressed because it is too large
Load Diff
4
frontend/favicon.svg
Normal file
4
frontend/favicon.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||
<rect width="100" height="100" rx="20" fill="#1a1a1a"/>
|
||||
<text x="50" y="70" font-family="Arial, sans-serif" font-size="60" font-weight="bold" fill="white" text-anchor="middle">P</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 259 B |
247
frontend/js/admin-users.js
Normal file
247
frontend/js/admin-users.js
Normal file
@@ -0,0 +1,247 @@
|
||||
/**
|
||||
* Admin Users Page
|
||||
* Manage all users in the system
|
||||
*/
|
||||
|
||||
let currentUser = null;
|
||||
let users = [];
|
||||
let offices = [];
|
||||
let managedOfficesMap = {}; // user_id -> [office_ids]
|
||||
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
if (!api.requireAuth()) return;
|
||||
|
||||
currentUser = await api.getCurrentUser();
|
||||
if (!currentUser) return;
|
||||
|
||||
// Only admins can access
|
||||
if (currentUser.role !== 'admin') {
|
||||
window.location.href = '/presence';
|
||||
return;
|
||||
}
|
||||
|
||||
await Promise.all([loadUsers(), loadOffices()]);
|
||||
await loadManagedOffices();
|
||||
renderUsers();
|
||||
setupEventListeners();
|
||||
});
|
||||
|
||||
async function loadUsers() {
|
||||
const response = await api.get('/api/users');
|
||||
if (response && response.ok) {
|
||||
users = await response.json();
|
||||
}
|
||||
}
|
||||
|
||||
async function loadOffices() {
|
||||
const response = await api.get('/api/offices');
|
||||
if (response && response.ok) {
|
||||
offices = await response.json();
|
||||
const select = document.getElementById('editOffice');
|
||||
offices.forEach(office => {
|
||||
const option = document.createElement('option');
|
||||
option.value = office.id;
|
||||
option.textContent = office.name;
|
||||
select.appendChild(option);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function loadManagedOffices() {
|
||||
// Load managed offices for all managers
|
||||
const response = await api.get('/api/offices/managers/list');
|
||||
if (response && response.ok) {
|
||||
const managers = await response.json();
|
||||
managedOfficesMap = {};
|
||||
managers.forEach(m => {
|
||||
managedOfficesMap[m.id] = m.offices.map(o => o.id);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function getManagedOfficeNames(userId) {
|
||||
const officeIds = managedOfficesMap[userId] || [];
|
||||
if (officeIds.length === 0) return '-';
|
||||
return officeIds.map(id => {
|
||||
const office = offices.find(o => o.id === id);
|
||||
return office ? office.name : id;
|
||||
}).join(', ');
|
||||
}
|
||||
|
||||
function renderUsers(filter = '') {
|
||||
const tbody = document.getElementById('usersBody');
|
||||
const filtered = filter
|
||||
? users.filter(u =>
|
||||
u.name.toLowerCase().includes(filter.toLowerCase()) ||
|
||||
u.email.toLowerCase().includes(filter.toLowerCase())
|
||||
)
|
||||
: users;
|
||||
|
||||
if (filtered.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="8" class="text-center">No users found</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = filtered.map(user => {
|
||||
const office = offices.find(o => o.id === user.office_id);
|
||||
const isManager = user.role === 'manager';
|
||||
const managedOffices = isManager ? getManagedOfficeNames(user.id) : '-';
|
||||
return `
|
||||
<tr>
|
||||
<td>${user.name}</td>
|
||||
<td>${user.email}</td>
|
||||
<td><span class="badge badge-${user.role}">${user.role}</span></td>
|
||||
<td>${office ? office.name : '-'}</td>
|
||||
<td>${managedOffices}</td>
|
||||
<td>${isManager ? (user.manager_parking_quota || 0) : '-'}</td>
|
||||
<td>${isManager ? (user.manager_spot_prefix || '-') : '-'}</td>
|
||||
<td>
|
||||
<button class="btn btn-sm btn-secondary" onclick="editUser('${user.id}')">Edit</button>
|
||||
${user.id !== currentUser.id ? `
|
||||
<button class="btn btn-sm btn-danger" onclick="deleteUser('${user.id}')">Delete</button>
|
||||
` : ''}
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function editUser(userId) {
|
||||
const user = users.find(u => u.id === userId);
|
||||
if (!user) return;
|
||||
|
||||
document.getElementById('userId').value = user.id;
|
||||
document.getElementById('editName').value = user.name;
|
||||
document.getElementById('editEmail').value = user.email;
|
||||
document.getElementById('editRole').value = user.role;
|
||||
document.getElementById('editOffice').value = user.office_id || '';
|
||||
document.getElementById('editQuota').value = user.manager_parking_quota || 0;
|
||||
document.getElementById('editPrefix').value = user.manager_spot_prefix || '';
|
||||
|
||||
const isManager = user.role === 'manager';
|
||||
|
||||
// Show/hide manager fields based on role
|
||||
document.getElementById('quotaGroup').style.display = isManager ? 'block' : 'none';
|
||||
document.getElementById('prefixGroup').style.display = isManager ? 'block' : 'none';
|
||||
document.getElementById('managedOfficesGroup').style.display = isManager ? 'block' : 'none';
|
||||
|
||||
// Build managed offices checkboxes
|
||||
const checkboxContainer = document.getElementById('managedOfficesCheckboxes');
|
||||
const userManagedOffices = managedOfficesMap[userId] || [];
|
||||
checkboxContainer.innerHTML = offices.map(office => `
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" name="managedOffice" value="${office.id}"
|
||||
${userManagedOffices.includes(office.id) ? 'checked' : ''}>
|
||||
${office.name}
|
||||
</label>
|
||||
`).join('');
|
||||
|
||||
document.getElementById('userModal').style.display = 'flex';
|
||||
}
|
||||
|
||||
async function deleteUser(userId) {
|
||||
const user = users.find(u => u.id === userId);
|
||||
if (!user) return;
|
||||
|
||||
if (!confirm(`Delete user "${user.name}"? This cannot be undone.`)) return;
|
||||
|
||||
const response = await api.delete(`/api/users/${userId}`);
|
||||
if (response && response.ok) {
|
||||
await loadUsers();
|
||||
await loadManagedOffices();
|
||||
renderUsers();
|
||||
utils.showMessage('User deleted', 'success');
|
||||
} else {
|
||||
const error = await response.json();
|
||||
utils.showMessage(error.detail || 'Failed to delete user', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function setupEventListeners() {
|
||||
// Search
|
||||
document.getElementById('searchInput').addEventListener('input', (e) => {
|
||||
renderUsers(e.target.value);
|
||||
});
|
||||
|
||||
// Modal
|
||||
document.getElementById('closeUserModal').addEventListener('click', () => {
|
||||
document.getElementById('userModal').style.display = 'none';
|
||||
});
|
||||
|
||||
document.getElementById('cancelUser').addEventListener('click', () => {
|
||||
document.getElementById('userModal').style.display = 'none';
|
||||
});
|
||||
|
||||
// Role change shows/hides manager fields
|
||||
document.getElementById('editRole').addEventListener('change', (e) => {
|
||||
const isManager = e.target.value === 'manager';
|
||||
document.getElementById('quotaGroup').style.display = isManager ? 'block' : 'none';
|
||||
document.getElementById('prefixGroup').style.display = isManager ? 'block' : 'none';
|
||||
document.getElementById('managedOfficesGroup').style.display = isManager ? 'block' : 'none';
|
||||
});
|
||||
|
||||
// Form submit
|
||||
document.getElementById('userForm').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const userId = document.getElementById('userId').value;
|
||||
const role = document.getElementById('editRole').value;
|
||||
|
||||
const data = {
|
||||
name: document.getElementById('editName').value,
|
||||
role: role,
|
||||
office_id: document.getElementById('editOffice').value || null
|
||||
};
|
||||
|
||||
if (role === 'manager') {
|
||||
data.manager_parking_quota = parseInt(document.getElementById('editQuota').value) || 0;
|
||||
data.manager_spot_prefix = document.getElementById('editPrefix').value.toUpperCase() || null;
|
||||
}
|
||||
|
||||
const response = await api.put(`/api/users/${userId}`, data);
|
||||
if (response && response.ok) {
|
||||
// Update managed offices if manager
|
||||
if (role === 'manager') {
|
||||
await updateManagedOffices(userId);
|
||||
}
|
||||
|
||||
document.getElementById('userModal').style.display = 'none';
|
||||
await loadUsers();
|
||||
await loadManagedOffices();
|
||||
renderUsers();
|
||||
utils.showMessage('User updated', 'success');
|
||||
} else {
|
||||
const error = await response.json();
|
||||
utils.showMessage(error.detail || 'Failed to update user', 'error');
|
||||
}
|
||||
});
|
||||
|
||||
utils.setupModalClose('userModal');
|
||||
}
|
||||
|
||||
async function updateManagedOffices(userId) {
|
||||
// Get currently selected offices
|
||||
const checkboxes = document.querySelectorAll('input[name="managedOffice"]:checked');
|
||||
const selectedOfficeIds = Array.from(checkboxes).map(cb => cb.value);
|
||||
|
||||
// Get current managed offices
|
||||
const currentOfficeIds = managedOfficesMap[userId] || [];
|
||||
|
||||
// Find offices to add and remove
|
||||
const toAdd = selectedOfficeIds.filter(id => !currentOfficeIds.includes(id));
|
||||
const toRemove = currentOfficeIds.filter(id => !selectedOfficeIds.includes(id));
|
||||
|
||||
// Add new memberships
|
||||
for (const officeId of toAdd) {
|
||||
await api.post(`/api/offices/${officeId}/managers`, { user_id: userId });
|
||||
}
|
||||
|
||||
// Remove old memberships
|
||||
for (const officeId of toRemove) {
|
||||
await api.delete(`/api/offices/${officeId}/managers/${userId}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Make functions globally accessible
|
||||
window.editUser = editUser;
|
||||
window.deleteUser = deleteUser;
|
||||
174
frontend/js/api.js
Normal file
174
frontend/js/api.js
Normal file
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* API Client Wrapper
|
||||
* Centralized API communication with auth handling
|
||||
*/
|
||||
|
||||
const api = {
|
||||
/**
|
||||
* Get the auth token from localStorage
|
||||
*/
|
||||
getToken() {
|
||||
return localStorage.getItem('access_token');
|
||||
},
|
||||
|
||||
/**
|
||||
* Set the auth token
|
||||
*/
|
||||
setToken(token) {
|
||||
localStorage.setItem('access_token', token);
|
||||
},
|
||||
|
||||
/**
|
||||
* Clear the auth token
|
||||
*/
|
||||
clearToken() {
|
||||
localStorage.removeItem('access_token');
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if user is authenticated
|
||||
*/
|
||||
isAuthenticated() {
|
||||
return !!this.getToken();
|
||||
},
|
||||
|
||||
/**
|
||||
* Redirect to login if not authenticated
|
||||
*/
|
||||
requireAuth() {
|
||||
if (!this.isAuthenticated()) {
|
||||
window.location.href = '/login';
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
|
||||
/**
|
||||
* Make an API request
|
||||
*/
|
||||
async request(method, url, data = null) {
|
||||
const options = {
|
||||
method,
|
||||
headers: {}
|
||||
};
|
||||
|
||||
const token = this.getToken();
|
||||
if (token) {
|
||||
options.headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
if (data) {
|
||||
options.headers['Content-Type'] = 'application/json';
|
||||
options.body = JSON.stringify(data);
|
||||
}
|
||||
|
||||
const response = await fetch(url, options);
|
||||
|
||||
// Handle 401 - redirect to login
|
||||
if (response.status === 401) {
|
||||
this.clearToken();
|
||||
window.location.href = '/login';
|
||||
return null;
|
||||
}
|
||||
|
||||
return response;
|
||||
},
|
||||
|
||||
/**
|
||||
* GET request
|
||||
*/
|
||||
async get(url) {
|
||||
return this.request('GET', url);
|
||||
},
|
||||
|
||||
/**
|
||||
* POST request
|
||||
*/
|
||||
async post(url, data) {
|
||||
return this.request('POST', url, data);
|
||||
},
|
||||
|
||||
/**
|
||||
* PUT request
|
||||
*/
|
||||
async put(url, data) {
|
||||
return this.request('PUT', url, data);
|
||||
},
|
||||
|
||||
/**
|
||||
* PATCH request
|
||||
*/
|
||||
async patch(url, data) {
|
||||
return this.request('PATCH', url, data);
|
||||
},
|
||||
|
||||
/**
|
||||
* DELETE request
|
||||
*/
|
||||
async delete(url) {
|
||||
return this.request('DELETE', url);
|
||||
},
|
||||
|
||||
/**
|
||||
* Get current user info
|
||||
*/
|
||||
async getCurrentUser() {
|
||||
const response = await this.get('/api/auth/me');
|
||||
if (response && response.ok) {
|
||||
return await response.json();
|
||||
}
|
||||
return null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Login
|
||||
*/
|
||||
async login(email, password) {
|
||||
const response = await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, password })
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
this.setToken(data.access_token);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
const error = await response.json();
|
||||
return { success: false, error: error.detail || 'Login failed' };
|
||||
},
|
||||
|
||||
/**
|
||||
* Register
|
||||
*/
|
||||
async register(email, password, name, officeId = null) {
|
||||
const response = await fetch('/api/auth/register', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, password, name, office_id: officeId })
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
this.setToken(data.access_token);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
const error = await response.json();
|
||||
return { success: false, error: error.detail || 'Registration failed' };
|
||||
},
|
||||
|
||||
/**
|
||||
* Logout
|
||||
*/
|
||||
async logout() {
|
||||
await this.post('/api/auth/logout', {});
|
||||
this.clearToken();
|
||||
window.location.href = '/login';
|
||||
}
|
||||
};
|
||||
|
||||
// Make globally available
|
||||
window.api = api;
|
||||
183
frontend/js/nav.js
Normal file
183
frontend/js/nav.js
Normal file
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* Navigation Component
|
||||
* Sidebar navigation and user menu
|
||||
*/
|
||||
|
||||
const MENU_ICON = `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="3" y1="6" x2="21" y2="6"></line>
|
||||
<line x1="3" y1="12" x2="21" y2="12"></line>
|
||||
<line x1="3" y1="18" x2="21" y2="18"></line>
|
||||
</svg>`;
|
||||
|
||||
const ICONS = {
|
||||
calendar: `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect>
|
||||
<line x1="16" y1="2" x2="16" y2="6"></line>
|
||||
<line x1="8" y1="2" x2="8" y2="6"></line>
|
||||
<line x1="3" y1="10" x2="21" y2="10"></line>
|
||||
</svg>`,
|
||||
users: `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path>
|
||||
<circle cx="9" cy="7" r="4"></circle>
|
||||
<path d="M23 21v-2a4 4 0 0 0-3-3.87"></path>
|
||||
<path d="M16 3.13a4 4 0 0 1 0 7.75"></path>
|
||||
</svg>`,
|
||||
rules: `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M9 11l3 3L22 4"></path>
|
||||
<path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"></path>
|
||||
</svg>`,
|
||||
user: `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path>
|
||||
<circle cx="12" cy="7" r="4"></circle>
|
||||
</svg>`,
|
||||
building: `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="6" y="2" width="12" height="20"></rect>
|
||||
<rect x="9" y="6" width="1.5" height="1.5" fill="currentColor"></rect>
|
||||
<rect x="13.5" y="6" width="1.5" height="1.5" fill="currentColor"></rect>
|
||||
<rect x="9" y="11" width="1.5" height="1.5" fill="currentColor"></rect>
|
||||
<rect x="13.5" y="11" width="1.5" height="1.5" fill="currentColor"></rect>
|
||||
<rect x="9" y="16" width="1.5" height="1.5" fill="currentColor"></rect>
|
||||
<rect x="13.5" y="16" width="1.5" height="1.5" fill="currentColor"></rect>
|
||||
</svg>`
|
||||
};
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ href: '/presence', icon: 'calendar', label: 'My Presence' },
|
||||
{ href: '/team-calendar', icon: 'users', label: 'Team Calendar', roles: ['admin', 'manager'] },
|
||||
{ href: '/office-rules', icon: 'rules', label: 'Office Rules', roles: ['admin', 'manager'] },
|
||||
{ href: '/admin/users', icon: 'user', label: 'Manage Users', roles: ['admin'] }
|
||||
];
|
||||
|
||||
function getIcon(name) {
|
||||
return ICONS[name] || '';
|
||||
}
|
||||
|
||||
function canAccessNavItem(item, userRole) {
|
||||
if (!item.roles || item.roles.length === 0) return true;
|
||||
return userRole && item.roles.includes(userRole);
|
||||
}
|
||||
|
||||
function renderNav(currentPath, userRole) {
|
||||
return NAV_ITEMS
|
||||
.filter(item => canAccessNavItem(item, userRole))
|
||||
.map(item => {
|
||||
const isActive = item.href === currentPath ||
|
||||
(currentPath !== '/' && item.href !== '/' && currentPath.startsWith(item.href));
|
||||
const activeClass = isActive ? ' active' : '';
|
||||
|
||||
return `<a href="${item.href}" class="nav-item${activeClass}">
|
||||
${getIcon(item.icon)}
|
||||
<span>${item.label}</span>
|
||||
</a>`;
|
||||
}).join('\n');
|
||||
}
|
||||
|
||||
async function initNav() {
|
||||
const navContainer = document.querySelector('.sidebar-nav');
|
||||
if (!navContainer) return;
|
||||
|
||||
const currentPath = window.location.pathname;
|
||||
let userRole = null;
|
||||
let currentUser = null;
|
||||
|
||||
// Get user info
|
||||
if (api.isAuthenticated()) {
|
||||
currentUser = await api.getCurrentUser();
|
||||
if (currentUser) {
|
||||
userRole = currentUser.role;
|
||||
}
|
||||
}
|
||||
|
||||
// Render navigation
|
||||
navContainer.innerHTML = renderNav(currentPath, userRole);
|
||||
|
||||
// Update user info in sidebar
|
||||
if (currentUser) {
|
||||
const userName = document.getElementById('userName');
|
||||
const userRole = document.getElementById('userRole');
|
||||
if (userName) userName.textContent = currentUser.name || 'User';
|
||||
if (userRole) userRole.textContent = currentUser.role || '-';
|
||||
}
|
||||
|
||||
// Setup user menu
|
||||
setupUserMenu();
|
||||
|
||||
// Setup mobile menu
|
||||
setupMobileMenu();
|
||||
}
|
||||
|
||||
function setupMobileMenu() {
|
||||
const sidebar = document.querySelector('.sidebar');
|
||||
const pageHeader = document.querySelector('.page-header');
|
||||
if (!sidebar || !pageHeader) return;
|
||||
|
||||
// Add menu toggle button to page header (at the start)
|
||||
const menuToggle = document.createElement('button');
|
||||
menuToggle.className = 'menu-toggle';
|
||||
menuToggle.innerHTML = MENU_ICON;
|
||||
menuToggle.setAttribute('aria-label', 'Toggle menu');
|
||||
pageHeader.insertBefore(menuToggle, pageHeader.firstChild);
|
||||
|
||||
// Add overlay
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'sidebar-overlay';
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
// Toggle sidebar
|
||||
menuToggle.addEventListener('click', () => {
|
||||
sidebar.classList.toggle('open');
|
||||
overlay.classList.toggle('open');
|
||||
});
|
||||
|
||||
// Close sidebar when clicking overlay
|
||||
overlay.addEventListener('click', () => {
|
||||
sidebar.classList.remove('open');
|
||||
overlay.classList.remove('open');
|
||||
});
|
||||
|
||||
// Close sidebar when clicking a nav item (on mobile)
|
||||
sidebar.querySelectorAll('.nav-item').forEach(item => {
|
||||
item.addEventListener('click', () => {
|
||||
if (window.innerWidth <= 768) {
|
||||
sidebar.classList.remove('open');
|
||||
overlay.classList.remove('open');
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function setupUserMenu() {
|
||||
const userMenuButton = document.getElementById('userMenuButton');
|
||||
const userDropdown = document.getElementById('userDropdown');
|
||||
const logoutButton = document.getElementById('logoutButton');
|
||||
|
||||
if (userMenuButton && userDropdown) {
|
||||
userMenuButton.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const isOpen = userDropdown.style.display === 'block';
|
||||
userDropdown.style.display = isOpen ? 'none' : 'block';
|
||||
});
|
||||
|
||||
document.addEventListener('click', () => {
|
||||
userDropdown.style.display = 'none';
|
||||
});
|
||||
|
||||
userDropdown.addEventListener('click', (e) => e.stopPropagation());
|
||||
}
|
||||
|
||||
if (logoutButton) {
|
||||
logoutButton.addEventListener('click', () => {
|
||||
api.logout();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Export for use in other scripts
|
||||
window.getIcon = getIcon;
|
||||
|
||||
// Auto-initialize
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', initNav);
|
||||
} else {
|
||||
initNav();
|
||||
}
|
||||
376
frontend/js/office-rules.js
Normal file
376
frontend/js/office-rules.js
Normal file
@@ -0,0 +1,376 @@
|
||||
/**
|
||||
* 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;
|
||||
370
frontend/js/presence.js
Normal file
370
frontend/js/presence.js
Normal file
@@ -0,0 +1,370 @@
|
||||
/**
|
||||
* 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');
|
||||
}
|
||||
});
|
||||
}
|
||||
408
frontend/js/team-calendar.js
Normal file
408
frontend/js/team-calendar.js
Normal file
@@ -0,0 +1,408 @@
|
||||
/**
|
||||
* Team Calendar Page
|
||||
* Shows presence and parking for all team members
|
||||
* Filtered by manager (manager-centric model)
|
||||
*/
|
||||
|
||||
let currentUser = null;
|
||||
let currentStartDate = null;
|
||||
let viewMode = 'week'; // 'week' or 'month'
|
||||
let managers = [];
|
||||
let teamData = [];
|
||||
let parkingDataLookup = {};
|
||||
let parkingAssignmentLookup = {};
|
||||
let selectedUserId = null;
|
||||
let selectedDate = null;
|
||||
let currentAssignmentId = null;
|
||||
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
if (!api.requireAuth()) return;
|
||||
|
||||
currentUser = await api.getCurrentUser();
|
||||
if (!currentUser) return;
|
||||
|
||||
// Only managers and admins can view
|
||||
if (currentUser.role === 'employee') {
|
||||
window.location.href = '/presence';
|
||||
return;
|
||||
}
|
||||
|
||||
// Initialize start date based on week start preference
|
||||
const weekStartDay = currentUser.week_start_day || 0;
|
||||
currentStartDate = utils.getWeekStart(new Date(), weekStartDay);
|
||||
|
||||
await loadManagers();
|
||||
await loadTeamData();
|
||||
renderCalendar();
|
||||
setupEventListeners();
|
||||
});
|
||||
|
||||
async function loadManagers() {
|
||||
const response = await api.get('/api/offices/managers/list');
|
||||
if (response && response.ok) {
|
||||
managers = await response.json();
|
||||
const select = document.getElementById('managerFilter');
|
||||
|
||||
// Filter managers based on user role
|
||||
let filteredManagers = managers;
|
||||
if (currentUser.role === 'manager') {
|
||||
// Manager only sees themselves
|
||||
filteredManagers = managers.filter(m => m.id === currentUser.id);
|
||||
}
|
||||
|
||||
filteredManagers.forEach(manager => {
|
||||
const option = document.createElement('option');
|
||||
option.value = manager.id;
|
||||
const officeNames = manager.offices.map(o => o.name).join(', ');
|
||||
option.textContent = `${manager.name} (${officeNames})`;
|
||||
select.appendChild(option);
|
||||
});
|
||||
|
||||
// Auto-select if only one manager (for manager role)
|
||||
if (filteredManagers.length === 1) {
|
||||
select.value = filteredManagers[0].id;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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() {
|
||||
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 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;
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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 = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||
let headerHtml = '<th>Name</th><th>Office</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 = date.toDateString() === new Date().toDateString();
|
||||
|
||||
let classes = [];
|
||||
if (isWeekend) classes.push('weekend');
|
||||
if (isHoliday) classes.push('holiday');
|
||||
if (isToday) classes.push('today');
|
||||
|
||||
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">No team members found</td></tr>`;
|
||||
return;
|
||||
}
|
||||
|
||||
let bodyHtml = '';
|
||||
teamData.forEach(member => {
|
||||
bodyHtml += `<tr>
|
||||
<td class="member-name">${member.name || 'Unknown'}</td>
|
||||
<td class="member-office">${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 = date.toDateString() === new Date().toDateString();
|
||||
|
||||
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}`);
|
||||
|
||||
// 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 || ''}">${parkingBadge}</td>`;
|
||||
}
|
||||
|
||||
bodyHtml += '</tr>';
|
||||
});
|
||||
body.innerHTML = bodyHtml;
|
||||
|
||||
// Add click handlers to cells
|
||||
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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
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];
|
||||
|
||||
// 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');
|
||||
}
|
||||
});
|
||||
|
||||
// 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;
|
||||
|
||||
const response = await api.post('/api/presence/admin/mark', {
|
||||
user_id: selectedUserId,
|
||||
date: selectedDate,
|
||||
status: status
|
||||
});
|
||||
|
||||
if (response && response.ok) {
|
||||
document.getElementById('dayModal').style.display = 'none';
|
||||
await loadTeamData();
|
||||
renderCalendar();
|
||||
} else {
|
||||
const error = await response.json();
|
||||
alert(error.detail || 'Failed to mark presence');
|
||||
}
|
||||
}
|
||||
|
||||
async function clearPresence() {
|
||||
if (!selectedUserId || !selectedDate) return;
|
||||
if (!confirm('Clear presence for this date?')) return;
|
||||
|
||||
const response = await api.delete(`/api/presence/admin/${selectedUserId}/${selectedDate}`);
|
||||
if (response && response.ok) {
|
||||
document.getElementById('dayModal').style.display = 'none';
|
||||
await loadTeamData();
|
||||
renderCalendar();
|
||||
}
|
||||
}
|
||||
|
||||
async function openReassignModal() {
|
||||
if (!currentAssignmentId) 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;
|
||||
}
|
||||
|
||||
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
|
||||
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');
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await api.post('/api/parking/reassign-spot', {
|
||||
assignment_id: currentAssignmentId,
|
||||
new_user_id: newUserId
|
||||
});
|
||||
|
||||
if (response && response.ok) {
|
||||
await loadTeamData();
|
||||
renderCalendar();
|
||||
document.getElementById('reassignModal').style.display = 'none';
|
||||
} else {
|
||||
const error = await response.json();
|
||||
alert(error.detail || 'Failed to reassign parking spot');
|
||||
}
|
||||
}
|
||||
|
||||
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 || 0;
|
||||
currentStartDate = utils.getWeekStart(new Date(), weekStartDay);
|
||||
}
|
||||
await loadTeamData();
|
||||
renderCalendar();
|
||||
});
|
||||
|
||||
// Manager filter
|
||||
document.getElementById('managerFilter').addEventListener('change', async () => {
|
||||
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');
|
||||
}
|
||||
222
frontend/js/utils.js
Normal file
222
frontend/js/utils.js
Normal file
@@ -0,0 +1,222 @@
|
||||
/**
|
||||
* Utility Functions
|
||||
* Date handling, holidays, and common helpers
|
||||
*/
|
||||
|
||||
// Holiday cache: { year: Set of date strings }
|
||||
const holidayCache = {};
|
||||
|
||||
/**
|
||||
* Load holidays for a year from API (called automatically)
|
||||
*/
|
||||
async function loadHolidaysForYear(year) {
|
||||
if (holidayCache[year]) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/auth/holidays/${year}`);
|
||||
if (response.ok) {
|
||||
const holidays = await response.json();
|
||||
holidayCache[year] = new Set(holidays.map(h => h.date));
|
||||
}
|
||||
} catch (e) {
|
||||
// Fall back to local calculation if API fails
|
||||
console.warn('Holiday API failed, using local fallback');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate Easter Sunday using Computus algorithm (fallback)
|
||||
*/
|
||||
function calculateEaster(year) {
|
||||
const a = year % 19;
|
||||
const b = Math.floor(year / 100);
|
||||
const c = year % 100;
|
||||
const d = Math.floor(b / 4);
|
||||
const e = b % 4;
|
||||
const f = Math.floor((b + 8) / 25);
|
||||
const g = Math.floor((b - f + 1) / 3);
|
||||
const h = (19 * a + b - d - g + 15) % 30;
|
||||
const i = Math.floor(c / 4);
|
||||
const k = c % 4;
|
||||
const l = (32 + 2 * e + 2 * i - h - k) % 7;
|
||||
const m = Math.floor((a + 11 * h + 22 * l) / 451);
|
||||
const month = Math.floor((h + l - 7 * m + 114) / 31);
|
||||
const day = ((h + l - 7 * m + 114) % 31) + 1;
|
||||
return new Date(year, month - 1, day);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a date is an Italian holiday
|
||||
* Uses cached API data if available, otherwise falls back to local calculation
|
||||
*/
|
||||
function isItalianHoliday(date) {
|
||||
const year = date.getFullYear();
|
||||
const dateStr = formatDate(date);
|
||||
|
||||
// Check cache first
|
||||
if (holidayCache[year] && holidayCache[year].has(dateStr)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Fallback: local calculation
|
||||
const month = date.getMonth() + 1;
|
||||
const day = date.getDate();
|
||||
|
||||
const fixedHolidays = [
|
||||
[1, 1], [1, 6], [4, 25], [5, 1], [6, 2],
|
||||
[8, 15], [11, 1], [12, 8], [12, 25], [12, 26]
|
||||
];
|
||||
|
||||
for (const [hm, hd] of fixedHolidays) {
|
||||
if (month === hm && day === hd) return true;
|
||||
}
|
||||
|
||||
// Easter Monday
|
||||
const easter = calculateEaster(year);
|
||||
const easterMonday = new Date(easter);
|
||||
easterMonday.setDate(easter.getDate() + 1);
|
||||
if (date.getMonth() === easterMonday.getMonth() && date.getDate() === easterMonday.getDate()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format date as YYYY-MM-DD
|
||||
*/
|
||||
function formatDate(date) {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format date for display
|
||||
*/
|
||||
function formatDateDisplay(dateStr) {
|
||||
const date = new Date(dateStr + 'T12:00:00');
|
||||
return date.toLocaleDateString('en-US', {
|
||||
weekday: 'short',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get month name
|
||||
*/
|
||||
function getMonthName(month) {
|
||||
const months = [
|
||||
'January', 'February', 'March', 'April', 'May', 'June',
|
||||
'July', 'August', 'September', 'October', 'November', 'December'
|
||||
];
|
||||
return months[month];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get day name
|
||||
*/
|
||||
function getDayName(dayIndex) {
|
||||
const days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||
return days[dayIndex];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get days in month
|
||||
*/
|
||||
function getDaysInMonth(year, month) {
|
||||
return new Date(year, month + 1, 0).getDate();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get start of week for a date
|
||||
*/
|
||||
function getWeekStart(date, weekStartDay = 0) {
|
||||
const d = new Date(date);
|
||||
const day = d.getDay();
|
||||
const diff = (day - weekStartDay + 7) % 7;
|
||||
d.setDate(d.getDate() - diff);
|
||||
d.setHours(0, 0, 0, 0);
|
||||
return d;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format date as short display (e.g., "Nov 26")
|
||||
*/
|
||||
function formatDateShort(date) {
|
||||
return date.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a temporary message (creates toast if no container)
|
||||
*/
|
||||
function showMessage(message, type = 'success', duration = 3000) {
|
||||
// Create toast container if it doesn't exist
|
||||
let toastContainer = document.getElementById('toastContainer');
|
||||
if (!toastContainer) {
|
||||
toastContainer = document.createElement('div');
|
||||
toastContainer.id = 'toastContainer';
|
||||
toastContainer.style.cssText = `
|
||||
position: fixed;
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
`;
|
||||
document.body.appendChild(toastContainer);
|
||||
}
|
||||
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `message ${type}`;
|
||||
toast.style.cssText = `
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
|
||||
animation: slideIn 0.2s ease;
|
||||
`;
|
||||
toast.textContent = message;
|
||||
toastContainer.appendChild(toast);
|
||||
|
||||
if (duration > 0) {
|
||||
setTimeout(() => {
|
||||
toast.style.animation = 'slideOut 0.2s ease';
|
||||
setTimeout(() => toast.remove(), 200);
|
||||
}, duration);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Close modal when clicking outside
|
||||
*/
|
||||
function setupModalClose(modalId) {
|
||||
const modal = document.getElementById(modalId);
|
||||
if (modal) {
|
||||
modal.addEventListener('click', (e) => {
|
||||
if (e.target.id === modalId) {
|
||||
modal.style.display = 'none';
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Export utilities
|
||||
window.utils = {
|
||||
loadHolidaysForYear,
|
||||
isItalianHoliday,
|
||||
formatDate,
|
||||
formatDateDisplay,
|
||||
formatDateShort,
|
||||
getMonthName,
|
||||
getDayName,
|
||||
getDaysInMonth,
|
||||
getWeekStart,
|
||||
showMessage,
|
||||
setupModalClose
|
||||
};
|
||||
132
frontend/pages/admin-users.html
Normal file
132
frontend/pages/admin-users.html
Normal file
@@ -0,0 +1,132 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Manage Users - Parking Manager</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||
<link rel="stylesheet" href="/css/styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<aside class="sidebar">
|
||||
<div class="sidebar-header">
|
||||
<h1>Parking Manager</h1>
|
||||
</div>
|
||||
<nav class="sidebar-nav"></nav>
|
||||
<div class="sidebar-footer">
|
||||
<div class="user-menu">
|
||||
<button class="user-button" id="userMenuButton">
|
||||
<div class="user-avatar">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path>
|
||||
<circle cx="12" cy="7" r="4"></circle>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="user-info">
|
||||
<div class="user-name" id="userName">Loading...</div>
|
||||
<div class="user-role" id="userRole">-</div>
|
||||
</div>
|
||||
</button>
|
||||
<div class="user-dropdown" id="userDropdown" style="display: none;">
|
||||
<a href="/profile" class="dropdown-item">Profile</a>
|
||||
<a href="/settings" class="dropdown-item">Settings</a>
|
||||
<hr class="dropdown-divider">
|
||||
<button class="dropdown-item" id="logoutButton">Logout</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main class="main-content">
|
||||
<header class="page-header">
|
||||
<h2>Manage Users</h2>
|
||||
<div class="header-actions">
|
||||
<input type="text" id="searchInput" class="form-input" placeholder="Search users...">
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="content-wrapper">
|
||||
<div class="card">
|
||||
<div class="data-table-container">
|
||||
<table class="data-table" id="usersTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Email</th>
|
||||
<th>Role</th>
|
||||
<th>Office</th>
|
||||
<th>Managed Offices</th>
|
||||
<th>Parking Quota</th>
|
||||
<th>Spot Prefix</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="usersBody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Edit User Modal -->
|
||||
<div class="modal" id="userModal" style="display: none;">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3 id="userModalTitle">Edit User</h3>
|
||||
<button class="modal-close" id="closeUserModal">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form id="userForm">
|
||||
<input type="hidden" id="userId">
|
||||
<div class="form-group">
|
||||
<label for="editName">Name</label>
|
||||
<input type="text" id="editName" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="editEmail">Email</label>
|
||||
<input type="email" id="editEmail" disabled>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="editRole">Role</label>
|
||||
<select id="editRole" required>
|
||||
<option value="employee">Employee</option>
|
||||
<option value="manager">Manager</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="editOffice">Office</label>
|
||||
<select id="editOffice">
|
||||
<option value="">No office</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group" id="quotaGroup" style="display: none;">
|
||||
<label for="editQuota">Parking Quota</label>
|
||||
<input type="number" id="editQuota" min="0" value="0">
|
||||
<small class="text-muted">Number of parking spots this manager can assign</small>
|
||||
</div>
|
||||
<div class="form-group" id="prefixGroup" style="display: none;">
|
||||
<label for="editPrefix">Spot Prefix</label>
|
||||
<input type="text" id="editPrefix" maxlength="2" placeholder="A, B, C...">
|
||||
<small class="text-muted">Letter prefix for parking spots (e.g., A for spots A1, A2...)</small>
|
||||
</div>
|
||||
<div class="form-group" id="managedOfficesGroup" style="display: none;">
|
||||
<label>Managed Offices</label>
|
||||
<div id="managedOfficesCheckboxes" class="checkbox-group"></div>
|
||||
<small class="text-muted">Select offices this manager controls</small>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button type="button" class="btn btn-secondary" id="cancelUser">Cancel</button>
|
||||
<button type="submit" class="btn btn-dark">Save</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/js/api.js"></script>
|
||||
<script src="/js/utils.js"></script>
|
||||
<script src="/js/nav.js"></script>
|
||||
<script src="/js/admin-users.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
32
frontend/pages/landing.html
Normal file
32
frontend/pages/landing.html
Normal file
@@ -0,0 +1,32 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Parking Manager</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||
<link rel="stylesheet" href="/css/styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="auth-container">
|
||||
<div class="auth-card">
|
||||
<div class="auth-header">
|
||||
<h1>Parking Manager</h1>
|
||||
<p>Manage office presence and parking assignments</p>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; flex-direction: column; gap: 1rem;">
|
||||
<a href="/login" class="btn btn-dark btn-full">Sign In</a>
|
||||
<a href="/register" class="btn btn-secondary btn-full">Create Account</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Redirect if already logged in
|
||||
if (localStorage.getItem('access_token')) {
|
||||
window.location.href = '/presence';
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
64
frontend/pages/login.html
Normal file
64
frontend/pages/login.html
Normal file
@@ -0,0 +1,64 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Login - Parking Manager</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||
<link rel="stylesheet" href="/css/styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="auth-container">
|
||||
<div class="auth-card">
|
||||
<div class="auth-header">
|
||||
<h1>Welcome Back</h1>
|
||||
<p>Sign in to your account</p>
|
||||
</div>
|
||||
|
||||
<div id="errorMessage"></div>
|
||||
|
||||
<form id="loginForm">
|
||||
<div class="form-group">
|
||||
<label for="email">Email</label>
|
||||
<input type="email" id="email" required autocomplete="email">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="password">Password</label>
|
||||
<input type="password" id="password" required autocomplete="current-password">
|
||||
</div>
|
||||
<button type="submit" class="btn btn-dark btn-full">Sign In</button>
|
||||
</form>
|
||||
|
||||
<div class="auth-footer">
|
||||
Don't have an account? <a href="/register">Sign up</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/js/api.js"></script>
|
||||
<script>
|
||||
// Redirect if already logged in
|
||||
if (api.isAuthenticated()) {
|
||||
window.location.href = '/presence';
|
||||
}
|
||||
|
||||
document.getElementById('loginForm').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const email = document.getElementById('email').value;
|
||||
const password = document.getElementById('password').value;
|
||||
const errorDiv = document.getElementById('errorMessage');
|
||||
|
||||
errorDiv.innerHTML = '';
|
||||
|
||||
const result = await api.login(email, password);
|
||||
|
||||
if (result.success) {
|
||||
window.location.href = '/presence';
|
||||
} else {
|
||||
errorDiv.innerHTML = `<div class="message error">${result.error}</div>`;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
215
frontend/pages/office-rules.html
Normal file
215
frontend/pages/office-rules.html
Normal file
@@ -0,0 +1,215 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Office Rules - Parking Manager</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||
<link rel="stylesheet" href="/css/styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<aside class="sidebar">
|
||||
<div class="sidebar-header">
|
||||
<h1>Parking Manager</h1>
|
||||
</div>
|
||||
<nav class="sidebar-nav"></nav>
|
||||
<div class="sidebar-footer">
|
||||
<div class="user-menu">
|
||||
<button class="user-button" id="userMenuButton">
|
||||
<div class="user-avatar">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path>
|
||||
<circle cx="12" cy="7" r="4"></circle>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="user-info">
|
||||
<div class="user-name" id="userName">Loading...</div>
|
||||
<div class="user-role" id="userRole">-</div>
|
||||
</div>
|
||||
</button>
|
||||
<div class="user-dropdown" id="userDropdown" style="display: none;">
|
||||
<a href="/profile" class="dropdown-item">Profile</a>
|
||||
<a href="/settings" class="dropdown-item">Settings</a>
|
||||
<hr class="dropdown-divider">
|
||||
<button class="dropdown-item" id="logoutButton">Logout</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main class="main-content">
|
||||
<header class="page-header">
|
||||
<h2>Office Rules</h2>
|
||||
<div class="header-actions">
|
||||
<select id="officeSelect" class="form-select">
|
||||
<option value="">Select Manager</option>
|
||||
</select>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="content-wrapper" id="rulesContent" style="display: none;">
|
||||
<!-- Weekly Closing Days -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3>Weekly Closing Days</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="text-muted" style="margin-bottom: 1rem;">Days of the week the office is regularly closed</p>
|
||||
<div class="weekday-checkboxes" id="weeklyClosingDays">
|
||||
<label><input type="checkbox" data-weekday="0"> Sunday</label>
|
||||
<label><input type="checkbox" data-weekday="1"> Monday</label>
|
||||
<label><input type="checkbox" data-weekday="2"> Tuesday</label>
|
||||
<label><input type="checkbox" data-weekday="3"> Wednesday</label>
|
||||
<label><input type="checkbox" data-weekday="4"> Thursday</label>
|
||||
<label><input type="checkbox" data-weekday="5"> Friday</label>
|
||||
<label><input type="checkbox" data-weekday="6"> Saturday</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Specific Closing Days -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3>Specific Closing Days</h3>
|
||||
<button class="btn btn-secondary btn-sm" id="addClosingDayBtn">Add</button>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="text-muted">Specific dates when the office is closed (holidays, etc.)</p>
|
||||
<div id="closingDaysList" class="rules-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Parking Guarantees -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3>Parking Guarantees</h3>
|
||||
<button class="btn btn-secondary btn-sm" id="addGuaranteeBtn">Add</button>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="text-muted">Users guaranteed a parking spot when present</p>
|
||||
<div id="guaranteesList" class="rules-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Parking Exclusions -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3>Parking Exclusions</h3>
|
||||
<button class="btn btn-secondary btn-sm" id="addExclusionBtn">Add</button>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="text-muted">Users excluded from parking assignment</p>
|
||||
<div id="exclusionsList" class="rules-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="content-wrapper" id="noOfficeMessage">
|
||||
<div class="card">
|
||||
<div class="card-body text-center">
|
||||
<p>Select a manager to manage their office rules</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Add Closing Day Modal -->
|
||||
<div class="modal" id="closingDayModal" style="display: none;">
|
||||
<div class="modal-content modal-small">
|
||||
<div class="modal-header">
|
||||
<h3>Add Closing Day</h3>
|
||||
<button class="modal-close" id="closeClosingDayModal">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form id="closingDayForm">
|
||||
<div class="form-group">
|
||||
<label for="closingDate">Date</label>
|
||||
<input type="date" id="closingDate" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="closingReason">Reason (optional)</label>
|
||||
<input type="text" id="closingReason" placeholder="e.g., Company holiday">
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button type="button" class="btn btn-secondary" id="cancelClosingDay">Cancel</button>
|
||||
<button type="submit" class="btn btn-dark">Add</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add Guarantee Modal -->
|
||||
<div class="modal" id="guaranteeModal" style="display: none;">
|
||||
<div class="modal-content modal-small">
|
||||
<div class="modal-header">
|
||||
<h3>Add Parking Guarantee</h3>
|
||||
<button class="modal-close" id="closeGuaranteeModal">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form id="guaranteeForm">
|
||||
<div class="form-group">
|
||||
<label for="guaranteeUser">User</label>
|
||||
<select id="guaranteeUser" required>
|
||||
<option value="">Select user...</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="guaranteeStartDate">Start Date (optional)</label>
|
||||
<input type="date" id="guaranteeStartDate">
|
||||
<small>Leave empty for no start limit</small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="guaranteeEndDate">End Date (optional)</label>
|
||||
<input type="date" id="guaranteeEndDate">
|
||||
<small>Leave empty for no end limit</small>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button type="button" class="btn btn-secondary" id="cancelGuarantee">Cancel</button>
|
||||
<button type="submit" class="btn btn-dark">Add</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add Exclusion Modal -->
|
||||
<div class="modal" id="exclusionModal" style="display: none;">
|
||||
<div class="modal-content modal-small">
|
||||
<div class="modal-header">
|
||||
<h3>Add Parking Exclusion</h3>
|
||||
<button class="modal-close" id="closeExclusionModal">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form id="exclusionForm">
|
||||
<div class="form-group">
|
||||
<label for="exclusionUser">User</label>
|
||||
<select id="exclusionUser" required>
|
||||
<option value="">Select user...</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="exclusionStartDate">Start Date (optional)</label>
|
||||
<input type="date" id="exclusionStartDate">
|
||||
<small>Leave empty for no start limit</small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="exclusionEndDate">End Date (optional)</label>
|
||||
<input type="date" id="exclusionEndDate">
|
||||
<small>Leave empty for no end limit</small>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button type="button" class="btn btn-secondary" id="cancelExclusion">Cancel</button>
|
||||
<button type="submit" class="btn btn-dark">Add</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/js/api.js"></script>
|
||||
<script src="/js/utils.js"></script>
|
||||
<script src="/js/nav.js"></script>
|
||||
<script src="/js/office-rules.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
186
frontend/pages/presence.html
Normal file
186
frontend/pages/presence.html
Normal file
@@ -0,0 +1,186 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>My Presence - Parking Manager</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||
<link rel="stylesheet" href="/css/styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<aside class="sidebar">
|
||||
<div class="sidebar-header">
|
||||
<h1>Parking Manager</h1>
|
||||
</div>
|
||||
<nav class="sidebar-nav"></nav>
|
||||
<div class="sidebar-footer">
|
||||
<div class="user-menu">
|
||||
<button class="user-button" id="userMenuButton">
|
||||
<div class="user-avatar">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path>
|
||||
<circle cx="12" cy="7" r="4"></circle>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="user-info">
|
||||
<div class="user-name" id="userName">Loading...</div>
|
||||
<div class="user-role" id="userRole">-</div>
|
||||
</div>
|
||||
</button>
|
||||
<div class="user-dropdown" id="userDropdown" style="display: none;">
|
||||
<a href="/profile" class="dropdown-item">Profile</a>
|
||||
<a href="/settings" class="dropdown-item">Settings</a>
|
||||
<hr class="dropdown-divider">
|
||||
<button class="dropdown-item" id="logoutButton">Logout</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main class="main-content">
|
||||
<header class="page-header">
|
||||
<h2>My Presence</h2>
|
||||
<div class="header-actions">
|
||||
<button class="btn btn-secondary" id="bulkMarkBtn">Bulk Mark</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="content-wrapper">
|
||||
<div class="card presence-card">
|
||||
<div class="calendar-header">
|
||||
<button class="btn-icon" id="prevMonth">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="15 18 9 12 15 6"></polyline>
|
||||
</svg>
|
||||
</button>
|
||||
<h3 id="currentMonth">Loading...</h3>
|
||||
<button class="btn-icon" id="nextMonth">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="9 18 15 12 9 6"></polyline>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="calendar-grid" id="calendarGrid"></div>
|
||||
|
||||
<div class="legend">
|
||||
<div class="legend-item">
|
||||
<div class="legend-color status-present"></div>
|
||||
<span>Present (Office)</span>
|
||||
</div>
|
||||
<div class="legend-item">
|
||||
<div class="legend-color status-remote"></div>
|
||||
<span>Remote</span>
|
||||
</div>
|
||||
<div class="legend-item">
|
||||
<div class="legend-color status-absent"></div>
|
||||
<span>Absent</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Day Modal -->
|
||||
<div class="modal" id="dayModal" style="display: none;">
|
||||
<div class="modal-content modal-small">
|
||||
<div class="modal-header">
|
||||
<h3 id="dayModalTitle">Mark Presence</h3>
|
||||
<button class="modal-close" id="closeDayModal">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="status-buttons">
|
||||
<button class="status-btn" data-status="present">
|
||||
<div class="status-icon status-present"></div>
|
||||
<span>Present</span>
|
||||
</button>
|
||||
<button class="status-btn" data-status="remote">
|
||||
<div class="status-icon status-remote"></div>
|
||||
<span>Remote</span>
|
||||
</button>
|
||||
<button class="status-btn" data-status="absent">
|
||||
<div class="status-icon status-absent"></div>
|
||||
<span>Absent</span>
|
||||
</button>
|
||||
</div>
|
||||
<div id="parkingSection" style="display: none; margin-top: 1rem; padding-top: 1rem; border-top: 1px solid var(--border);">
|
||||
<div id="parkingInfo" style="margin-bottom: 0.75rem; font-size: 0.9rem;"></div>
|
||||
<div style="display: flex; gap: 0.5rem;">
|
||||
<button class="btn btn-secondary" style="flex: 1;" id="reassignParkingBtn">Reassign</button>
|
||||
<button class="btn btn-secondary" style="flex: 1;" id="releaseParkingBtn">Release</button>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn-secondary btn-full" id="clearDayBtn" style="margin-top: 1rem;">Clear Presence</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Reassign Parking Modal -->
|
||||
<div class="modal" id="reassignModal" style="display: none;">
|
||||
<div class="modal-content modal-small">
|
||||
<div class="modal-header">
|
||||
<h3>Reassign Parking Spot</h3>
|
||||
<button class="modal-close" id="closeReassignModal">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p id="reassignSpotInfo" style="margin-bottom: 1rem; font-size: 0.9rem;"></p>
|
||||
<div class="form-group">
|
||||
<label for="reassignUser">Assign to</label>
|
||||
<select id="reassignUser" required>
|
||||
<option value="">Select user...</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button type="button" class="btn btn-secondary" id="cancelReassign">Cancel</button>
|
||||
<button type="button" class="btn btn-dark" id="confirmReassign">Reassign</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bulk Mark Modal -->
|
||||
<div class="modal" id="bulkMarkModal" style="display: none;">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3>Bulk Mark Presence</h3>
|
||||
<button class="modal-close" id="closeBulkModal">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form id="bulkMarkForm">
|
||||
<div class="form-group">
|
||||
<label for="startDate">Start Date</label>
|
||||
<input type="date" id="startDate" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="endDate">End Date</label>
|
||||
<input type="date" id="endDate" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="bulkStatus">Status</label>
|
||||
<select id="bulkStatus" required>
|
||||
<option value="present">Present (Office)</option>
|
||||
<option value="remote">Remote</option>
|
||||
<option value="absent">Absent</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group checkbox-group">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" id="weekdaysOnly">
|
||||
<span>Weekdays only (Mon-Fri)</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button type="button" class="btn btn-secondary" id="cancelBulk">Cancel</button>
|
||||
<button type="submit" class="btn btn-dark">Mark Dates</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/js/api.js"></script>
|
||||
<script src="/js/utils.js"></script>
|
||||
<script src="/js/nav.js"></script>
|
||||
<script src="/js/presence.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
188
frontend/pages/profile.html
Normal file
188
frontend/pages/profile.html
Normal file
@@ -0,0 +1,188 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Profile - Parking Manager</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||
<link rel="stylesheet" href="/css/styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<aside class="sidebar">
|
||||
<div class="sidebar-header">
|
||||
<h1>Parking Manager</h1>
|
||||
</div>
|
||||
<nav class="sidebar-nav"></nav>
|
||||
<div class="sidebar-footer">
|
||||
<div class="user-menu">
|
||||
<button class="user-button" id="userMenuButton">
|
||||
<div class="user-avatar">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path>
|
||||
<circle cx="12" cy="7" r="4"></circle>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="user-info">
|
||||
<div class="user-name" id="userName">Loading...</div>
|
||||
<div class="user-role" id="userRole">-</div>
|
||||
</div>
|
||||
</button>
|
||||
<div class="user-dropdown" id="userDropdown" style="display: none;">
|
||||
<a href="/profile" class="dropdown-item">Profile</a>
|
||||
<a href="/settings" class="dropdown-item">Settings</a>
|
||||
<hr class="dropdown-divider">
|
||||
<button class="dropdown-item" id="logoutButton">Logout</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main class="main-content">
|
||||
<header class="page-header">
|
||||
<h2>Profile</h2>
|
||||
</header>
|
||||
|
||||
<div class="content-wrapper">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3>Personal Information</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form id="profileForm">
|
||||
<div class="form-group">
|
||||
<label for="name">Full Name</label>
|
||||
<input type="text" id="name" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="email">Email</label>
|
||||
<input type="email" id="email" disabled>
|
||||
<small class="text-muted">Email cannot be changed</small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="office">Office</label>
|
||||
<select id="office">
|
||||
<option value="">No office</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-dark">Save Changes</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3>Change Password</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form id="passwordForm">
|
||||
<div class="form-group">
|
||||
<label for="currentPassword">Current Password</label>
|
||||
<input type="password" id="currentPassword" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="newPassword">New Password</label>
|
||||
<input type="password" id="newPassword" required minlength="8">
|
||||
<small>Minimum 8 characters</small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="confirmPassword">Confirm New Password</label>
|
||||
<input type="password" id="confirmPassword" required>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-dark">Change Password</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script src="/js/api.js"></script>
|
||||
<script src="/js/utils.js"></script>
|
||||
<script src="/js/nav.js"></script>
|
||||
<script>
|
||||
let currentUser = null;
|
||||
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
if (!api.requireAuth()) return;
|
||||
|
||||
currentUser = await api.getCurrentUser();
|
||||
if (!currentUser) return;
|
||||
|
||||
await loadOffices();
|
||||
populateForm();
|
||||
setupEventListeners();
|
||||
});
|
||||
|
||||
async function loadOffices() {
|
||||
const response = await api.get('/api/offices');
|
||||
if (response && response.ok) {
|
||||
const offices = await response.json();
|
||||
const select = document.getElementById('office');
|
||||
offices.forEach(office => {
|
||||
const option = document.createElement('option');
|
||||
option.value = office.id;
|
||||
option.textContent = office.name;
|
||||
select.appendChild(option);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function populateForm() {
|
||||
document.getElementById('name').value = currentUser.name || '';
|
||||
document.getElementById('email').value = currentUser.email;
|
||||
document.getElementById('office').value = currentUser.office_id || '';
|
||||
}
|
||||
|
||||
function setupEventListeners() {
|
||||
// Profile form
|
||||
document.getElementById('profileForm').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const data = {
|
||||
name: document.getElementById('name').value,
|
||||
office_id: document.getElementById('office').value || null
|
||||
};
|
||||
|
||||
const response = await api.put('/api/users/me/profile', data);
|
||||
if (response && response.ok) {
|
||||
utils.showMessage('Profile updated successfully', 'success');
|
||||
currentUser = await api.getCurrentUser();
|
||||
} else {
|
||||
const error = await response.json();
|
||||
utils.showMessage(error.detail || 'Failed to update profile', 'error');
|
||||
}
|
||||
});
|
||||
|
||||
// Password form
|
||||
document.getElementById('passwordForm').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const newPassword = document.getElementById('newPassword').value;
|
||||
const confirmPassword = document.getElementById('confirmPassword').value;
|
||||
|
||||
if (newPassword !== confirmPassword) {
|
||||
utils.showMessage('Passwords do not match', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const data = {
|
||||
current_password: document.getElementById('currentPassword').value,
|
||||
new_password: newPassword
|
||||
};
|
||||
|
||||
const response = await api.post('/api/users/me/change-password', data);
|
||||
if (response && response.ok) {
|
||||
utils.showMessage('Password changed successfully', 'success');
|
||||
document.getElementById('passwordForm').reset();
|
||||
} else {
|
||||
const error = await response.json();
|
||||
utils.showMessage(error.detail || 'Failed to change password', 'error');
|
||||
}
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
98
frontend/pages/register.html
Normal file
98
frontend/pages/register.html
Normal file
@@ -0,0 +1,98 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Register - Parking Manager</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||
<link rel="stylesheet" href="/css/styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="auth-container">
|
||||
<div class="auth-card">
|
||||
<div class="auth-header">
|
||||
<h1>Create Account</h1>
|
||||
<p>Sign up for a new account</p>
|
||||
</div>
|
||||
|
||||
<div id="errorMessage"></div>
|
||||
|
||||
<form id="registerForm">
|
||||
<div class="form-group">
|
||||
<label for="name">Full Name</label>
|
||||
<input type="text" id="name" required autocomplete="name">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="email">Email</label>
|
||||
<input type="email" id="email" required autocomplete="email">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="password">Password</label>
|
||||
<input type="password" id="password" required autocomplete="new-password" minlength="8">
|
||||
<small>Minimum 8 characters</small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="office">Office (optional)</label>
|
||||
<select id="office">
|
||||
<option value="">Select an office...</option>
|
||||
</select>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-dark btn-full">Create Account</button>
|
||||
</form>
|
||||
|
||||
<div class="auth-footer">
|
||||
Already have an account? <a href="/login">Sign in</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/js/api.js"></script>
|
||||
<script>
|
||||
// Redirect if already logged in
|
||||
if (api.isAuthenticated()) {
|
||||
window.location.href = '/presence';
|
||||
}
|
||||
|
||||
// Load offices
|
||||
async function loadOffices() {
|
||||
try {
|
||||
const response = await fetch('/api/offices');
|
||||
if (response.ok) {
|
||||
const offices = await response.json();
|
||||
const select = document.getElementById('office');
|
||||
offices.forEach(office => {
|
||||
const option = document.createElement('option');
|
||||
option.value = office.id;
|
||||
option.textContent = office.name;
|
||||
select.appendChild(option);
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load offices:', error);
|
||||
}
|
||||
}
|
||||
|
||||
loadOffices();
|
||||
|
||||
document.getElementById('registerForm').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const name = document.getElementById('name').value;
|
||||
const email = document.getElementById('email').value;
|
||||
const password = document.getElementById('password').value;
|
||||
const officeId = document.getElementById('office').value || null;
|
||||
const errorDiv = document.getElementById('errorMessage');
|
||||
|
||||
errorDiv.innerHTML = '';
|
||||
|
||||
const result = await api.register(email, password, name, officeId);
|
||||
|
||||
if (result.success) {
|
||||
window.location.href = '/presence';
|
||||
} else {
|
||||
errorDiv.innerHTML = `<div class="message error">${result.error}</div>`;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
217
frontend/pages/settings.html
Normal file
217
frontend/pages/settings.html
Normal file
@@ -0,0 +1,217 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Settings - Parking Manager</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||
<link rel="stylesheet" href="/css/styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<aside class="sidebar">
|
||||
<div class="sidebar-header">
|
||||
<h1>Parking Manager</h1>
|
||||
</div>
|
||||
<nav class="sidebar-nav"></nav>
|
||||
<div class="sidebar-footer">
|
||||
<div class="user-menu">
|
||||
<button class="user-button" id="userMenuButton">
|
||||
<div class="user-avatar">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path>
|
||||
<circle cx="12" cy="7" r="4"></circle>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="user-info">
|
||||
<div class="user-name" id="userName">Loading...</div>
|
||||
<div class="user-role" id="userRole">-</div>
|
||||
</div>
|
||||
</button>
|
||||
<div class="user-dropdown" id="userDropdown" style="display: none;">
|
||||
<a href="/profile" class="dropdown-item">Profile</a>
|
||||
<a href="/settings" class="dropdown-item">Settings</a>
|
||||
<hr class="dropdown-divider">
|
||||
<button class="dropdown-item" id="logoutButton">Logout</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main class="main-content">
|
||||
<header class="page-header">
|
||||
<h2>Settings</h2>
|
||||
</header>
|
||||
|
||||
<div class="content-wrapper">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3>Preferences</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form id="settingsForm">
|
||||
<div class="form-group">
|
||||
<label for="weekStartDay">Week Starts On</label>
|
||||
<select id="weekStartDay">
|
||||
<option value="0">Sunday</option>
|
||||
<option value="1">Monday</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-dark">Save Settings</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3>Parking Notifications</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form id="notificationForm">
|
||||
<div class="form-group">
|
||||
<label class="toggle-label">
|
||||
<span>Weekly Summary</span>
|
||||
<label class="toggle-switch">
|
||||
<input type="checkbox" id="notifyWeeklyParking">
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
</label>
|
||||
<small class="text-muted">Receive weekly parking assignments summary every Friday at 12:00</small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="toggle-label">
|
||||
<span>Daily Reminder</span>
|
||||
<label class="toggle-switch">
|
||||
<input type="checkbox" id="notifyDailyParking">
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
</label>
|
||||
<small class="text-muted">Receive daily parking reminder on working days</small>
|
||||
</div>
|
||||
<div class="form-group" id="dailyTimeGroup" style="margin-left: 1rem;">
|
||||
<label>Reminder Time</label>
|
||||
<div style="display: flex; gap: 0.5rem; align-items: center;">
|
||||
<select id="notifyDailyHour" style="width: 80px;">
|
||||
<!-- Hours populated by JS -->
|
||||
</select>
|
||||
<span>:</span>
|
||||
<select id="notifyDailyMinute" style="width: 80px;">
|
||||
<option value="0">00</option>
|
||||
<option value="15">15</option>
|
||||
<option value="30">30</option>
|
||||
<option value="45">45</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="toggle-label">
|
||||
<span>Assignment Changes</span>
|
||||
<label class="toggle-switch">
|
||||
<input type="checkbox" id="notifyParkingChanges">
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
</label>
|
||||
<small class="text-muted">Receive immediate notifications when your parking assignment changes</small>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-dark">Save Notifications</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script src="/js/api.js"></script>
|
||||
<script src="/js/utils.js"></script>
|
||||
<script src="/js/nav.js"></script>
|
||||
<script>
|
||||
let currentUser = null;
|
||||
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
if (!api.requireAuth()) return;
|
||||
|
||||
currentUser = await api.getCurrentUser();
|
||||
if (!currentUser) return;
|
||||
|
||||
populateHourSelect();
|
||||
populateForm();
|
||||
setupEventListeners();
|
||||
});
|
||||
|
||||
function populateHourSelect() {
|
||||
const select = document.getElementById('notifyDailyHour');
|
||||
for (let h = 0; h < 24; h++) {
|
||||
const option = document.createElement('option');
|
||||
option.value = h;
|
||||
option.textContent = h.toString().padStart(2, '0');
|
||||
select.appendChild(option);
|
||||
}
|
||||
}
|
||||
|
||||
function populateForm() {
|
||||
document.getElementById('weekStartDay').value = currentUser.week_start_day || 0;
|
||||
|
||||
// Notification settings
|
||||
document.getElementById('notifyWeeklyParking').checked = currentUser.notify_weekly_parking !== 0;
|
||||
document.getElementById('notifyDailyParking').checked = currentUser.notify_daily_parking !== 0;
|
||||
document.getElementById('notifyDailyHour').value = currentUser.notify_daily_parking_hour || 8;
|
||||
document.getElementById('notifyDailyMinute').value = currentUser.notify_daily_parking_minute || 0;
|
||||
document.getElementById('notifyParkingChanges').checked = currentUser.notify_parking_changes !== 0;
|
||||
|
||||
updateDailyTimeVisibility();
|
||||
}
|
||||
|
||||
function updateDailyTimeVisibility() {
|
||||
const enabled = document.getElementById('notifyDailyParking').checked;
|
||||
document.getElementById('dailyTimeGroup').style.display = enabled ? 'block' : 'none';
|
||||
}
|
||||
|
||||
function setupEventListeners() {
|
||||
// Settings form
|
||||
document.getElementById('settingsForm').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const data = {
|
||||
week_start_day: parseInt(document.getElementById('weekStartDay').value)
|
||||
};
|
||||
|
||||
const response = await api.put('/api/users/me/settings', data);
|
||||
if (response && response.ok) {
|
||||
utils.showMessage('Settings saved successfully', 'success');
|
||||
currentUser = await api.getCurrentUser();
|
||||
} else {
|
||||
const error = await response.json();
|
||||
utils.showMessage(error.detail || 'Failed to save settings', 'error');
|
||||
}
|
||||
});
|
||||
|
||||
// Notification form
|
||||
document.getElementById('notificationForm').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const data = {
|
||||
notify_weekly_parking: document.getElementById('notifyWeeklyParking').checked ? 1 : 0,
|
||||
notify_daily_parking: document.getElementById('notifyDailyParking').checked ? 1 : 0,
|
||||
notify_daily_parking_hour: parseInt(document.getElementById('notifyDailyHour').value),
|
||||
notify_daily_parking_minute: parseInt(document.getElementById('notifyDailyMinute').value),
|
||||
notify_parking_changes: document.getElementById('notifyParkingChanges').checked ? 1 : 0
|
||||
};
|
||||
|
||||
const response = await api.put('/api/users/me/settings', data);
|
||||
if (response && response.ok) {
|
||||
utils.showMessage('Notification settings saved', 'success');
|
||||
currentUser = await api.getCurrentUser();
|
||||
} else {
|
||||
const error = await response.json();
|
||||
utils.showMessage(error.detail || 'Failed to save notifications', 'error');
|
||||
}
|
||||
});
|
||||
|
||||
// Toggle daily time visibility
|
||||
document.getElementById('notifyDailyParking').addEventListener('change', updateDailyTimeVisibility);
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
157
frontend/pages/team-calendar.html
Normal file
157
frontend/pages/team-calendar.html
Normal file
@@ -0,0 +1,157 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Team Calendar - Parking Manager</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||
<link rel="stylesheet" href="/css/styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<aside class="sidebar">
|
||||
<div class="sidebar-header">
|
||||
<h1>Parking Manager</h1>
|
||||
</div>
|
||||
<nav class="sidebar-nav"></nav>
|
||||
<div class="sidebar-footer">
|
||||
<div class="user-menu">
|
||||
<button class="user-button" id="userMenuButton">
|
||||
<div class="user-avatar">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path>
|
||||
<circle cx="12" cy="7" r="4"></circle>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="user-info">
|
||||
<div class="user-name" id="userName">Loading...</div>
|
||||
<div class="user-role" id="userRole">-</div>
|
||||
</div>
|
||||
</button>
|
||||
<div class="user-dropdown" id="userDropdown" style="display: none;">
|
||||
<a href="/profile" class="dropdown-item">Profile</a>
|
||||
<a href="/settings" class="dropdown-item">Settings</a>
|
||||
<hr class="dropdown-divider">
|
||||
<button class="dropdown-item" id="logoutButton">Logout</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main class="main-content">
|
||||
<header class="page-header">
|
||||
<h2>Team Calendar</h2>
|
||||
<div class="header-actions">
|
||||
<select id="viewToggle" class="form-select" style="min-width: 100px;">
|
||||
<option value="week">Week</option>
|
||||
<option value="month">Month</option>
|
||||
</select>
|
||||
<select id="managerFilter" class="form-select">
|
||||
<option value="">All Managers</option>
|
||||
</select>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="content-wrapper">
|
||||
<div class="card">
|
||||
<div class="calendar-header">
|
||||
<button class="btn-icon" id="prevWeek">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="15 18 9 12 15 6"></polyline>
|
||||
</svg>
|
||||
</button>
|
||||
<h3 id="currentWeek">Loading...</h3>
|
||||
<button class="btn-icon" id="nextWeek">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="9 18 15 12 9 6"></polyline>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="team-calendar-container">
|
||||
<table class="team-calendar-table" id="teamCalendarTable">
|
||||
<thead>
|
||||
<tr id="calendarHeader"></tr>
|
||||
</thead>
|
||||
<tbody id="calendarBody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="legend">
|
||||
<div class="legend-item">
|
||||
<div class="legend-color status-present"></div>
|
||||
<span>Present</span>
|
||||
</div>
|
||||
<div class="legend-item">
|
||||
<div class="legend-color status-remote"></div>
|
||||
<span>Remote</span>
|
||||
</div>
|
||||
<div class="legend-item">
|
||||
<div class="legend-color status-absent"></div>
|
||||
<span>Absent</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Day Status Modal -->
|
||||
<div class="modal" id="dayModal" style="display: none;">
|
||||
<div class="modal-content modal-small">
|
||||
<div class="modal-header">
|
||||
<h3 id="dayModalTitle">Mark Presence</h3>
|
||||
<button class="modal-close" id="closeDayModal">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p id="dayModalUser" style="margin-bottom: 1rem; font-weight: 500;"></p>
|
||||
<div class="status-buttons">
|
||||
<button class="status-btn" data-status="present">
|
||||
<div class="status-icon status-present"></div>
|
||||
<span>Present</span>
|
||||
</button>
|
||||
<button class="status-btn" data-status="remote">
|
||||
<div class="status-icon status-remote"></div>
|
||||
<span>Remote</span>
|
||||
</button>
|
||||
<button class="status-btn" data-status="absent">
|
||||
<div class="status-icon status-absent"></div>
|
||||
<span>Absent</span>
|
||||
</button>
|
||||
</div>
|
||||
<div id="parkingSection" style="display: none; margin-top: 1rem; padding-top: 1rem; border-top: 1px solid var(--border);">
|
||||
<div id="parkingInfo" style="margin-bottom: 0.75rem; font-size: 0.9rem;"></div>
|
||||
<button class="btn btn-secondary btn-full" id="reassignParkingBtn">Reassign Spot</button>
|
||||
</div>
|
||||
<button class="btn btn-secondary btn-full" id="clearDayBtn" style="margin-top: 1rem;">Clear Presence</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Reassign Parking Modal -->
|
||||
<div class="modal" id="reassignModal" style="display: none;">
|
||||
<div class="modal-content modal-small">
|
||||
<div class="modal-header">
|
||||
<h3>Reassign Parking Spot</h3>
|
||||
<button class="modal-close" id="closeReassignModal">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p id="reassignSpotInfo" style="margin-bottom: 1rem; font-size: 0.9rem;"></p>
|
||||
<div class="form-group">
|
||||
<label for="reassignUser">Assign to</label>
|
||||
<select id="reassignUser" required>
|
||||
<option value="">Select user...</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button type="button" class="btn btn-secondary" id="cancelReassign">Cancel</button>
|
||||
<button type="button" class="btn btn-dark" id="confirmReassign">Reassign</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/js/api.js"></script>
|
||||
<script src="/js/utils.js"></script>
|
||||
<script src="/js/nav.js"></script>
|
||||
<script src="/js/team-calendar.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user