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:
688
receipts.html
688
receipts.html
@@ -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 = "✓";
|
||||
} 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) + " › " + 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} — 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">⏲</div>
|
||||
<div class="time-info">
|
||||
${hoursDisplay}
|
||||
<div class="meta">${date} — ${timeRange}${projectInfo ? " — " + projectInfo : ""}${entry.note ? " — " + escapeHtml(entry.note) : ""}</div>
|
||||
</div>
|
||||
<div class="time-actions">
|
||||
<button title="Edit" data-edit-time="${entry.id}">✎</button>
|
||||
<button title="Delete" data-delete-time="${entry.id}">×</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
189
server.py
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user