Add clock in/clock out workflow for time tracking

Replace the single-modal time entry flow with a toggle-based clock in/out
system. Tapping the FAB on the Time tab now opens a simplified Clock In
modal (project + note); tapping again while clocked in fills in the time
out. A green banner shows elapsed time when clocked in, and the full
manual entry modal remains accessible via a link.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-22 20:50:13 -06:00
parent f7e704943e
commit 8442846feb
2 changed files with 888 additions and 17 deletions

View File

@@ -485,6 +485,31 @@
}
.manage-item button:hover { background: #fee; }
/* Tab bar */
.tab-bar {
display: flex;
max-width: 600px;
margin: 0 auto;
padding: 12px 16px 0;
gap: 0;
}
.tab-bar button {
flex: 1;
padding: 10px 0;
border: none;
background: none;
font-size: 0.95rem;
font-weight: 600;
color: #888;
cursor: pointer;
border-bottom: 3px solid transparent;
transition: color 0.15s, border-color 0.15s;
}
.tab-bar button.active {
color: #1a1a2e;
border-bottom-color: #e94560;
}
/* Filter bar */
.filter-bar {
max-width: 600px;
@@ -500,6 +525,130 @@
background: #fafafa;
color: #333;
}
/* Time entry cards */
.time-card {
background: #fff;
border-radius: 12px;
padding: 14px 16px;
margin-bottom: 12px;
box-shadow: 0 1px 4px rgba(0,0,0,0.06);
display: flex;
gap: 14px;
align-items: center;
}
.time-icon {
width: 56px;
height: 56px;
border-radius: 8px;
background: #e8eaf6;
color: #283593;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.4rem;
flex-shrink: 0;
}
.time-info { flex: 1; min-width: 0; }
.time-info .hours { font-size: 1.1rem; font-weight: 700; }
.time-info .hours-label { font-size: 0.85rem; color: #888; font-weight: 400; }
.time-info .meta {
font-size: 0.8rem;
color: #888;
margin-top: 2px;
}
.time-actions { display: flex; gap: 6px; flex-shrink: 0; }
.time-actions button {
background: none;
border: none;
cursor: pointer;
font-size: 1.1rem;
padding: 4px;
border-radius: 4px;
color: #888;
}
.time-actions button:hover { background: #f0f0f0; }
/* Clock banner */
.clock-banner {
background: linear-gradient(135deg, #1b5e20, #2e7d32);
color: #fff;
border-radius: 12px;
padding: 14px 16px;
margin-bottom: 12px;
display: flex;
align-items: center;
gap: 12px;
box-shadow: 0 2px 8px rgba(27,94,32,0.3);
}
.clock-banner.hidden { display: none; }
.clock-banner .banner-dot {
width: 12px;
height: 12px;
border-radius: 50%;
background: #69f0ae;
flex-shrink: 0;
animation: pulse-dot 1.5s ease-in-out infinite;
}
.clock-banner .banner-info { flex: 1; min-width: 0; }
.clock-banner .banner-project { font-weight: 600; font-size: 0.95rem; }
.clock-banner .banner-elapsed { font-size: 0.85rem; opacity: 0.85; margin-top: 2px; }
.clock-banner .banner-clockout {
background: rgba(255,255,255,0.2);
border: 1px solid rgba(255,255,255,0.4);
color: #fff;
padding: 8px 16px;
border-radius: 8px;
font-weight: 600;
font-size: 0.85rem;
cursor: pointer;
flex-shrink: 0;
}
.clock-banner .banner-clockout:active { background: rgba(255,255,255,0.3); }
@keyframes pulse-dot {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.5; transform: scale(0.85); }
}
/* Open time card (clocked in) */
.time-card.open {
border-left: 3px solid #4caf50;
}
.time-card.open .time-icon {
background: #e8f5e9;
color: #2e7d32;
position: relative;
}
.time-card.open .time-icon::after {
content: "";
position: absolute;
top: 4px;
right: 4px;
width: 8px;
height: 8px;
border-radius: 50%;
background: #4caf50;
animation: pulse-dot 1.5s ease-in-out infinite;
}
/* FAB clocked-in state */
#add-btn.fab-clocked-in {
background: #2e7d32;
box-shadow: 0 4px 12px rgba(46,125,50,0.4);
}
/* Clock-in modal manual entry link */
.manual-entry-link {
display: block;
text-align: center;
margin-top: 12px;
font-size: 0.85rem;
color: #888;
cursor: pointer;
text-decoration: underline;
}
.manual-entry-link:hover { color: #666; }
</style>
</head>
<body>
@@ -515,16 +664,42 @@
</div>
</header>
<div class="tab-bar">
<button id="tab-receipts" class="active">Receipts</button>
<button id="tab-time">Time</button>
</div>
<div class="filter-bar" id="filter-bar" style="display:none;">
<select id="filter-project">
<option value="">All receipts</option>
</select>
</div>
<div class="container" id="receipt-list">
<div class="empty-state" id="empty-state">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M9 14l-4 4m0 0l4 4m-4-4h11a4 4 0 000-8h-1"/></svg>
<p>No receipts yet.<br>Tap <strong>+</strong> to add one.</p>
<div id="receipts-view">
<div class="container" id="receipt-list">
<div class="empty-state" id="empty-state">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M9 14l-4 4m0 0l4 4m-4-4h11a4 4 0 000-8h-1"/></svg>
<p>No receipts yet.<br>Tap <strong>+</strong> to add one.</p>
</div>
</div>
</div>
<div id="time-view" style="display:none;">
<div class="container">
<div class="clock-banner hidden" id="clock-banner">
<div class="banner-dot"></div>
<div class="banner-info">
<div class="banner-project" id="banner-project"></div>
<div class="banner-elapsed" id="banner-elapsed"></div>
</div>
<button class="banner-clockout" id="banner-clockout">Clock Out</button>
</div>
</div>
<div class="container" id="time-list">
<div class="empty-state" id="time-empty-state">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/></svg>
<p>No time entries yet.<br>Tap <strong>+</strong> to add one.</p>
</div>
</div>
</div>
@@ -637,6 +812,70 @@
</div>
</div>
<!-- Time Entry modal -->
<div class="modal-overlay" id="time-modal-overlay">
<div class="modal">
<h2 id="time-modal-title">New Time Entry</h2>
<div class="field">
<label>Date</label>
<input type="date" id="time-date-input">
</div>
<div class="field">
<label>Time In</label>
<input type="time" id="time-in-input">
</div>
<div class="field">
<label>Time Out</label>
<input type="time" id="time-out-input">
</div>
<div class="field">
<label>Project</label>
<select id="time-project-input">
<option value="">Select project</option>
</select>
</div>
<div class="field">
<label>Note (optional)</label>
<input type="text" id="time-note-input" placeholder="Short description">
</div>
<div class="btn-row">
<button class="btn-cancel" id="time-btn-cancel">Cancel</button>
<button class="btn-save" id="time-btn-save">Save</button>
</div>
</div>
</div>
<!-- Clock In modal -->
<div class="modal-overlay" id="clockin-modal-overlay">
<div class="modal">
<h2>Clock In</h2>
<div class="field">
<label>Project</label>
<select id="clockin-project-input">
<option value="">Select project</option>
</select>
</div>
<div class="field">
<label>Note (optional)</label>
<input type="text" id="clockin-note-input" placeholder="What are you working on?">
</div>
<div class="btn-row">
<button class="btn-cancel" id="clockin-btn-cancel">Cancel</button>
<button class="btn-save" id="clockin-btn-save" style="background:#2e7d32;">Clock In</button>
</div>
<span class="manual-entry-link" id="clockin-manual-link">Manual entry (set times)</span>
</div>
</div>
<!-- Login overlay -->
<div class="login-overlay" id="login-overlay">
<div class="login-box">
@@ -682,11 +921,14 @@
<script>
(function () {
let receipts = [];
let timeEntries = [];
let settings = { customers: [], projects: [] };
let editingId = null;
let currentPhotoBlob = null; // Blob from resized image
let currentPhotoUrl = null; // Object URL or /api/photos/<id> URL for display
let activeFilter = ""; // "" = all, "none" = no project, or a project id
let activeTab = "receipts"; // "receipts" or "time"
let editingTimeId = null;
// DOM refs
const list = document.getElementById("receipt-list");
@@ -709,6 +951,32 @@
const photoPreview = document.getElementById("photo-preview");
const photoPreviewImg = document.getElementById("photo-preview-img");
// Time entry DOM refs
const receiptsView = document.getElementById("receipts-view");
const timeView = document.getElementById("time-view");
const timeList = document.getElementById("time-list");
const timeEmptyState = document.getElementById("time-empty-state");
const tabReceipts = document.getElementById("tab-receipts");
const tabTime = document.getElementById("tab-time");
const timeModalOverlay = document.getElementById("time-modal-overlay");
const timeModalTitle = document.getElementById("time-modal-title");
const timeDateInput = document.getElementById("time-date-input");
const timeInInput = document.getElementById("time-in-input");
const timeOutInput = document.getElementById("time-out-input");
const timeProjectInput = document.getElementById("time-project-input");
const timeNoteInput = document.getElementById("time-note-input");
const timeBtnSave = document.getElementById("time-btn-save");
// Clock in/out DOM refs
const clockBanner = document.getElementById("clock-banner");
const bannerProject = document.getElementById("banner-project");
const bannerElapsed = document.getElementById("banner-elapsed");
const bannerClockout = document.getElementById("banner-clockout");
const clockinModalOverlay = document.getElementById("clockin-modal-overlay");
const clockinProjectInput = document.getElementById("clockin-project-input");
const clockinNoteInput = document.getElementById("clockin-note-input");
const addBtn = document.getElementById("add-btn");
function formatMoney(n) {
return "$" + Number(n).toFixed(2);
}
@@ -717,10 +985,129 @@
return cat.charAt(0).toUpperCase() + cat.slice(1);
}
function calcHours(entry) {
try {
const [h1, m1] = entry.timeIn.split(":").map(Number);
const [h2, m2] = entry.timeOut.split(":").map(Number);
return (h2 * 60 + m2 - h1 * 60 - m1) / 60;
} catch (e) { return 0; }
}
function formatHours(h) {
return h.toFixed(2) + " hrs";
}
// --- Clock In / Clock Out helpers ---
function getOpenEntry() {
return timeEntries.find(e => e.timeOut === "");
}
function nowTimeStr() {
const now = new Date();
return String(now.getHours()).padStart(2, "0") + ":" + String(now.getMinutes()).padStart(2, "0");
}
function elapsedStr(entry) {
if (!entry || !entry.timeIn) return "";
const [h1, m1] = entry.timeIn.split(":").map(Number);
const now = new Date();
let mins = now.getHours() * 60 + now.getMinutes() - (h1 * 60 + m1);
if (mins < 0) mins += 24 * 60; // crossed midnight
const h = Math.floor(mins / 60);
const m = mins % 60;
if (h > 0) return h + "h " + m + "m";
return m + "m";
}
function updateClockBanner() {
const open = getOpenEntry();
if (open) {
let projectName = "No project";
if (open.projectId) {
const proj = getProjectById(open.projectId);
if (proj) {
const cust = getCustomerById(proj.customerId);
projectName = cust ? cust.name + " \u203A " + proj.name : proj.name;
}
}
bannerProject.textContent = projectName;
bannerElapsed.textContent = "Clocked in " + elapsedStr(open);
clockBanner.classList.remove("hidden");
addBtn.classList.add("fab-clocked-in");
addBtn.innerHTML = "&#10003;";
} else {
clockBanner.classList.add("hidden");
addBtn.classList.remove("fab-clocked-in");
addBtn.innerHTML = "+";
}
}
function renderClockinProjectDropdown() {
let html = '<option value="">Select project</option>';
settings.customers.forEach(c => {
const projects = settings.projects.filter(p => p.customerId === c.id);
if (projects.length === 0) return;
html += `<optgroup label="${escapeHtml(c.name)}">`;
projects.forEach(p => {
html += `<option value="${p.id}">${escapeHtml(p.name)}</option>`;
});
html += '</optgroup>';
});
clockinProjectInput.innerHTML = html;
}
function openClockinModal() {
renderClockinProjectDropdown();
clockinNoteInput.value = "";
clockinModalOverlay.classList.add("open");
clockinProjectInput.focus();
}
function closeClockinModal() {
clockinModalOverlay.classList.remove("open");
}
async function clockIn(projectId, note) {
const id = "t_" + Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
const entry = {
id: id,
date: todayStr(),
timeIn: nowTimeStr(),
timeOut: "",
projectId: projectId,
note: note
};
try {
await apiSaveTimeEntry(entry);
timeEntries.push(entry);
renderTime();
} catch (err) {
alert("Error clocking in: " + err.message);
}
}
async function clockOut() {
const open = getOpenEntry();
if (!open) return;
open.timeOut = nowTimeStr();
try {
await apiSaveTimeEntry(open);
renderTime();
} catch (err) {
alert("Error clocking out: " + err.message);
}
}
function updateTotal() {
const visible = filteredReceipts();
const total = visible.reduce((s, r) => s + Number(r.amount), 0);
totalBanner.textContent = "Total: " + formatMoney(total);
if (activeTab === "receipts") {
const visible = filteredReceipts();
const total = visible.reduce((s, r) => s + Number(r.amount), 0);
totalBanner.textContent = "Total: " + formatMoney(total);
} else {
const visible = filteredTimeEntries();
const total = visible.reduce((s, e) => s + calcHours(e), 0);
totalBanner.textContent = "Total: " + formatHours(total);
}
}
function getProjectById(id) {
@@ -733,7 +1120,8 @@
function renderFilterDropdown() {
const prev = activeFilter;
let html = '<option value="">All receipts</option><option value="none">No project</option>';
const allLabel = activeTab === "receipts" ? "All receipts" : "All time entries";
let html = `<option value="">${allLabel}</option><option value="none">No project</option>`;
settings.customers.forEach(c => {
const projects = settings.projects.filter(p => p.customerId === c.id);
if (projects.length === 0) return;
@@ -754,6 +1142,12 @@
return receipts.filter(r => r.projectId === activeFilter);
}
function filteredTimeEntries() {
if (!activeFilter) return timeEntries;
if (activeFilter === "none") return timeEntries.filter(e => !e.projectId);
return timeEntries.filter(e => e.projectId === activeFilter);
}
function renderProjectDropdown(selectedId) {
let html = '<option value="">No project</option>';
settings.customers.forEach(c => {
@@ -828,6 +1222,90 @@
updateTotal();
}
function renderTime() {
timeList.querySelectorAll(".time-card").forEach(el => el.remove());
renderFilterDropdown();
const visible = filteredTimeEntries();
if (visible.length === 0) {
timeEmptyState.style.display = "";
updateClockBanner();
updateTotal();
return;
}
timeEmptyState.style.display = "none";
// Sort: open entries first, then by date+timeIn descending
const sorted = [...visible].sort((a, b) => {
const aOpen = a.timeOut === "" ? 0 : 1;
const bOpen = b.timeOut === "" ? 0 : 1;
if (aOpen !== bOpen) return aOpen - bOpen;
return (b.date + b.timeIn).localeCompare(a.date + a.timeIn);
});
sorted.forEach(entry => {
const isOpen = entry.timeOut === "";
const card = document.createElement("div");
card.className = isOpen ? "time-card open" : "time-card";
const hours = calcHours(entry);
const date = new Date(entry.date + "T12:00:00").toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" });
let projectInfo = "";
if (entry.projectId) {
const proj = getProjectById(entry.projectId);
if (proj) {
const cust = getCustomerById(proj.customerId);
projectInfo = cust ? escapeHtml(cust.name) + " &rsaquo; " + escapeHtml(proj.name) : escapeHtml(proj.name);
}
}
let hoursDisplay, timeRange;
if (isOpen) {
hoursDisplay = `<span class="hours" data-open-elapsed="${entry.id}">${elapsedStr(entry)}</span> <span class="hours-label">in progress</span>`;
timeRange = `${entry.timeIn} &mdash; now`;
} else {
hoursDisplay = `<span class="hours">${hours.toFixed(2)}</span> <span class="hours-label">hrs</span>`;
timeRange = `${entry.timeIn} to ${entry.timeOut}`;
}
card.innerHTML = `
<div class="time-icon">&#9202;</div>
<div class="time-info">
${hoursDisplay}
<div class="meta">${date} &mdash; ${timeRange}${projectInfo ? " &mdash; " + projectInfo : ""}${entry.note ? " &mdash; " + escapeHtml(entry.note) : ""}</div>
</div>
<div class="time-actions">
<button title="Edit" data-edit-time="${entry.id}">&#9998;</button>
<button title="Delete" data-delete-time="${entry.id}">&times;</button>
</div>
`;
timeList.appendChild(card);
});
updateClockBanner();
updateTotal();
}
function switchTab(tab) {
activeTab = tab;
if (tab === "receipts") {
tabReceipts.classList.add("active");
tabTime.classList.remove("active");
receiptsView.style.display = "";
timeView.style.display = "none";
addBtn.classList.remove("fab-clocked-in");
addBtn.innerHTML = "+";
render();
} else {
tabTime.classList.add("active");
tabReceipts.classList.remove("active");
receiptsView.style.display = "none";
timeView.style.display = "";
renderTime();
}
}
function escapeHtml(str) {
const d = document.createElement("div");
d.textContent = str;
@@ -1049,9 +1527,17 @@
}
// Events
document.getElementById("add-btn").addEventListener("click", () => {
openModal(null);
photoInput.click();
addBtn.addEventListener("click", () => {
if (activeTab === "time") {
if (getOpenEntry()) {
clockOut();
} else {
openClockinModal();
}
} else {
openModal(null);
photoInput.click();
}
});
document.getElementById("btn-cancel").addEventListener("click", closeModal);
@@ -1429,13 +1915,203 @@
// Export to Excel
document.getElementById("export-btn").addEventListener("click", () => {
const visible = filteredReceipts();
if (visible.length === 0) { alert("No receipts to export."); return; }
let url = "/api/export";
if (activeFilter) url += "?project=" + encodeURIComponent(activeFilter);
window.location.href = url;
if (activeTab === "time") {
const visible = filteredTimeEntries();
if (visible.length === 0) { alert("No time entries to export."); return; }
let url = "/api/timesheet/export";
if (activeFilter) url += "?project=" + encodeURIComponent(activeFilter);
window.location.href = url;
} else {
const visible = filteredReceipts();
if (visible.length === 0) { alert("No receipts to export."); return; }
let url = "/api/export";
if (activeFilter) url += "?project=" + encodeURIComponent(activeFilter);
window.location.href = url;
}
});
// --- Tab switching ---
tabReceipts.addEventListener("click", () => switchTab("receipts"));
tabTime.addEventListener("click", () => switchTab("time"));
// --- Time entry CRUD ---
async function apiLoadTimesheet() {
const r = await fetch("/api/timesheet");
if (handleUnauthorized(r)) throw new Error("Unauthorized");
if (!r.ok) throw new Error("Failed to load timesheet");
return r.json();
}
async function apiSaveTimeEntry(entry) {
const r = await fetch("/api/timesheet", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(entry)
});
if (handleUnauthorized(r)) throw new Error("Unauthorized");
if (!r.ok) throw new Error("Failed to save time entry");
return r.json();
}
async function apiDeleteTimeEntry(id) {
const r = await fetch("/api/timesheet/" + id, { method: "DELETE" });
if (handleUnauthorized(r)) throw new Error("Unauthorized");
if (!r.ok) throw new Error("Failed to delete time entry");
}
function renderTimeProjectDropdown(selectedId) {
let html = '<option value="">Select project</option>';
settings.customers.forEach(c => {
const projects = settings.projects.filter(p => p.customerId === c.id);
if (projects.length === 0) return;
html += `<optgroup label="${escapeHtml(c.name)}">`;
projects.forEach(p => {
const sel = p.id === selectedId ? " selected" : "";
html += `<option value="${p.id}"${sel}>${escapeHtml(p.name)}</option>`;
});
html += '</optgroup>';
});
timeProjectInput.innerHTML = html;
}
function openTimeModal(entry) {
if (entry) {
editingTimeId = entry.id;
timeModalTitle.textContent = "Edit Time Entry";
timeDateInput.value = entry.date || "";
timeInInput.value = entry.timeIn || "";
timeOutInput.value = entry.timeOut || "";
timeNoteInput.value = entry.note || "";
renderTimeProjectDropdown(entry.projectId || "");
} else {
editingTimeId = null;
timeModalTitle.textContent = "New Time Entry";
timeDateInput.value = todayStr();
timeInInput.value = "";
timeOutInput.value = "";
timeNoteInput.value = "";
renderTimeProjectDropdown("");
}
timeModalOverlay.classList.add("open");
timeInInput.focus();
}
function closeTimeModal() {
timeModalOverlay.classList.remove("open");
}
document.getElementById("time-btn-cancel").addEventListener("click", closeTimeModal);
timeModalOverlay.addEventListener("click", e => {
if (e.target === timeModalOverlay) closeTimeModal();
});
timeBtnSave.addEventListener("click", async () => {
if (!timeProjectInput.value) {
timeProjectInput.style.borderColor = "#e94560";
timeProjectInput.focus();
return;
}
timeProjectInput.style.borderColor = "";
if (!timeInInput.value) {
timeInInput.style.borderColor = "#e94560";
timeInInput.focus();
return;
}
timeInInput.style.borderColor = "";
timeOutInput.style.borderColor = "";
const id = editingTimeId || "t_" + Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
const entry = {
id: id,
date: timeDateInput.value || todayStr(),
timeIn: timeInInput.value,
timeOut: timeOutInput.value,
projectId: timeProjectInput.value,
note: timeNoteInput.value.trim()
};
timeBtnSave.disabled = true;
timeBtnSave.textContent = "Saving...";
try {
await apiSaveTimeEntry(entry);
if (editingTimeId) {
const idx = timeEntries.findIndex(e => e.id === editingTimeId);
if (idx !== -1) timeEntries[idx] = entry;
} else {
timeEntries.push(entry);
}
renderTime();
closeTimeModal();
} catch (err) {
alert("Error saving time entry: " + err.message);
} finally {
timeBtnSave.disabled = false;
timeBtnSave.textContent = "Save";
}
});
// Time list event delegation
timeList.addEventListener("click", async e => {
const delBtn = e.target.closest("[data-delete-time]");
if (delBtn) {
const id = delBtn.dataset.deleteTime;
if (confirm("Delete this time entry?")) {
try {
await apiDeleteTimeEntry(id);
timeEntries = timeEntries.filter(e => e.id !== id);
renderTime();
} catch (err) {
alert("Error deleting time entry: " + err.message);
}
}
return;
}
const editBtn = e.target.closest("[data-edit-time]");
if (editBtn) {
const entry = timeEntries.find(e => e.id === editBtn.dataset.editTime);
if (entry) openTimeModal(entry);
return;
}
});
// --- Clock In modal events ---
document.getElementById("clockin-btn-cancel").addEventListener("click", closeClockinModal);
clockinModalOverlay.addEventListener("click", e => {
if (e.target === clockinModalOverlay) closeClockinModal();
});
document.getElementById("clockin-btn-save").addEventListener("click", async () => {
if (!clockinProjectInput.value) {
clockinProjectInput.style.borderColor = "#e94560";
clockinProjectInput.focus();
return;
}
clockinProjectInput.style.borderColor = "";
await clockIn(clockinProjectInput.value, clockinNoteInput.value.trim());
closeClockinModal();
});
document.getElementById("clockin-manual-link").addEventListener("click", () => {
closeClockinModal();
openTimeModal(null);
});
bannerClockout.addEventListener("click", () => {
clockOut();
});
// Update elapsed timer every 60 seconds
setInterval(() => {
const open = getOpenEntry();
if (!open) return;
// Update banner elapsed text
bannerElapsed.textContent = "Clocked in " + elapsedStr(open);
// Update card elapsed text
const elSpan = document.querySelector(`[data-open-elapsed="${open.id}"]`);
if (elSpan) elSpan.textContent = elapsedStr(open);
}, 60000);
// --- Manage Customers & Projects ---
const manageOverlay = document.getElementById("manage-overlay");
const customerListEl = document.getElementById("customer-list");
@@ -1493,7 +2169,11 @@
filterProject.addEventListener("change", () => {
activeFilter = filterProject.value;
render();
if (activeTab === "time") {
renderTime();
} else {
render();
}
});
document.getElementById("manage-btn").addEventListener("click", () => {
@@ -1587,6 +2267,7 @@
try {
receipts = await apiLoadReceipts();
settings = await apiLoadSettings();
timeEntries = await apiLoadTimesheet();
} catch (e) {
console.error("Failed to load data:", e);
return;
@@ -1602,6 +2283,7 @@
hideLogin();
receipts = await r.json();
try { settings = await apiLoadSettings(); } catch (e) {}
try { timeEntries = await apiLoadTimesheet(); } catch (e) {}
render();
}
})();