217 lines
18 KiB
HTML
217 lines
18 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<link rel="manifest" href="/manifest.json">
|
|
<title>TimeTracker</title>
|
|
<script src="https://cdn.tailwindcss.com"></script>
|
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
<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">
|
|
<style>
|
|
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>
|
|
|
|
<body class="bg-gray-100">
|
|
|
|
<div id="app" class="min-h-screen">
|
|
<header class="bg-white shadow-md">
|
|
<nav class="container mx-auto px-4 sm:px-6 py-3 flex justify-between items-center">
|
|
<div class="flex items-center">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-blue-600"><path d="M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10z"></path><path d="M12 6v6l4 2"></path></svg>
|
|
<h1 class="text-xl font-bold text-gray-800 ml-2">TimeTracker</h1>
|
|
</div>
|
|
<div id="nav-user-controls" class="hidden flex items-center">
|
|
<span id="welcome-message" class="text-gray-600 mr-3 text-sm sm:text-base truncate"></span>
|
|
<button id="sign-out-btn" class="bg-red-500 text-white py-2 px-3 sm:px-4 rounded-lg hover:bg-red-600 text-sm whitespace-nowrap">Sign Out</button>
|
|
</div>
|
|
</nav>
|
|
</header>
|
|
|
|
<main class="container mx-auto px-4 sm:px-6 py-8">
|
|
<div id="message-box" class="hidden"></div>
|
|
<div id="loading-spinner" class="hidden text-center py-4"></div>
|
|
<div id="auth-view"></div>
|
|
<div id="employee-dashboard" class="hidden"></div>
|
|
<div id="admin-dashboard" class="hidden"></div>
|
|
<div id="admin-archive-view" class="hidden"></div>
|
|
<div id="admin-time-off-history-view" class="hidden"></div>
|
|
</main>
|
|
|
|
<div id="modal-container"></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;
|
|
|
|
// --- 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) : 'N/A';
|
|
const formatDateTime = (s) => s ? new Date(s).toLocaleString() : 'N/A';
|
|
const formatDate = (s) => s ? new Date(s).toLocaleDateString() : '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 s = Math.floor(ms / 1000); const h = Math.floor(s / 3600); const m = Math.floor((s % 3600) / 60); return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(s % 60).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();
|
|
if (!response.ok) {
|
|
const errorData = text ? JSON.parse(text) : {};
|
|
throw new Error(errorData.message || `HTTP error ${response.status}`);
|
|
}
|
|
return { success: true, data: text ? JSON.parse(text) : null };
|
|
} catch (error) {
|
|
showMessage(error.message, 'error');
|
|
return { success: false };
|
|
} finally {
|
|
showLoading(false);
|
|
}
|
|
}
|
|
|
|
// --- View Management ---
|
|
const showView = (viewName) => { clearInterval(employeeTimerInterval); Object.keys(mainViews).forEach(v => mainViews[v].classList.toggle('hidden', v !== viewName)); }
|
|
|
|
// --- UI Rendering ---
|
|
function updateUI() {
|
|
try {
|
|
const storedUser = localStorage.getItem('user');
|
|
authToken = localStorage.getItem('authToken');
|
|
user = storedUser ? JSON.parse(storedUser) : null;
|
|
|
|
if (authToken && user) {
|
|
navUserControls.classList.remove('hidden');
|
|
welcomeMessage.textContent = `${user.username}`;
|
|
user.role === 'admin' ? (showView('admin'), renderAdminDashboard()) : (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">Log In</button></form></div>`;
|
|
document.getElementById('auth-form').addEventListener('submit', handleAuthSubmit);
|
|
}
|
|
|
|
async function renderEmployeeDashboard() {
|
|
clearInterval(employeeTimerInterval);
|
|
const [statusRes, timeOffRes] = await Promise.all([apiCall('/status'), apiCall('/user/time-off-requests')]);
|
|
if (!statusRes.success || !timeOffRes.success) return;
|
|
|
|
const entries = statusRes.data;
|
|
const requests = timeOffRes.data;
|
|
const last = entries[0];
|
|
const punchedIn = last?.status === 'in';
|
|
let totalMilliseconds = entries.reduce((acc, e) => e.status === 'out' ? 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>`; // Employee dashboard code is unchanged
|
|
document.getElementById('punch-btn').addEventListener('click', handlePunch);
|
|
// ... other employee event listeners
|
|
}
|
|
|
|
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.status === 'out' ? new Date(entry.punch_out_time) - new Date(entry.punch_in_time) : new Date() - new Date(entry.punch_in_time)); acc[entry.username] = (acc[entry.username] || 0) + dur; return acc; }, {});
|
|
const punchedInEntries = allTimeEntries.filter(e => e.status === 'in');
|
|
|
|
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-col sm:flex-row justify-between items-start sm:items-center">
|
|
<h2 class="text-2xl font-bold mb-4 sm:mb-0">Admin Dashboard</h2>
|
|
<div class="flex space-x-2">
|
|
<button id="view-archives-btn" class="px-4 py-2 bg-gray-500 text-white rounded-lg text-sm whitespace-nowrap">View Archives</button>
|
|
<button id="archive-btn" class="px-4 py-2 bg-amber-500 text-white rounded-lg text-sm whitespace-nowrap">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-2">Currently Punched In</h3>
|
|
<ul id="punched-in-list" 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 w-full sm:w-auto justify-between 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}">Clock Out</button>
|
|
</div>
|
|
</li>
|
|
`).join('') || '<li class="p-4 text-center">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 whitespace-nowrap">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 whitespace-nowrap">Employee</th><th class="p-2 whitespace-nowrap">Dates</th><th class="p-2 whitespace-nowrap">Reason</th><th class="p-2 whitespace-nowrap">Actions</th></tr></thead><tbody id="time-off-requests-table">${pendingRequests.map(r => `<tr class="border-t"><td class="p-2">${r.username}</td><td class="p-2">${formatDate(r.start_date)} - ${formatDate(r.end_date)}</td><td class="p-2">${r.reason||''}</td><td class="p-2 space-x-1"><button class="approve-request-btn text-green-600 font-semibold" data-id="${r.id}">Approve</button><button class="deny-request-btn text-red-600 font-semibold" data-id="${r.id}">Deny</button></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.keys(employeeTotals).map(u => `<tr class="border-t"><td class="p-2 font-medium">${u}</td><td class="p-2">${formatDecimal(employeeTotals[u])}</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">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 whitespace-nowrap">Employee</th><th class="p-2 whitespace-nowrap">In</th><th class="p-2 whitespace-nowrap">Out</th><th class="p-2 whitespace-nowrap">Duration</th><th class="p-2 whitespace-nowrap">Actions</th></tr></thead><tbody id="admin-table">${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">${formatDecimal(new Date(e.punch_out_time) - new Date(e.punch_in_time))}</td><td class="p-2 space-x-2 whitespace-nowrap"><button class="edit-btn text-blue-600 font-semibold" data-id="${e.id}">Edit</button><button class="delete-btn text-red-600 font-semibold" data-id="${e.id}">Delete</button></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">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 Punch</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><input type="datetime-local" id="add-punch-in" class="w-full p-2 border rounded" required><input type="datetime-local" id="add-punch-out" class="w-full p-2 border rounded" required><button type="submit" class="w-full bg-purple-600 text-white p-2 rounded">Add Punch</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 whitespace-nowrap">Username</th><th class="p-2 whitespace-nowrap">Role</th><th class="p-2 whitespace-nowrap">Actions</th></tr></thead><tbody id="manage-users-table">${allUsers.map(u => `<tr class="border-t"><td class="p-2 font-medium">${u.username}</td><td class="p-2">${u.role}</td><td class="p-2 space-x-2 whitespace-nowrap">${u.isPrimary ? `<span class="text-sm text-gray-500">Primary Admin</span>` : `<button class="reset-pw-btn text-blue-600 font-semibold" data-username="${u.username}">Reset PW</button><button class="change-role-btn text-purple-600 font-semibold" data-username="${u.username}" data-role="${u.role}">${u.role === 'admin' ? 'Demote' : 'Promote'}</button>${u.username !== user.username ? `<button class="delete-user-btn text-red-600 font-semibold" data-username="${u.username}">Delete</button>` : ''}`}</td></tr>`).join('')}</tbody></table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>`;
|
|
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('admin-dashboard').addEventListener('click', handleAdminDashboardClick);
|
|
}
|
|
|
|
// ... other functions (renderArchiveView, renderTimeOffHistoryView, modals, event handlers) are unchanged ...
|
|
|
|
// --- Initializer ---
|
|
signOutBtn.addEventListener('click', () => handleSignOut());
|
|
updateUI();
|
|
</script>
|
|
|
|
</body>
|
|
</html> |