Files
TimeTracker/desktop/src/renderer/js/app.js
T
Dries Peeters e6e49dedd7 feat(desktop): rebuild renderer with React and Vite
Move the desktop app onto a Vite-powered React shell with username/password setup, diagnostics, themed core views, offline sync queueing, and tighter Electron runtime boundaries.
2026-04-25 17:34:20 +02:00

2241 lines
80 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Main application logic
// First-run depends on ../shared/config.js exposing window.config before this bundle (see index.html).
require('./utils/helpers');
const ApiClient = require('./api/client');
const { createConnectionManager } = require('./connection/connection_manager');
const { CONNECTION_STATE } = require('./connection/connection_state');
const { startTimerWithReconcile, stopTimerWithReconcile } = require('./connection/timer_operations');
const { classifyAxiosError } = require('./api/client');
const { showError, showSuccess } = require('./ui/notifications');
const state = require('./state');
const {
formatDuration,
formatDurationLong,
formatDateTime,
isValidUrl,
normalizeServerUrlInput,
} = window.Helpers || {};
const { storeGet, storeSet, storeDelete, storeClear } = window.config || {};
/** @type {ReturnType<typeof createConnectionManager> | null} */
let connectionManager = null;
/** @type {'welcome'|'server'|'token'} */
let loginWizardStep = 'welcome';
function truncateUrl(url, maxLen) {
const s = String(url || '');
const m = maxLen || 42;
if (s.length <= m) return s;
return s.slice(0, m - 1) + '…';
}
// Initialize app
async function initApp() {
if (
typeof storeGet !== 'function' ||
typeof storeSet !== 'function' ||
typeof storeDelete !== 'function' ||
typeof storeClear !== 'function'
) {
throw new Error('Desktop configuration bridge is unavailable.');
}
connectionManager = createConnectionManager({
storeGet,
storeSet,
storeDelete,
storeClear,
onCacheClear: () => {
if (typeof state.clearViewCaches === 'function') state.clearViewCaches();
},
});
connectionManager.subscribe(() => {
state.apiClient = connectionManager.getClient();
updateConnectionFromManager();
});
setupEventListeners();
setupTrayListeners();
const boot = await connectionManager.bootstrapFromStore();
if (boot.ok) {
state.authFailureStreak = 0;
showMainScreen();
await loadInitialData();
} else if (boot.reason === 'offline' && boot.hadCredentials) {
showLoginScreen({
prefillServerUrl: connectionManager.getSnapshot().serverUrl || '',
openTokenStep: true,
bannerMessage: 'You appear to be offline. Reconnect to the network, then use Log in.',
});
} else if (boot.reason === 'session' && boot.session) {
showLoginScreen({ prefillServerUrl: connectionManager.getSnapshot().serverUrl || '', sessionError: boot.session });
} else if (boot.reason === 'token_server_mismatch') {
showLoginScreen({
prefillServerUrl: connectionManager.getSnapshot().serverUrl || '',
bannerMessage: connectionManager.getSnapshot().lastError || 'Please sign in again.',
});
} else if (boot.reason === 'no_server') {
showLoginScreen({ prefillServerUrl: '', startAtServer: true });
} else if (boot.reason === 'no_token') {
showLoginScreen({
prefillServerUrl: connectionManager.getSnapshot().serverUrl || '',
openTokenStep: true,
});
} else if (boot.reason === 'bootstrap_timeout') {
showLoginScreen({
prefillServerUrl: connectionManager.getSnapshot().serverUrl || '',
openTokenStep: true,
bannerMessage: boot.message || 'Server did not respond in time. Check the URL or network, then try signing in again.',
});
} else if (boot.reason === 'session_unreachable') {
showLoginScreen({
prefillServerUrl: connectionManager.getSnapshot().serverUrl || '',
openTokenStep: true,
bannerMessage: boot.message || 'Server is not reachable. Check the URL or network, then try signing in again.',
});
} else {
showLoginScreen({ prefillServerUrl: connectionManager.getSnapshot().serverUrl || '' });
}
startConnectionCheck();
window.addEventListener('online', async () => {
if (!connectionManager.getClient()) {
const retry = await connectionManager.bootstrapFromStore();
if (retry.ok && document.getElementById('main-screen')?.classList.contains('active')) {
state.authFailureStreak = 0;
await loadInitialData();
}
}
await checkConnection();
});
}
async function loadInitialData() {
try {
await loadCurrentUserProfile();
} catch (err) {
console.error('Initial profile load failed:', err);
}
try {
await loadDashboard();
} catch (err) {
console.error('Initial dashboard load failed:', err);
}
}
function setupTrayListeners() {
// Listen for tray timer actions
if (window.electronAPI && window.electronAPI.onTrayAction) {
window.electronAPI.onTrayAction((action) => {
if (action === 'start-timer' && !state.isTimerRunning) {
// Tray wants to start timer - show the start dialog
handleStartTimer();
} else if (action === 'stop-timer' && state.isTimerRunning) {
// Tray wants to stop timer
handleStopTimer();
}
});
}
}
function startConnectionCheck() {
// Check connection every 30 seconds
state.connectionCheckInterval = setInterval(async () => {
await checkConnection();
}, 30000);
// Initial check
checkConnection();
}
async function checkConnection() {
if (typeof navigator !== 'undefined' && navigator.onLine && !connectionManager.getClient()) {
const snap = connectionManager.getSnapshot();
if (snap.serverUrl && (await storeGet('api_token'))) {
const boot = await connectionManager.bootstrapFromStore();
if (boot.ok && document.getElementById('main-screen')?.classList.contains('active')) {
state.authFailureStreak = 0;
await loadCurrentUserProfile();
}
}
}
if (!state.apiClient) {
updateConnectionFromManager();
return;
}
const session = await connectionManager.validateSessionRefresh();
if (session.ok) {
state.authFailureStreak = 0;
updateConnectionFromManager();
return;
}
updateConnectionFromManager();
if (session.code === 'UNAUTHORIZED') {
state.authFailureStreak = (state.authFailureStreak || 0) + 1;
if (state.authFailureStreak >= 2 && document.getElementById('main-screen')?.classList.contains('active')) {
await forceRelogin(session.message || 'Your session is no longer valid. Please sign in again.');
}
} else {
state.authFailureStreak = 0;
}
}
async function loadCurrentUserProfile() {
if (!state.apiClient) return;
try {
const me = await state.apiClient.getUsersMe();
const user = me.user || {};
const role = String(user.role || '').toLowerCase();
const roleCanApprove = ['admin', 'owner', 'manager', 'approver'].includes(role);
state.currentUserProfile = {
id: user.id,
is_admin: Boolean(user.is_admin),
can_approve: Boolean(user.is_admin) || roleCanApprove,
};
} catch (err) {
console.error('loadCurrentUserProfile failed:', err);
if (err && err.stack) console.error(err.stack);
state.currentUserProfile = { id: null, is_admin: false, can_approve: false };
const { message } = classifyAxiosError(err);
showError(message || 'Could not load your user profile. Some actions may be unavailable until the connection improves.');
}
}
function updateConnectionFromManager() {
if (!connectionManager) return;
const snap = connectionManager.getSnapshot();
const statusEl = document.getElementById('connection-status');
const urlEl = document.getElementById('connection-url-label');
const timeEl = document.getElementById('connection-last-ok');
if (!statusEl) return;
let cssSuffix = 'disconnected';
let title = '';
let label = 'Connection status: ';
switch (snap.state) {
case CONNECTION_STATE.CONNECTED:
cssSuffix = 'connected';
title = snap.serverUrl || 'Connected';
label += 'Connected';
statusEl.textContent = '●';
break;
case CONNECTION_STATE.OFFLINE:
cssSuffix = 'offline';
title = snap.lastError || 'Offline';
label += 'Offline';
statusEl.textContent = '●';
break;
case CONNECTION_STATE.CONNECTING:
cssSuffix = 'connecting';
title = snap.lastError || 'Connecting…';
label += 'Connecting';
statusEl.textContent = '◐';
break;
case CONNECTION_STATE.ERROR:
cssSuffix = 'error';
title = snap.lastError || 'Connection error';
label += 'Error';
statusEl.textContent = '●';
break;
default:
title = snap.serverUrl || 'Not configured';
label += 'Not configured';
statusEl.textContent = '○';
}
statusEl.className = 'connection-status connection-' + cssSuffix;
statusEl.title = title;
statusEl.setAttribute('aria-label', label);
if (urlEl) {
urlEl.textContent = snap.serverUrl ? truncateUrl(snap.serverUrl) : '—';
urlEl.title = snap.serverUrl || '';
}
if (timeEl) {
timeEl.textContent = snap.lastConnectedAt ? formatDateTime(new Date(snap.lastConnectedAt)) : '—';
}
}
async function forceRelogin(message) {
state.authFailureStreak = 0;
const url = await storeGet('server_url');
if (state.isTimerRunning) {
state.isTimerRunning = false;
stopTimerPolling();
}
await connectionManager.logoutKeepServer();
showLoginScreen({
prefillServerUrl: url ? ApiClient.normalizeBaseUrl(String(url)) : '',
openTokenStep: true,
bannerMessage: message,
});
}
function showWizardWelcomeStep() {
loginWizardStep = 'welcome';
const w = document.getElementById('wizard-step-welcome');
const s1 = document.getElementById('wizard-step-server');
const s2 = document.getElementById('wizard-step-token');
if (w) w.style.display = '';
if (s1) s1.style.display = 'none';
if (s2) s2.style.display = 'none';
}
function showWizardServerStep() {
loginWizardStep = 'server';
const w = document.getElementById('wizard-step-welcome');
const s1 = document.getElementById('wizard-step-server');
const s2 = document.getElementById('wizard-step-token');
if (w) w.style.display = 'none';
if (s1) s1.style.display = '';
if (s2) s2.style.display = 'none';
}
function showWizardTokenStep() {
loginWizardStep = 'token';
const w = document.getElementById('wizard-step-welcome');
const s1 = document.getElementById('wizard-step-server');
const s2 = document.getElementById('wizard-step-token');
if (w) w.style.display = 'none';
if (s1) s1.style.display = 'none';
if (s2) s2.style.display = '';
}
function resetLoginWizard() {
showWizardWelcomeStep();
const contServer = document.getElementById('login-wizard-continue-server');
if (contServer) contServer.disabled = true;
const testBtn = document.getElementById('login-test-server-btn');
if (testBtn) testBtn.disabled = false;
clearLoginError();
}
function clearLoginError() {
showLoginError('');
}
function setupEventListeners() {
// Login form
const loginForm = document.getElementById('login-form');
if (loginForm) {
loginForm.addEventListener('submit', handleLogin);
}
const loginTestServerBtn = document.getElementById('login-test-server-btn');
const loginWizardContinue = document.getElementById('login-wizard-continue');
const loginWizardContinueServer = document.getElementById('login-wizard-continue-server');
const loginWizardBack = document.getElementById('login-wizard-back');
if (loginTestServerBtn) loginTestServerBtn.addEventListener('click', handleLoginTestServer);
if (loginWizardContinue) loginWizardContinue.addEventListener('click', handleLoginWizardContinue);
if (loginWizardContinueServer) loginWizardContinueServer.addEventListener('click', handleLoginWizardContinue);
if (loginWizardBack) loginWizardBack.addEventListener('click', handleLoginWizardBack);
// Navigation
document.querySelectorAll('.nav-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
const view = e.target.dataset.view;
switchView(view);
});
});
// Window controls
const minimizeBtn = document.getElementById('minimize-btn');
const maximizeBtn = document.getElementById('maximize-btn');
const closeBtn = document.getElementById('close-btn');
if (minimizeBtn) minimizeBtn.addEventListener('click', () => window.electronAPI?.minimizeWindow());
if (maximizeBtn) maximizeBtn.addEventListener('click', () => window.electronAPI?.maximizeWindow());
if (closeBtn) closeBtn.addEventListener('click', () => window.electronAPI?.closeWindow());
// Timer controls
const startTimerBtn = document.getElementById('start-timer-btn');
const stopTimerBtn = document.getElementById('stop-timer-btn');
if (startTimerBtn) startTimerBtn.addEventListener('click', handleStartTimer);
if (stopTimerBtn) stopTimerBtn.addEventListener('click', handleStopTimer);
// Logout
const logoutBtn = document.getElementById('logout-btn');
if (logoutBtn) logoutBtn.addEventListener('click', handleLogout);
// Settings
const saveSettingsBtn = document.getElementById('save-settings-btn');
const testConnectionBtn = document.getElementById('test-connection-btn');
const autoSyncInput = document.getElementById('auto-sync');
if (saveSettingsBtn) saveSettingsBtn.addEventListener('click', handleSaveSettings);
if (testConnectionBtn) testConnectionBtn.addEventListener('click', handleTestConnection);
const resetConfigBtn = document.getElementById('reset-configuration-btn');
if (resetConfigBtn) resetConfigBtn.addEventListener('click', handleResetConfiguration);
if (autoSyncInput) {
autoSyncInput.addEventListener('change', () => updateSyncIntervalState());
}
// Time entries
const addEntryBtn = document.getElementById('add-entry-btn');
const filterEntriesBtn = document.getElementById('filter-entries-btn');
const applyFilterBtn = document.getElementById('apply-filter-btn');
const clearFilterBtn = document.getElementById('clear-filter-btn');
const addExpenseBtn = document.getElementById('add-expense-btn');
const refreshPeriodsBtn = document.getElementById('refresh-periods-btn');
const addInvoiceBtn = document.getElementById('add-invoice-btn');
const addTimeoffBtn = document.getElementById('add-timeoff-btn');
const invoiceSearchInput = document.getElementById('invoice-search');
const expenseSearchInput = document.getElementById('expense-search');
const timeoffSearchInput = document.getElementById('timeoff-search');
const invoicePrevPageBtn = document.getElementById('invoice-prev-page-btn');
const invoiceNextPageBtn = document.getElementById('invoice-next-page-btn');
const expensePrevPageBtn = document.getElementById('expense-prev-page-btn');
const expenseNextPageBtn = document.getElementById('expense-next-page-btn');
if (addEntryBtn) addEntryBtn.addEventListener('click', () => showTimeEntryForm());
if (filterEntriesBtn) filterEntriesBtn.addEventListener('click', toggleFilters);
if (applyFilterBtn) applyFilterBtn.addEventListener('click', applyFilters);
if (clearFilterBtn) clearFilterBtn.addEventListener('click', clearFilters);
if (addExpenseBtn) addExpenseBtn.addEventListener('click', () => showCreateExpenseDialog());
if (refreshPeriodsBtn) refreshPeriodsBtn.addEventListener('click', () => loadWorkforce());
if (addInvoiceBtn) addInvoiceBtn.addEventListener('click', () => showCreateInvoiceDialog());
if (addTimeoffBtn) addTimeoffBtn.addEventListener('click', () => showCreateTimeOffDialog());
if (invoiceSearchInput) {
invoiceSearchInput.addEventListener('input', (e) => {
state.viewFilters.invoiceQuery = String(e.target.value || '').trim().toLowerCase();
renderInvoices();
});
}
if (expenseSearchInput) {
expenseSearchInput.addEventListener('input', (e) => {
state.viewFilters.expenseQuery = String(e.target.value || '').trim().toLowerCase();
renderExpenses();
});
}
if (timeoffSearchInput) {
timeoffSearchInput.addEventListener('input', (e) => {
state.viewFilters.timeoffQuery = String(e.target.value || '').trim().toLowerCase();
renderTimeOffRequests();
});
}
if (invoicePrevPageBtn) invoicePrevPageBtn.addEventListener('click', () => changeInvoicePage(-1));
if (invoiceNextPageBtn) invoiceNextPageBtn.addEventListener('click', () => changeInvoicePage(1));
if (expensePrevPageBtn) expensePrevPageBtn.addEventListener('click', () => changeExpensePage(-1));
if (expenseNextPageBtn) expenseNextPageBtn.addEventListener('click', () => changeExpensePage(1));
}
async function handleLoginTestServer() {
clearLoginError();
const raw = document.getElementById('server-url')?.value.trim() || '';
const normalizedInput = normalizeServerUrlInput(raw);
if (!normalizedInput || !isValidUrl(normalizedInput)) {
showLoginError('Enter a valid server URL (e.g. https://your-server.com or http://192.168.1.10:5000)');
return;
}
const serverUrl = ApiClient.normalizeBaseUrl(normalizedInput);
const testBtn = document.getElementById('login-test-server-btn');
const contServer = document.getElementById('login-wizard-continue-server');
if (testBtn) testBtn.disabled = true;
if (contServer) contServer.disabled = true;
const pub = await connectionManager.testServer(serverUrl);
if (testBtn) testBtn.disabled = false;
if (contServer) contServer.disabled = true;
if (!pub.ok) {
showLoginError(pub.message);
return;
}
const ver = pub.app_version ? ` (server version ${pub.app_version})` : '';
showSuccess(`TimeTracker server detected${ver}. Continue to sign in.`);
if (contServer) contServer.disabled = false;
}
async function handleLoginWizardContinue() {
clearLoginError();
if (loginWizardStep === 'welcome') {
showWizardServerStep();
return;
}
const raw = document.getElementById('server-url')?.value.trim() || '';
const normalizedInput = normalizeServerUrlInput(raw);
if (!normalizedInput || !isValidUrl(normalizedInput)) {
showLoginError('Enter a valid server URL');
return;
}
const serverUrl = ApiClient.normalizeBaseUrl(normalizedInput);
const contServer = document.getElementById('login-wizard-continue-server');
if (contServer) contServer.disabled = true;
const pub = await connectionManager.testServer(serverUrl);
if (!pub.ok) {
if (contServer) contServer.disabled = true;
showLoginError(pub.message);
return;
}
if (contServer) contServer.disabled = false;
showWizardTokenStep();
}
function handleLoginWizardBack() {
clearLoginError();
if (loginWizardStep === 'token') {
showWizardServerStep();
return;
}
if (loginWizardStep === 'server') {
showWizardWelcomeStep();
return;
}
showWizardWelcomeStep();
}
async function handleLogin(e) {
e.preventDefault();
const raw = document.getElementById('server-url')?.value.trim() || '';
const normalizedInput = normalizeServerUrlInput(raw);
if (!normalizedInput || !isValidUrl(normalizedInput)) {
showLoginError('Please enter a valid server URL');
return;
}
const serverUrl = ApiClient.normalizeBaseUrl(normalizedInput);
const username = document.getElementById('login-username')?.value.trim() || '';
const password = document.getElementById('login-password')?.value || '';
if (!username || !password) {
showLoginError('Please enter your username and password');
return;
}
const result = await connectionManager.login(serverUrl, username, password);
if (result.ok) {
state.authFailureStreak = 0;
showMainScreen();
await loadInitialData();
} else {
const msg = result.session?.message || result.message || 'Login failed';
showLoginError(msg);
if (result.step === 'auth' && (result.session?.code === 'UNAUTHORIZED' || result.session?.code === 'FORBIDDEN')) {
const contServer = document.getElementById('login-wizard-continue-server');
if (contServer) contServer.disabled = false;
showWizardTokenStep();
} else if (result.step === 'server') {
showWizardServerStep();
} else {
showWizardServerStep();
}
}
}
function showLoginError(message) {
const errorDiv = document.getElementById('login-error');
if (!errorDiv) return;
errorDiv.textContent = message || '';
if (message) {
errorDiv.classList.add('show');
} else {
errorDiv.classList.remove('show');
}
}
function showLoginScreen(options = {}) {
document.getElementById('loading-screen').classList.remove('active');
document.getElementById('login-screen').classList.add('active');
document.getElementById('main-screen').classList.remove('active');
state.authFailureStreak = 0;
const su = document.getElementById('server-url');
if (su && options.prefillServerUrl !== undefined && options.prefillServerUrl !== null) {
su.value = String(options.prefillServerUrl || '');
}
if (options.openTokenStep) {
const contServer = document.getElementById('login-wizard-continue-server');
if (contServer) contServer.disabled = false;
showWizardTokenStep();
if (options.bannerMessage) {
showLoginError(options.bannerMessage);
} else {
clearLoginError();
}
return;
}
if (options.startAtServer) {
showWizardServerStep();
if (options.bannerMessage) {
showLoginError(options.bannerMessage);
} else {
clearLoginError();
}
return;
}
if (options.bannerMessage && !options.sessionError) {
resetLoginWizard();
showLoginError(options.bannerMessage);
return;
}
if (options.sessionError) {
const se = options.sessionError;
if (se.code === 'UNAUTHORIZED' || se.code === 'FORBIDDEN') {
const contServer = document.getElementById('login-wizard-continue-server');
if (contServer) contServer.disabled = false;
showWizardTokenStep();
showLoginError(se.message || 'Authentication failed');
return;
}
resetLoginWizard();
showLoginError(se.message || 'Could not reach the server');
return;
}
resetLoginWizard();
}
function showMainScreen() {
document.getElementById('loading-screen').classList.remove('active');
document.getElementById('login-screen').classList.remove('active');
document.getElementById('main-screen').classList.add('active');
}
function switchView(view) {
// Update navigation
document.querySelectorAll('.nav-btn').forEach(btn => {
btn.classList.remove('active');
});
document.querySelector(`[data-view="${view}"]`).classList.add('active');
// Update views
document.querySelectorAll('.view').forEach(v => {
v.classList.remove('active');
});
document.getElementById(`${view}-view`).classList.add('active');
state.currentView = view;
// Load view data
if (view === 'dashboard') {
loadDashboard();
} else if (view === 'projects') {
loadProjects();
} else if (view === 'entries') {
loadTimeEntries();
loadProjectsForFilter();
} else if (view === 'invoices') {
loadInvoices();
} else if (view === 'expenses') {
loadExpenses();
} else if (view === 'workforce') {
loadWorkforce();
} else if (view === 'settings') {
loadSettings();
}
}
async function loadDashboard() {
if (!state.apiClient) return;
try {
// Get timer status
const timerResponse = await state.apiClient.getTimerStatus();
if (timerResponse.data.active) {
state.isTimerRunning = true;
updateTimerDisplay(timerResponse.data.timer);
startTimerPolling();
}
// Get today's summary
const today = new Date().toISOString().split('T')[0];
const entriesResponse = await state.apiClient.getTimeEntries({ startDate: today, endDate: today });
const totalSeconds = entriesResponse.data.time_entries?.reduce((sum, entry) => {
return sum + (entry.duration_seconds || 0);
}, 0) || 0;
document.getElementById('today-summary').textContent = formatDuration(totalSeconds);
// Load recent entries
loadRecentEntries();
} catch (error) {
console.error('Error loading dashboard:', error);
if (error && error.stack) console.error(error.stack);
const { message } = classifyAxiosError(error);
showError(message || 'Could not load the dashboard.');
}
}
async function loadRecentEntries() {
if (!state.apiClient) return;
try {
const response = await state.apiClient.getTimeEntries({ perPage: 5 });
const entries = response.data.time_entries || [];
const entriesList = document.getElementById('recent-entries');
if (entries.length === 0) {
entriesList.innerHTML = '<p class="empty-state">No recent entries</p>';
return;
}
entriesList.innerHTML = entries.map(entry => `
<div class="entry-item">
<div class="entry-info">
<h3>${entry.project?.name || 'Unknown Project'}</h3>
<p>${formatDateTime(entry.start_time)}</p>
</div>
<div class="entry-time">${formatDuration(entry.duration_seconds || 0)}</div>
</div>
`).join('');
} catch (error) {
console.error('Error loading recent entries:', error);
if (error && error.stack) console.error(error.stack);
const { message } = classifyAxiosError(error);
showError(message || 'Could not load recent entries.');
}
}
async function loadProjects() {
if (!state.apiClient) return;
try {
const response = await state.apiClient.getProjects({ status: 'active' });
const projects = response.data.projects || [];
const projectsList = document.getElementById('projects-list');
if (projects.length === 0) {
projectsList.innerHTML = '<p class="empty-state">No projects found</p>';
return;
}
projectsList.innerHTML = projects.map(project => `
<div class="project-card" onclick="selectProject(${project.id})">
<h3>${project.name}</h3>
<p>${project.client || 'No client'}</p>
</div>
`).join('');
} catch (error) {
console.error('Error loading projects:', error);
if (error && error.stack) console.error(error.stack);
const { message } = classifyAxiosError(error);
showError(message || 'Could not load projects.');
}
}
function selectProject(projectId) {
currentFilters = {
...currentFilters,
projectId: projectId || null,
};
switchView('entries');
}
let currentFilters = {
startDate: null,
endDate: null,
projectId: null,
};
async function loadTimeEntries() {
if (!state.apiClient) return;
try {
const params = { perPage: 50 };
if (currentFilters.startDate) params.startDate = currentFilters.startDate;
if (currentFilters.endDate) params.endDate = currentFilters.endDate;
if (currentFilters.projectId) params.projectId = currentFilters.projectId;
const response = await state.apiClient.getTimeEntries(params);
const entries = response.data.time_entries || [];
const entriesList = document.getElementById('entries-list');
if (entries.length === 0) {
entriesList.innerHTML = '<p class="empty-state">No time entries</p>';
return;
}
entriesList.innerHTML = entries.map(entry => `
<div class="entry-item" data-entry-id="${entry.id}">
<div class="entry-info">
<h3>${entry.project?.name || 'Unknown Project'}</h3>
${entry.task ? `<p class="entry-task">${entry.task.name}</p>` : ''}
<p class="entry-time-range">
${formatDateTime(entry.start_time)} - ${entry.end_time ? formatDateTime(entry.end_time) : 'Running'}
</p>
${entry.notes ? `<p class="entry-notes">${entry.notes}</p>` : ''}
${entry.tags ? `<p class="entry-tags">Tags: ${entry.tags}</p>` : ''}
${entry.billable ? '<span class="badge badge-success">Billable</span>' : ''}
</div>
<div class="entry-actions">
<div class="entry-time">${formatDuration(entry.duration_seconds || 0)}</div>
<button class="btn btn-sm btn-secondary" onclick="editTimeEntry(${entry.id})">Edit</button>
<button class="btn btn-sm btn-danger" onclick="deleteTimeEntry(${entry.id})">Delete</button>
</div>
</div>
`).join('');
} catch (error) {
console.error('Error loading time entries:', error);
showError('Failed to load time entries: ' + (error.response?.data?.error || error.message));
}
}
function editTimeEntry(entryId) {
showTimeEntryForm(entryId);
}
async function deleteTimeEntry(entryId) {
if (!confirm('Are you sure you want to delete this time entry?')) {
return;
}
if (!state.apiClient) return;
try {
await state.apiClient.deleteTimeEntry(entryId);
loadTimeEntries();
showSuccess('Time entry deleted successfully');
} catch (error) {
showError('Failed to delete time entry: ' + (error.response?.data?.error || error.message));
}
}
async function handleStartTimer() {
if (!state.apiClient) return;
// Show project selection dialog
const result = await showStartTimerDialog();
if (!result) return; // User cancelled
try {
const response = await startTimerWithReconcile(state.apiClient, {
projectId: result.projectId,
taskId: result.taskId,
notes: result.notes,
});
if (response.data && response.data.timer) {
state.isTimerRunning = true;
updateTimerDisplay(response.data.timer);
startTimerPolling();
document.getElementById('start-timer-btn').style.display = 'none';
document.getElementById('stop-timer-btn').style.display = 'block';
}
} catch (error) {
console.error('Failed to start timer:', error);
if (error && error.stack) console.error(error.stack);
const { message } = classifyAxiosError(error);
showError(message || 'Failed to start timer: ' + (error.response?.data?.error || error.message));
}
}
async function showStartTimerDialog() {
return new Promise(async (resolve) => {
// Load projects and time entry requirements
let projects = [];
let requirements = { require_task: false, require_description: false, description_min_length: 20 };
try {
const projectsResponse = await state.apiClient.getProjects({ status: 'active' });
projects = projectsResponse.data.projects || [];
try {
const usersMeResponse = await state.apiClient.getUsersMe();
if (usersMeResponse && usersMeResponse.time_entry_requirements) {
requirements = usersMeResponse.time_entry_requirements;
}
} catch (meErr) {
console.error('getUsersMe for timer dialog:', meErr);
if (meErr && meErr.stack) console.error(meErr.stack);
const { message } = classifyAxiosError(meErr);
showError(message || 'Could not load time entry rules; using defaults.');
}
} catch (error) {
console.error('Failed to load projects for timer dialog:', error);
if (error && error.stack) console.error(error.stack);
const { message } = classifyAxiosError(error);
showError(message || 'Failed to load projects');
resolve(null);
return;
}
if (projects.length === 0) {
showError('No active projects found');
resolve(null);
return;
}
// Create modal
const modal = document.createElement('div');
modal.className = 'modal';
modal.innerHTML = `
<div class="modal-content">
<div class="modal-header">
<h3>Start Timer</h3>
<button class="modal-close" onclick="this.closest('.modal').remove()">×</button>
</div>
<div class="modal-body">
<div class="form-group">
<label for="timer-project-select">Project *</label>
<select id="timer-project-select" class="form-control" required>
<option value="">Select a project...</option>
${projects.map(p => `<option value="${p.id}">${p.name}</option>`).join('')}
</select>
</div>
<div class="form-group">
<label for="timer-task-select">${requirements.require_task ? 'Task *' : 'Task (Optional)'}</label>
<select id="timer-task-select" class="form-control">
<option value="">No task</option>
</select>
</div>
<div class="form-group">
<label for="timer-notes-input">${requirements.require_description ? 'Notes *' : 'Notes (Optional)'}</label>
<textarea id="timer-notes-input" class="form-control" rows="3" placeholder="What are you working on?"></textarea>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" onclick="this.closest('.modal').remove()">Cancel</button>
<button class="btn btn-primary" id="start-timer-confirm">Start</button>
</div>
</div>
`;
document.body.appendChild(modal);
const projectSelect = modal.querySelector('#timer-project-select');
const taskSelect = modal.querySelector('#timer-task-select');
const notesInput = modal.querySelector('#timer-notes-input');
const confirmBtn = modal.querySelector('#start-timer-confirm');
// Load tasks when project changes
projectSelect.addEventListener('change', async (e) => {
const projectId = parseInt(e.target.value);
if (!projectId) {
taskSelect.innerHTML = '<option value="">No task</option>';
return;
}
try {
const tasksResponse = await state.apiClient.getTasks({ projectId: projectId });
const tasks = tasksResponse.data.tasks || [];
taskSelect.innerHTML = '<option value="">No task</option>' +
tasks.map(t => `<option value="${t.id}">${t.name}</option>`).join('');
} catch (error) {
console.error('Failed to load tasks:', error);
}
});
// Handle confirm
confirmBtn.addEventListener('click', () => {
const projectId = parseInt(projectSelect.value);
if (!projectId) {
showError('Please select a project');
return;
}
const taskId = taskSelect.value ? parseInt(taskSelect.value) : null;
if (requirements.require_task && !taskId) {
showError('A task must be selected when logging time for a project');
return;
}
const notes = notesInput.value.trim();
if (requirements.require_description) {
if (!notes) {
showError('A description is required when logging time');
return;
}
const minLen = requirements.description_min_length || 20;
if (notes.length < minLen) {
showError(`Description must be at least ${minLen} characters`);
return;
}
}
modal.remove();
resolve({ projectId, taskId, notes: notes || null });
});
// Close on backdrop click
modal.addEventListener('click', (e) => {
if (e.target === modal) {
modal.remove();
resolve(null);
}
});
});
}
async function handleStopTimer() {
if (!state.apiClient) return;
try {
await stopTimerWithReconcile(state.apiClient);
state.isTimerRunning = false;
stopTimerPolling();
document.getElementById('timer-display').textContent = '00:00:00';
document.getElementById('timer-project').textContent = 'No active timer';
document.getElementById('timer-task').style.display = 'none';
document.getElementById('timer-notes').style.display = 'none';
document.getElementById('start-timer-btn').style.display = 'block';
document.getElementById('stop-timer-btn').style.display = 'none';
// Notify tray
updateTimerDisplay(null);
// Refresh entries list
loadTimeEntries();
loadRecentEntries();
} catch (error) {
console.error('Error stopping timer:', error);
if (error && error.stack) console.error(error.stack);
const { message } = classifyAxiosError(error);
showError(message || 'Failed to stop timer: ' + (error.response?.data?.error || error.message));
}
}
function startTimerPolling() {
if (state.timerInterval) clearInterval(state.timerInterval);
state.timerInterval = setInterval(async () => {
if (!state.apiClient || !state.isTimerRunning) return;
try {
const response = await state.apiClient.getTimerStatus();
if (response.data.active) {
updateTimerDisplay(response.data.timer);
} else {
state.isTimerRunning = false;
stopTimerPolling();
}
} catch (error) {
console.error('Error polling timer:', error);
if (error && error.stack) console.error(error.stack);
const { message } = classifyAxiosError(error);
connectionManager.signalError(message || 'Lost connection while syncing the active timer.');
updateConnectionFromManager();
const now = Date.now();
if (!state.lastTimerPollUserMessageAt || now - state.lastTimerPollUserMessageAt > 60000) {
state.lastTimerPollUserMessageAt = now;
showError(
'Lost connection while syncing the active timer. Check the connection indicator; polling will retry.',
);
}
}
}, 5000); // Poll every 5 seconds
}
function stopTimerPolling() {
if (state.timerInterval) {
clearInterval(state.timerInterval);
state.timerInterval = null;
}
}
function updateTimerDisplay(timer) {
if (!timer) {
// Notify tray that timer is stopped
if (window.electronAPI && window.electronAPI.sendTimerStatus) {
window.electronAPI.sendTimerStatus({ active: false });
}
return;
}
const startTime = new Date(timer.start_time);
const now = new Date();
const seconds = Math.floor((now - startTime) / 1000);
document.getElementById('timer-display').textContent = formatDurationLong(seconds);
document.getElementById('timer-project').textContent = timer.project?.name || 'Unknown Project';
// Show task if available
const taskEl = document.getElementById('timer-task');
if (timer.task) {
taskEl.textContent = timer.task.name;
taskEl.style.display = 'block';
} else {
taskEl.style.display = 'none';
}
// Show notes if available
const notesEl = document.getElementById('timer-notes');
if (timer.notes) {
notesEl.textContent = timer.notes;
notesEl.style.display = 'block';
} else {
notesEl.style.display = 'none';
}
// Notify tray that timer is running
if (window.electronAPI && window.electronAPI.sendTimerStatus) {
window.electronAPI.sendTimerStatus({ active: true, timer: timer });
}
}
async function loadInvoices() {
if (!state.apiClient) return;
try {
const response = await state.apiClient.getInvoices({
page: state.pagination.invoices.page,
perPage: state.pagination.invoices.perPage,
});
state.cachedInvoices = response.data.invoices || [];
state.viewLimits.invoices = 20;
const pagination = response.data.pagination || {};
state.pagination.invoices.totalPages = Number(pagination.pages || pagination.total_pages || 1) || 1;
state.pagination.invoices.total = Number(pagination.total || state.cachedInvoices.length) || state.cachedInvoices.length;
renderInvoices();
renderInvoicePager();
} catch (error) {
console.error('Error loading invoices:', error);
showError('Failed to load invoices: ' + (error.response?.data?.error || error.message));
}
}
function renderInvoicePager() {
const indicator = document.getElementById('invoice-page-indicator');
const prevBtn = document.getElementById('invoice-prev-page-btn');
const nextBtn = document.getElementById('invoice-next-page-btn');
if (indicator) {
indicator.textContent = `Page ${state.pagination.invoices.page}/${state.pagination.invoices.totalPages}`;
}
if (prevBtn) {
prevBtn.disabled = state.pagination.invoices.page <= 1;
}
if (nextBtn) {
nextBtn.disabled = state.pagination.invoices.page >= state.pagination.invoices.totalPages;
}
}
async function changeInvoicePage(delta) {
const nextPage = state.pagination.invoices.page + delta;
if (nextPage < 1 || nextPage > state.pagination.invoices.totalPages) {
return;
}
state.pagination.invoices.page = nextPage;
await loadInvoices();
}
function renderInvoices() {
const list = document.getElementById('invoices-list');
if (!list) return;
const filtered = state.cachedInvoices.filter((invoice) => {
const q = state.viewFilters.invoiceQuery;
if (!q) return true;
const haystack = `${invoice.invoice_number || ''} ${invoice.client_name || ''} ${invoice.status || ''}`.toLowerCase();
return haystack.includes(q);
});
if (filtered.length === 0) {
list.innerHTML = '<p class="empty-state">No invoices</p>';
return;
}
const limited = filtered.slice(0, state.viewLimits.invoices);
const rowsHtml = limited.map((invoice) => {
const number = invoice.invoice_number || invoice.id || 'N/A';
const status = invoice.status || 'unknown';
const total = invoice.total_amount ?? invoice.total ?? '-';
const totalNumber = Number(invoice.total_amount ?? invoice.total ?? 0) || 0;
return `
<div class="entry-item">
<div class="entry-info">
<h3>Invoice ${number}</h3>
<p>Status: ${status}</p>
</div>
<div class="entry-actions">
<div class="entry-time">${total}</div>
<button class="btn btn-sm btn-secondary" onclick="updateInvoiceStatusAction(${invoice.id}, 'sent')">Mark Sent</button>
<button class="btn btn-sm btn-secondary" onclick="markInvoicePaidAction(${invoice.id}, ${totalNumber})">Mark Paid</button>
<button class="btn btn-sm btn-danger" onclick="updateInvoiceStatusAction(${invoice.id}, 'cancelled')">Cancel</button>
</div>
</div>
`;
}).join('');
const hasMore = filtered.length > limited.length;
list.innerHTML = rowsHtml + (
hasMore
? `<div style="padding-top:8px;"><button class="btn btn-secondary" onclick="loadMoreInvoices()">Load More</button></div>`
: ''
);
}
function loadMoreInvoices() {
state.viewLimits.invoices += 20;
renderInvoices();
}
async function loadExpenses() {
if (!state.apiClient) return;
try {
const response = await state.apiClient.getExpenses({
page: state.pagination.expenses.page,
perPage: state.pagination.expenses.perPage,
});
state.cachedExpenses = response.data.expenses || [];
state.viewLimits.expenses = 20;
const pagination = response.data.pagination || {};
state.pagination.expenses.totalPages = Number(pagination.pages || pagination.total_pages || 1) || 1;
state.pagination.expenses.total = Number(pagination.total || state.cachedExpenses.length) || state.cachedExpenses.length;
renderExpenses();
renderExpensePager();
} catch (error) {
console.error('Error loading expenses:', error);
showError('Failed to load expenses: ' + (error.response?.data?.error || error.message));
}
}
function renderExpensePager() {
const indicator = document.getElementById('expense-page-indicator');
const prevBtn = document.getElementById('expense-prev-page-btn');
const nextBtn = document.getElementById('expense-next-page-btn');
if (indicator) {
indicator.textContent = `Page ${state.pagination.expenses.page}/${state.pagination.expenses.totalPages}`;
}
if (prevBtn) {
prevBtn.disabled = state.pagination.expenses.page <= 1;
}
if (nextBtn) {
nextBtn.disabled = state.pagination.expenses.page >= state.pagination.expenses.totalPages;
}
}
async function changeExpensePage(delta) {
const nextPage = state.pagination.expenses.page + delta;
if (nextPage < 1 || nextPage > state.pagination.expenses.totalPages) {
return;
}
state.pagination.expenses.page = nextPage;
await loadExpenses();
}
function renderExpenses() {
const list = document.getElementById('expenses-list');
if (!list) return;
const filtered = state.cachedExpenses.filter((expense) => {
const q = state.viewFilters.expenseQuery;
if (!q) return true;
const haystack = `${expense.title || ''} ${expense.category || ''} ${expense.expense_date || ''}`.toLowerCase();
return haystack.includes(q);
});
if (filtered.length === 0) {
list.innerHTML = '<p class="empty-state">No expenses</p>';
return;
}
const limited = filtered.slice(0, state.viewLimits.expenses);
const rowsHtml = limited.map((expense) => {
const category = expense.category || 'General';
const amount = expense.amount ?? '-';
const date = expense.expense_date || expense.date || '';
return `
<div class="entry-item">
<div class="entry-info">
<h3>${category}</h3>
<p>${date}</p>
</div>
<div class="entry-time">${amount}</div>
</div>
`;
}).join('');
const hasMore = filtered.length > limited.length;
list.innerHTML = rowsHtml + (
hasMore
? `<div style="padding-top:8px;"><button class="btn btn-secondary" onclick="loadMoreExpenses()">Load More</button></div>`
: ''
);
}
function loadMoreExpenses() {
state.viewLimits.expenses += 20;
renderExpenses();
}
async function loadWorkforce() {
if (!state.apiClient) return;
try {
const start = new Date();
start.setDate(start.getDate() - start.getDay() + 1);
const end = new Date(start);
end.setDate(start.getDate() + 6);
const startDate = start.toISOString().split('T')[0];
const endDate = end.toISOString().split('T')[0];
const [periodsResponse, capacityResponse, requestsResponse, balancesResponse] = await Promise.all([
state.apiClient.getTimesheetPeriods({ startDate, endDate }),
state.apiClient.getCapacityReport({ startDate, endDate }),
state.apiClient.getTimeOffRequests({}),
state.apiClient.getTimeOffBalances({}),
]);
state.cachedWorkforce = {
periods: periodsResponse.data.timesheet_periods || [],
capacity: capacityResponse.data.capacity || [],
timeOffRequests: requestsResponse.data.time_off_requests || [],
balances: balancesResponse.data.balances || [],
};
state.viewLimits.timeoff = 20;
renderWorkforce();
} catch (error) {
console.error('Error loading workforce view:', error);
showError('Failed to load workforce data: ' + (error.response?.data?.error || error.message));
}
}
function renderWorkforce() {
renderPeriods();
renderCapacity();
renderTimeOffRequests();
renderBalances();
}
function renderPeriods() {
const periods = state.cachedWorkforce.periods || [];
const periodsList = document.getElementById('periods-list');
if (!periodsList) return;
if (periods.length === 0) {
periodsList.innerHTML = '<p class="empty-state">No periods</p>';
return;
}
periodsList.innerHTML = periods.map((period) => `
<div class="entry-item">
<div class="entry-info">
<h3>${period.period_start} - ${period.period_end}</h3>
<p>Status: ${period.status}</p>
</div>
<div class="entry-actions">
${String(period.status || '').toLowerCase() === 'draft'
? `<button class="btn btn-sm btn-primary" onclick="submitTimesheetPeriodAction(${period.id})">Submit</button>`
: ''}
${(String(period.status || '').toLowerCase() === 'submitted' && state.currentUserProfile.can_approve)
? `<button class="btn btn-sm btn-primary" onclick="reviewTimesheetPeriodAction(${period.id}, true)">Approve</button>`
: ''}
${(String(period.status || '').toLowerCase() === 'submitted' && state.currentUserProfile.can_approve)
? `<button class="btn btn-sm btn-danger" onclick="reviewTimesheetPeriodAction(${period.id}, false)">Reject</button>`
: ''}
${['draft', 'rejected'].includes(String(period.status || '').toLowerCase())
? `<button class="btn btn-sm btn-danger" onclick="deleteTimesheetPeriodAction(${period.id})">Delete</button>`
: ''}
</div>
</div>
`).join('');
}
function renderCapacity() {
const capacity = state.cachedWorkforce.capacity || [];
const capacityList = document.getElementById('capacity-list');
if (!capacityList) return;
if (capacity.length === 0) {
capacityList.innerHTML = '<p class="empty-state">No capacity rows</p>';
return;
}
capacityList.innerHTML = capacity.map((row) => {
const username = row.username || row.user_id || 'User';
const expected = row.expected_hours ?? 0;
const allocated = row.allocated_hours ?? 0;
const util = row.utilization_pct ?? 0;
return `
<div class="entry-item">
<div class="entry-info">
<h3>${username}</h3>
<p>Expected ${expected}h | Allocated ${allocated}h</p>
</div>
<div class="entry-time">${util}%</div>
</div>
`;
}).join('');
}
function renderTimeOffRequests() {
const requests = state.cachedWorkforce.timeOffRequests || [];
const timeoffList = document.getElementById('timeoff-list');
if (!timeoffList) return;
const filtered = requests.filter((req) => {
const q = state.viewFilters.timeoffQuery;
if (!q) return true;
const haystack = `${req.leave_type_name || ''} ${req.status || ''} ${req.start_date || ''} ${req.end_date || ''}`.toLowerCase();
return haystack.includes(q);
});
if (filtered.length === 0) {
timeoffList.innerHTML = '<p class="empty-state">No time-off requests</p>';
return;
}
const limited = filtered.slice(0, state.viewLimits.timeoff);
const rowsHtml = limited.map((req) => {
const leaveType = req.leave_type_name || 'Leave';
const status = req.status || '';
const pending = String(status).toLowerCase() === 'submitted';
const canReview = pending && state.currentUserProfile.can_approve;
return `
<div class="entry-item">
<div class="entry-info">
<h3>${leaveType}</h3>
<p>${req.start_date} - ${req.end_date}</p>
</div>
<div class="entry-actions">
<div class="entry-time">${status}</div>
${canReview ? `<button class="btn btn-sm btn-primary" onclick="reviewTimeOffRequestAction(${req.id}, true)">Approve</button>` : ''}
${canReview ? `<button class="btn btn-sm btn-danger" onclick="reviewTimeOffRequestAction(${req.id}, false)">Reject</button>` : ''}
${['draft', 'submitted', 'cancelled'].includes(String(status).toLowerCase()) && (req.user_id === state.currentUserProfile.id || state.currentUserProfile.can_approve)
? `<button class="btn btn-sm btn-danger" onclick="deleteTimeOffRequestAction(${req.id})">Delete</button>`
: ''}
</div>
</div>
`;
}).join('');
const hasMore = filtered.length > limited.length;
timeoffList.innerHTML = rowsHtml + (
hasMore
? `<div style="padding-top:8px;"><button class="btn btn-secondary" onclick="loadMoreTimeOffRequests()">Load More</button></div>`
: ''
);
}
function loadMoreTimeOffRequests() {
state.viewLimits.timeoff += 20;
renderTimeOffRequests();
}
function renderBalances() {
const balances = state.cachedWorkforce.balances || [];
const balancesList = document.getElementById('balances-list');
if (!balancesList) return;
if (balances.length === 0) {
balancesList.innerHTML = '<p class="empty-state">No leave balances</p>';
return;
}
balancesList.innerHTML = balances.map((bal) => {
const leaveType = bal.leave_type_name || 'Leave';
const remaining = bal.remaining_hours ?? bal.balance_hours ?? 0;
return `
<div class="entry-item">
<div class="entry-info">
<h3>${leaveType}</h3>
</div>
<div class="entry-time">${remaining}h</div>
</div>
`;
}).join('');
}
async function showCreateInvoiceDialog() {
if (!state.apiClient) return;
try {
const [projectsResponse, clientsResponse] = await Promise.all([
state.apiClient.getProjects({ status: 'active', perPage: 100 }),
state.apiClient.getClients({ status: 'active', perPage: 100 }),
]);
const projects = projectsResponse.data.projects || [];
const clients = clientsResponse.data.clients || [];
if (projects.length === 0 || clients.length === 0) {
showError('Need at least one active project and client to create an invoice');
return;
}
const modal = document.createElement('div');
modal.className = 'modal';
modal.innerHTML = `
<div class="modal-content" style="max-width: 560px;">
<div class="modal-header">
<h3>Create Invoice</h3>
<button class="modal-close" onclick="this.closest('.modal').remove()">×</button>
</div>
<div class="modal-body">
<div class="form-group">
<label for="invoice-project-select">Project *</label>
<select id="invoice-project-select" class="form-control">
${projects.map((p) => `<option value="${p.id}">${p.name}</option>`).join('')}
</select>
</div>
<div class="form-group">
<label for="invoice-client-select">Client *</label>
<select id="invoice-client-select" class="form-control">
${clients.map((c) => `<option value="${c.id}">${c.name}</option>`).join('')}
</select>
</div>
<div class="form-group">
<label for="invoice-due-date">Due date *</label>
<input type="date" id="invoice-due-date" class="form-control" value="${new Date(Date.now() + 14 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]}">
</div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" onclick="this.closest('.modal').remove()">Cancel</button>
<button class="btn btn-primary" id="invoice-create-btn">Create</button>
</div>
</div>
`;
document.body.appendChild(modal);
const createBtn = modal.querySelector('#invoice-create-btn');
createBtn.addEventListener('click', async () => {
const projectId = Number(modal.querySelector('#invoice-project-select').value);
const clientId = Number(modal.querySelector('#invoice-client-select').value);
const dueDate = modal.querySelector('#invoice-due-date').value;
const client = clients.find((c) => Number(c.id) === clientId);
if (!projectId || !clientId || !client || !dueDate) {
showError('Please provide all required fields');
return;
}
await state.apiClient.createInvoice({
project_id: projectId,
client_id: clientId,
client_name: client.name,
due_date: dueDate,
});
modal.remove();
showSuccess('Invoice created successfully');
await loadInvoices();
});
modal.addEventListener('click', (e) => {
if (e.target === modal) {
modal.remove();
}
});
} catch (error) {
showError('Failed to create invoice: ' + (error.response?.data?.error || error.message));
}
}
async function submitTimesheetPeriodAction(periodId) {
if (!state.apiClient) return;
try {
await state.apiClient.submitTimesheetPeriod(periodId);
showSuccess('Timesheet period submitted');
await loadWorkforce();
} catch (error) {
showError('Failed to submit period: ' + (error.response?.data?.error || error.message));
}
}
async function reviewTimesheetPeriodAction(periodId, approve) {
if (!state.apiClient) return;
try {
if (approve) {
await state.apiClient.approveTimesheetPeriod(periodId, {});
showSuccess('Timesheet period approved');
} else {
await state.apiClient.rejectTimesheetPeriod(periodId, {});
showSuccess('Timesheet period rejected');
}
await loadWorkforce();
} catch (error) {
showError('Failed to review period: ' + (error.response?.data?.error || error.message));
}
}
async function deleteTimesheetPeriodAction(periodId) {
if (!state.apiClient) return;
if (!confirm('Are you sure you want to delete this timesheet period?')) return;
try {
await state.apiClient.deleteTimesheetPeriod(periodId);
showSuccess('Timesheet period deleted');
await loadWorkforce();
} catch (error) {
showError('Failed to delete period: ' + (error.response?.data?.error || error.message));
}
}
async function showCreateTimeOffDialog() {
if (!state.apiClient) return;
try {
const leaveTypesResponse = await state.apiClient.getLeaveTypes();
const leaveTypes = leaveTypesResponse.data.leave_types || [];
if (leaveTypes.length === 0) {
showError('No leave types available');
return;
}
const modal = document.createElement('div');
modal.className = 'modal';
const today = new Date().toISOString().split('T')[0];
modal.innerHTML = `
<div class="modal-content" style="max-width: 560px;">
<div class="modal-header">
<h3>Create Time-Off Request</h3>
<button class="modal-close" onclick="this.closest('.modal').remove()">×</button>
</div>
<div class="modal-body">
<div class="form-group">
<label for="timeoff-leave-type">Leave type *</label>
<select id="timeoff-leave-type" class="form-control">
${leaveTypes.map((lt) => `<option value="${lt.id}">${lt.name}</option>`).join('')}
</select>
</div>
<div class="form-group">
<label for="timeoff-start-date">Start date *</label>
<input type="date" id="timeoff-start-date" class="form-control" value="${today}">
</div>
<div class="form-group">
<label for="timeoff-end-date">End date *</label>
<input type="date" id="timeoff-end-date" class="form-control" value="${today}">
</div>
<div class="form-group">
<label for="timeoff-hours">Requested hours (optional)</label>
<input type="number" step="0.25" id="timeoff-hours" class="form-control">
</div>
<div class="form-group">
<label for="timeoff-comment">Comment (optional)</label>
<textarea id="timeoff-comment" class="form-control" rows="2"></textarea>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" onclick="this.closest('.modal').remove()">Cancel</button>
<button class="btn btn-primary" id="timeoff-create-btn">Create</button>
</div>
</div>
`;
document.body.appendChild(modal);
modal.querySelector('#timeoff-create-btn').addEventListener('click', async () => {
const leaveTypeId = Number(modal.querySelector('#timeoff-leave-type').value);
const startDate = modal.querySelector('#timeoff-start-date').value;
const endDate = modal.querySelector('#timeoff-end-date').value;
const hoursValue = modal.querySelector('#timeoff-hours').value.trim();
const requestedHours = hoursValue ? Number(hoursValue) : null;
const comment = modal.querySelector('#timeoff-comment').value.trim();
if (!leaveTypeId || !startDate || !endDate) {
showError('Please provide leave type and dates');
return;
}
if (hoursValue && !Number.isFinite(requestedHours)) {
showError('requested_hours must be numeric');
return;
}
await state.apiClient.createTimeOffRequest({
leaveTypeId,
startDate,
endDate,
requestedHours,
comment,
submit: true,
});
modal.remove();
showSuccess('Time-off request created');
await loadWorkforce();
});
modal.addEventListener('click', (e) => {
if (e.target === modal) {
modal.remove();
}
});
} catch (error) {
showError('Failed to create time-off request: ' + (error.response?.data?.error || error.message));
}
}
async function showCreateExpenseDialog() {
if (!state.apiClient) return;
try {
const modal = document.createElement('div');
modal.className = 'modal';
modal.innerHTML = `
<div class="modal-content" style="max-width: 560px;">
<div class="modal-header">
<h3>Create Expense</h3>
<button class="modal-close" onclick="this.closest('.modal').remove()">×</button>
</div>
<div class="modal-body">
<div class="form-group">
<label for="expense-title">Title *</label>
<input type="text" id="expense-title" class="form-control" placeholder="Taxi to client office">
</div>
<div class="form-group">
<label for="expense-category">Category *</label>
<input type="text" id="expense-category" class="form-control" value="travel">
</div>
<div class="form-group">
<label for="expense-amount">Amount *</label>
<input type="number" step="0.01" id="expense-amount" class="form-control">
</div>
<div class="form-group">
<label for="expense-date">Expense date *</label>
<input type="date" id="expense-date" class="form-control" value="${new Date().toISOString().split('T')[0]}">
</div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" onclick="this.closest('.modal').remove()">Cancel</button>
<button class="btn btn-primary" id="expense-create-btn">Create</button>
</div>
</div>
`;
document.body.appendChild(modal);
modal.querySelector('#expense-create-btn').addEventListener('click', async () => {
const title = modal.querySelector('#expense-title').value.trim();
const category = modal.querySelector('#expense-category').value.trim();
const amount = Number(modal.querySelector('#expense-amount').value);
const expenseDate = modal.querySelector('#expense-date').value;
if (!title || !category || !expenseDate || !Number.isFinite(amount) || amount <= 0) {
showError('Please provide valid title/category/amount/date');
return;
}
await state.apiClient.createExpense({
title,
category,
amount,
expense_date: expenseDate,
});
modal.remove();
showSuccess('Expense created successfully');
await loadExpenses();
});
modal.addEventListener('click', (e) => {
if (e.target === modal) {
modal.remove();
}
});
} catch (error) {
showError('Failed to create expense: ' + (error.response?.data?.error || error.message));
}
}
async function updateInvoiceStatusAction(invoiceId, status) {
if (!state.apiClient) return;
try {
await state.apiClient.updateInvoice(invoiceId, { status });
showSuccess(`Invoice marked ${status}`);
await loadInvoices();
} catch (error) {
showError('Failed to update invoice: ' + (error.response?.data?.error || error.message));
}
}
async function markInvoicePaidAction(invoiceId, totalAmount) {
if (!state.apiClient) return;
const amountPaid = Number(totalAmount || 0);
if (!Number.isFinite(amountPaid) || amountPaid <= 0) {
showError('Invoice total is invalid; cannot mark paid');
return;
}
try {
await state.apiClient.updateInvoice(invoiceId, { amount_paid: amountPaid });
showSuccess('Invoice marked paid');
await loadInvoices();
} catch (error) {
showError('Failed to mark paid: ' + (error.response?.data?.error || error.message));
}
}
async function reviewTimeOffRequestAction(requestId, approve) {
if (!state.apiClient) return;
try {
if (approve) {
await state.apiClient.approveTimeOffRequest(requestId, {});
showSuccess('Time-off request approved');
} else {
await state.apiClient.rejectTimeOffRequest(requestId, {});
showSuccess('Time-off request rejected');
}
await loadWorkforce();
} catch (error) {
showError('Failed to review time-off request: ' + (error.response?.data?.error || error.message));
}
}
async function deleteTimeOffRequestAction(requestId) {
if (!state.apiClient) return;
if (!confirm('Are you sure you want to delete this time-off request?')) return;
try {
await state.apiClient.deleteTimeOffRequest(requestId);
showSuccess('Time-off request deleted');
await loadWorkforce();
} catch (error) {
showError('Failed to delete time-off request: ' + (error.response?.data?.error || error.message));
}
}
async function loadSettings() {
// Load current settings
const serverUrl = await storeGet('server_url') || '';
const username = await storeGet('username') || '';
const autoSync = await storeGet('auto_sync');
const syncInterval = await storeGet('sync_interval');
const serverUrlInput = document.getElementById('settings-server-url');
const usernameInput = document.getElementById('settings-username');
const passwordInput = document.getElementById('settings-password');
const autoSyncInput = document.getElementById('auto-sync');
const syncIntervalInput = document.getElementById('sync-interval');
if (serverUrlInput) {
serverUrlInput.value = serverUrl ? ApiClient.normalizeBaseUrl(String(serverUrl)) : '';
}
if (usernameInput) {
usernameInput.value = username ? String(username) : '';
}
if (passwordInput) {
passwordInput.value = '';
}
if (autoSyncInput) {
autoSyncInput.checked = autoSync !== null ? Boolean(autoSync) : true;
}
if (syncIntervalInput) {
syncIntervalInput.value = (syncInterval || 60).toString();
}
updateSyncIntervalState();
}
function updateSyncIntervalState() {
const autoSyncInput = document.getElementById('auto-sync');
const syncIntervalInput = document.getElementById('sync-interval');
if (!autoSyncInput || !syncIntervalInput) return;
syncIntervalInput.disabled = !autoSyncInput.checked;
}
async function handleSaveSettings() {
const serverUrlInput = document.getElementById('settings-server-url');
const usernameInput = document.getElementById('settings-username');
const passwordInput = document.getElementById('settings-password');
const autoSyncInput = document.getElementById('auto-sync');
const syncIntervalInput = document.getElementById('sync-interval');
const messageDiv = document.getElementById('settings-message');
if (!serverUrlInput || !usernameInput || !passwordInput || !autoSyncInput || !syncIntervalInput) return;
const rawServer = serverUrlInput.value.trim();
const normalizedInput = normalizeServerUrlInput(rawServer);
const username = usernameInput.value.trim();
const password = passwordInput.value;
const autoSync = autoSyncInput.checked;
const syncInterval = parseInt(syncIntervalInput.value, 10);
if (!normalizedInput || !isValidUrl(normalizedInput)) {
showSettingsMessage('Please enter a valid server URL', 'error');
return;
}
const serverUrl = ApiClient.normalizeBaseUrl(normalizedInput);
if (!username || !password) {
showSettingsMessage('Please enter your username and password to save settings', 'error');
return;
}
if (Number.isNaN(syncInterval) || syncInterval < 10) {
showSettingsMessage('Sync interval must be at least 10 seconds', 'error');
return;
}
try {
const saved = await connectionManager.saveServerAndCredentials(serverUrl, username, password, {
auto_sync: autoSync,
sync_interval: syncInterval,
});
if (!saved.ok) {
showSettingsMessage(saved.message || saved.session?.message || 'Could not save settings.', 'error');
updateConnectionFromManager();
return;
}
state.authFailureStreak = 0;
await loadCurrentUserProfile();
updateConnectionFromManager();
showSettingsMessage('Settings saved successfully!', 'success');
passwordInput.value = '';
serverUrlInput.value = serverUrl;
} catch (error) {
console.error('Error saving settings:', error);
if (error && error.stack) console.error(error.stack);
showSettingsMessage('Error saving settings: ' + (error.message || String(error)), 'error');
}
}
async function handleTestConnection() {
const serverUrlInput = document.getElementById('settings-server-url');
const usernameInput = document.getElementById('settings-username');
const passwordInput = document.getElementById('settings-password');
const messageDiv = document.getElementById('settings-message');
if (!serverUrlInput || !usernameInput || !passwordInput) return;
const rawServer = serverUrlInput.value.trim();
const normalizedInput = normalizeServerUrlInput(rawServer);
const username = usernameInput.value.trim();
const password = passwordInput.value;
if (!normalizedInput || !isValidUrl(normalizedInput)) {
showSettingsMessage('Please enter a valid server URL', 'error');
return;
}
const serverUrl = ApiClient.normalizeBaseUrl(normalizedInput);
if (!username || !password) {
showSettingsMessage('Please enter your username and password to test connection', 'error');
return;
}
try {
showSettingsMessage('Testing connection...', 'info');
const r = await connectionManager.testServerAndCredentials(serverUrl, username, password);
if (!r.ok) {
showSettingsMessage(r.message || 'Connection test failed.', 'error');
updateConnectionFromManager();
return;
}
const snap = connectionManager.getSnapshot();
if (snap.serverUrl === serverUrl && connectionManager.getClient()) {
await connectionManager.validateSessionRefresh();
}
updateConnectionFromManager();
const ver = r.app_version ? ` (${r.app_version})` : '';
showSettingsMessage(`Connection successful: credentials are valid${ver}.`, 'success');
} catch (error) {
console.error('Error testing connection:', error);
if (error && error.stack) console.error(error.stack);
const { message } = classifyAxiosError(error);
showSettingsMessage(message || 'Connection error: ' + error.message, 'error');
}
}
function showSettingsMessage(message, type = 'info') {
const messageDiv = document.getElementById('settings-message');
if (!messageDiv) return;
messageDiv.textContent = message;
messageDiv.className = `message message-${type}`;
messageDiv.style.display = 'block';
// Auto-hide after 5 seconds for success/info messages
if (type === 'success' || type === 'info') {
setTimeout(() => {
messageDiv.style.display = 'none';
}, 5000);
}
}
async function handleLogout() {
if (!confirm('Sign out of this desktop app? Your server URL will be kept.')) return;
if (state.isTimerRunning) {
state.isTimerRunning = false;
stopTimerPolling();
}
await connectionManager.logoutKeepServer();
showLoginScreen({ prefillServerUrl: connectionManager.getSnapshot().serverUrl || '' });
}
async function handleResetConfiguration() {
if (
!confirm(
'Reset all app configuration (server URL, token, sync settings)? This cannot be undone.',
)
) {
return;
}
if (state.isTimerRunning) {
state.isTimerRunning = false;
stopTimerPolling();
}
await connectionManager.fullStoreReset();
showLoginScreen({ prefillServerUrl: '', startAtServer: true });
}
// Initialize when DOM is ready
async function safeInitApp() {
try {
await initApp();
} catch (err) {
console.error('initApp failed:', err);
try {
showLoginScreen({
prefillServerUrl: '',
startAtServer: true,
bannerMessage:
'Startup failed. Please re-enter your server URL and sign in again.',
});
} catch (e) {
console.error('Failed to show login screen after init failure:', e);
}
}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', safeInitApp);
} else {
safeInitApp();
}
// Filter functions
function toggleFilters() {
const filtersEl = document.getElementById('entries-filters');
if (filtersEl) {
filtersEl.style.display = filtersEl.style.display === 'none' ? 'block' : 'none';
}
}
async function applyFilters() {
const startDate = document.getElementById('filter-start-date')?.value || null;
const endDate = document.getElementById('filter-end-date')?.value || null;
const projectId = document.getElementById('filter-project')?.value
? parseInt(document.getElementById('filter-project').value)
: null;
currentFilters = { startDate, endDate, projectId };
await loadTimeEntries();
}
function clearFilters() {
currentFilters = { startDate: null, endDate: null, projectId: null };
document.getElementById('filter-start-date').value = '';
document.getElementById('filter-end-date').value = '';
document.getElementById('filter-project').value = '';
loadTimeEntries();
}
// Load projects for filter dropdown
async function loadProjectsForFilter() {
if (!state.apiClient) return;
try {
const response = await state.apiClient.getProjects({ status: 'active' });
const projects = response.data.projects || [];
const select = document.getElementById('filter-project');
if (select) {
select.innerHTML = '<option value="">All Projects</option>' +
projects.map(p => `<option value="${p.id}">${p.name}</option>`).join('');
if (currentFilters.projectId) {
select.value = String(currentFilters.projectId);
}
}
} catch (error) {
console.error('Error loading projects for filter:', error);
if (error && error.stack) console.error(error.stack);
const { message } = classifyAxiosError(error);
showError(message || 'Could not load projects for filter.');
}
}
// Time entry form
async function showTimeEntryForm(entryId = null) {
if (!state.apiClient) return;
// Load projects and time entry requirements
let projects = [];
let requirements = { require_task: false, require_description: false, description_min_length: 20 };
try {
const projectsResponse = await state.apiClient.getProjects({ status: 'active' });
projects = projectsResponse.data.projects || [];
try {
const usersMeResponse = await state.apiClient.getUsersMe();
if (usersMeResponse && usersMeResponse.time_entry_requirements) {
requirements = usersMeResponse.time_entry_requirements;
}
} catch (meErr) {
console.error('getUsersMe for time entry form:', meErr);
if (meErr && meErr.stack) console.error(meErr.stack);
const { message } = classifyAxiosError(meErr);
showError(message || 'Could not load time entry rules; using defaults.');
}
} catch (error) {
console.error('Failed to load projects for time entry form:', error);
if (error && error.stack) console.error(error.stack);
const { message } = classifyAxiosError(error);
showError(message || 'Failed to load projects');
return;
}
// Load entry if editing
let entry = null;
if (entryId) {
try {
const entryResponse = await state.apiClient.getTimeEntry(entryId);
entry = entryResponse.data.time_entry;
} catch (error) {
showError('Failed to load time entry');
return;
}
}
// Load tasks if project is selected
let tasks = [];
const projectId = entry ? entry.project_id : null;
if (projectId) {
try {
const tasksResponse = await state.apiClient.getTasks({ projectId: projectId });
tasks = tasksResponse.data.tasks || [];
} catch (error) {
console.error('Failed to load tasks:', error);
}
}
// Create modal
const modal = document.createElement('div');
modal.className = 'modal';
const startDate = entry
? new Date(entry.start_time).toISOString().split('T')[0]
: new Date().toISOString().split('T')[0];
const startTime = entry
? new Date(entry.start_time).toTimeString().slice(0, 5)
: new Date().toTimeString().slice(0, 5);
const endDate = entry && entry.end_time
? new Date(entry.end_time).toISOString().split('T')[0]
: '';
const endTime = entry && entry.end_time
? new Date(entry.end_time).toTimeString().slice(0, 5)
: '';
modal.innerHTML = `
<div class="modal-content" style="max-width: 600px;">
<div class="modal-header">
<h3>${entryId ? 'Edit' : 'Add'} Time Entry</h3>
<button class="modal-close" onclick="this.closest('.modal').remove()">×</button>
</div>
<div class="modal-body">
<div class="form-group">
<label for="entry-project-select">Project *</label>
<select id="entry-project-select" class="form-control" required>
<option value="">Select a project...</option>
${projects.map(p => `<option value="${p.id}" ${entry && entry.project_id === p.id ? 'selected' : ''}>${p.name}</option>`).join('')}
</select>
</div>
<div class="form-group">
<label for="entry-task-select">${requirements.require_task ? 'Task *' : 'Task (Optional)'}</label>
<select id="entry-task-select" class="form-control">
<option value="">No task</option>
${tasks.map(t => `<option value="${t.id}" ${entry && entry.task_id === t.id ? 'selected' : ''}>${t.name}</option>`).join('')}
</select>
</div>
<div class="form-row">
<div class="form-group">
<label for="entry-start-date">Start Date *</label>
<input type="date" id="entry-start-date" class="form-control" value="${startDate}" required>
</div>
<div class="form-group">
<label for="entry-start-time">Start Time *</label>
<input type="time" id="entry-start-time" class="form-control" value="${startTime}" required>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="entry-end-date">End Date (Optional)</label>
<input type="date" id="entry-end-date" class="form-control" value="${endDate}">
</div>
<div class="form-group">
<label for="entry-end-time">End Time (Optional)</label>
<input type="time" id="entry-end-time" class="form-control" value="${endTime}">
</div>
</div>
<div class="form-group">
<label for="entry-notes">${requirements.require_description ? 'Notes *' : 'Notes'}</label>
<textarea id="entry-notes" class="form-control" rows="3">${entry?.notes || ''}</textarea>
</div>
<div class="form-group">
<label for="entry-tags">Tags (comma-separated)</label>
<input type="text" id="entry-tags" class="form-control" value="${entry?.tags || ''}">
</div>
<div class="form-group">
<label>
<input type="checkbox" id="entry-billable" ${entry ? (entry.billable ? 'checked' : '') : 'checked'}>
Billable
</label>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" onclick="this.closest('.modal').remove()">Cancel</button>
<button class="btn btn-primary" id="save-entry-btn">${entryId ? 'Update' : 'Create'}</button>
</div>
</div>
`;
document.body.appendChild(modal);
const projectSelect = modal.querySelector('#entry-project-select');
const taskSelect = modal.querySelector('#entry-task-select');
const saveBtn = modal.querySelector('#save-entry-btn');
// Load tasks when project changes
projectSelect.addEventListener('change', async (e) => {
const projectId = parseInt(e.target.value);
if (!projectId) {
taskSelect.innerHTML = '<option value="">No task</option>';
return;
}
try {
const tasksResponse = await state.apiClient.getTasks({ projectId: projectId });
const tasks = tasksResponse.data.tasks || [];
taskSelect.innerHTML = '<option value="">No task</option>' +
tasks.map(t => `<option value="${t.id}">${t.name}</option>`).join('');
} catch (error) {
console.error('Failed to load tasks:', error);
}
});
// Handle save
saveBtn.addEventListener('click', async () => {
const projectId = parseInt(projectSelect.value);
if (!projectId) {
showError('Please select a project');
return;
}
const taskId = taskSelect.value ? parseInt(taskSelect.value) : null;
if (requirements.require_task && !taskId) {
showError('A task must be selected when logging time for a project');
return;
}
const notesEl = document.getElementById('entry-notes');
const notes = notesEl ? notesEl.value.trim() : '';
if (requirements.require_description) {
if (!notes) {
showError('A description is required when logging time');
return;
}
const minLen = requirements.description_min_length || 20;
if (notes.length < minLen) {
showError(`Description must be at least ${minLen} characters`);
return;
}
}
const startDate = document.getElementById('entry-start-date').value;
const startTime = document.getElementById('entry-start-time').value;
const endDate = document.getElementById('entry-end-date').value;
const endTime = document.getElementById('entry-end-time').value;
const notesForApi = notes || null;
const tags = document.getElementById('entry-tags').value.trim() || null;
const billable = document.getElementById('entry-billable').checked;
const startDateTime = new Date(`${startDate}T${startTime}`).toISOString();
const endDateTime = (endDate && endTime)
? new Date(`${endDate}T${endTime}`).toISOString()
: null;
try {
if (entryId) {
await state.apiClient.updateTimeEntry(entryId, {
project_id: projectId,
task_id: taskId,
start_time: startDateTime,
end_time: endDateTime,
notes: notesForApi,
tags: tags,
billable: billable,
});
showSuccess('Time entry updated successfully');
} else {
await state.apiClient.createTimeEntry({
project_id: projectId,
task_id: taskId,
start_time: startDateTime,
end_time: endDateTime,
notes: notesForApi,
tags: tags,
billable: billable,
});
showSuccess('Time entry created successfully');
}
modal.remove();
loadTimeEntries();
} catch (error) {
showError('Failed to save time entry: ' + (error.response?.data?.error || error.message));
}
});
// Close on backdrop click
modal.addEventListener('click', (e) => {
if (e.target === modal) {
modal.remove();
}
});
}