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,17 +664,43 @@
</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 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>
<button id="add-btn" aria-label="Add receipt">+</button>
@@ -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() {
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", () => {
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", () => {
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;
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();
}
})();

189
server.py
View File

@@ -31,6 +31,7 @@ NC_DAV_ROOT = "" # set after login, e.g. /remote.php/dav/files/<user>/business-
NC_AUTH = ()
RECEIPTS_FILE = "receipts.json"
TIMESHEET_FILE = "timesheet.json"
SETTINGS_FILE = "settings.json"
AUTH_FILE = "auth.json"
PHOTOS_DIR = "photos"
@@ -204,6 +205,105 @@ def build_excel(receipts):
return buf.getvalue()
# --- Helpers for timesheet.json -----------------------------------------------
def load_timesheet():
"""Read timesheet.json from Nextcloud. Returns a list."""
r = nc_get(TIMESHEET_FILE)
if r.status_code == 404:
return []
r.raise_for_status()
return r.json()
def save_timesheet(entries):
"""Write timesheet.json to Nextcloud."""
data = json.dumps(entries, indent=2).encode()
r = nc_put(TIMESHEET_FILE, data, "application/json")
r.raise_for_status()
def build_timesheet_excel(entries):
"""Build an .xlsx file from time entries. Returns bytes."""
wb = openpyxl.Workbook()
ws = wb.active
ws.title = "Timesheet"
settings = load_settings()
project_map = {p["id"]: p for p in settings.get("projects", [])}
customer_map = {c["id"]: c for c in settings.get("customers", [])}
header_font = Font(bold=True, color="FFFFFF", size=11)
header_fill = PatternFill("solid", fgColor="1A1A2E")
header_align = Alignment(horizontal="center")
thin_border = Border(bottom=Side(style="thin", color="DDDDDD"))
hours_fmt = '0.00'
headers = ["Date", "Time In", "Time Out", "Hours", "Customer", "Project", "Note"]
col_widths = [14, 12, 12, 10, 20, 20, 40]
for col_idx, (label, width) in enumerate(zip(headers, col_widths), 1):
cell = ws.cell(row=1, column=col_idx, value=label)
cell.font = header_font
cell.fill = header_fill
cell.alignment = header_align
ws.column_dimensions[cell.column_letter].width = width
for row_idx, entry in enumerate(entries, 2):
ws.cell(row=row_idx, column=1, value=entry.get("date", "")).border = thin_border
ws.cell(row=row_idx, column=2, value=entry.get("timeIn", "")).border = thin_border
t_out = entry.get("timeOut", "")
ws.cell(row=row_idx, column=3, value=t_out if t_out else "In progress").border = thin_border
hours = 0
try:
t_in = entry.get("timeIn", "")
if t_in and t_out:
h1, m1 = map(int, t_in.split(":"))
h2, m2 = map(int, t_out.split(":"))
hours = (h2 * 60 + m2 - h1 * 60 - m1) / 60.0
except (ValueError, AttributeError):
pass
hrs_cell = ws.cell(row=row_idx, column=4, value=hours)
hrs_cell.number_format = hours_fmt
hrs_cell.border = thin_border
project_name = ""
customer_name = ""
pid = entry.get("projectId", "")
if pid and pid in project_map:
proj = project_map[pid]
project_name = proj.get("name", "")
cid = proj.get("customerId", "")
if cid and cid in customer_map:
customer_name = customer_map[cid].get("name", "")
ws.cell(row=row_idx, column=5, value=customer_name).border = thin_border
ws.cell(row=row_idx, column=6, value=project_name).border = thin_border
ws.cell(row=row_idx, column=7, value=entry.get("note", "")).border = thin_border
if entries:
total_row = len(entries) + 2
total_label = ws.cell(row=total_row, column=1, value="TOTAL")
total_label.font = Font(bold=True)
total_hours = 0
for entry in entries:
try:
t_in = entry.get("timeIn", "")
t_out = entry.get("timeOut", "")
if t_in and t_out:
h1, m1 = map(int, t_in.split(":"))
h2, m2 = map(int, t_out.split(":"))
total_hours += (h2 * 60 + m2 - h1 * 60 - m1) / 60.0
except (ValueError, AttributeError):
pass
total_cell = ws.cell(row=total_row, column=4, value=total_hours)
total_cell.font = Font(bold=True)
total_cell.number_format = hours_fmt
buf = io.BytesIO()
wb.save(buf)
return buf.getvalue()
# --- Receipt extraction via Claude Haiku ---------------------------------------
def extract_receipt_info(jpeg_bytes):
@@ -439,6 +539,62 @@ class Handler(BaseHTTPRequestHandler):
self._send_error(500, str(e))
return
# GET /api/timesheet
if self.path == "/api/timesheet":
if not self._check_session():
return
try:
entries = load_timesheet()
self._send_json(entries)
except Exception as e:
self._send_error(502, str(e))
return
# GET /api/timesheet/export — export timesheet as Excel
if self.path.startswith("/api/timesheet/export"):
if not self._check_session():
return
try:
parsed = urlparse(self.path)
qs = parse_qs(parsed.query)
project_filter = qs.get("project", [None])[0]
entries = load_timesheet()
if project_filter == "none":
entries = [e for e in entries if not e.get("projectId")]
elif project_filter:
entries = [e for e in entries if e.get("projectId") == project_filter]
settings_data = load_settings()
project_map = {p["id"]: p for p in settings_data.get("projects", [])}
customer_map = {c["id"]: c for c in settings_data.get("customers", [])}
def sanitize(s):
return re.sub(r'[^\w\s-]', '', s).strip().replace(" ", "_")
filename = "timesheet"
if project_filter and project_filter != "none":
proj = project_map.get(project_filter)
if proj:
cust = customer_map.get(proj.get("customerId", ""))
parts = []
if cust:
parts.append(sanitize(cust["name"]))
parts.append(sanitize(proj["name"]))
filename = "-".join(parts) + "-timesheet" if parts else "timesheet"
excel_bytes = build_timesheet_excel(entries)
self.send_response(200)
self.send_header("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
self.send_header("Content-Disposition",
f'attachment; filename="{filename}.xlsx"')
self.send_header("Content-Length", str(len(excel_bytes)))
self.end_headers()
self.wfile.write(excel_bytes)
except Exception as e:
self._send_error(500, str(e))
return
# GET /api/settings
if self.path == "/api/settings":
if not self._check_session():
@@ -583,6 +739,24 @@ class Handler(BaseHTTPRequestHandler):
self._send_error(500, str(e))
return
# POST /api/timesheet — upsert a time entry
if self.path == "/api/timesheet":
if not self._check_session():
return
try:
data = json.loads(self._read_body())
entries = load_timesheet()
idx = next((i for i, e in enumerate(entries) if e["id"] == data["id"]), None)
if idx is not None:
entries[idx] = data
else:
entries.append(data)
save_timesheet(entries)
self._send_json(data, 200)
except Exception as e:
self._send_error(500, str(e))
return
# POST /api/extract-receipt — extract total + date from receipt image
if self.path == "/api/extract-receipt":
if not self._check_session():
@@ -686,6 +860,21 @@ class Handler(BaseHTTPRequestHandler):
self._send_error(500, str(e))
return
# DELETE /api/timesheet/<id>
m = re.fullmatch(r"/api/timesheet/([A-Za-z0-9_-]+)", self.path)
if m:
if not self._check_session():
return
entry_id = m.group(1)
try:
entries = load_timesheet()
entries = [e for e in entries if e["id"] != entry_id]
save_timesheet(entries)
self._send_json({"ok": True})
except Exception as e:
self._send_error(500, str(e))
return
self._send_error(404, "Not found")
# Suppress default logging noise