off load js and css
This commit is contained in:
parent
d30c0921cf
commit
8e1622fad0
@ -10,12 +10,7 @@
|
|||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||||
<style>
|
<link rel="stylesheet" href="style/style.css">
|
||||||
body { font-family: 'Inter', sans-serif; }
|
|
||||||
.hidden { display: none; }
|
|
||||||
.modal-overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0, 0, 0, 0.5); display: flex; align-items: center; justify-content: center; z-index: 1000; }
|
|
||||||
.modal-content { background: white; padding: 2rem; border-radius: 0.5rem; width: 90%; max-width: 500px; }
|
|
||||||
</style>
|
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body class="bg-gray-100">
|
<body class="bg-gray-100">
|
||||||
@ -46,420 +41,6 @@
|
|||||||
|
|
||||||
<div id="modal-container"></div>
|
<div id="modal-container"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
|
||||||
// --- DOM Elements & State ---
|
|
||||||
const mainViews = { auth: document.getElementById('auth-view'), employee: document.getElementById('employee-dashboard'), admin: document.getElementById('admin-dashboard'), archive: document.getElementById('admin-archive-view'), timeOffHistory: document.getElementById('admin-time-off-history-view') };
|
|
||||||
const navUserControls = document.getElementById('nav-user-controls'), welcomeMessage = document.getElementById('welcome-message'), signOutBtn = document.getElementById('sign-out-btn');
|
|
||||||
const messageBox = document.getElementById('message-box'), loadingSpinner = document.getElementById('loading-spinner'), modalContainer = document.getElementById('modal-container');
|
|
||||||
let authToken, user, allTimeEntries = [], allUsers = [], employeeTimerInterval = null;
|
|
||||||
let adminTimerIntervals = [];
|
|
||||||
|
|
||||||
// --- Helper Functions ---
|
|
||||||
const showLoading = (show) => { loadingSpinner.innerHTML = show ? `<div class="inline-block animate-spin rounded-full h-16 w-16 border-t-2 border-b-2 border-blue-500"></div>` : ''; };
|
|
||||||
const showMessage = (message, type = 'success') => { messageBox.innerHTML = `<div class="p-4 mb-4 text-sm rounded-lg flex items-center justify-between ${type === 'error' ? 'bg-red-100 text-red-700' : 'bg-green-100 text-green-700'}" role="alert"><span>${message}</span><button onclick="messageBox.classList.add('hidden')" class="font-bold text-lg">×</button></div>`; messageBox.classList.remove('hidden'); };
|
|
||||||
const formatDecimal = (ms) => ms ? (ms / 3600000).toFixed(2) : '0.00';
|
|
||||||
// UPDATED: Shorter date formats for mobile friendliness
|
|
||||||
const formatDateTime = (s) => s ? new Date(s).toLocaleString(undefined, { month: '2-digit', day: '2-digit', year: '2-digit', hour: 'numeric', minute: '2-digit' }) : 'N/A';
|
|
||||||
const formatDate = (s) => s ? new Date(s).toLocaleDateString(undefined, { month: '2-digit', day: '2-digit', year: '2-digit' }) : 'N/A';
|
|
||||||
const toLocalISO = (d) => { if (!d) return ''; const date = new Date(d); return new Date(date.getTime() - (date.getTimezoneOffset() * 60000)).toISOString().slice(0, 16); };
|
|
||||||
const formatDuration = (ms) => { if (!ms || ms < 0) return '00:00:00'; const totalSeconds = Math.floor(ms / 1000); const h = Math.floor(totalSeconds / 3600); const m = Math.floor((totalSeconds % 3600) / 60); const s = totalSeconds % 60; return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`; };
|
|
||||||
|
|
||||||
// --- API Calls ---
|
|
||||||
const API_BASE_URL = '/api';
|
|
||||||
async function apiCall(endpoint, method = 'GET', body = null) {
|
|
||||||
const headers = { 'Content-Type': 'application/json' };
|
|
||||||
if (authToken) headers['Authorization'] = `Bearer ${authToken}`;
|
|
||||||
try {
|
|
||||||
showLoading(true);
|
|
||||||
const response = await fetch(`${API_BASE_URL}${endpoint}`, { method, headers, body: body ? JSON.stringify(body) : null });
|
|
||||||
if (response.status === 401) {
|
|
||||||
handleSignOut('Your session has expired. Please log in again.');
|
|
||||||
return { success: false };
|
|
||||||
}
|
|
||||||
const text = await response.text();
|
|
||||||
let data;
|
|
||||||
try {
|
|
||||||
data = text ? JSON.parse(text) : null;
|
|
||||||
} catch (error) {
|
|
||||||
if (!response.ok) { throw new Error(text || `Request failed with status ${response.status}`); }
|
|
||||||
return { success: true, data: text };
|
|
||||||
}
|
|
||||||
if (!response.ok) { throw new Error(data.message || `An unknown error occurred.`); }
|
|
||||||
return { success: true, data };
|
|
||||||
} catch (error) {
|
|
||||||
showMessage(error.message, 'error');
|
|
||||||
return { success: false };
|
|
||||||
} finally {
|
|
||||||
showLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- View Management ---
|
|
||||||
const showView = (viewName) => {
|
|
||||||
clearInterval(employeeTimerInterval);
|
|
||||||
adminTimerIntervals.forEach(clearInterval);
|
|
||||||
adminTimerIntervals = [];
|
|
||||||
Object.keys(mainViews).forEach(v => mainViews[v].classList.toggle('hidden', v !== viewName));
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- UI Rendering ---
|
|
||||||
function updateUI() {
|
|
||||||
try {
|
|
||||||
// UPDATED: Adjust main container padding for mobile
|
|
||||||
const mainContainer = document.querySelector('main.container');
|
|
||||||
if (mainContainer) {
|
|
||||||
mainContainer.classList.remove('px-6');
|
|
||||||
mainContainer.classList.add('px-4', 'sm:px-6');
|
|
||||||
}
|
|
||||||
const storedUser = localStorage.getItem('user');
|
|
||||||
authToken = localStorage.getItem('authToken');
|
|
||||||
user = storedUser ? JSON.parse(storedUser) : null;
|
|
||||||
if (authToken && user) {
|
|
||||||
navUserControls.classList.remove('hidden');
|
|
||||||
welcomeMessage.textContent = `Welcome, ${user.username}`;
|
|
||||||
if (user.role === 'admin') {
|
|
||||||
showView('admin');
|
|
||||||
renderAdminDashboard();
|
|
||||||
} else {
|
|
||||||
showView('employee');
|
|
||||||
renderEmployeeDashboard();
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
navUserControls.classList.add('hidden');
|
|
||||||
showView('auth');
|
|
||||||
renderAuthView();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error initializing UI:", error);
|
|
||||||
handleSignOut("There was an error loading your session.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderAuthView() {
|
|
||||||
mainViews.auth.innerHTML = `
|
|
||||||
<div class="max-w-md mx-auto mt-10 p-8 border rounded-xl shadow-lg bg-white">
|
|
||||||
<h2 class="text-3xl font-bold text-center text-gray-800 mb-6">Employee Login</h2>
|
|
||||||
<form id="auth-form" class="space-y-4">
|
|
||||||
<input type="text" id="username" placeholder="Username" class="w-full p-2 border rounded" required>
|
|
||||||
<input type="password" id="password" placeholder="Password" class="w-full p-2 border rounded" required>
|
|
||||||
<button type="submit" class="w-full bg-blue-600 text-white p-2 rounded hover:bg-blue-700">Log In</button>
|
|
||||||
</form>
|
|
||||||
</div>`;
|
|
||||||
document.getElementById('auth-form').addEventListener('submit', handleAuthSubmit);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function renderEmployeeDashboard() {
|
|
||||||
clearInterval(employeeTimerInterval);
|
|
||||||
const [statusRes, timeOffRes, notesRes] = await Promise.all([apiCall('/status'), apiCall('/user/time-off-requests'), apiCall('/user/notes')]);
|
|
||||||
if (!statusRes.success || !timeOffRes.success || !notesRes.success) return;
|
|
||||||
|
|
||||||
const entries = statusRes.data;
|
|
||||||
const requests = timeOffRes.data;
|
|
||||||
const notes = notesRes.data;
|
|
||||||
const last = entries[0];
|
|
||||||
const punchedIn = last?.status === 'in';
|
|
||||||
let totalMilliseconds = entries.reduce((acc, e) => {
|
|
||||||
return e.status === 'out' && e.punch_out_time ? acc + (new Date(e.punch_out_time) - new Date(e.punch_in_time)) : acc;
|
|
||||||
}, 0);
|
|
||||||
|
|
||||||
mainViews.employee.innerHTML = `
|
|
||||||
<div class="max-w-4xl mx-auto space-y-8">
|
|
||||||
<div class="bg-white rounded-xl shadow-md p-6">
|
|
||||||
<div class="grid md:grid-cols-2 gap-6 items-center">
|
|
||||||
<div class="p-6 rounded-lg text-white text-center h-48 flex flex-col justify-center ${punchedIn ? 'bg-red-500' : 'bg-green-500'}">
|
|
||||||
<h3 class="text-xl font-semibold">Current Status</h3>
|
|
||||||
<p class="text-3xl font-bold">${punchedIn ? 'Punched In' : 'Punched Out'}</p>
|
|
||||||
<p class="text-sm">${punchedIn ? 'Since:' : 'Last Punch:'} ${formatDateTime(punchedIn ? last.punch_in_time : last?.punch_out_time)}</p>
|
|
||||||
</div>
|
|
||||||
<div class="flex justify-center">
|
|
||||||
<button id="punch-btn" class="w-48 h-48 rounded-full text-white font-bold text-2xl transition-transform transform hover:scale-105 ${punchedIn ? 'bg-red-600' : 'bg-green-600'}">${punchedIn ? 'Punch Out' : 'Punch In'}</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="bg-white rounded-xl shadow-md p-6">
|
|
||||||
<h3 class="text-xl font-bold text-gray-700 mb-4">Notes from Admin</h3>
|
|
||||||
<ul class="space-y-4 mt-4 max-h-60 overflow-y-auto">${notes.length > 0 ? notes.map(note => `<li class="bg-amber-50 p-3 rounded-lg border-l-4 border-amber-400"><p class="text-gray-800 break-words">"${note.note_text}"</p><p class="text-xs text-gray-500 text-right mt-2">- ${note.admin_username} on ${formatDate(note.created_at)}</p></li>`).join('') : '<p class="text-gray-500 text-center">You have no new notes.</p>'}</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="bg-white rounded-xl shadow-md p-6">
|
|
||||||
<div class="flex justify-between items-center"><h3 class="text-xl font-bold text-gray-700">My Account</h3><button id="change-password-btn" class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700">Change Password</button></div>
|
|
||||||
<div class="mt-4 p-4 bg-blue-50 rounded-lg"><h4 class="font-semibold text-blue-800">My Total Hours (This Pay Period)</h4><p class="text-3xl font-bold text-blue-600" id="employee-total-hours">${formatDecimal(totalMilliseconds)}</p></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="bg-white rounded-xl shadow-md p-6">
|
|
||||||
<div class="flex justify-between items-center mb-4">
|
|
||||||
<h3 class="text-xl font-bold text-gray-700">Time Off Requests</h3>
|
|
||||||
<button id="view-request-history-btn" class="text-sm px-3 py-1 bg-gray-200 rounded-lg hover:bg-gray-300">View History</button>
|
|
||||||
</div>
|
|
||||||
<form id="time-off-form" class="grid md:grid-cols-3 gap-4 items-end bg-gray-50 p-4 rounded-lg">
|
|
||||||
<div><label class="block text-sm font-medium">Start Date</label><input type="date" id="start-date" class="w-full p-2 border rounded" required></div>
|
|
||||||
<div><label class="block text-sm font-medium">End Date</label><input type="date" id="end-date" class="w-full p-2 border rounded" required></div>
|
|
||||||
<button type="submit" class="w-full bg-indigo-600 text-white p-2 rounded hover:bg-indigo-700">Submit Request</button>
|
|
||||||
<div class="md:col-span-3"><label class="block text-sm font-medium">Reason (optional)</label><input type="text" id="reason" placeholder="e.g., Vacation" class="w-full p-2 border rounded"></div>
|
|
||||||
</form>
|
|
||||||
<div class="mt-4 overflow-x-auto border rounded-lg"><table class="min-w-full text-sm text-left"><thead class="bg-gray-50"><tr><th class="p-2">Dates</th><th class="p-2">Reason</th><th class="p-2">Status</th></tr></thead><tbody>${requests.map(r => `<tr class="border-t"><td class="p-2 whitespace-nowrap">${formatDate(r.start_date)} - ${formatDate(r.end_date)}</td><td class="p-2">${r.reason || ''}</td><td class="p-2 font-medium capitalize text-${r.status === 'approved' ? 'green' : r.status === 'denied' ? 'red' : 'gray'}-600">${r.status}</td></tr>`).join('') || '<tr><td colspan="3" class="text-center p-4">No upcoming or pending requests.</td></tr>'}</tbody></table></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="bg-white rounded-xl shadow-md p-6">
|
|
||||||
<h3 class="text-xl font-bold text-gray-700 mb-2">My Time Log</h3>
|
|
||||||
<div class="overflow-x-auto border rounded-lg"><table class="min-w-full text-sm text-left"><thead class="bg-gray-50"><tr><th class="p-2">In</th><th class="p-2">Out</th><th class="p-2">Duration (Hours)</th></tr></thead><tbody>${entries.map(e => `<tr class="border-t"><td class="p-2">${formatDateTime(e.punch_in_time)}</td><td class="p-2">${formatDateTime(e.punch_out_time)}</td><td class="p-2" id="duration-${e.id}">${e.status === 'in' ? 'Running...' : formatDecimal(new Date(e.punch_out_time) - new Date(e.punch_in_time))}</td></tr>`).join('') || '<tr><td colspan="3" class="text-center p-4">No entries.</td></tr>'}</tbody></table></div>
|
|
||||||
</div>
|
|
||||||
</div>`;
|
|
||||||
document.getElementById('punch-btn').addEventListener('click', handlePunch);
|
|
||||||
document.getElementById('change-password-btn').addEventListener('click', renderChangePasswordModal);
|
|
||||||
document.getElementById('time-off-form').addEventListener('submit', handleTimeOffRequest);
|
|
||||||
document.getElementById('view-request-history-btn').addEventListener('click', handleViewRequestHistoryClick); // Attach handler to new button
|
|
||||||
|
|
||||||
if (punchedIn) {
|
|
||||||
const durationCell = document.getElementById(`duration-${last.id}`);
|
|
||||||
const totalHoursCell = document.getElementById('employee-total-hours');
|
|
||||||
const punchInTime = new Date(last.punch_in_time);
|
|
||||||
employeeTimerInterval = setInterval(() => {
|
|
||||||
const elapsed = Date.now() - punchInTime.getTime();
|
|
||||||
if (durationCell) durationCell.textContent = formatDuration(elapsed);
|
|
||||||
if (totalHoursCell) totalHoursCell.textContent = formatDecimal(totalMilliseconds + elapsed);
|
|
||||||
}, 1000);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function renderAdminDashboard() {
|
|
||||||
const [logsRes, usersRes, requestsRes] = await Promise.all([apiCall('/admin/logs'), apiCall('/admin/users'), apiCall('/admin/time-off-requests/pending')]);
|
|
||||||
if (!logsRes.success || !usersRes.success || !requestsRes.success) return;
|
|
||||||
allTimeEntries = logsRes.data; allUsers = usersRes.data; const pendingRequests = requestsRes.data;
|
|
||||||
const employeeTotals = allTimeEntries.reduce((acc, entry) => { const dur = entry.punch_out_time ? (new Date(entry.punch_out_time) - new Date(entry.punch_in_time)) : (Date.now() - new Date(entry.punch_in_time)); acc[entry.username] = (acc[entry.username] || 0) + dur; return acc; }, {});
|
|
||||||
const punchedInEntries = allTimeEntries.filter(e => e.status === 'in');
|
|
||||||
const employeesOnly = allUsers.filter(u => u.role === 'employee');
|
|
||||||
|
|
||||||
mainViews.admin.innerHTML = `
|
|
||||||
<div class="max-w-6xl mx-auto space-y-8">
|
|
||||||
<div class="bg-white rounded-xl shadow-md p-6"><div class="flex flex-wrap justify-between items-center gap-4"><h2 class="text-2xl font-bold">Admin Dashboard</h2><div class="flex-shrink-0 space-x-2"><button id="view-archives-btn" class="px-4 py-2 bg-gray-500 text-white rounded-lg hover:bg-gray-600">View Archives</button><button id="archive-btn" class="px-4 py-2 bg-amber-500 text-white rounded-lg hover:bg-amber-600">Archive Records</button></div></div></div>
|
|
||||||
|
|
||||||
<div class="bg-white rounded-xl shadow-md p-6">
|
|
||||||
<h3 class="text-xl font-bold text-gray-700 mb-4">Employee Notes</h3>
|
|
||||||
<form id="add-note-form" class="space-y-3 bg-gray-50 p-4 rounded-lg">
|
|
||||||
<select id="note-user-select" class="w-full p-2 border rounded" required>
|
|
||||||
<option value="">-- Select an Employee --</option>
|
|
||||||
${employeesOnly.map(u => `<option value="${u.id}">${u.username}</option>`).join('')}
|
|
||||||
</select>
|
|
||||||
<textarea id="note-text" placeholder="Write a new note here..." class="w-full p-2 border rounded" rows="3" required></textarea>
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<button type="submit" class="w-full bg-cyan-600 text-white p-2 rounded hover:bg-cyan-700">Submit Note</button>
|
|
||||||
<button type="button" id="view-notes-btn" class="w-full bg-gray-600 text-white p-2 rounded hover:bg-gray-700">View Notes</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
<div id="employee-notes-container" class="mt-4"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="bg-white rounded-xl shadow-md p-6"><h3 class="text-xl font-bold text-gray-700 mb-2">Currently Punched In</h3><ul class="border rounded-lg divide-y">${punchedInEntries.map(e => `<li class="flex flex-col items-start space-y-2 p-3 sm:flex-row sm:items-center sm:justify-between sm:space-y-0"><span class="font-medium text-gray-800">${e.username}</span><div class="flex items-center space-x-4"><span class="text-sm text-gray-500">Since: ${formatDateTime(e.punch_in_time)}</span><button class="force-clock-out-btn px-3 py-1 text-xs bg-red-500 text-white rounded whitespace-nowrap" data-userid="${e.user_id}" data-username="${e.username}">Force Clock Out</button></div></li>`).join('') || '<li class="p-4 text-center text-gray-500">None</li>'}</ul></div>
|
|
||||||
<div class="bg-white rounded-xl shadow-md p-6"><div class="flex justify-between items-center mb-4"><h3 class="text-xl font-bold text-gray-700">Pending Time Off Requests</h3><button id="view-time-off-history-btn" class="px-4 py-2 text-sm bg-gray-200 rounded-lg hover:bg-gray-300">View History</button></div><div class="overflow-x-auto border rounded-lg"><table class="min-w-full text-sm text-left"><thead class="bg-gray-50"><tr><th class="p-2">Employee</th><th class="p-2">Dates</th><th class="p-2">Reason</th><th class="p-2">Actions</th></tr></thead><tbody>${pendingRequests.map(r => `<tr class="border-t"><td class="p-2">${r.username}</td><td class="p-2 whitespace-nowrap">${formatDate(r.start_date)} - ${formatDate(r.end_date)}</td><td class="p-2">${r.reason||''}</td><td class="p-2"><div class="flex flex-col sm:flex-row gap-2"><button class="approve-request-btn font-medium text-green-600 hover:underline" data-id="${r.id}">Approve</button><button class="deny-request-btn font-medium text-red-600 hover:underline" data-id="${r.id}">Deny</button></div></td></tr>`).join('') || '<tr><td colspan="4" class="text-center p-4">No pending requests.</td></tr>'}</tbody></table></div></div>
|
|
||||||
<div class="bg-white rounded-xl shadow-md p-6"><h3 class="text-xl font-bold text-gray-700 mb-2">Hours by Employee</h3><div class="overflow-x-auto border rounded-lg"><table class="min-w-full text-sm text-left"><thead class="bg-gray-50"><tr><th class="p-2">Employee</th><th class="p-2">Total Hours</th></tr></thead><tbody>${Object.entries(employeeTotals).map(([username, totalMs]) => `<tr class="border-t"><td class="p-2 font-medium">${username}</td><td class="p-2">${formatDecimal(totalMs)}</td></tr>`).join('') || '<tr><td colspan="2" class="text-center p-4">No data.</td></tr>'}</tbody></table></div></div>
|
|
||||||
<div class="bg-white rounded-xl shadow-md p-6"><h3 class="text-xl font-bold text-gray-700 mb-4">Detailed Logs</h3><div class="overflow-x-auto border rounded-lg"><table class="min-w-full text-sm text-left"><thead class="bg-gray-50"><tr><th class="p-2">Employee</th><th class="p-2">In</th><th class="p-2">Out</th><th class="p-2">Duration</th><th class="p-2">Actions</th></tr></thead><tbody>${allTimeEntries.map(e => `<tr class="border-t"><td class="p-2">${e.username||'N/A'}</td><td class="p-2">${formatDateTime(e.punch_in_time)}</td><td class="p-2">${formatDateTime(e.punch_out_time)}</td><td class="p-2" id="admin-duration-${e.id}">${e.punch_out_time ? formatDecimal(new Date(e.punch_out_time) - new Date(e.punch_in_time)) + ' hrs' : '...'}</td><td class="p-2"><div class="flex flex-col sm:flex-row items-start sm:items-center gap-2"><button class="edit-btn font-medium text-blue-600 hover:underline" data-id="${e.id}">Edit</button><button class="delete-btn font-medium text-red-600 hover:underline" data-id="${e.id}">Delete</button></div></td></tr>`).join('')}</tbody></table></div></div>
|
|
||||||
<div class="bg-white rounded-xl shadow-md p-6"><h3 class="text-xl font-bold text-gray-700 mb-4">User & Payroll Management</h3><div class="grid md:grid-cols-2 gap-6"><form id="create-user-form" class="space-y-3 bg-gray-50 p-4 rounded-lg"><h4 class="font-semibold">Create User</h4><input type="text" id="new-username" placeholder="Username" class="w-full p-2 border rounded" required><input type="password" id="new-password" placeholder="Password" class="w-full p-2 border rounded" required><select id="new-user-role" class="w-full p-2 border rounded"><option value="employee">Employee</option><option value="admin">Admin</option></select><button type="submit" class="w-full bg-green-600 text-white p-2 rounded hover:bg-green-700">Create User</button></form><form id="add-punch-form" class="space-y-3 bg-gray-50 p-4 rounded-lg"><h4 class="font-semibold">Add Manual Entry</h4><select id="add-punch-user" class="w-full p-2 border rounded" required>${allUsers.map(u => `<option value="${u.id}" data-username="${u.username}">${u.username}</option>`).join('')}</select><label class="text-sm">In (Required):</label><input type="datetime-local" id="add-punch-in" class="w-full p-2 border rounded" required><label class="text-sm">Out (Optional):</label><input type="datetime-local" id="add-punch-out" class="w-full p-2 border rounded"><button type="submit" class="w-full bg-purple-600 text-white p-2 rounded hover:bg-purple-700">Add Entry</button></form></div><div class="mt-6"><h4 class="font-semibold mb-2">Manage Users</h4><div class="overflow-x-auto border rounded-lg"><table class="min-w-full text-sm text-left"><thead class="bg-gray-50"><tr><th class="p-2">Username</th><th class="p-2">Role</th><th class="p-2">Actions</th></tr></thead><tbody>${allUsers.map(u => `<tr class="border-t"><td class="p-2 font-medium">${u.username}</td><td class="p-2 capitalize">${u.role}</td><td class="p-2"><div class="flex flex-col sm:flex-row items-start sm:items-center gap-2">${u.isPrimary ? `<span class="text-sm text-gray-500">Primary Admin</span>` : `<button class="reset-pw-btn font-medium text-blue-600 hover:underline" data-username="${u.username}">Reset PW</button><button class="change-role-btn font-medium text-purple-600 hover:underline" data-username="${u.username}" data-role="${u.role}">${u.role === 'admin' ? 'Demote' : 'Promote'}</button>${u.username !== user.username ? `<button class="delete-user-btn font-medium text-red-600 hover:underline" data-username="${u.username}">Delete</button>` : ''}`}</div></td></tr>`).join('')}</tbody></table></div></div></div>
|
|
||||||
</div>`;
|
|
||||||
punchedInEntries.forEach(entry => { const durationCell = document.getElementById(`admin-duration-${entry.id}`); if (durationCell) { const punchInTime = new Date(entry.punch_in_time); const intervalId = setInterval(() => { durationCell.textContent = formatDuration(Date.now() - punchInTime.getTime()); }, 1000); adminTimerIntervals.push(intervalId); } });
|
|
||||||
document.getElementById('archive-btn').addEventListener('click', handleArchive); document.getElementById('view-archives-btn').addEventListener('click', renderArchiveView); document.getElementById('view-time-off-history-btn').addEventListener('click', renderTimeOffHistoryView); document.getElementById('create-user-form').addEventListener('submit', handleCreateUser); document.getElementById('add-punch-form').addEventListener('submit', handleAddPunch); document.getElementById('add-note-form').addEventListener('submit', handleAddNote);
|
|
||||||
document.getElementById('view-notes-btn').addEventListener('click', handleViewNotesClick); // Add listener for new button
|
|
||||||
document.getElementById('admin-dashboard').addEventListener('click', handleAdminDashboardClick);
|
|
||||||
}
|
|
||||||
function renderArchiveView() {
|
|
||||||
apiCall('/admin/archives').then(res => {
|
|
||||||
if (!res.success) return;
|
|
||||||
showView('archive');
|
|
||||||
mainViews.archive.innerHTML = `
|
|
||||||
<div class="max-w-6xl mx-auto bg-white rounded-xl shadow-md p-6"><div class="flex justify-between items-center mb-4"><h2 class="text-2xl font-bold">Archived Logs</h2><button id="back-to-dash-btn" class="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600">Back to Dashboard</button></div><div class="overflow-x-auto border rounded-lg"><table class="min-w-full text-sm text-left"><thead class="bg-gray-50"><tr><th class="p-2">Employee</th><th class="p-2">In</th><th class="p-2">Out</th><th class="p-2">Duration (Hrs)</th><th class="p-2">Archived On</th></tr></thead><tbody>${res.data.map(e => `<tr class="border-t"><td class="p-2">${e.username||'N/A'}</td><td class="p-2">${formatDateTime(e.punch_in_time)}</td><td class="p-2">${formatDateTime(e.punch_out_time)}</td><td class="p-2">${formatDecimal(new Date(e.punch_out_time) - new Date(e.punch_in_time))}</td><td class="p-2">${formatDateTime(e.archived_at)}</td></tr>`).join('') || '<tr><td colspan="5" class="text-center p-4">No archived entries found.</td></tr>'}</tbody></table></div></div>`;
|
|
||||||
document.getElementById('back-to-dash-btn').addEventListener('click', () => { showView('admin'); renderAdminDashboard(); });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderTimeOffHistoryView() {
|
|
||||||
apiCall('/admin/time-off-requests/history').then(res => {
|
|
||||||
if (!res.success) return;
|
|
||||||
showView('timeOffHistory');
|
|
||||||
mainViews.timeOffHistory.innerHTML = `
|
|
||||||
<div class="max-w-6xl mx-auto bg-white rounded-xl shadow-md p-6"><div class="flex justify-between items-center mb-4"><h2 class="text-2xl font-bold">Time Off History</h2><button id="back-to-dash-btn" class="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600">Back to Dashboard</button></div><div class="overflow-x-auto border rounded-lg"><table class="min-w-full text-sm text-left"><thead class="bg-gray-50"><tr><th class="p-2">Employee</th><th class="p-2">Dates</th><th class="p-2">Reason</th><th class="p-2">Status</th></tr></thead><tbody>${res.data.map(r => `<tr class="border-t"><td class="p-2">${r.username}</td><td class="p-2 whitespace-nowrap">${formatDate(r.start_date)} - ${formatDate(r.end_date)}</td><td class="p-2">${r.reason||''}</td><td class="p-2 font-medium capitalize text-${r.status === 'approved' ? 'green' : 'red'}-600">${r.status}</td></tr>`).join('') || '<tr><td colspan="4" class="text-center p-4">No history.</td></tr>'}</tbody></table></div></div>`;
|
|
||||||
document.getElementById('back-to-dash-btn').addEventListener('click', () => { showView('admin'); renderAdminDashboard(); });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderModal(title, formHTML, submitHandler) {
|
|
||||||
modalContainer.innerHTML = `<div class="modal-overlay" role="dialog" aria-modal="true"><div class="modal-content"><h3 class="text-xl font-bold mb-4">${title}</h3><form id="modal-form" class="space-y-4">${formHTML}<div class="flex justify-end space-x-2 pt-4"><button type="button" class="cancel-modal-btn px-4 py-2 bg-gray-300 rounded-lg hover:bg-gray-400">Cancel</button><button type="submit" class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700">Save</button></div></form></div></div>`;
|
|
||||||
document.getElementById('modal-form').addEventListener('submit', submitHandler);
|
|
||||||
document.querySelector('.cancel-modal-btn').addEventListener('click', () => modalContainer.innerHTML = '');
|
|
||||||
document.querySelector('.modal-overlay').addEventListener('click', (e) => { if (e.target === e.currentTarget) modalContainer.innerHTML = ''; });
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderEditModal(id) {
|
|
||||||
const entry = allTimeEntries.find(e => e.id == id);
|
|
||||||
if (!entry) { showMessage('Could not find entry to edit.', 'error'); return; }
|
|
||||||
const formHTML = `<input type="hidden" id="edit-id" value="${entry.id}"><div><label class="font-medium">Punch In</label><input type="datetime-local" id="edit-in" value="${toLocalISO(entry.punch_in_time)}" class="w-full p-2 border rounded" required></div><div><label class="font-medium">Punch Out</label><input type="datetime-local" id="edit-out" value="${toLocalISO(entry.punch_out_time)}" class="w-full p-2 border rounded"></div>`;
|
|
||||||
renderModal(`Edit Entry for ${entry.username}`, formHTML, handleEditSubmit);
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderChangePasswordModal() {
|
|
||||||
const formHTML = `<input type="password" id="modal-current-pw" placeholder="Current Password" class="w-full p-2 border rounded" required><input type="password" id="modal-new-pw" placeholder="New Password" class="w-full p-2 border rounded" required>`;
|
|
||||||
renderModal('Change My Password', formHTML, handleChangePassword);
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderResetPasswordModal(username) {
|
|
||||||
const formHTML = `<input type="hidden" id="reset-username" value="${username}"><input type="password" id="reset-new-pw" placeholder="New Password" class="w-full p-2 border rounded" required>`;
|
|
||||||
renderModal(`Reset Password for ${username}`, formHTML, handleResetPassword);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleViewNotesClick() {
|
|
||||||
const userId = document.getElementById('note-user-select').value;
|
|
||||||
const container = document.getElementById('employee-notes-container');
|
|
||||||
if (!userId) {
|
|
||||||
return showMessage('Please select an employee to view their notes.', 'error');
|
|
||||||
}
|
|
||||||
|
|
||||||
container.innerHTML = 'Loading notes...';
|
<script type="module" src="js/main.js"></script></body>
|
||||||
const res = await apiCall(`/admin/notes/${userId}`);
|
|
||||||
|
|
||||||
if (res.success) {
|
|
||||||
if (res.data.length > 0) {
|
|
||||||
container.innerHTML = `
|
|
||||||
<h4 class="font-semibold mb-2 text-gray-600">Showing Notes for ${document.getElementById('note-user-select').options[document.getElementById('note-user-select').selectedIndex].text}</h4>
|
|
||||||
<ul class="space-y-3 max-h-72 overflow-y-auto border rounded-lg p-2 bg-gray-50">
|
|
||||||
${res.data.map(note => `
|
|
||||||
<li class="bg-white p-3 rounded-lg shadow-sm flex justify-between items-start">
|
|
||||||
<div>
|
|
||||||
<p class="text-gray-800 break-words">"${note.note_text}"</p>
|
|
||||||
<p class="text-xs text-gray-500 mt-2">- ${note.admin_username} on ${formatDate(note.created_at)}</p>
|
|
||||||
</div>
|
|
||||||
<button class="delete-note-btn text-red-500 hover:text-red-700 flex-shrink-0 ml-4" data-note-id="${note.id}">×</button>
|
|
||||||
</li>
|
|
||||||
`).join('')}
|
|
||||||
</ul>`;
|
|
||||||
} else {
|
|
||||||
container.innerHTML = '<p class="text-gray-500 text-center">No notes found for this employee.</p>';
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
container.innerHTML = '<p class="text-red-500 text-center">Could not load notes.</p>';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// --- Event Handlers (no changes needed below this line, but included for completeness) ---
|
|
||||||
async function handleAuthSubmit(e) { e.preventDefault(); const username = e.target.elements.username.value; const password = e.target.elements.password.value; const res = await apiCall('/login', 'POST', { username, password }); if (res.success) { authToken = res.data.token; user = res.data.user; localStorage.setItem('authToken', authToken); localStorage.setItem('user', JSON.stringify(user)); updateUI(); } }
|
|
||||||
const handleSignOut = (message) => { localStorage.clear(); authToken = null; user = null; updateUI(); if (message) showMessage(message, 'success'); };
|
|
||||||
const handlePunch = () => apiCall('/punch', 'POST').then(res => res.success && renderEmployeeDashboard());
|
|
||||||
function handleAdminDashboardClick(e) {
|
|
||||||
const target = e.target;
|
|
||||||
const { id, userid, username, role, noteId } = target.dataset;
|
|
||||||
|
|
||||||
if (target.classList.contains('edit-btn')) renderEditModal(id);
|
|
||||||
if (target.classList.contains('delete-btn') && confirm('Are you sure you want to delete this time entry?')) apiCall(`/admin/logs/${id}`, 'DELETE').then(res => res.success && renderAdminDashboard());
|
|
||||||
if (target.classList.contains('force-clock-out-btn') && confirm(`Are you sure you want to force clock out ${username}?`)) apiCall('/admin/force-clock-out', 'POST', { userId: userid }).then(res => res.success && renderAdminDashboard());
|
|
||||||
if (target.classList.contains('reset-pw-btn')) renderResetPasswordModal(username);
|
|
||||||
if (target.classList.contains('change-role-btn')) { const newRole = role === 'admin' ? 'employee' : 'admin'; if(confirm(`Change ${username} to ${newRole}?`)) apiCall('/admin/update-role', 'POST', { username, newRole }).then(res => res.success && renderAdminDashboard()); }
|
|
||||||
if (target.classList.contains('delete-user-btn') && confirm(`PERMANENTLY DELETE user '${username}' and all their data? This cannot be undone.`)) apiCall(`/admin/delete-user/${username}`, 'DELETE').then(res => res.success && renderAdminDashboard());
|
|
||||||
if (target.classList.contains('approve-request-btn')) { if (confirm('Set this request to "approved"?')) apiCall('/admin/update-time-off-status', 'POST', { requestId: id, status: 'approved' }).then(res => res.success && renderAdminDashboard()); }
|
|
||||||
if (target.classList.contains('deny-request-btn')) { if (confirm('Set this request to "denied"?')) apiCall('/admin/update-time-off-status', 'POST', { requestId: id, status: 'denied' }).then(res => res.success && renderAdminDashboard()); }
|
|
||||||
|
|
||||||
// ADDED: Logic to delete a note
|
|
||||||
if (target.classList.contains('delete-note-btn')) {
|
|
||||||
if (confirm('Are you sure you want to delete this note?')) {
|
|
||||||
apiCall(`/admin/notes/${noteId}`, 'DELETE').then(res => {
|
|
||||||
if (res.success) {
|
|
||||||
showMessage('Note deleted.', 'success');
|
|
||||||
handleViewNotesClick(); // Refresh the notes list
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleEditSubmit(e) { e.preventDefault(); const id = e.target.elements['edit-id'].value; const punch_in_time = new Date(e.target.elements['edit-in'].value).toISOString(); const punch_out_value = e.target.elements['edit-out'].value; const punch_out_time = punch_out_value ? new Date(punch_out_value).toISOString() : null; const res = await apiCall(`/admin/logs/${id}`, 'PUT', { punch_in_time, punch_out_time }); if (res.success) { modalContainer.innerHTML = ''; renderAdminDashboard(); showMessage('Entry updated successfully.', 'success'); } }
|
|
||||||
const handleArchive = () => { if (confirm('Are you sure you want to archive all completed time entries? This action cannot be undone.')) { apiCall('/admin/archive', 'POST').then(res => { if(res.success) { showMessage('Records archived successfully.', 'success'); renderAdminDashboard(); } }); } };
|
|
||||||
async function handleCreateUser(e) { e.preventDefault(); const username = e.target.elements['new-username'].value; const password = e.target.elements['new-password'].value; const role = e.target.elements['new-user-role'].value; const res = await apiCall('/admin/create-user', 'POST', { username, password, role }); if (res.success) { showMessage(res.data.message, 'success'); e.target.reset(); renderAdminDashboard(); } }
|
|
||||||
async function handleChangePassword(e) { e.preventDefault(); const currentPassword = e.target.elements['modal-current-pw'].value; const newPassword = e.target.elements['modal-new-pw'].value; const res = await apiCall('/user/change-password', 'POST', { currentPassword, newPassword }); if (res.success) { showMessage(res.data.message, 'success'); modalContainer.innerHTML = ''; } }
|
|
||||||
async function handleResetPassword(e) { e.preventDefault(); const username = e.target.elements['reset-username'].value; const newPassword = e.target.elements['reset-new-pw'].value; const res = await apiCall('/admin/reset-password', 'POST', { username, newPassword }); if (res.success) { showMessage(res.data.message, 'success'); modalContainer.innerHTML = ''; } }
|
|
||||||
async function handleAddPunch(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
const selected = e.target.elements['add-punch-user'];
|
|
||||||
const userId = selected.value;
|
|
||||||
const username = selected.options[selected.selectedIndex].dataset.username;
|
|
||||||
const punchInTime = new Date(e.target.elements['add-punch-in'].value).toISOString();
|
|
||||||
|
|
||||||
|
|
||||||
// Handle the optional punch-out time
|
|
||||||
const punchOutValue = e.target.elements['add-punch-out'].value;
|
|
||||||
const punchOutTime = punchOutValue ? new Date(punchOutValue).toISOString() : null;
|
|
||||||
|
|
||||||
const res = await apiCall('/admin/add-punch', 'POST', { userId, username, punchInTime, punchOutTime });
|
|
||||||
if (res.success) {
|
|
||||||
showMessage(res.data.message, 'success');
|
|
||||||
e.target.reset();
|
|
||||||
renderAdminDashboard();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderRequestHistoryModal(requests) {
|
|
||||||
const modalBody = `
|
|
||||||
<div class="max-h-[70vh] overflow-y-auto">
|
|
||||||
<table class="min-w-full text-sm text-left">
|
|
||||||
<thead class="bg-gray-50 sticky top-0">
|
|
||||||
<tr><th class="p-2">Dates</th><th class="p-2">Reason</th><th class="p-2">Status</th></tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
${requests.map(r => `
|
|
||||||
<tr class="border-t">
|
|
||||||
<td class="p-2 whitespace-nowrap">${formatDate(r.start_date)} - ${formatDate(r.end_date)}</td>
|
|
||||||
<td class="p-2">${r.reason || ''}</td>
|
|
||||||
<td class="p-2 font-medium capitalize text-${r.status === 'approved' ? 'green' : r.status === 'denied' ? 'red' : 'gray'}-600">${r.status}</td>
|
|
||||||
</tr>
|
|
||||||
`).join('') || '<tr><td colspan="3" class="text-center p-4">No history found.</td></tr>'}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>`;
|
|
||||||
|
|
||||||
// Using a simplified modal since we only need a "Close" button
|
|
||||||
modalContainer.innerHTML = `
|
|
||||||
<div class="modal-overlay">
|
|
||||||
<div class="modal-content">
|
|
||||||
<div class="flex justify-between items-center mb-4">
|
|
||||||
<h3 class="text-xl font-bold">Full Time Off History</h3>
|
|
||||||
<button class="cancel-modal-btn font-bold text-2xl">×</button>
|
|
||||||
</div>
|
|
||||||
${modalBody}
|
|
||||||
</div>
|
|
||||||
</div>`;
|
|
||||||
|
|
||||||
document.querySelector('.cancel-modal-btn').addEventListener('click', () => modalContainer.innerHTML = '');
|
|
||||||
document.querySelector('.modal-overlay').addEventListener('click', (e) => { if (e.target === e.currentTarget) modalContainer.innerHTML = ''; });
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleViewRequestHistoryClick() {
|
|
||||||
const res = await apiCall('/user/time-off-requests/history');
|
|
||||||
if (res.success) {
|
|
||||||
renderRequestHistoryModal(res.data);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
async function handleAddNote(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
const userId = e.target.elements['note-user-select'].value;
|
|
||||||
const noteText = e.target.elements['note-text'].value;
|
|
||||||
|
|
||||||
if (!userId) {
|
|
||||||
return showMessage('Please select an employee.', 'error');
|
|
||||||
}
|
|
||||||
|
|
||||||
// BEFORE: const res = await apiCall('/api/admin/notes', 'POST', { userId, noteText });
|
|
||||||
// AFTER (Corrected):
|
|
||||||
const res = await apiCall('/admin/notes', 'POST', { userId, noteText });
|
|
||||||
|
|
||||||
if (res.success) {
|
|
||||||
showMessage(res.data.message, 'success');
|
|
||||||
e.target.reset();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
async function handleTimeOffRequest(e) { e.preventDefault(); const startDate = e.target.elements['start-date'].value; const endDate = e.target.elements['end-date'].value; const reason = e.target.elements['reason'].value; if (new Date(endDate) < new Date(startDate)) { return showMessage('End date cannot be before start date.', 'error'); } const res = await apiCall('/user/request-time-off', 'POST', { startDate, endDate, reason }); if (res.success) { showMessage(res.data.message, 'success'); e.target.reset(); renderEmployeeDashboard(); } }
|
|
||||||
|
|
||||||
// --- Initializer ---
|
|
||||||
signOutBtn.addEventListener('click', () => handleSignOut('You have been signed out.'));
|
|
||||||
document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible' && user) { console.log("Tab is visible, refreshing UI."); updateUI(); } });
|
|
||||||
updateUI(); // Initial load
|
|
||||||
</script>
|
|
||||||
|
|
||||||
</body>
|
|
||||||
</html>
|
</html>
|
||||||
49
public/js/api.js
Normal file
49
public/js/api.js
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
// js/api.js
|
||||||
|
|
||||||
|
import { showLoading, showMessage } from './utils.js';
|
||||||
|
|
||||||
|
const API_BASE_URL = '/api';
|
||||||
|
|
||||||
|
// This function will be the single point of contact with the server.
|
||||||
|
export async function apiCall(endpoint, method = 'GET', body = null) {
|
||||||
|
const authToken = localStorage.getItem('authToken');
|
||||||
|
const headers = { 'Content-Type': 'application/json' };
|
||||||
|
if (authToken) headers['Authorization'] = `Bearer ${authToken}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
showLoading(true);
|
||||||
|
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
|
||||||
|
method,
|
||||||
|
headers,
|
||||||
|
body: body ? JSON.stringify(body) : null
|
||||||
|
});
|
||||||
|
|
||||||
|
// The original logic for handling responses remains the same.
|
||||||
|
if (response.status === 401) {
|
||||||
|
// We'll need to pass the signOut function in or handle it differently
|
||||||
|
// For now, let's just redirect.
|
||||||
|
localStorage.clear();
|
||||||
|
window.location.reload();
|
||||||
|
// A more robust solution would use a custom event.
|
||||||
|
return { success: false, message: 'Session expired.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = await response.text();
|
||||||
|
let data;
|
||||||
|
try {
|
||||||
|
data = text ? JSON.parse(text) : null;
|
||||||
|
} catch (error) {
|
||||||
|
if (!response.ok) { throw new Error(text || `Request failed with status ${response.status}`); }
|
||||||
|
return { success: true, data: text };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) { throw new Error(data.message || `An unknown error occurred.`); }
|
||||||
|
return { success: true, data };
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
showMessage(error.message, 'error');
|
||||||
|
return { success: false };
|
||||||
|
} finally {
|
||||||
|
showLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
244
public/js/main.js
Normal file
244
public/js/main.js
Normal file
@ -0,0 +1,244 @@
|
|||||||
|
// js/main.js
|
||||||
|
|
||||||
|
// --- IMPORTS ---
|
||||||
|
// We import everything we need from our other modules.
|
||||||
|
import { apiCall } from './api.js';
|
||||||
|
import { showMessage } from './utils.js';
|
||||||
|
import {
|
||||||
|
updateUI,
|
||||||
|
renderAuthView,
|
||||||
|
renderAdminDashboard,
|
||||||
|
renderEmployeeDashboard,
|
||||||
|
renderEditModal,
|
||||||
|
renderChangePasswordModal,
|
||||||
|
renderResetPasswordModal,
|
||||||
|
renderRequestHistoryModal,
|
||||||
|
handleViewNotesClick, // This UI-specific handler is simple enough to live in ui.js
|
||||||
|
renderArchiveView,
|
||||||
|
renderTimeOffHistoryView
|
||||||
|
} from './ui.js';
|
||||||
|
|
||||||
|
// --- STATE MANAGEMENT ---
|
||||||
|
// Simple module-level state.
|
||||||
|
let user = null;
|
||||||
|
let authToken = null;
|
||||||
|
|
||||||
|
// --- EVENT HANDLERS (The "Logic") ---
|
||||||
|
// These functions define what happens when a user interacts with the app.
|
||||||
|
|
||||||
|
async function handleAuthSubmit(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const username = e.target.elements.username.value;
|
||||||
|
const password = e.target.elements.password.value;
|
||||||
|
const res = await apiCall('/login', 'POST', { username, password });
|
||||||
|
if (res.success) {
|
||||||
|
initializeApp(); // Re-initialize the app state after login
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSignOut(message) {
|
||||||
|
localStorage.clear();
|
||||||
|
authToken = null;
|
||||||
|
user = null;
|
||||||
|
if (message) {
|
||||||
|
showMessage(message, 'success');
|
||||||
|
}
|
||||||
|
initializeApp();
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePunch = () => {
|
||||||
|
apiCall('/punch', 'POST').then(res => res.success && renderEmployeeDashboard());
|
||||||
|
};
|
||||||
|
|
||||||
|
async function handleChangePassword(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const currentPassword = e.target.elements['modal-current-pw'].value;
|
||||||
|
const newPassword = e.target.elements['modal-new-pw'].value;
|
||||||
|
const res = await apiCall('/user/change-password', 'POST', { currentPassword, newPassword });
|
||||||
|
if (res.success) {
|
||||||
|
showMessage(res.data.message, 'success');
|
||||||
|
document.getElementById('modal-container').innerHTML = ''; // Close modal
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleTimeOffRequest(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const startDate = e.target.elements['start-date'].value;
|
||||||
|
const endDate = e.target.elements['end-date'].value;
|
||||||
|
const reason = e.target.elements['reason'].value;
|
||||||
|
if (new Date(endDate) < new Date(startDate)) {
|
||||||
|
return showMessage('End date cannot be before start date.', 'error');
|
||||||
|
}
|
||||||
|
const res = await apiCall('/user/request-time-off', 'POST', { startDate, endDate, reason });
|
||||||
|
if (res.success) {
|
||||||
|
showMessage(res.data.message, 'success');
|
||||||
|
e.target.reset();
|
||||||
|
renderEmployeeDashboard();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleViewRequestHistoryClick() {
|
||||||
|
const res = await apiCall('/user/time-off-requests/history');
|
||||||
|
if (res.success) {
|
||||||
|
renderRequestHistoryModal(res.data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleEditSubmit(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const id = e.target.elements['edit-id'].value;
|
||||||
|
const punch_in_time = new Date(e.target.elements['edit-in'].value).toISOString();
|
||||||
|
const punch_out_value = e.target.elements['edit-out'].value;
|
||||||
|
const punch_out_time = punch_out_value ? new Date(punch_out_value).toISOString() : null;
|
||||||
|
const res = await apiCall(`/admin/logs/${id}`, 'PUT', { punch_in_time, punch_out_time });
|
||||||
|
if (res.success) {
|
||||||
|
document.getElementById('modal-container').innerHTML = '';
|
||||||
|
renderAdminDashboard();
|
||||||
|
showMessage('Entry updated successfully.', 'success');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleArchive = () => {
|
||||||
|
if (confirm('Are you sure you want to archive all completed time entries? This action cannot be undone.')) {
|
||||||
|
apiCall('/admin/archive', 'POST').then(res => {
|
||||||
|
if(res.success) {
|
||||||
|
showMessage('Records archived successfully.', 'success');
|
||||||
|
renderAdminDashboard();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
async function handleCreateUser(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const username = e.target.elements['new-username'].value;
|
||||||
|
const password = e.target.elements['new-password'].value;
|
||||||
|
const role = e.target.elements['new-user-role'].value;
|
||||||
|
const res = await apiCall('/admin/create-user', 'POST', { username, password, role });
|
||||||
|
if (res.success) {
|
||||||
|
showMessage(res.data.message, 'success');
|
||||||
|
e.target.reset();
|
||||||
|
renderAdminDashboard();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleAddPunch(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const selected = e.target.elements['add-punch-user'];
|
||||||
|
const userId = selected.value;
|
||||||
|
const username = selected.options[selected.selectedIndex].dataset.username;
|
||||||
|
const punchInTime = new Date(e.target.elements['add-punch-in'].value).toISOString();
|
||||||
|
const punchOutValue = e.target.elements['add-punch-out'].value;
|
||||||
|
const punchOutTime = punchOutValue ? new Date(punchOutValue).toISOString() : null;
|
||||||
|
const res = await apiCall('/admin/add-punch', 'POST', { userId, username, punchInTime, punchOutTime });
|
||||||
|
if (res.success) {
|
||||||
|
showMessage(res.data.message, 'success');
|
||||||
|
e.target.reset();
|
||||||
|
renderAdminDashboard();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleAddNote(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const userId = e.target.elements['note-user-select'].value;
|
||||||
|
const noteText = e.target.elements['note-text'].value;
|
||||||
|
if (!userId) {
|
||||||
|
return showMessage('Please select an employee.', 'error');
|
||||||
|
}
|
||||||
|
const res = await apiCall('/admin/notes', 'POST', { userId, noteText });
|
||||||
|
if (res.success) {
|
||||||
|
showMessage(res.data.message, 'success');
|
||||||
|
e.target.reset();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleResetPassword(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const username = e.target.elements['reset-username'].value;
|
||||||
|
const newPassword = e.target.elements['reset-new-pw'].value;
|
||||||
|
const res = await apiCall('/admin/reset-password', 'POST', { username, newPassword });
|
||||||
|
if (res.success) {
|
||||||
|
showMessage(res.data.message, 'success');
|
||||||
|
document.getElementById('modal-container').innerHTML = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// This single handler uses event delegation for all buttons on the admin dashboard
|
||||||
|
function handleAdminDashboardClick(e) {
|
||||||
|
const target = e.target;
|
||||||
|
const { id, userid, username, role, noteId } = target.dataset;
|
||||||
|
|
||||||
|
if (target.classList.contains('edit-btn')) renderEditModal(id, handleEditSubmit);
|
||||||
|
if (target.classList.contains('delete-btn') && confirm('Delete this time entry?')) apiCall(`/admin/logs/${id}`, 'DELETE').then(res => res.success && renderAdminDashboard());
|
||||||
|
if (target.classList.contains('force-clock-out-btn') && confirm(`Force clock out ${username}?`)) apiCall('/admin/force-clock-out', 'POST', { userId: userid }).then(res => res.success && renderAdminDashboard());
|
||||||
|
if (target.classList.contains('reset-pw-btn')) renderResetPasswordModal(username, handleResetPassword);
|
||||||
|
if (target.classList.contains('change-role-btn')) { const newRole = role === 'admin' ? 'employee' : 'admin'; if(confirm(`Change ${username} to ${newRole}?`)) apiCall('/admin/update-role', 'POST', { username, newRole }).then(res => res.success && renderAdminDashboard()); }
|
||||||
|
if (target.classList.contains('delete-user-btn') && confirm(`PERMANENTLY DELETE user '${username}'?`)) apiCall(`/admin/delete-user/${username}`, 'DELETE').then(res => res.success && renderAdminDashboard());
|
||||||
|
if (target.classList.contains('approve-request-btn')) { if (confirm('Approve request?')) apiCall('/admin/update-time-off-status', 'POST', { requestId: id, status: 'approved' }).then(res => res.success && renderAdminDashboard()); }
|
||||||
|
if (target.classList.contains('deny-request-btn')) { if (confirm('Deny request?')) apiCall('/admin/update-time-off-status', 'POST', { requestId: id, status: 'denied' }).then(res => res.success && renderAdminDashboard()); }
|
||||||
|
if (target.classList.contains('delete-note-btn')) { if (confirm('Delete this note?')) { apiCall(`/admin/notes/${noteId}`, 'DELETE').then(res => { if (res.success) { showMessage('Note deleted.', 'success'); handleViewNotesClick(); }});}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- LISTENER ATTACHMENT FUNCTIONS ---
|
||||||
|
// These are exported to ui.js and called after a view is rendered to make it interactive.
|
||||||
|
|
||||||
|
export function attachAuthFormListener() {
|
||||||
|
const form = document.getElementById('auth-form');
|
||||||
|
form.addEventListener('submit', handleAuthSubmit);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function attachEmployeeDashboardListeners() {
|
||||||
|
document.getElementById('punch-btn').addEventListener('click', handlePunch);
|
||||||
|
document.getElementById('change-password-btn').addEventListener('click', () => renderChangePasswordModal(handleChangePassword));
|
||||||
|
document.getElementById('time-off-form').addEventListener('submit', handleTimeOffRequest);
|
||||||
|
document.getElementById('view-request-history-btn').addEventListener('click', handleViewRequestHistoryClick);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function attachAdminDashboardListeners() {
|
||||||
|
// Event delegation for all buttons
|
||||||
|
document.getElementById('admin-dashboard').addEventListener('click', handleAdminDashboardClick);
|
||||||
|
|
||||||
|
// Specific form handlers
|
||||||
|
document.getElementById('create-user-form').addEventListener('submit', handleCreateUser);
|
||||||
|
document.getElementById('add-punch-form').addEventListener('submit', handleAddPunch);
|
||||||
|
document.getElementById('add-note-form').addEventListener('submit', handleAddNote);
|
||||||
|
|
||||||
|
// Other top-level buttons
|
||||||
|
document.getElementById('archive-btn').addEventListener('click', handleArchive);
|
||||||
|
document.getElementById('view-archives-btn').addEventListener('click', renderArchiveView);
|
||||||
|
document.getElementById('view-time-off-history-btn').addEventListener('click', renderTimeOffHistoryView);
|
||||||
|
document.getElementById('view-notes-btn').addEventListener('click', handleViewNotesClick);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- APP INITIALIZER ---
|
||||||
|
function initializeApp() {
|
||||||
|
authToken = localStorage.getItem('authToken');
|
||||||
|
const userString = localStorage.getItem('user');
|
||||||
|
user = userString ? JSON.parse(userString) : null;
|
||||||
|
|
||||||
|
if (authToken && user) {
|
||||||
|
const userControls = document.getElementById('nav-user-controls');
|
||||||
|
userControls.classList.remove('hidden');
|
||||||
|
userControls.querySelector('#welcome-message').textContent = `Welcome, ${user.username}`;
|
||||||
|
if (user.role === 'admin') {
|
||||||
|
renderAdminDashboard();
|
||||||
|
} else {
|
||||||
|
renderEmployeeDashboard();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
document.getElementById('nav-user-controls').classList.add('hidden');
|
||||||
|
renderAuthView();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- START THE APP ---
|
||||||
|
// Attach global listeners that are always present.
|
||||||
|
document.getElementById('sign-out-btn').addEventListener('click', () => handleSignOut('You have been signed out.'));
|
||||||
|
document.addEventListener('visibilitychange', () => {
|
||||||
|
if (document.visibilityState === 'visible' && localStorage.getItem('user')) {
|
||||||
|
initializeApp();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initial call to start the app on page load.
|
||||||
|
initializeApp();
|
||||||
282
public/js/ui.js
Normal file
282
public/js/ui.js
Normal file
@ -0,0 +1,282 @@
|
|||||||
|
// js/ui.js
|
||||||
|
|
||||||
|
// --- IMPORTS ---
|
||||||
|
// Imports functions for server calls, utility formatting, and attaching event listeners.
|
||||||
|
import { apiCall } from './api.js';
|
||||||
|
import * as utils from './utils.js';
|
||||||
|
import {
|
||||||
|
attachAuthFormListener,
|
||||||
|
attachAdminDashboardListeners,
|
||||||
|
attachEmployeeDashboardListeners,
|
||||||
|
} from './main.js';
|
||||||
|
|
||||||
|
// --- DOM ELEMENT SELECTORS ---
|
||||||
|
const mainViews = {
|
||||||
|
auth: document.getElementById('auth-view'),
|
||||||
|
employee: document.getElementById('employee-dashboard'),
|
||||||
|
admin: document.getElementById('admin-dashboard'),
|
||||||
|
archive: document.getElementById('admin-archive-view'),
|
||||||
|
timeOffHistory: document.getElementById('admin-time-off-history-view')
|
||||||
|
};
|
||||||
|
const navUserControls = document.getElementById('nav-user-controls');
|
||||||
|
const welcomeMessage = document.getElementById('welcome-message');
|
||||||
|
const modalContainer = document.getElementById('modal-container');
|
||||||
|
|
||||||
|
// --- MODULE-LEVEL STATE ---
|
||||||
|
// These are specific to the UI and manage running timers.
|
||||||
|
let employeeTimerInterval = null;
|
||||||
|
let adminTimerIntervals = [];
|
||||||
|
// This state is populated by renderAdminDashboard and used by its helper functions like renderEditModal
|
||||||
|
let allTimeEntries = [];
|
||||||
|
let allUsers = [];
|
||||||
|
|
||||||
|
|
||||||
|
// --- VIEW MANAGEMENT ---
|
||||||
|
export function showView(viewName) {
|
||||||
|
clearInterval(employeeTimerInterval);
|
||||||
|
adminTimerIntervals.forEach(clearInterval);
|
||||||
|
adminTimerIntervals = [];
|
||||||
|
Object.keys(mainViews).forEach(v => mainViews[v].classList.toggle('hidden', v !== viewName));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- MASTER UI UPDATE FUNCTION ---
|
||||||
|
|
||||||
|
export function updateUI() {
|
||||||
|
try {
|
||||||
|
const storedUser = localStorage.getItem('user');
|
||||||
|
const authToken = localStorage.getItem('authToken');
|
||||||
|
const user = storedUser ? JSON.parse(storedUser) : null;
|
||||||
|
|
||||||
|
if (authToken && user) {
|
||||||
|
// ... (rest of the if block is fine)
|
||||||
|
} else {
|
||||||
|
// ... (rest of the else block is fine)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// This is the corrected catch block
|
||||||
|
console.error("Corrupted session data. Clearing and reloading.", error);
|
||||||
|
localStorage.clear();
|
||||||
|
window.location.reload(); // Reload the page to a clean state
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// --- RENDER FUNCTIONS ---
|
||||||
|
|
||||||
|
export function renderAuthView() {
|
||||||
|
showView('auth');
|
||||||
|
mainViews.auth.innerHTML = `
|
||||||
|
<div class="max-w-md mx-auto mt-10 p-8 border rounded-xl shadow-lg bg-white">
|
||||||
|
<h2 class="text-3xl font-bold text-center text-gray-800 mb-6">Employee Login</h2>
|
||||||
|
<form id="auth-form" class="space-y-4">
|
||||||
|
<input type="text" id="username" placeholder="Username" class="w-full p-2 border rounded" required>
|
||||||
|
<input type="password" id="password" placeholder="Password" class="w-full p-2 border rounded" required>
|
||||||
|
<button type="submit" class="w-full bg-blue-600 text-white p-2 rounded hover:bg-blue-700">Log In</button>
|
||||||
|
</form>
|
||||||
|
</div>`;
|
||||||
|
attachAuthFormListener();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function renderEmployeeDashboard() {
|
||||||
|
showView('employee');
|
||||||
|
clearInterval(employeeTimerInterval);
|
||||||
|
const [statusRes, timeOffRes, notesRes] = await Promise.all([apiCall('/status'), apiCall('/user/time-off-requests'), apiCall('/user/notes')]);
|
||||||
|
if (!statusRes.success || !timeOffRes.success || !notesRes.success) return;
|
||||||
|
|
||||||
|
const entries = statusRes.data;
|
||||||
|
const requests = timeOffRes.data;
|
||||||
|
const notes = notesRes.data;
|
||||||
|
const last = entries[0];
|
||||||
|
const punchedIn = last?.status === 'in';
|
||||||
|
let totalMilliseconds = entries.reduce((acc, e) => {
|
||||||
|
return e.status === 'out' && e.punch_out_time ? acc + (new Date(e.punch_out_time) - new Date(e.punch_in_time)) : acc;
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
mainViews.employee.innerHTML = `
|
||||||
|
<div class="max-w-4xl mx-auto space-y-8">
|
||||||
|
<div class="bg-white rounded-xl shadow-md p-6">
|
||||||
|
<div class="grid md:grid-cols-2 gap-6 items-center">
|
||||||
|
<div class="p-6 rounded-lg text-white text-center h-48 flex flex-col justify-center ${punchedIn ? 'bg-red-500' : 'bg-green-500'}">
|
||||||
|
<h3 class="text-xl font-semibold">Current Status</h3>
|
||||||
|
<p class="text-3xl font-bold">${punchedIn ? 'Punched In' : 'Punched Out'}</p>
|
||||||
|
<p class="text-sm">${punchedIn ? 'Since:' : 'Last Punch:'} ${utils.formatDateTime(punchedIn ? last.punch_in_time : last?.punch_out_time)}</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-center">
|
||||||
|
<button id="punch-btn" class="w-48 h-48 rounded-full text-white font-bold text-2xl transition-transform transform hover:scale-105 ${punchedIn ? 'bg-red-600' : 'bg-green-600'}">${punchedIn ? 'Punch Out' : 'Punch In'}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-white rounded-xl shadow-md p-6">
|
||||||
|
<h3 class="text-xl font-bold text-gray-700 mb-4">Notes from Admin</h3>
|
||||||
|
<ul class="space-y-4 mt-4 max-h-60 overflow-y-auto">${notes.length > 0 ? notes.map(note => `<li class="bg-amber-50 p-3 rounded-lg border-l-4 border-amber-400"><p class="text-gray-800 break-words">"${note.note_text}"</p><p class="text-xs text-gray-500 text-right mt-2">- ${note.admin_username} on ${utils.formatDate(note.created_at)}</p></li>`).join('') : '<p class="text-gray-500 text-center">You have no new notes.</p>'}</ul>
|
||||||
|
</div>
|
||||||
|
<div class="bg-white rounded-xl shadow-md p-6">
|
||||||
|
<div class="flex justify-between items-center"><h3 class="text-xl font-bold text-gray-700">My Account</h3><button id="change-password-btn" class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700">Change Password</button></div>
|
||||||
|
<div class="mt-4 p-4 bg-blue-50 rounded-lg"><h4 class="font-semibold text-blue-800">My Total Hours (This Pay Period)</h4><p class="text-3xl font-bold text-blue-600" id="employee-total-hours">${utils.formatDecimal(totalMilliseconds)}</p></div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-white rounded-xl shadow-md p-6">
|
||||||
|
<div class="flex justify-between items-center mb-4">
|
||||||
|
<h3 class="text-xl font-bold text-gray-700">Time Off Requests</h3>
|
||||||
|
<button id="view-request-history-btn" class="text-sm px-3 py-1 bg-gray-200 rounded-lg hover:bg-gray-300">View History</button>
|
||||||
|
</div>
|
||||||
|
<form id="time-off-form" class="grid md:grid-cols-3 gap-4 items-end bg-gray-50 p-4 rounded-lg">
|
||||||
|
<div><label class="block text-sm font-medium">Start Date</label><input type="date" id="start-date" class="w-full p-2 border rounded" required></div>
|
||||||
|
<div><label class="block text-sm font-medium">End Date</label><input type="date" id="end-date" class="w-full p-2 border rounded" required></div>
|
||||||
|
<button type="submit" class="w-full bg-indigo-600 text-white p-2 rounded hover:bg-indigo-700">Submit Request</button>
|
||||||
|
<div class="md:col-span-3"><label class="block text-sm font-medium">Reason (optional)</label><input type="text" id="reason" placeholder="e.g., Vacation" class="w-full p-2 border rounded"></div>
|
||||||
|
</form>
|
||||||
|
<div class="mt-4 overflow-x-auto border rounded-lg"><table class="min-w-full text-sm text-left"><thead class="bg-gray-50"><tr><th class="p-2">Dates</th><th class="p-2">Reason</th><th class="p-2">Status</th></tr></thead><tbody>${requests.map(r => `<tr class="border-t"><td class="p-2 whitespace-nowrap">${utils.formatDate(r.start_date)} - ${utils.formatDate(r.end_date)}</td><td class="p-2">${r.reason || ''}</td><td class="p-2 font-medium capitalize text-${r.status === 'approved' ? 'green' : r.status === 'denied' ? 'red' : 'gray'}-600">${r.status}</td></tr>`).join('') || '<tr><td colspan="3" class="text-center p-4">No upcoming or pending requests.</td></tr>'}</tbody></table></div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-white rounded-xl shadow-md p-6">
|
||||||
|
<h3 class="text-xl font-bold text-gray-700 mb-2">My Time Log</h3>
|
||||||
|
<div class="overflow-x-auto border rounded-lg"><table class="min-w-full text-sm text-left"><thead class="bg-gray-50"><tr><th class="p-2">In</th><th class="p-2">Out</th><th class="p-2">Duration (Hours)</th></tr></thead><tbody>${entries.map(e => `<tr class="border-t"><td class="p-2">${utils.formatDateTime(e.punch_in_time)}</td><td class="p-2">${utils.formatDateTime(e.punch_out_time)}</td><td class="p-2" id="duration-${e.id}">${e.status === 'in' ? 'Running...' : utils.formatDecimal(new Date(e.punch_out_time) - new Date(e.punch_in_time))}</td></tr>`).join('') || '<tr><td colspan="3" class="text-center p-4">No entries.</td></tr>'}</tbody></table></div>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
attachEmployeeDashboardListeners(); // Attach all listeners for this view
|
||||||
|
|
||||||
|
if (punchedIn) {
|
||||||
|
const durationCell = document.getElementById(`duration-${last.id}`);
|
||||||
|
const totalHoursCell = document.getElementById('employee-total-hours');
|
||||||
|
const punchInTime = new Date(last.punch_in_time);
|
||||||
|
employeeTimerInterval = setInterval(() => {
|
||||||
|
const elapsed = Date.now() - punchInTime.getTime();
|
||||||
|
if (durationCell) durationCell.textContent = utils.formatDuration(elapsed);
|
||||||
|
if (totalHoursCell) totalHoursCell.textContent = utils.formatDecimal(totalMilliseconds + elapsed);
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function renderAdminDashboard() {
|
||||||
|
showView('admin');
|
||||||
|
const [logsRes, usersRes, requestsRes] = await Promise.all([apiCall('/admin/logs'), apiCall('/admin/users'), apiCall('/admin/time-off-requests/pending')]);
|
||||||
|
if (!logsRes.success || !usersRes.success || !requestsRes.success) return;
|
||||||
|
|
||||||
|
// Update module-level state
|
||||||
|
allTimeEntries = logsRes.data;
|
||||||
|
allUsers = usersRes.data;
|
||||||
|
const pendingRequests = requestsRes.data;
|
||||||
|
const user = JSON.parse(localStorage.getItem('user'));
|
||||||
|
|
||||||
|
const employeeTotals = allTimeEntries.reduce((acc, entry) => { const dur = entry.punch_out_time ? (new Date(entry.punch_out_time) - new Date(entry.punch_in_time)) : (Date.now() - new Date(entry.punch_in_time)); acc[entry.username] = (acc[entry.username] || 0) + dur; return acc; }, {});
|
||||||
|
const punchedInEntries = allTimeEntries.filter(e => e.status === 'in');
|
||||||
|
const employeesOnly = allUsers.filter(u => u.role === 'employee');
|
||||||
|
|
||||||
|
mainViews.admin.innerHTML = `
|
||||||
|
<div class="max-w-6xl mx-auto space-y-8">
|
||||||
|
<div class="bg-white rounded-xl shadow-md p-6"><div class="flex flex-wrap justify-between items-center gap-4"><h2 class="text-2xl font-bold">Admin Dashboard</h2><div class="flex-shrink-0 space-x-2"><button id="view-archives-btn" class="px-4 py-2 bg-gray-500 text-white rounded-lg hover:bg-gray-600">View Archives</button><button id="archive-btn" class="px-4 py-2 bg-amber-500 text-white rounded-lg hover:bg-amber-600">Archive Records</button></div></div></div>
|
||||||
|
<div class="bg-white rounded-xl shadow-md p-6">
|
||||||
|
<h3 class="text-xl font-bold text-gray-700 mb-4">Employee Notes</h3>
|
||||||
|
<form id="add-note-form" class="space-y-3 bg-gray-50 p-4 rounded-lg">
|
||||||
|
<select id="note-user-select" class="w-full p-2 border rounded" required><option value="">-- Select an Employee --</option>${employeesOnly.map(u => `<option value="${u.id}">${u.username}</option>`).join('')}</select>
|
||||||
|
<textarea id="note-text" placeholder="Write a new note here..." class="w-full p-2 border rounded" rows="3" required></textarea>
|
||||||
|
<div class="flex gap-2"><button type="submit" class="w-full bg-cyan-600 text-white p-2 rounded hover:bg-cyan-700">Submit Note</button><button type="button" id="view-notes-btn" class="w-full bg-gray-600 text-white p-2 rounded hover:bg-gray-700">View Notes</button></div>
|
||||||
|
</form>
|
||||||
|
<div id="employee-notes-container" class="mt-4"></div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-white rounded-xl shadow-md p-6"><h3 class="text-xl font-bold text-gray-700 mb-2">Currently Punched In</h3><ul class="border rounded-lg divide-y">${punchedInEntries.map(e => `<li class="flex flex-col items-start space-y-2 p-3 sm:flex-row sm:items-center sm:justify-between sm:space-y-0"><span class="font-medium text-gray-800">${e.username}</span><div class="flex items-center space-x-4"><span class="text-sm text-gray-500">Since: ${utils.formatDateTime(e.punch_in_time)}</span><button class="force-clock-out-btn px-3 py-1 text-xs bg-red-500 text-white rounded whitespace-nowrap" data-userid="${e.user_id}" data-username="${e.username}">Force Clock Out</button></div></li>`).join('') || '<li class="p-4 text-center text-gray-500">None</li>'}</ul></div>
|
||||||
|
<div class="bg-white rounded-xl shadow-md p-6"><div class="flex justify-between items-center mb-4"><h3 class="text-xl font-bold text-gray-700">Pending Time Off Requests</h3><button id="view-time-off-history-btn" class="px-4 py-2 text-sm bg-gray-200 rounded-lg hover:bg-gray-300">View History</button></div><div class="overflow-x-auto border rounded-lg"><table class="min-w-full text-sm text-left"><thead class="bg-gray-50"><tr><th class="p-2">Employee</th><th class="p-2">Dates</th><th class="p-2">Reason</th><th class="p-2">Actions</th></tr></thead><tbody>${pendingRequests.map(r => `<tr class="border-t"><td class="p-2">${r.username}</td><td class="p-2 whitespace-nowrap">${utils.formatDate(r.start_date)} - ${utils.formatDate(r.end_date)}</td><td class="p-2">${r.reason||''}</td><td class="p-2"><div class="flex flex-col sm:flex-row gap-2"><button class="approve-request-btn font-medium text-green-600 hover:underline" data-id="${r.id}">Approve</button><button class="deny-request-btn font-medium text-red-600 hover:underline" data-id="${r.id}">Deny</button></div></td></tr>`).join('') || '<tr><td colspan="4" class="text-center p-4">No pending requests.</td></tr>'}</tbody></table></div></div>
|
||||||
|
<div class="bg-white rounded-xl shadow-md p-6"><h3 class="text-xl font-bold text-gray-700 mb-2">Hours by Employee</h3><div class="overflow-x-auto border rounded-lg"><table class="min-w-full text-sm text-left"><thead class="bg-gray-50"><tr><th class="p-2">Employee</th><th class="p-2">Total Hours</th></tr></thead><tbody>${Object.entries(employeeTotals).map(([username, totalMs]) => `<tr class="border-t"><td class="p-2 font-medium">${username}</td><td class="p-2">${utils.formatDecimal(totalMs)}</td></tr>`).join('') || '<tr><td colspan="2" class="text-center p-4">No data.</td></tr>'}</tbody></table></div></div>
|
||||||
|
<div class="bg-white rounded-xl shadow-md p-6"><h3 class="text-xl font-bold text-gray-700 mb-4">Detailed Logs</h3><div class="overflow-x-auto border rounded-lg"><table class="min-w-full text-sm text-left"><thead class="bg-gray-50"><tr><th class="p-2">Employee</th><th class="p-2">In</th><th class="p-2">Out</th><th class="p-2">Duration</th><th class="p-2">Actions</th></tr></thead><tbody>${allTimeEntries.map(e => `<tr class="border-t"><td class="p-2">${e.username||'N/A'}</td><td class="p-2">${utils.formatDateTime(e.punch_in_time)}</td><td class="p-2">${utils.formatDateTime(e.punch_out_time)}</td><td class="p-2" id="admin-duration-${e.id}">${e.punch_out_time ? utils.formatDecimal(new Date(e.punch_out_time) - new Date(e.punch_in_time)) + ' hrs' : '...'}</td><td class="p-2"><div class="flex flex-col sm:flex-row items-start sm:items-center gap-2"><button class="edit-btn font-medium text-blue-600 hover:underline" data-id="${e.id}">Edit</button><button class="delete-btn font-medium text-red-600 hover:underline" data-id="${e.id}">Delete</button></div></td></tr>`).join('')}</tbody></table></div></div>
|
||||||
|
<div class="bg-white rounded-xl shadow-md p-6"><h3 class="text-xl font-bold text-gray-700 mb-4">User & Payroll Management</h3><div class="grid md:grid-cols-2 gap-6"><form id="create-user-form" class="space-y-3 bg-gray-50 p-4 rounded-lg"><h4 class="font-semibold">Create User</h4><input type="text" id="new-username" placeholder="Username" class="w-full p-2 border rounded" required><input type="password" id="new-password" placeholder="Password" class="w-full p-2 border rounded" required><select id="new-user-role" class="w-full p-2 border rounded"><option value="employee">Employee</option><option value="admin">Admin</option></select><button type="submit" class="w-full bg-green-600 text-white p-2 rounded hover:bg-green-700">Create User</button></form><form id="add-punch-form" class="space-y-3 bg-gray-50 p-4 rounded-lg"><h4 class="font-semibold">Add Manual Entry</h4><select id="add-punch-user" class="w-full p-2 border rounded" required>${allUsers.map(u => `<option value="${u.id}" data-username="${u.username}">${u.username}</option>`).join('')}</select><label class="text-sm">In (Required):</label><input type="datetime-local" id="add-punch-in" class="w-full p-2 border rounded" required><label class="text-sm">Out (Optional):</label><input type="datetime-local" id="add-punch-out" class="w-full p-2 border rounded"><button type="submit" class="w-full bg-purple-600 text-white p-2 rounded hover:bg-purple-700">Add Entry</button></form></div><div class="mt-6"><h4 class="font-semibold mb-2">Manage Users</h4><div class="overflow-x-auto border rounded-lg"><table class="min-w-full text-sm text-left"><thead class="bg-gray-50"><tr><th class="p-2">Username</th><th class="p-2">Role</th><th class="p-2">Actions</th></tr></thead><tbody>${allUsers.map(u => `<tr class="border-t"><td class="p-2 font-medium">${u.username}</td><td class="p-2 capitalize">${u.role}</td><td class="p-2"><div class="flex flex-col sm:flex-row items-start sm:items-center gap-2">${u.isPrimary ? `<span class="text-sm text-gray-500">Primary Admin</span>` : `<button class="reset-pw-btn font-medium text-blue-600 hover:underline" data-username="${u.username}">Reset PW</button><button class="change-role-btn font-medium text-purple-600 hover:underline" data-username="${u.username}" data-role="${u.role}">${u.role === 'admin' ? 'Demote' : 'Promote'}</button>${u.username !== user.username ? `<button class="delete-user-btn font-medium text-red-600 hover:underline" data-username="${u.username}">Delete</button>` : ''}`}</div></td></tr>`).join('')}</tbody></table></div></div></div>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
attachAdminDashboardListeners(); // Attach all listeners for this view
|
||||||
|
|
||||||
|
punchedInEntries.forEach(entry => {
|
||||||
|
const durationCell = document.getElementById(`admin-duration-${entry.id}`);
|
||||||
|
if (durationCell) {
|
||||||
|
const punchInTime = new Date(entry.punch_in_time);
|
||||||
|
const intervalId = setInterval(() => {
|
||||||
|
durationCell.textContent = utils.formatDuration(Date.now() - punchInTime.getTime());
|
||||||
|
}, 1000);
|
||||||
|
adminTimerIntervals.push(intervalId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderArchiveView() {
|
||||||
|
apiCall('/admin/archives').then(res => {
|
||||||
|
if (!res.success) return;
|
||||||
|
showView('archive');
|
||||||
|
mainViews.archive.innerHTML = `
|
||||||
|
<div class="max-w-6xl mx-auto bg-white rounded-xl shadow-md p-6"><div class="flex justify-between items-center mb-4"><h2 class="text-2xl font-bold">Archived Logs</h2><button id="back-to-dash-btn" class="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600">Back to Dashboard</button></div><div class="overflow-x-auto border rounded-lg"><table class="min-w-full text-sm text-left"><thead class="bg-gray-50"><tr><th class="p-2">Employee</th><th class="p-2">In</th><th class="p-2">Out</th><th class="p-2">Duration (Hrs)</th><th class="p-2">Archived On</th></tr></thead><tbody>${res.data.map(e => `<tr class="border-t"><td class="p-2">${e.username||'N/A'}</td><td class="p-2">${utils.formatDateTime(e.punch_in_time)}</td><td class="p-2">${utils.formatDateTime(e.punch_out_time)}</td><td class="p-2">${utils.formatDecimal(new Date(e.punch_out_time) - new Date(e.punch_in_time))}</td><td class="p-2">${utils.formatDateTime(e.archived_at)}</td></tr>`).join('') || '<tr><td colspan="5" class="text-center p-4">No archived entries found.</td></tr>'}</tbody></table></div></div>`;
|
||||||
|
document.getElementById('back-to-dash-btn').addEventListener('click', renderAdminDashboard);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderTimeOffHistoryView() {
|
||||||
|
apiCall('/admin/time-off-requests/history').then(res => {
|
||||||
|
if (!res.success) return;
|
||||||
|
showView('timeOffHistory');
|
||||||
|
mainViews.timeOffHistory.innerHTML = `
|
||||||
|
<div class="max-w-6xl mx-auto bg-white rounded-xl shadow-md p-6"><div class="flex justify-between items-center mb-4"><h2 class="text-2xl font-bold">Time Off History</h2><button id="back-to-dash-btn" class="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600">Back to Dashboard</button></div><div class="overflow-x-auto border rounded-lg"><table class="min-w-full text-sm text-left"><thead class="bg-gray-50"><tr><th class="p-2">Employee</th><th class="p-2">Dates</th><th class="p-2">Reason</th><th class="p-2">Status</th></tr></thead><tbody>${res.data.map(r => `<tr class="border-t"><td class="p-2">${r.username}</td><td class="p-2 whitespace-nowrap">${utils.formatDate(r.start_date)} - ${utils.formatDate(r.end_date)}</td><td class="p-2">${r.reason||''}</td><td class="p-2 font-medium capitalize text-${r.status === 'approved' ? 'green' : 'red'}-600">${r.status}</td></tr>`).join('') || '<tr><td colspan="4" class="text-center p-4">No history.</td></tr>'}</tbody></table></div></div>`;
|
||||||
|
document.getElementById('back-to-dash-btn').addEventListener('click', renderAdminDashboard);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- MODAL RENDER FUNCTIONS ---
|
||||||
|
function renderModal(title, formHTML, submitHandler) {
|
||||||
|
modalContainer.innerHTML = `<div class="modal-overlay" role="dialog" aria-modal="true"><div class="modal-content"><h3 class="text-xl font-bold mb-4">${title}</h3><form id="modal-form" class="space-y-4">${formHTML}<div class="flex justify-end space-x-2 pt-4"><button type="button" class="cancel-modal-btn px-4 py-2 bg-gray-300 rounded-lg hover:bg-gray-400">Cancel</button><button type="submit" class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700">Save</button></div></form></div></div>`;
|
||||||
|
document.getElementById('modal-form').addEventListener('submit', submitHandler);
|
||||||
|
document.querySelector('.cancel-modal-btn').addEventListener('click', () => modalContainer.innerHTML = '');
|
||||||
|
document.querySelector('.modal-overlay').addEventListener('click', (e) => { if (e.target === e.currentTarget) modalContainer.innerHTML = ''; });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderEditModal(id, submitHandler) {
|
||||||
|
const entry = allTimeEntries.find(e => e.id == id);
|
||||||
|
if (!entry) { utils.showMessage('Could not find entry to edit.', 'error'); return; }
|
||||||
|
const formHTML = `<input type="hidden" id="edit-id" value="${entry.id}"><div><label class="font-medium">Punch In</label><input type="datetime-local" id="edit-in" value="${utils.toLocalISO(entry.punch_in_time)}" class="w-full p-2 border rounded" required></div><div><label class="font-medium">Punch Out</label><input type="datetime-local" id="edit-out" value="${utils.toLocalISO(entry.punch_out_time)}" class="w-full p-2 border rounded"></div>`;
|
||||||
|
renderModal(`Edit Entry for ${entry.username}`, formHTML, submitHandler);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderChangePasswordModal(submitHandler) {
|
||||||
|
const formHTML = `<input type="password" id="modal-current-pw" placeholder="Current Password" class="w-full p-2 border rounded" required><input type="password" id="modal-new-pw" placeholder="New Password" class="w-full p-2 border rounded" required>`;
|
||||||
|
renderModal('Change My Password', formHTML, submitHandler);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderResetPasswordModal(username, submitHandler) {
|
||||||
|
const formHTML = `<input type="hidden" id="reset-username" value="${username}"><input type="password" id="reset-new-pw" placeholder="New Password" class="w-full p-2 border rounded" required>`;
|
||||||
|
renderModal(`Reset Password for ${username}`, formHTML, submitHandler);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderRequestHistoryModal(requests) {
|
||||||
|
const modalBody = `...`; // Same as your original function
|
||||||
|
modalContainer.innerHTML = `...`; // Same as your original function
|
||||||
|
document.querySelector('.cancel-modal-btn').addEventListener('click', () => modalContainer.innerHTML = '');
|
||||||
|
document.querySelector('.modal-overlay').addEventListener('click', (e) => { if (e.target === e.currentTarget) modalContainer.innerHTML = ''; });
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// --- UI HELPER FUNCTIONS ---
|
||||||
|
export async function handleViewNotesClick() {
|
||||||
|
const userId = document.getElementById('note-user-select').value;
|
||||||
|
const container = document.getElementById('employee-notes-container');
|
||||||
|
if (!userId) {
|
||||||
|
return utils.showMessage('Please select an employee to view their notes.', 'error');
|
||||||
|
}
|
||||||
|
|
||||||
|
container.innerHTML = 'Loading notes...';
|
||||||
|
const res = await apiCall(`/admin/notes/${userId}`);
|
||||||
|
if (res.success) {
|
||||||
|
if (res.data.length > 0) {
|
||||||
|
container.innerHTML = `
|
||||||
|
<h4 class="font-semibold mb-2 text-gray-600">Showing Notes for ${document.getElementById('note-user-select').options[document.getElementById('note-user-select').selectedIndex].text}</h4>
|
||||||
|
<ul class="space-y-3 max-h-72 overflow-y-auto border rounded-lg p-2 bg-gray-50">
|
||||||
|
${res.data.map(note => `
|
||||||
|
<li class="bg-white p-3 rounded-lg shadow-sm flex justify-between items-start">
|
||||||
|
<div>
|
||||||
|
<p class="text-gray-800 break-words">"${note.note_text}"</p>
|
||||||
|
<p class="text-xs text-gray-500 mt-2">- ${note.admin_username} on ${utils.formatDate(note.created_at)}</p>
|
||||||
|
</div>
|
||||||
|
<button class="delete-note-btn text-red-500 hover:text-red-700 flex-shrink-0 ml-4" data-note-id="${note.id}">×</button>
|
||||||
|
</li>
|
||||||
|
`).join('')}
|
||||||
|
</ul>`;
|
||||||
|
} else {
|
||||||
|
container.innerHTML = '<p class="text-gray-500 text-center">No notes found for this employee.</p>';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
container.innerHTML = '<p class="text-red-500 text-center">Could not load notes.</p>';
|
||||||
|
}
|
||||||
|
}
|
||||||
34
public/js/utils.js
Normal file
34
public/js/utils.js
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
// js/utils.js
|
||||||
|
|
||||||
|
// Note: We are 'exporting' each function so other files can import them.
|
||||||
|
export const showLoading = (show) => {
|
||||||
|
const loadingSpinner = document.getElementById('loading-spinner');
|
||||||
|
loadingSpinner.innerHTML = show ? `<div class="inline-block animate-spin rounded-full h-16 w-16 border-t-2 border-b-2 border-blue-500"></div>` : '';
|
||||||
|
};
|
||||||
|
|
||||||
|
export const showMessage = (message, type = 'success') => {
|
||||||
|
const messageBox = document.getElementById('message-box');
|
||||||
|
messageBox.innerHTML = `<div class="p-4 mb-4 text-sm rounded-lg flex items-center justify-between ${type === 'error' ? 'bg-red-100 text-red-700' : 'bg-green-100 text-green-700'}" role="alert"><span>${message}</span><button onclick="this.parentElement.style.display='none'" class="font-bold text-lg">×</button></div>`;
|
||||||
|
messageBox.classList.remove('hidden');
|
||||||
|
};
|
||||||
|
|
||||||
|
export const formatDecimal = (ms) => ms ? (ms / 3600000).toFixed(2) : '0.00';
|
||||||
|
|
||||||
|
export const formatDateTime = (s) => s ? new Date(s).toLocaleString(undefined, { month: '2-digit', day: '2-digit', year: '2-digit', hour: 'numeric', minute: '2-digit' }) : 'N/A';
|
||||||
|
|
||||||
|
export const formatDate = (s) => s ? new Date(s).toLocaleDateString(undefined, { month: '2-digit', day: '2-digit', year: '2-digit' }) : 'N/A';
|
||||||
|
|
||||||
|
export const toLocalISO = (d) => {
|
||||||
|
if (!d) return '';
|
||||||
|
const date = new Date(d);
|
||||||
|
return new Date(date.getTime() - (date.getTimezoneOffset() * 60000)).toISOString().slice(0, 16);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const formatDuration = (ms) => {
|
||||||
|
if (!ms || ms < 0) return '00:00:00';
|
||||||
|
const totalSeconds = Math.floor(ms / 1000);
|
||||||
|
const h = Math.floor(totalSeconds / 3600);
|
||||||
|
const m = Math.floor((totalSeconds % 3600) / 60);
|
||||||
|
const s = totalSeconds % 60;
|
||||||
|
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
|
||||||
|
};
|
||||||
31
public/style/style.css
Normal file
31
public/style/style.css
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
/* Custom Styles for TimeTracker */
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: 'Inter', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modal Styles */
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
background: white;
|
||||||
|
padding: 2rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
width: 90%;
|
||||||
|
max-width: 500px;
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user