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; }
|
.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 */
|
||||||
.filter-bar {
|
.filter-bar {
|
||||||
max-width: 600px;
|
max-width: 600px;
|
||||||
@@ -500,6 +525,130 @@
|
|||||||
background: #fafafa;
|
background: #fafafa;
|
||||||
color: #333;
|
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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -515,16 +664,42 @@
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</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;">
|
<div class="filter-bar" id="filter-bar" style="display:none;">
|
||||||
<select id="filter-project">
|
<select id="filter-project">
|
||||||
<option value="">All receipts</option>
|
<option value="">All receipts</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="container" id="receipt-list">
|
<div id="receipts-view">
|
||||||
<div class="empty-state" id="empty-state">
|
<div class="container" id="receipt-list">
|
||||||
<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>
|
<div class="empty-state" id="empty-state">
|
||||||
<p>No receipts yet.<br>Tap <strong>+</strong> to add one.</p>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -637,6 +812,70 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 -->
|
<!-- Login overlay -->
|
||||||
<div class="login-overlay" id="login-overlay">
|
<div class="login-overlay" id="login-overlay">
|
||||||
<div class="login-box">
|
<div class="login-box">
|
||||||
@@ -682,11 +921,14 @@
|
|||||||
<script>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
let receipts = [];
|
let receipts = [];
|
||||||
|
let timeEntries = [];
|
||||||
let settings = { customers: [], projects: [] };
|
let settings = { customers: [], projects: [] };
|
||||||
let editingId = null;
|
let editingId = null;
|
||||||
let currentPhotoBlob = null; // Blob from resized image
|
let currentPhotoBlob = null; // Blob from resized image
|
||||||
let currentPhotoUrl = null; // Object URL or /api/photos/<id> URL for display
|
let currentPhotoUrl = null; // Object URL or /api/photos/<id> URL for display
|
||||||
let activeFilter = ""; // "" = all, "none" = no project, or a project id
|
let activeFilter = ""; // "" = all, "none" = no project, or a project id
|
||||||
|
let activeTab = "receipts"; // "receipts" or "time"
|
||||||
|
let editingTimeId = null;
|
||||||
|
|
||||||
// DOM refs
|
// DOM refs
|
||||||
const list = document.getElementById("receipt-list");
|
const list = document.getElementById("receipt-list");
|
||||||
@@ -709,6 +951,32 @@
|
|||||||
const photoPreview = document.getElementById("photo-preview");
|
const photoPreview = document.getElementById("photo-preview");
|
||||||
const photoPreviewImg = document.getElementById("photo-preview-img");
|
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) {
|
function formatMoney(n) {
|
||||||
return "$" + Number(n).toFixed(2);
|
return "$" + Number(n).toFixed(2);
|
||||||
}
|
}
|
||||||
@@ -717,10 +985,129 @@
|
|||||||
return cat.charAt(0).toUpperCase() + cat.slice(1);
|
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() {
|
function updateTotal() {
|
||||||
const visible = filteredReceipts();
|
if (activeTab === "receipts") {
|
||||||
const total = visible.reduce((s, r) => s + Number(r.amount), 0);
|
const visible = filteredReceipts();
|
||||||
totalBanner.textContent = "Total: " + formatMoney(total);
|
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) {
|
function getProjectById(id) {
|
||||||
@@ -733,7 +1120,8 @@
|
|||||||
|
|
||||||
function renderFilterDropdown() {
|
function renderFilterDropdown() {
|
||||||
const prev = activeFilter;
|
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 => {
|
settings.customers.forEach(c => {
|
||||||
const projects = settings.projects.filter(p => p.customerId === c.id);
|
const projects = settings.projects.filter(p => p.customerId === c.id);
|
||||||
if (projects.length === 0) return;
|
if (projects.length === 0) return;
|
||||||
@@ -754,6 +1142,12 @@
|
|||||||
return receipts.filter(r => r.projectId === activeFilter);
|
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) {
|
function renderProjectDropdown(selectedId) {
|
||||||
let html = '<option value="">No project</option>';
|
let html = '<option value="">No project</option>';
|
||||||
settings.customers.forEach(c => {
|
settings.customers.forEach(c => {
|
||||||
@@ -828,6 +1222,90 @@
|
|||||||
updateTotal();
|
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) {
|
function escapeHtml(str) {
|
||||||
const d = document.createElement("div");
|
const d = document.createElement("div");
|
||||||
d.textContent = str;
|
d.textContent = str;
|
||||||
@@ -1049,9 +1527,17 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Events
|
// Events
|
||||||
document.getElementById("add-btn").addEventListener("click", () => {
|
addBtn.addEventListener("click", () => {
|
||||||
openModal(null);
|
if (activeTab === "time") {
|
||||||
photoInput.click();
|
if (getOpenEntry()) {
|
||||||
|
clockOut();
|
||||||
|
} else {
|
||||||
|
openClockinModal();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
openModal(null);
|
||||||
|
photoInput.click();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
document.getElementById("btn-cancel").addEventListener("click", closeModal);
|
document.getElementById("btn-cancel").addEventListener("click", closeModal);
|
||||||
|
|
||||||
@@ -1429,13 +1915,203 @@
|
|||||||
|
|
||||||
// Export to Excel
|
// Export to Excel
|
||||||
document.getElementById("export-btn").addEventListener("click", () => {
|
document.getElementById("export-btn").addEventListener("click", () => {
|
||||||
const visible = filteredReceipts();
|
if (activeTab === "time") {
|
||||||
if (visible.length === 0) { alert("No receipts to export."); return; }
|
const visible = filteredTimeEntries();
|
||||||
let url = "/api/export";
|
if (visible.length === 0) { alert("No time entries to export."); return; }
|
||||||
if (activeFilter) url += "?project=" + encodeURIComponent(activeFilter);
|
let url = "/api/timesheet/export";
|
||||||
window.location.href = url;
|
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 ---
|
// --- Manage Customers & Projects ---
|
||||||
const manageOverlay = document.getElementById("manage-overlay");
|
const manageOverlay = document.getElementById("manage-overlay");
|
||||||
const customerListEl = document.getElementById("customer-list");
|
const customerListEl = document.getElementById("customer-list");
|
||||||
@@ -1493,7 +2169,11 @@
|
|||||||
|
|
||||||
filterProject.addEventListener("change", () => {
|
filterProject.addEventListener("change", () => {
|
||||||
activeFilter = filterProject.value;
|
activeFilter = filterProject.value;
|
||||||
render();
|
if (activeTab === "time") {
|
||||||
|
renderTime();
|
||||||
|
} else {
|
||||||
|
render();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById("manage-btn").addEventListener("click", () => {
|
document.getElementById("manage-btn").addEventListener("click", () => {
|
||||||
@@ -1587,6 +2267,7 @@
|
|||||||
try {
|
try {
|
||||||
receipts = await apiLoadReceipts();
|
receipts = await apiLoadReceipts();
|
||||||
settings = await apiLoadSettings();
|
settings = await apiLoadSettings();
|
||||||
|
timeEntries = await apiLoadTimesheet();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Failed to load data:", e);
|
console.error("Failed to load data:", e);
|
||||||
return;
|
return;
|
||||||
@@ -1602,6 +2283,7 @@
|
|||||||
hideLogin();
|
hideLogin();
|
||||||
receipts = await r.json();
|
receipts = await r.json();
|
||||||
try { settings = await apiLoadSettings(); } catch (e) {}
|
try { settings = await apiLoadSettings(); } catch (e) {}
|
||||||
|
try { timeEntries = await apiLoadTimesheet(); } catch (e) {}
|
||||||
render();
|
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 = ()
|
NC_AUTH = ()
|
||||||
|
|
||||||
RECEIPTS_FILE = "receipts.json"
|
RECEIPTS_FILE = "receipts.json"
|
||||||
|
TIMESHEET_FILE = "timesheet.json"
|
||||||
SETTINGS_FILE = "settings.json"
|
SETTINGS_FILE = "settings.json"
|
||||||
AUTH_FILE = "auth.json"
|
AUTH_FILE = "auth.json"
|
||||||
PHOTOS_DIR = "photos"
|
PHOTOS_DIR = "photos"
|
||||||
@@ -204,6 +205,105 @@ def build_excel(receipts):
|
|||||||
return buf.getvalue()
|
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 ---------------------------------------
|
# --- Receipt extraction via Claude Haiku ---------------------------------------
|
||||||
|
|
||||||
def extract_receipt_info(jpeg_bytes):
|
def extract_receipt_info(jpeg_bytes):
|
||||||
@@ -439,6 +539,62 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
self._send_error(500, str(e))
|
self._send_error(500, str(e))
|
||||||
return
|
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
|
# GET /api/settings
|
||||||
if self.path == "/api/settings":
|
if self.path == "/api/settings":
|
||||||
if not self._check_session():
|
if not self._check_session():
|
||||||
@@ -583,6 +739,24 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
self._send_error(500, str(e))
|
self._send_error(500, str(e))
|
||||||
return
|
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
|
# POST /api/extract-receipt — extract total + date from receipt image
|
||||||
if self.path == "/api/extract-receipt":
|
if self.path == "/api/extract-receipt":
|
||||||
if not self._check_session():
|
if not self._check_session():
|
||||||
@@ -686,6 +860,21 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
self._send_error(500, str(e))
|
self._send_error(500, str(e))
|
||||||
return
|
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")
|
self._send_error(404, "Not found")
|
||||||
|
|
||||||
# Suppress default logging noise
|
# Suppress default logging noise
|
||||||
|
|||||||
Reference in New Issue
Block a user