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:
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