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:
716
receipts.html
716
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,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 = "✓";
|
||||
} 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) + " › " + 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", () => {
|
||||
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();
|
||||
}
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user