initial commit

This commit is contained in:
chris 2025-07-28 12:56:38 -04:00
commit 53b9087078
6 changed files with 566 additions and 0 deletions

20
Dockerfile Normal file
View File

@ -0,0 +1,20 @@
# Use a slim, Debian-based Node.js runtime for better compatibility
FROM node:18-slim
# Set the working directory in the container
WORKDIR /usr/src/app
# Copy package.json and package-lock.json to leverage Docker's layer caching
COPY package*.json ./
# Install app dependencies
RUN npm install --omit=dev
# Copy all project files, including the 'public' directory, into the container
COPY . .
# Make port 3000 available
EXPOSE 3000
# Define the command to run your app
CMD [ "node", "server.js" ]

BIN
data/timetracker.db Normal file

Binary file not shown.

27
docker-compose.yaml Normal file
View File

@ -0,0 +1,27 @@
# Define the services (containers) for your application
services:
# The name of our service
time-tracker-app:
# Build the Docker image from the Dockerfile in the current directory (.)
build: .
# Name the container for easier management
container_name: time-tracker
# Restart policy: always restart the container if it stops.
# This is useful for production environments to ensure the app is always running.
restart: always
# Keep the container running by allocating a pseudo-TTY
tty: true
# Port mapping: map port 3000 on the host machine to port 3000 in the container.
# This allows you to access the application via http://<your-server-ip>:3000
ports:
- "3002:3000"
# Volume mapping: persist the database file.
# This creates a 'data' folder in your project directory on the host machine
# and links it to the /usr/src/app/data directory inside the container.
# The timetracker.db will be stored here, ensuring data is not lost.
volumes:
- ./data:/usr/src/app/data
# Environment file: tells Docker Compose to use the .env file in the
# current directory to set environment variables inside the container.
env_file:
- .env

243
index.html Normal file
View File

@ -0,0 +1,243 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<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 -->
<header class="bg-white shadow-md">
<nav class="container mx-auto 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">
<span id="welcome-message" class="text-gray-600 mr-4 hidden md:block"></span>
<button id="sign-out-btn" class="bg-red-500 text-white py-2 px-4 rounded-lg hover:bg-red-600">Sign Out</button>
</div>
</nav>
</header>
<main class="container mx-auto 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>
</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') };
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 = localStorage.getItem('authToken'), user = JSON.parse(localStorage.getItem('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">&times;</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 ---
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${endpoint}`, { method, headers, body: body ? JSON.stringify(body) : null });
if (!response.ok) {
// Check for token expiration / invalid token
if (response.status === 401 || response.status === 403) {
handleSignOut('Your session has expired. Please log in again.');
return { success: false }; // Stop further processing
}
const text = await response.text();
const data = text ? JSON.parse(text) : {};
throw new Error(data.message || `HTTP error ${response.status}`);
}
const text = await response.text();
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() {
if (authToken && user) {
navUserControls.classList.remove('hidden');
welcomeMessage.textContent = `Welcome, ${user.username}`;
user.role === 'admin' ? (showView('admin'), renderAdminDashboard()) : (showView('employee'), renderEmployeeDashboard());
} else {
navUserControls.classList.add('hidden');
showView('auth');
renderAuthView();
}
}
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 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 ${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">
<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</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">
<h3 class="text-xl font-bold text-gray-700 mb-4">Time Off Requests</h3>
<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">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">${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 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);
if (punchedIn) {
const durationCell = document.getElementById(`duration-${last.id}`), totalHoursCell = document.getElementById('employee-total-hours'), punchInTime = new Date(last.punch_in_time);
employeeTimerInterval = setInterval(() => { const elapsed = new Date() - punchInTime; durationCell.textContent = formatDuration(elapsed); 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')]);
if (!logsRes.success || !usersRes.success || !requestsRes.success) return;
allTimeEntries = logsRes.data; allUsers = usersRes.data; const allRequests = 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 justify-between items-center mb-4"><h2 class="text-2xl font-bold">Admin Dashboard</h2><div class="space-x-2"><button id="view-archives-btn" class="px-4 py-2 bg-gray-500 text-white rounded-lg">View Archives</button><button id="archive-btn" class="px-4 py-2 bg-amber-500 text-white rounded-lg">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 justify-between items-center p-3"><span class="font-medium">${e.username}</span><div class="flex items-center space-x-4"><span class="text-sm text-gray-600">Since: ${formatDateTime(e.punch_in_time)}</span><button class="force-clock-out-btn px-2 py-1 text-xs bg-red-500 text-white rounded" 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"><h3 class="text-xl font-bold text-gray-700 mb-4">Time Off Requests</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">Dates</th><th class="p-2">Reason</th><th class="p-2">Status</th><th class="p-2">Actions</th></tr></thead><tbody id="time-off-requests-table">${allRequests.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 font-medium capitalize text-${r.status === 'approved' ? 'green' : r.status === 'denied' ? 'red' : 'gray'}-600">${r.status}</td><td class="p-2 space-x-1">${r.status === 'pending' ? `<button class="approve-request-btn text-green-600" data-id="${r.id}">Approve</button><button class="deny-request-btn text-red-600" data-id="${r.id}">Deny</button>` : ''}</td></tr>`).join('') || '<tr><td colspan="5" class="text-center p-4">No 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"><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 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"><button class="edit-btn text-blue-600" data-id="${e.id}">Edit</button><button class="delete-btn text-red-600" 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">Username</th><th class="p-2">Role</th><th class="p-2">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">${u.isPrimary ? `<span class="text-sm text-gray-500">Primary Admin</span>` : `<button class="reset-pw-btn text-blue-600" data-username="${u.username}">Reset PW</button><button class="change-role-btn text-purple-600" 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" 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('create-user-form').addEventListener('submit', handleCreateUser);
document.getElementById('add-punch-form').addEventListener('submit', handleAddPunch);
document.getElementById('admin-dashboard').addEventListener('click', handleAdminDashboardClick);
}
function renderArchiveView() { /* ... unchanged ... */ }
function renderModal(title, formHTML, submitHandler) {
modalContainer.innerHTML = `<div class="modal-overlay"><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"><button type="button" class="cancel-modal-btn px-4 py-2 bg-gray-200 rounded">Cancel</button><button type="submit" class="px-4 py-2 bg-blue-600 text-white rounded">Save</button></div></form></div></div>`;
document.getElementById('modal-form').addEventListener('submit', submitHandler);
document.querySelector('.cancel-modal-btn').addEventListener('click', () => modalContainer.innerHTML = '');
}
function renderEditModal(id) {
const entry = allTimeEntries.find(e => e.id == id);
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 Time Entry', 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);
}
// --- Event Handlers ---
async function handleAuthSubmit(e) { e.preventDefault(); const username = e.target.elements.username.value, 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, 'error'); };
const handlePunch = () => apiCall('/punch', 'POST').then(res => res.success && renderEmployeeDashboard());
function handleAdminDashboardClick(e) {
const target = e.target;
const { id, userid, username, role } = target.dataset;
if (target.classList.contains('edit-btn')) renderEditModal(id);
if (target.classList.contains('delete-btn') && confirm('Delete entry?')) apiCall(`/admin/logs/${id}`, 'DELETE').then(res => res.success && renderAdminDashboard());
if (target.classList.contains('force-clock-out-btn') && confirm('Force clock out?')) 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()); }
}
async function handleEditSubmit(e) { e.preventDefault(); const id = e.target.elements['edit-id'].value, punch_in_time = new Date(e.target.elements['edit-in'].value).toISOString(), punch_out_time = e.target.elements['edit-out'].value ? new Date(e.target.elements['edit-out'].value).toISOString() : null; const res = await apiCall(`/admin/logs/${id}`, 'PUT', { punch_in_time, punch_out_time }); if (res.success) { modalContainer.innerHTML = ''; renderAdminDashboard(); } }
const handleArchive = () => confirm('Archive all completed entries?') && apiCall('/admin/archive', 'POST').then(res => res.success && renderAdminDashboard());
async function handleCreateUser(e) { e.preventDefault(); const username = e.target.elements['new-username'].value, password = e.target.elements['new-password'].value, 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, 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, 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(); const punchOutTime = new Date(e.target.elements['add-punch-out'].value).toISOString(); const res = await apiCall('/admin/add-punch', 'POST', { userId, username, punchInTime, punchOutTime }); if (res.success) { showMessage(res.data.message, 'success'); e.target.reset(); renderAdminDashboard(); } }
const handleViewArchivesBtn = renderArchiveView;
async function handleTimeOffRequest(e) { e.preventDefault(); const startDate = e.target.elements['start-date'].value, endDate = e.target.elements['end-date'].value, reason = e.target.elements['reason'].value; 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());
updateUI();
</script>
</body>
</html>

12
package.json Normal file
View File

@ -0,0 +1,12 @@
{
"dependencies": {
"bcryptjs": "^3.0.2",
"cors": "^2.8.5",
"dotenv": "^17.2.1",
"express": "^5.1.0",
"jsonwebtoken": "^9.0.2",
"pm2": "^6.0.8",
"sqlite": "^5.1.1",
"sqlite3": "^5.1.7"
}
}

264
server.js Normal file
View File

@ -0,0 +1,264 @@
// --- Time Tracker Backend Server (with Time Off Requests) ---
require('dotenv').config();
const express = require('express');
const sqlite3 = require('sqlite3').verbose();
const bcrypt = require('bcryptjs');
const jwt = require('jsonwebtoken');
const cors = require('cors');
const path = require('path');
// --- Server Configuration ---
const PORT = process.env.PORT || 3000;
const JWT_SECRET = process.env.JWT_SECRET || 'default_secret_key';
const ADMIN_USERNAME = process.env.ADMIN_USERNAME || 'admin';
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'adminpassword';
const app = express();
app.use(cors());
app.use(express.json());
const dbPath = path.resolve(__dirname, 'data', 'timetracker.db');
const db = new sqlite3.Database(dbPath, (err) => {
if (err) console.error("Error opening database", err.message);
else {
console.log("Connected to the SQLite database.");
initializeDatabase();
}
});
function initializeDatabase() {
db.serialize(() => {
db.run(`CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT UNIQUE NOT NULL, password TEXT NOT NULL, role TEXT NOT NULL DEFAULT 'employee')`);
db.run(`CREATE TABLE IF NOT EXISTS time_entries (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, username TEXT, punch_in_time DATETIME NOT NULL, punch_out_time DATETIME, status TEXT NOT NULL, FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE)`);
db.run(`CREATE TABLE IF NOT EXISTS archived_time_entries (id INTEGER PRIMARY KEY, user_id INTEGER NOT NULL, username TEXT, punch_in_time DATETIME NOT NULL, punch_out_time DATETIME, status TEXT NOT NULL, archived_at DATETIME NOT NULL)`);
// NEW: Table for time off requests
db.run(`CREATE TABLE IF NOT EXISTS time_off_requests (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, username TEXT, start_date TEXT NOT NULL, end_date TEXT NOT NULL, reason TEXT, status TEXT NOT NULL DEFAULT 'pending', FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE)`);
db.get('SELECT * FROM users WHERE username = ?', [ADMIN_USERNAME], (err, row) => {
if (!row) {
bcrypt.hash(ADMIN_PASSWORD, 10, (err, hashedPassword) => {
db.run('INSERT INTO users (username, password, role) VALUES (?, ?, ?)', [ADMIN_USERNAME, hashedPassword, 'admin']);
});
}
});
});
}
// --- Middleware ---
const requireRole = (role) => (req, res, next) => {
if (req.user && req.user.role === role) next();
else res.status(403).json({ message: "Access denied." });
};
function authenticateToken(req, res, next) {
const token = req.headers['authorization']?.split(' ')[1];
if (token == null) return res.sendStatus(401);
jwt.verify(token, JWT_SECRET, (err, user) => {
if (err) return res.sendStatus(403);
req.user = user;
next();
});
}
// --- API Routes ---
app.post('/api/login', (req, res) => {
const { username, password } = req.body;
db.get('SELECT * FROM users WHERE username = ?', [username], (err, user) => {
if (!user) return res.status(404).json({ message: "User not found." });
bcrypt.compare(password, user.password, (err, isMatch) => {
if (!isMatch) return res.status(401).json({ message: "Invalid credentials." });
const tokenPayload = { id: user.id, username: user.username, role: user.role };
const token = jwt.sign(tokenPayload, JWT_SECRET, { expiresIn: '30d' });
res.json({ token, user: tokenPayload });
});
});
});
app.post('/api/punch', authenticateToken, (req, res) => {
const { id, username } = req.user;
db.get(`SELECT * FROM time_entries WHERE user_id = ? ORDER BY punch_in_time DESC LIMIT 1`, [id], (err, last) => {
if (last && last.status === 'in') {
db.run(`UPDATE time_entries SET punch_out_time = ?, status = 'out' WHERE id = ?`, [new Date().toISOString(), last.id], () => res.json({ message: "Punched out." }));
} else {
db.run(`INSERT INTO time_entries (user_id, username, punch_in_time, status) VALUES (?, ?, ?, 'in')`, [id, username, new Date().toISOString()], () => res.json({ message: "Punched in." }));
}
});
});
app.get('/api/status', authenticateToken, (req, res) => {
db.all(`SELECT * FROM time_entries WHERE user_id = ? ORDER BY punch_in_time DESC`, [req.user.id], (err, rows) => res.json(rows));
});
app.post('/api/user/change-password', authenticateToken, (req, res) => {
const { currentPassword, newPassword } = req.body;
const { id } = req.user;
db.get('SELECT * FROM users WHERE id = ?', [id], (err, user) => {
if (err || !user) return res.status(500).json({ message: "Could not find user." });
bcrypt.compare(currentPassword, user.password, (err, isMatch) => {
if (!isMatch) return res.status(401).json({ message: "Incorrect current password." });
bcrypt.hash(newPassword, 10, (err, hashedPassword) => {
db.run('UPDATE users SET password = ? WHERE id = ?', [hashedPassword, id], (err) => {
res.json({ message: "Password updated successfully." });
});
});
});
});
});
// NEW: Employee requests time off
app.post('/api/user/request-time-off', authenticateToken, (req, res) => {
const { startDate, endDate, reason } = req.body;
const { id, username } = req.user;
if (!startDate || !endDate) return res.status(400).json({ message: "Start and end dates are required." });
const sql = `INSERT INTO time_off_requests (user_id, username, start_date, end_date, reason) VALUES (?, ?, ?, ?, ?)`;
db.run(sql, [id, username, startDate, endDate, reason], function(err) {
if (err) return res.status(500).json({ message: "Failed to submit request." });
res.status(201).json({ message: "Time off request submitted." });
});
});
// NEW: Employee views their time off requests
app.get('/api/user/time-off-requests', authenticateToken, (req, res) => {
const { id } = req.user;
db.all("SELECT * FROM time_off_requests WHERE user_id = ? ORDER BY start_date DESC", [id], (err, rows) => {
if (err) return res.status(500).json({ message: "Failed to fetch requests." });
res.json(rows);
});
});
// --- Admin Routes ---
app.post('/api/admin/create-user', authenticateToken, requireRole('admin'), (req, res) => {
const { username, password, role } = req.body;
const userRole = (role === 'admin' || role === 'employee') ? role : 'employee';
bcrypt.hash(password, 10, (err, hashedPassword) => {
db.run('INSERT INTO users (username, password, role) VALUES (?, ?, ?)', [username, hashedPassword, userRole], function(err) {
if (err) return res.status(409).json({ message: "Username already exists." });
res.status(201).json({ message: `User '${username}' created as ${userRole}.` });
});
});
});
app.delete('/api/admin/delete-user/:username', authenticateToken, requireRole('admin'), (req, res) => {
const { username } = req.params;
if (username === req.user.username) return res.status(400).json({ message: "Cannot delete your own account." });
if (username === ADMIN_USERNAME) return res.status(403).json({ message: "The primary admin account cannot be deleted." });
db.get("SELECT id FROM users WHERE username = ?", [username], (err, userToDelete) => {
if (!userToDelete) return res.status(404).json({ message: "User not found." });
db.serialize(() => {
db.run('BEGIN TRANSACTION');
db.run('DELETE FROM time_entries WHERE user_id = ?', [userToDelete.id]);
db.run('DELETE FROM archived_time_entries WHERE user_id = ?', [userToDelete.id]);
db.run('DELETE FROM users WHERE id = ?', [userToDelete.id], () => db.run('COMMIT', () => res.json({ message: `User '${username}' deleted.` })));
});
});
});
app.post('/api/admin/reset-password', authenticateToken, requireRole('admin'), (req, res) => {
const { username, newPassword } = req.body;
if (username === ADMIN_USERNAME) return res.status(403).json({ message: "The primary admin's password cannot be reset by another user." });
bcrypt.hash(newPassword, 10, (err, hashedPassword) => {
db.run('UPDATE users SET password = ? WHERE username = ?', [hashedPassword, username], function(err) {
if (this.changes === 0) return res.status(404).json({ message: "User not found." });
res.json({ message: `Password for '${username}' has been reset.` });
});
});
});
app.get('/api/admin/users', authenticateToken, requireRole('admin'), (req, res) => {
db.all("SELECT id, username, role FROM users", [], (err, rows) => {
if(err) return res.status(500).json({message: "Database error fetching users."});
const usersWithFlags = rows.map(row => ({ ...row, isPrimary: row.username === ADMIN_USERNAME }));
res.json(usersWithFlags);
});
});
app.post('/api/admin/update-role', authenticateToken, requireRole('admin'), (req, res) => {
const { username, newRole } = req.body;
if (username === ADMIN_USERNAME) return res.status(403).json({ message: "The primary admin's role cannot be changed." });
if (!['admin', 'employee'].includes(newRole)) return res.status(400).json({ message: "Invalid role." });
db.get("SELECT role FROM users WHERE username = ?", [username], (err, userToUpdate) => {
if (!userToUpdate) return res.status(404).json({ message: "User not found." });
if (userToUpdate.role === 'admin' && newRole === 'employee') {
db.get("SELECT COUNT(*) as adminCount FROM users WHERE role = 'admin'", (err, row) => {
if (row.adminCount <= 1) return res.status(400).json({ message: "Cannot remove the last administrator." });
db.run('UPDATE users SET role = ? WHERE username = ?', [newRole, username], () => res.json({ message: `Role for '${username}' updated.` }));
});
} else {
db.run('UPDATE users SET role = ? WHERE username = ?', [newRole, username], () => res.json({ message: `Role for '${username}' updated.` }));
}
});
});
app.post('/api/admin/add-punch', authenticateToken, requireRole('admin'), (req, res) => {
const { userId, username, punchInTime, punchOutTime } = req.body;
db.run(`INSERT INTO time_entries (user_id, username, punch_in_time, punch_out_time, status) VALUES (?, ?, ?, ?, 'out')`, [userId, username, punchInTime, punchOutTime], () => res.status(201).json({message: "Time punch added."}));
});
app.post('/api/admin/force-clock-out', authenticateToken, requireRole('admin'), (req, res) => {
const { userId } = req.body;
db.run(`UPDATE time_entries SET punch_out_time = ?, status = 'out' WHERE user_id = ? AND status = 'in'`, [new Date().toISOString(), userId], function(err) {
if(this.changes === 0) return res.status(404).json({message: "No active punch-in found."});
res.json({message: "User has been clocked out."});
});
});
// NEW: Get all time off requests
app.get('/api/admin/time-off-requests', authenticateToken, requireRole('admin'), (req, res) => {
db.all("SELECT * FROM time_off_requests ORDER BY start_date DESC", [], (err, rows) => {
if (err) return res.status(500).json({ message: "Failed to fetch requests." });
res.json(rows);
});
});
// NEW: Update status of a time off request
app.post('/api/admin/update-time-off-status', authenticateToken, requireRole('admin'), (req, res) => {
const { requestId, status } = req.body;
if (!requestId || !['approved', 'denied'].includes(status)) {
return res.status(400).json({ message: "Request ID and a valid status are required." });
}
db.run("UPDATE time_off_requests SET status = ? WHERE id = ?", [status, requestId], function(err) {
if (err) return res.status(500).json({ message: "Failed to update status." });
if (this.changes === 0) return res.status(404).json({ message: "Request not found." });
res.json({ message: `Request has been ${status}.` });
});
});
app.get('/api/admin/logs', authenticateToken, requireRole('admin'), (req, res) => {
db.all(`SELECT * FROM time_entries ORDER BY punch_in_time DESC`, (err, rows) => res.json(rows));
});
app.delete('/api/admin/logs/:id', authenticateToken, requireRole('admin'), (req, res) => {
db.run('DELETE FROM time_entries WHERE id = ?', [req.params.id], () => res.json({ message: 'Entry deleted.' }));
});
app.put('/api/admin/logs/:id', authenticateToken, requireRole('admin'), (req, res) => {
const { punch_in_time, punch_out_time } = req.body;
const status = punch_out_time ? 'out' : 'in';
db.run(`UPDATE time_entries SET punch_in_time = ?, punch_out_time = ?, status = ? WHERE id = ?`, [punch_in_time, punch_out_time, status, req.params.id], () => res.json({ message: 'Entry updated.' }));
});
app.post('/api/admin/archive', authenticateToken, requireRole('admin'), (req, res) => {
db.all(`SELECT * FROM time_entries WHERE status = 'out'`, (err, rows) => {
if (rows.length === 0) return res.json({ message: "No entries to archive." });
const archiveTime = new Date().toISOString();
db.serialize(() => {
db.run('BEGIN TRANSACTION');
const insert = db.prepare('INSERT INTO archived_time_entries VALUES (?, ?, ?, ?, ?, ?, ?)');
rows.forEach(r => insert.run(r.id, r.user_id, r.username, r.punch_in_time, r.punch_out_time, r.status, archiveTime));
insert.finalize();
const idsToDelete = rows.map(r => r.id);
db.run(`DELETE FROM time_entries WHERE id IN (${idsToDelete.map(() => '?').join(',')})`, idsToDelete);
db.run('COMMIT', () => res.json({ message: `Archived ${rows.length} entries.` }));
});
});
});
app.get('/api/admin/archives', authenticateToken, requireRole('admin'), (req, res) => {
db.all(`SELECT * FROM archived_time_entries ORDER BY archived_at DESC, id DESC`, (err, rows) => res.json(rows));
});
app.listen(PORT, () => console.log(`Server running on http://localhost:${PORT}`));