This commit is contained in:
Trevor Humphrey 2026-01-15 15:46:35 -05:00
commit 5a29141d9b
39 changed files with 9170 additions and 0 deletions

22
Dockerfile Normal file
View File

@ -0,0 +1,22 @@
FROM python:3.12-slim
# System deps for pandas/openpyxl, Postgres, and Argon2 (via argon2-cffi)
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \
libpq-dev \
libffi-dev \
fonts-dejavu \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY requirements.txt /app/
RUN pip install --no-cache-dir -r requirements.txt
COPY app /app/app
ENV PYTHONUNBUFFERED=1
ENV PORT=5070
EXPOSE 5070
# Start the FastAPI app with Uvicorn
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "5070", "--log-level", "info"]

330
app/attendance.py Normal file
View File

@ -0,0 +1,330 @@
from datetime import date, timedelta
from typing import List, Dict, Tuple, Optional
from fastapi import APIRouter, Request, Depends, Query, HTTPException
from fastapi.responses import PlainTextResponse
from sqlalchemy import func, and_, case
from sqlalchemy.orm import Session
from .db import get_session
from .models import Employee, TimeEntry, TimesheetStatus
router = APIRouter(prefix="/attendance", tags=["Attendance"])
def _daterange(d1: date, d2: date):
cur = d1
step = timedelta(days=1)
while cur <= d2:
yield cur
cur += step
def _to_date(v: Optional[str], fallback: date) -> date:
try:
if v:
return date.fromisoformat(v)
except Exception:
pass
return fallback
@router.get("", name="attendance_builder")
def attendance_builder_page(
request: Request,
start: Optional[str] = Query(None), # YYYY-MM-DD
end: Optional[str] = Query(None), # YYYY-MM-DD
employee_id: Optional[str] = Query("all"), # "all" or single id
include_weekends: int = Query(0),
db: Session = Depends(get_session),
):
# Admin-only
if not request.session.get("is_admin"):
raise HTTPException(status_code=403, detail="Admin access required")
# Defaults: current month
today = date.today()
default_start = today.replace(day=1)
start_d = _to_date(start, default_start)
end_d = _to_date(end, today)
if end_d < start_d:
start_d, end_d = end_d, start_d
# Employees list
all_emps = db.query(Employee).order_by(Employee.name.asc()).all()
# Determine selected ids from dropdown value
selected_ids: List[int]
if not employee_id or employee_id == "all":
selected_ids = [e.id for e in all_emps]
else:
try:
selected_ids = [int(employee_id)]
except Exception:
selected_ids = [e.id for e in all_emps]
# Normalize PTO type (trim+lower)
normalized_pto = func.lower(func.trim(func.coalesce(TimeEntry.pto_type, "")))
# Hours and flags per PTO subtype
off_hours_expr = func.coalesce(
func.sum(case((normalized_pto.like("off%"), func.coalesce(TimeEntry.pto_hours, 0)), else_=0)),
0,
).label("off")
off_flag_expr = func.coalesce(func.max(case((normalized_pto.like("off%"), 1), else_=0)), 0).label("off_flag")
sick_hours_expr = func.coalesce(
func.sum(case((normalized_pto.like("sick%"), func.coalesce(TimeEntry.pto_hours, 0)), else_=0)),
0,
).label("sick")
sick_flag_expr = func.coalesce(func.max(case((normalized_pto.like("sick%"), 1), else_=0)), 0).label("sick_flag")
pto_hours_expr = func.coalesce(
func.sum(case((normalized_pto.like("pto%"), func.coalesce(TimeEntry.pto_hours, 0)), else_=0)),
0,
).label("pto")
# Aggregate per employee/date (SUBMITTED ONLY)
q = (
db.query(
TimeEntry.employee_id.label("emp_id"),
TimeEntry.work_date.label("d"),
func.coalesce(func.sum(TimeEntry.total_hours), 0).label("total"),
func.coalesce(func.sum(TimeEntry.break_hours), 0).label("breaks"),
pto_hours_expr,
off_hours_expr,
off_flag_expr,
sick_hours_expr,
sick_flag_expr,
func.coalesce(func.sum(TimeEntry.holiday_hours), 0).label("holiday"),
func.coalesce(func.sum(TimeEntry.bereavement_hours), 0).label("other"), # "Other" from bereavement bucket
)
.join(
TimesheetStatus,
and_(
TimesheetStatus.timesheet_id == TimeEntry.timesheet_id,
TimesheetStatus.employee_id == TimeEntry.employee_id,
TimesheetStatus.status == "submitted",
),
)
.filter(
TimeEntry.work_date >= start_d,
TimeEntry.work_date <= end_d,
TimeEntry.employee_id.in_(selected_ids) if selected_ids else True,
)
)
rows = (
q.group_by(TimeEntry.employee_id, TimeEntry.work_date)
.order_by(TimeEntry.employee_id.asc(), TimeEntry.work_date.asc())
.all()
)
# Build (emp_id, date) -> summary
per_day: Dict[Tuple[int, date], Dict[str, float]] = {}
for r in rows:
worked = float(r.total or 0) - float(r.breaks or 0)
if worked < 0:
worked = 0.0
per_day[(int(r.emp_id), r.d)] = {
"worked": worked,
"pto": float(r.pto or 0),
"off": float(r.off or 0),
"off_flag": int(r.off_flag or 0),
"sick": float(r.sick or 0),
"sick_flag": int(r.sick_flag or 0),
"holiday": float(r.holiday or 0),
"other": float(r.other or 0),
}
days = [d for d in _daterange(start_d, end_d) if include_weekends or d.weekday() < 5]
visual = []
for e in all_emps:
if selected_ids and e.id not in selected_ids:
continue
# Has any submitted data in range? Used for classifying gaps.
has_data = any(k[0] == e.id for k in per_day.keys())
cells = []
totals = {
"worked_days": 0,
"off_days": 0,
"sick_days": 0,
"pto_days": 0,
"holiday_days": 0,
"other_days": 0,
}
hours = {"worked": 0.0, "off": 0.0, "sick": 0.0, "pto": 0.0, "holiday": 0.0, "other": 0.0}
for d in days:
info = per_day.get((e.id, d))
weekend = d.weekday() >= 5
if info:
# Precedence: holiday > off > sick > pto > other > worked
if info["holiday"] > 0:
st = "holiday"
totals["holiday_days"] += 1
hours["holiday"] += info["holiday"]
elif info["off_flag"] > 0 or info["off"] > 0:
st = "off"
totals["off_days"] += 1
hours["off"] += info["off"]
elif info["sick_flag"] > 0 or info["sick"] > 0:
st = "sick"
totals["sick_days"] += 1
hours["sick"] += info["sick"]
elif info["pto"] > 0:
st = "pto"
totals["pto_days"] += 1
hours["pto"] += info["pto"]
elif info["other"] > 0:
st = "other"
totals["other_days"] += 1
hours["other"] += info["other"]
elif info["worked"] > 0:
st = "worked"
totals["worked_days"] += 1
hours["worked"] += info["worked"]
else:
st = "nodata"
else:
if weekend:
st = "weekend"
else:
# Gaps (weekday without submitted row) count as Off when the employee has any submissions in range
st = "off" if has_data else "nodata"
if st == "off":
totals["off_days"] += 1
# No hours added for inferred Off gap
cells.append({"date": d, "status": st})
visual.append({
"employee": e,
"cells": cells,
"totals": totals,
"hours": {k: round(v, 2) for k, v in hours.items()},
})
return request.app.state.templates.TemplateResponse(
"attendance.html",
{
"request": request,
"employees": all_emps,
"selected_ids": selected_ids,
"selected_employee_id": (None if (not employee_id or employee_id == 'all') else int(employee_id)),
"start": start_d,
"end": end_d,
"days": days,
"include_weekends": include_weekends,
"visual": visual,
},
)
@router.get("/export.csv", response_class=PlainTextResponse)
def attendance_export_csv(
request: Request,
start: str = Query(...),
end: str = Query(...),
employee_id: Optional[str] = Query("all"),
include_weekends: int = Query(0),
db: Session = Depends(get_session),
):
if not request.session.get("is_admin"):
raise HTTPException(status_code=403, detail="Admin access required")
start_d = _to_date(start, date.today())
end_d = _to_date(end, date.today())
if end_d < start_d:
start_d, end_d = end_d, start_d
ids: List[int] = []
all_emps = db.query(Employee).order_by(Employee.name.asc()).all()
if not employee_id or employee_id == "all":
ids = [e.id for e in all_emps]
else:
try:
ids = [int(employee_id)]
except Exception:
ids = [e.id for e in all_emps]
normalized_pto = func.lower(func.trim(func.coalesce(TimeEntry.pto_type, "")))
off_hours_expr = func.coalesce(
func.sum(case((normalized_pto.like("off%"), func.coalesce(TimeEntry.pto_hours, 0)), else_=0)),
0,
).label("off")
off_flag_expr = func.coalesce(func.max(case((normalized_pto.like("off%"), 1), else_=0)), 0).label("off_flag")
sick_hours_expr = func.coalesce(
func.sum(case((normalized_pto.like("sick%"), func.coalesce(TimeEntry.pto_hours, 0)), else_=0)),
0,
).label("sick")
sick_flag_expr = func.coalesce(func.max(case((normalized_pto.like("sick%"), 1), else_=0)), 0).label("sick_flag")
pto_hours_expr = func.coalesce(
func.sum(case((normalized_pto.like("pto%"), func.coalesce(TimeEntry.pto_hours, 0)), else_=0)),
0,
).label("pto")
q = (
db.query(
Employee.name,
TimeEntry.employee_id,
TimeEntry.work_date,
func.coalesce(func.sum(TimeEntry.total_hours), 0).label("total"),
func.coalesce(func.sum(TimeEntry.break_hours), 0).label("breaks"),
pto_hours_expr,
off_hours_expr,
off_flag_expr,
sick_hours_expr,
sick_flag_expr,
func.coalesce(func.sum(TimeEntry.holiday_hours), 0).label("holiday"),
func.coalesce(func.sum(TimeEntry.bereavement_hours), 0).label("other"),
)
.join(Employee, Employee.id == TimeEntry.employee_id)
.join(
TimesheetStatus,
and_(
TimesheetStatus.timesheet_id == TimeEntry.timesheet_id,
TimesheetStatus.employee_id == TimeEntry.employee_id,
TimesheetStatus.status == "submitted",
),
)
.filter(
TimeEntry.work_date >= start_d,
TimeEntry.work_date <= end_d,
TimeEntry.employee_id.in_(ids) if ids else True,
)
)
rows = (
q.group_by(Employee.name, TimeEntry.employee_id, TimeEntry.work_date)
.order_by(Employee.name.asc(), TimeEntry.work_date.asc())
.all()
)
out = ["Employee,Date,Status,WorkedHours,OffHours,SickHours,PTOHours,HolidayHours,OtherHours"]
for r in rows:
worked = float(r.total or 0) - float(r.breaks or 0)
if worked < 0:
worked = 0.0
if (r.holiday or 0) > 0:
st = "holiday"
elif int(r.off_flag or 0) > 0 or (r.off or 0) > 0:
st = "off"
elif int(r.sick_flag or 0) > 0 or (r.sick or 0) > 0:
st = "sick"
elif (r.pto or 0) > 0:
st = "pto"
elif (r.other or 0) > 0:
st = "other"
elif worked > 0:
st = "worked"
else:
st = "nodata"
out.append(
f"{r[0]},{r.work_date.isoformat()},{st},{worked:.2f},{float(r.off or 0):.2f},"
f"{float(r.sick or 0):.2f},{float(r.pto or 0):.2f},{float(r.holiday or 0):.2f},{float(r.other or 0):.2f}"
)
return PlainTextResponse("\n".join(out), media_type="text/csv")

62
app/auth.py Normal file
View File

@ -0,0 +1,62 @@
import inspect
from functools import wraps
from typing import Callable, Any, Tuple, Optional
from fastapi import Request
from fastapi.responses import RedirectResponse
from passlib.context import CryptContext
# Accept Argon2 (preferred) and legacy bcrypt; new hashes will be Argon2.
pwd_context = CryptContext(
schemes=["argon2", "bcrypt"],
deprecated="auto",
)
def hash_password(p: str) -> str:
return pwd_context.hash(p)
def verify_password(p: str, hashed: str) -> bool:
return pwd_context.verify(p, hashed)
def verify_and_update_password(p: str, hashed: str) -> Tuple[bool, Optional[str]]:
"""
Returns (verified, new_hash). If verified is True and new_hash is not None,
caller should persist the new_hash (Argon2) to upgrade legacy bcrypt.
"""
try:
return pwd_context.verify_and_update(p, hashed)
except Exception:
return False, None
def _extract_request(args, kwargs) -> Optional[Request]:
req: Optional[Request] = kwargs.get("request")
if isinstance(req, Request):
return req
for a in args:
if isinstance(a, Request):
return a
return None
def login_required(endpoint: Callable[..., Any]):
"""
Decorator that supports both sync and async FastAPI endpoints.
Redirects to /login when no session is present.
"""
if inspect.iscoroutinefunction(endpoint):
@wraps(endpoint)
async def async_wrapper(*args, **kwargs):
request = _extract_request(args, kwargs)
if not request or not request.session.get("user_id"):
return RedirectResponse(url="/login", status_code=303)
return await endpoint(*args, **kwargs)
return async_wrapper
else:
@wraps(endpoint)
def sync_wrapper(*args, **kwargs):
request = _extract_request(args, kwargs)
if not request or not request.session.get("user_id"):
return RedirectResponse(url="/login", status_code=303)
return endpoint(*args, **kwargs)
return sync_wrapper
def get_current_user(request: Request):
return {"id": request.session.get("user_id"), "username": request.session.get("username")}

19
app/db.py Normal file
View File

@ -0,0 +1,19 @@
import os
from sqlalchemy import create_engine, text
from sqlalchemy.orm import sessionmaker
DATABASE_URL = os.getenv("DATABASE_URL", "postgresql+psycopg://timekeeper:timekeeper_pw@db:5432/timekeeper")
engine = create_engine(DATABASE_URL, pool_pre_ping=True, future=True)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine, future=True)
def get_session():
db = SessionLocal()
try:
yield db
finally:
db.close()
def ping_db():
with engine.connect() as conn:
conn.execute(text("SELECT 1"))

950
app/dept_importer.py Normal file
View File

@ -0,0 +1,950 @@
import os
import io
import csv
import json
from datetime import datetime, date as date_type, time as time_type, timedelta
from typing import Dict, List, Optional, Any, Tuple
from decimal import Decimal
from fastapi import APIRouter, Request, Depends, UploadFile, File, Form, Query, HTTPException
from fastapi.responses import RedirectResponse, HTMLResponse
from sqlalchemy.orm import Session
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, func, text
from .db import get_session
from .models import Base, Employee, TimeEntry, TimesheetPeriod
from .utils import enumerate_timesheets_global, D, q2
from .process_excel import (
detect_header_map,
parse_date,
parse_datetime_value,
parse_time_value,
safe_decimal,
)
router = APIRouter(prefix="/import/department", tags=["Department Import"])
class ImportBatch(Base):
__tablename__ = "import_batches"
id = Column(Integer, primary_key=True, autoincrement=True)
timesheet_id = Column(Integer, index=True, nullable=False)
source_name = Column(String(255), nullable=True)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
class ImportBatchItem(Base):
__tablename__ = "import_batch_items"
id = Column(Integer, primary_key=True, autoincrement=True)
batch_id = Column(Integer, ForeignKey("import_batches.id"), nullable=False, index=True)
time_entry_id = Column(Integer, ForeignKey("time_entries.id"), nullable=False, index=True)
# -------------------------
# Mapping helpers
# -------------------------
# Hide "Hours Worked Minus Break" from the mapping UI to avoid confusion; backend still auto-detects it.
TARGET_FIELDS: List[Tuple[str, str, bool]] = [
("employee", "Employee Name", True),
("work_date", "Work Date", True),
("clock_in", "Clock In", False),
("clock_out", "Clock Out", False),
("break_hours", "Break Hours", False),
("total_hours", "Hours Worked", False),
# ("total_minus_break", "Hours Worked Minus Break", False), # HIDDEN FROM UI
("pto_hours", "PTO Hours", False),
("pto_type", "PTO Type", False),
("holiday_hours", "Holiday Hours", False),
("bereavement_hours", "Bereavement/Other Hours", False),
]
def _store_upload_file(data: bytes, original_name: str) -> str:
os.makedirs("uploads", exist_ok=True)
_, ext = os.path.splitext(original_name)
slug = f"dept-map-{datetime.utcnow().strftime('%Y%m%d%H%M%S')}-{os.getpid()}{ext or ''}"
path = os.path.join("uploads", slug)
with open(path, "wb") as f:
f.write(data)
return path
def _list_sheets_with_headers(xlsx_path: str) -> List[Dict[str, Any]]:
import openpyxl
wb = openpyxl.load_workbook(xlsx_path, data_only=True)
out: List[Dict[str, Any]] = []
for ws in wb.worksheets:
header_row_idx = None
header_vals = []
auto_map = {}
for r in range(1, min(ws.max_row, 50) + 1):
vals = [cell.value for cell in ws[r]]
header_vals = vals
auto_map = detect_header_map(vals)
if auto_map:
header_row_idx = r
break
out.append({
"sheet_name": ws.title,
"header_row_idx": header_row_idx,
"header_vals": header_vals,
"auto_map": auto_map,
})
return out
def _default_sheet_name(sheets_info: List[Dict[str, Any]]) -> Optional[str]:
names = [s["sheet_name"] for s in sheets_info] if sheets_info else []
for nm in names:
if nm.lower().strip() == "final time clock report":
return nm
for s in (sheets_info or []):
if s.get("auto_map"):
return s["sheet_name"]
return names[0] if names else None
# Helpers
def _hours_from_value(value) -> Decimal:
if value is None or value == "":
return D(0)
if isinstance(value, (int, float)):
val = float(value)
if val < 0:
val = 0.0
hours = val * 24.0 if val <= 1.0 else val
return q2(D(hours))
if isinstance(value, datetime):
t = value.time()
return q2(D(t.hour) + D(t.minute) / D(60) + D(t.second) / D(3600))
if isinstance(value, time_type):
return q2(D(value.hour) + D(value.minute) / D(60) + D(value.second) / D(3600))
if isinstance(value, str):
v = value.strip()
if ":" in v:
try:
parts = [int(p) for p in v.split(":")]
if len(parts) == 2:
h, m = parts
return q2(D(h) + D(m) / D(60))
elif len(parts) == 3:
h, m, s = parts
return q2(D(h) + D(m) / D(60) + D(s) / D(3600))
except Exception:
pass
try:
return q2(D(v))
except Exception:
return D(0)
return D(0)
def _find_header_index(header_vals: List[Any], include: List[str]) -> Optional[int]:
hn = [str(v).strip().lower() if v is not None else "" for v in header_vals]
for i, h in enumerate(hn):
if all(tok in h for tok in include):
return i
return None
def _parse_rows_with_mapping(xlsx_path: str, sheet_name: str, header_row_idx: int, mapping: Dict[str, Optional[int]]) -> List[Dict]:
import openpyxl
wb = openpyxl.load_workbook(xlsx_path, data_only=True)
ws = wb[sheet_name]
rows_out: List[Dict] = []
# Detect helpful headers (even if hidden in UI)
header_vals = [cell.value for cell in ws[header_row_idx]]
idx_break_start = _find_header_index(header_vals, ["break", "start", "time"])
idx_break_end = _find_header_index(header_vals, ["break", "end", "time"])
idx_total_minus_break = _find_header_index(header_vals, ["hours", "worked", "minus", "break"])
idx_shift_start = _find_header_index(header_vals, ["shift", "start", "time"])
idx_shift_end = _find_header_index(header_vals, ["shift", "end", "time"])
idx_hours_scheduled = _find_header_index(header_vals, ["hours", "scheduled"])
max_row = ws.max_row or 0
for r in range((header_row_idx or 1) + 1, max_row + 1):
vals = [cell.value for cell in ws[r]]
def getv(key: str):
idx = mapping.get(key)
if idx is None:
return None
return vals[idx] if idx < len(vals) else None
def get_header(idx: Optional[int]):
if idx is None:
return None
return vals[idx] if idx < len(vals) else None
emp_raw = getv("employee")
date_raw = getv("work_date")
if not emp_raw or not date_raw:
continue
employee_name = str(emp_raw).strip()
work_date = parse_date(date_raw)
if not work_date:
continue
clock_in_dt = None
clock_out_dt = None
ci_raw = getv("clock_in")
co_raw = getv("clock_out")
if ci_raw is not None:
clock_in_dt = parse_datetime_value(ci_raw)
if not clock_in_dt:
t = parse_time_value(ci_raw)
clock_in_dt = datetime.combine(work_date, t) if t else None
if co_raw is not None:
clock_out_dt = parse_datetime_value(co_raw)
if not clock_out_dt:
t = parse_time_value(co_raw)
clock_out_dt = datetime.combine(work_date, t) if t else None
if clock_in_dt and clock_out_dt and clock_out_dt <= clock_in_dt:
clock_out_dt = clock_out_dt + timedelta(days=1)
# Break hours from mapped column
break_hours_taken = safe_decimal(getv("break_hours"), D(0))
# If blank/zero, derive from Break Start/End
if (break_hours_taken is None) or (break_hours_taken == D(0)):
bs_raw = get_header(idx_break_start)
be_raw = get_header(idx_break_end)
start_dt = None
end_dt = None
if bs_raw is not None:
start_dt = parse_datetime_value(bs_raw)
if not start_dt:
bt = parse_time_value(bs_raw)
start_dt = datetime.combine(work_date, bt) if bt else None
if be_raw is not None:
end_dt = parse_datetime_value(be_raw)
if not end_dt:
et = parse_time_value(be_raw)
end_dt = datetime.combine(work_date, et) if et else None
if start_dt and end_dt:
if end_dt <= start_dt:
end_dt = end_dt + timedelta(days=1)
break_hours_taken = q2(D((end_dt - start_dt).total_seconds()) / D(3600))
# Total hours ("Hours Worked")
total_raw = getv("total_hours") if mapping.get("total_hours") is not None else None
total_from_sheet = safe_decimal(total_raw) if (total_raw not in (None, "")) else None
# Fallback from "Hours Worked Minus Break" (hidden in UI but auto-detected)
alt_raw = getv("total_minus_break") if mapping.get("total_minus_break") is not None else get_header(idx_total_minus_break)
alt_total_minus_break = safe_decimal(alt_raw) if (alt_raw not in (None, "")) else None
# Scheduled hours: use explicit "Hours Scheduled" or derive from Shift Start/End
scheduled_hours = None
hs_raw = get_header(idx_hours_scheduled)
if hs_raw not in (None, ""):
scheduled_hours = safe_decimal(hs_raw, D(0))
else:
ss_raw = get_header(idx_shift_start)
se_raw = get_header(idx_shift_end)
ss_dt = None
se_dt = None
if ss_raw is not None:
ss_dt = parse_datetime_value(ss_raw)
if not ss_dt:
st = parse_time_value(ss_raw)
ss_dt = datetime.combine(work_date, st) if st else None
if se_raw is not None:
se_dt = parse_datetime_value(se_raw)
if not se_dt:
et = parse_time_value(se_raw)
se_dt = datetime.combine(work_date, et) if et else None
if ss_dt and se_dt:
if se_dt <= ss_dt:
se_dt = se_dt + timedelta(days=1)
scheduled_hours = q2(D((se_dt - ss_dt).total_seconds()) / D(3600))
pto_hours = safe_decimal(getv("pto_hours"), D(0))
pto_type_val = getv("pto_type")
pto_type = (str(pto_type_val).strip() if pto_type_val is not None and not isinstance(pto_type_val, (int, float, datetime)) else None)
holiday_hours = safe_decimal(getv("holiday_hours"), D(0))
bereavement_hours = safe_decimal(getv("bereavement_hours"), D(0))
# Determine Total Hours (Hours Worked)
if total_from_sheet is None:
if alt_total_minus_break is not None and break_hours_taken is not None:
total_from_sheet = q2(alt_total_minus_break + break_hours_taken)
else:
if clock_in_dt and clock_out_dt:
total_from_sheet = q2(D((clock_out_dt - clock_in_dt).total_seconds()) / D(3600))
else:
total_from_sheet = D(0)
else:
total_from_sheet = q2(total_from_sheet)
if pto_hours > D(0):
pto_type = None
rows_out.append({
"employee_name": employee_name,
"work_date": work_date,
"clock_in": clock_in_dt,
"clock_out": clock_out_dt,
"break_hours": q2(break_hours_taken or D(0)),
"total_hours": q2(total_from_sheet),
"scheduled_hours": q2(scheduled_hours or D(0)),
"pto_hours": q2(pto_hours),
"pto_type": pto_type,
"holiday_hours": q2(holiday_hours),
"bereavement_hours": q2(bereavement_hours),
})
return rows_out
def _csv_headers_and_map(csv_bytes: bytes) -> Tuple[List[str], Dict[str, int]]:
text_stream = io.StringIO(csv_bytes.decode("utf-8", errors="replace"))
reader = csv.reader(text_stream)
rows = list(reader)
if not rows:
return [], {}
header_vals = rows[0]
auto_map = detect_header_map(header_vals)
return header_vals, auto_map
def _parse_csv_with_mapping(csv_path: str, mapping: Dict[str, Optional[int]]) -> List[Dict]:
rows_out: List[Dict] = []
with open(csv_path, "r", encoding="utf-8", errors="replace") as f:
reader = csv.reader(f)
all_rows = list(reader)
if not all_rows:
return rows_out
header_vals = all_rows[0]
idx_break_start = _find_header_index(header_vals, ["break", "start", "time"])
idx_break_end = _find_header_index(header_vals, ["break", "end", "time"])
idx_total_minus_break = _find_header_index(header_vals, ["hours", "worked", "minus", "break"])
idx_shift_start = _find_header_index(header_vals, ["shift", "start", "time"])
idx_shift_end = _find_header_index(header_vals, ["shift", "end", "time"])
idx_hours_scheduled = _find_header_index(header_vals, ["hours", "scheduled"])
body = all_rows[1:]
for vals in body:
def getv(key: str):
idx = mapping.get(key)
if idx is None:
return None
if idx < 0 or idx >= len(vals):
return None
return vals[idx]
def get_header(idx: Optional[int]):
if idx is None or idx < 0 or idx >= len(vals):
return None
return vals[idx]
emp_raw = getv("employee")
date_raw = getv("work_date")
if not emp_raw or not date_raw:
continue
employee_name = str(emp_raw).strip()
work_date = parse_date(date_raw)
if not work_date:
continue
clock_in_dt = None
clock_out_dt = None
ci_raw = getv("clock_in")
co_raw = getv("clock_out")
if ci_raw is not None:
clock_in_dt = parse_datetime_value(ci_raw)
if not clock_in_dt:
t = parse_time_value(ci_raw)
clock_in_dt = datetime.combine(work_date, t) if t else None
if co_raw is not None:
clock_out_dt = parse_datetime_value(co_raw)
if not clock_out_dt:
t = parse_time_value(co_raw)
clock_out_dt = datetime.combine(work_date, t) if t else None
if clock_in_dt and clock_out_dt and clock_out_dt <= clock_in_dt:
clock_out_dt = clock_out_dt + timedelta(days=1)
break_hours_taken = safe_decimal(getv("break_hours"), D(0))
if (break_hours_taken is None) or (break_hours_taken == D(0)):
bs_raw = get_header(idx_break_start)
be_raw = get_header(idx_break_end)
start_dt = None
end_dt = None
if bs_raw is not None:
start_dt = parse_datetime_value(bs_raw)
if not start_dt:
bt = parse_time_value(bs_raw)
start_dt = datetime.combine(work_date, bt) if bt else None
if be_raw is not None:
end_dt = parse_datetime_value(be_raw)
if not end_dt:
et = parse_time_value(be_raw)
end_dt = datetime.combine(work_date, et) if et else None
if start_dt and end_dt:
if end_dt <= start_dt:
end_dt = end_dt + timedelta(days=1)
break_hours_taken = q2(D((end_dt - start_dt).total_seconds()) / D(3600))
total_raw = getv("total_hours") if mapping.get("total_hours") is not None else None
total_from_sheet = safe_decimal(total_raw) if (total_raw not in (None, "")) else None
alt_raw = getv("total_minus_break") if mapping.get("total_minus_break") is not None else get_header(idx_total_minus_break)
alt_total_minus_break = safe_decimal(alt_raw) if (alt_raw not in (None, "")) else None
# Scheduled hours
scheduled_hours = None
hs_raw = get_header(idx_hours_scheduled)
if hs_raw not in (None, ""):
scheduled_hours = safe_decimal(hs_raw, D(0))
else:
ss_raw = get_header(idx_shift_start)
se_raw = get_header(idx_shift_end)
ss_dt = None
se_dt = None
if ss_raw is not None:
ss_dt = parse_datetime_value(ss_raw)
if not ss_dt:
st = parse_time_value(ss_raw)
ss_dt = datetime.combine(work_date, st) if st else None
if se_raw is not None:
se_dt = parse_datetime_value(se_raw)
if not se_dt:
et = parse_time_value(se_raw)
se_dt = datetime.combine(work_date, et) if et else None
if ss_dt and se_dt:
if se_dt <= ss_dt:
se_dt = se_dt + timedelta(days=1)
scheduled_hours = q2(D((se_dt - ss_dt).total_seconds()) / D(3600))
pto_hours = safe_decimal(getv("pto_hours"), D(0))
pto_type_val = getv("pto_type")
pto_type = (str(pto_type_val).strip() if pto_type_val is not None else None)
holiday_hours = safe_decimal(getv("holiday_hours"), D(0))
bereavement_hours = safe_decimal(getv("bereavement_hours"), D(0))
if total_from_sheet is None:
if alt_total_minus_break is not None and break_hours_taken is not None:
total_from_sheet = q2(alt_total_minus_break + break_hours_taken)
else:
if clock_in_dt and clock_out_dt:
total_from_sheet = q2(D((clock_out_dt - clock_in_dt).total_seconds()) / D(3600))
else:
total_from_sheet = D(0)
else:
total_from_sheet = q2(total_from_sheet)
if pto_hours > D(0):
pto_type = None
rows_out.append({
"employee_name": employee_name,
"work_date": work_date,
"clock_in": clock_in_dt,
"clock_out": clock_out_dt,
"break_hours": q2(break_hours_taken or D(0)),
"total_hours": q2(total_from_sheet),
"scheduled_hours": q2(scheduled_hours or D(0)),
"pto_hours": q2(pto_hours),
"pto_type": pto_type,
"holiday_hours": q2(holiday_hours),
"bereavement_hours": q2(bereavement_hours),
})
return rows_out
# -------------------------
# Routes
# -------------------------
def _active_timesheet(db: Session) -> Optional[TimesheetPeriod]:
sheets = enumerate_timesheets_global(db)
if not sheets:
return None
tid = sheets[-1][0]
return db.query(TimesheetPeriod).get(tid)
def _within_period(d: date_type, ts: TimesheetPeriod) -> bool:
return ts.period_start <= d <= ts.period_end
def _dedup_exists(db: Session, employee_id: int, timesheet_id: int, work_date: date_type, clock_in: Optional[datetime], clock_out: Optional[datetime]) -> bool:
q = db.query(TimeEntry).filter(
TimeEntry.employee_id == employee_id,
TimeEntry.timesheet_id == timesheet_id,
TimeEntry.work_date == work_date,
)
for r in q.all():
if (r.clock_in or None) == (clock_in or None) and (r.clock_out or None) == (clock_out or None):
return True
return False
@router.get("", response_class=HTMLResponse)
def importer_home(request: Request, db: Session = Depends(get_session), timesheet_id: Optional[int] = Query(None)):
if not request.session.get("is_admin"):
raise HTTPException(status_code=403, detail="Admin access required")
sheets = enumerate_timesheets_global(db)
period_options = [{"timesheet_id": tid, "display": (name or f"{ps}..{pe}")} for tid, ps, pe, name in sheets]
active_ts = db.query(TimesheetPeriod).get(timesheet_id) if timesheet_id else _active_timesheet(db)
return request.app.state.templates.TemplateResponse(
"dept_importer_upload.html",
{"request": request, "period_options": period_options, "active_ts": active_ts.id if active_ts else None},
)
@router.post("/upload", response_class=HTMLResponse)
async def importer_upload(
request: Request,
file: UploadFile = File(...),
timesheet_id: int = Form(...),
restrict_to_period: int = Form(1),
db: Session = Depends(get_session),
):
if not request.session.get("is_admin"):
raise HTTPException(status_code=403, detail="Admin access required")
ts = db.query(TimesheetPeriod).get(timesheet_id)
if not ts:
raise HTTPException(status_code=404, detail="Time Period not found")
data = await file.read()
ext = os.path.splitext(file.filename.lower())[1]
if ext not in (".xlsx", ".xlsm", ".xls", ".csv", ".txt"):
raise HTTPException(status_code=400, detail="Unsupported file type. Please upload XLSX/XLS or CSV/TXT.")
uploaded_path = _store_upload_file(data, file.filename)
ctx: Dict[str, Any] = {
"kind": "excel" if ext in (".xlsx", ".xlsm", ".xls") else "csv",
"path": uploaded_path,
"timesheet_id": timesheet_id,
"restrict_to_period": int(restrict_to_period),
"mode": "department",
}
if ctx["kind"] == "excel":
sheets_info = _list_sheets_with_headers(uploaded_path)
if not sheets_info:
raise HTTPException(status_code=400, detail="No sheets found in workbook.")
default_sheet = _default_sheet_name(sheets_info) or sheets_info[0]["sheet_name"]
ctx["sheets_info"] = sheets_info
ctx["default_sheet"] = default_sheet
else:
header_vals, auto_map = _csv_headers_and_map(data)
if not header_vals:
raise HTTPException(status_code=400, detail="Empty CSV")
ctx["sheets_info"] = [{
"sheet_name": "CSV",
"header_row_idx": 1,
"header_vals": header_vals,
"auto_map": auto_map,
}]
ctx["default_sheet"] = "CSV"
os.makedirs("uploads", exist_ok=True)
map_slug = f"dept-mapctx-{datetime.utcnow().strftime('%Y%m%d%H%M%S')}-{os.getpid()}.json"
map_path = os.path.join("uploads", map_slug)
with open(map_path, "w", encoding="utf-8") as f:
json.dump(ctx, f)
return request.app.state.templates.TemplateResponse(
"dept_importer_map.html",
{
"request": request,
"map_slug": map_slug,
"timesheet_id": timesheet_id,
"restrict_to_period": int(restrict_to_period),
"sheets_info": ctx["sheets_info"],
"sheet_name": ctx["default_sheet"],
"target_fields": TARGET_FIELDS,
"kind": ctx["kind"],
"mode": ctx["mode"],
},
)
@router.get("/start-initial", response_class=HTMLResponse)
def start_initial_mapping(
request: Request,
timesheet_id: int = Query(...),
src: str = Query(...),
):
if not request.session.get("is_admin"):
raise HTTPException(status_code=403, detail="Admin access required")
if not os.path.exists(src):
raise HTTPException(status_code=400, detail="Uploaded file not found; please re-upload.")
_, ext = os.path.splitext(src.lower())
if ext not in (".xlsx", ".xlsm", ".xls", ".csv", ".txt"):
raise HTTPException(status_code=400, detail="Unsupported file type.")
ctx: Dict[str, Any] = {
"kind": "excel" if ext in (".xlsx", ".xlsm", ".xls") else "csv",
"path": src,
"timesheet_id": timesheet_id,
"restrict_to_period": 0,
"mode": "initial",
}
if ctx["kind"] == "excel":
sheets_info = _list_sheets_with_headers(src)
if not sheets_info:
raise HTTPException(status_code=400, detail="No sheets found in workbook.")
default_sheet = _default_sheet_name(sheets_info) or sheets_info[0]["sheet_name"]
ctx["sheets_info"] = sheets_info
ctx["default_sheet"] = default_sheet
else:
with open(src, "rb") as f:
data = f.read()
header_vals, auto_map = _csv_headers_and_map(data)
if not header_vals:
raise HTTPException(status_code=400, detail="Empty CSV")
ctx["sheets_info"] = [{
"sheet_name": "CSV",
"header_row_idx": 1,
"header_vals": header_vals,
"auto_map": auto_map,
}]
ctx["default_sheet"] = "CSV"
os.makedirs("uploads", exist_ok=True)
map_slug = f"dept-mapctx-{datetime.utcnow().strftime('%Y%m%d%H%M%S')}-{os.getpid()}.json"
map_path = os.path.join("uploads", map_slug)
with open(map_path, "w", encoding="utf-8") as f:
json.dump(ctx, f)
return request.app.state.templates.TemplateResponse(
"dept_importer_map.html",
{
"request": request,
"map_slug": map_slug,
"timesheet_id": timesheet_id,
"restrict_to_period": 0,
"sheets_info": ctx["sheets_info"],
"sheet_name": ctx["default_sheet"],
"target_fields": TARGET_FIELDS,
"kind": ctx["kind"],
"mode": ctx["mode"],
},
)
@router.get("/map", response_class=HTMLResponse)
def importer_map_get(
request: Request,
map_slug: str = Query(...),
sheet_name: Optional[str] = Query(None),
):
path = os.path.join("uploads", map_slug)
if not os.path.exists(path):
raise HTTPException(status_code=400, detail="Mapping context expired. Please re-upload.")
with open(path, "r", encoding="utf-8") as f:
ctx = json.load(f)
sheets_info = ctx.get("sheets_info") or []
timesheet_id = ctx.get("timesheet_id")
restrict_to_period = int(ctx.get("restrict_to_period") or 1)
selected = sheet_name or ctx.get("default_sheet")
return request.app.state.templates.TemplateResponse(
"dept_importer_map.html",
{
"request": request,
"map_slug": map_slug,
"timesheet_id": timesheet_id,
"restrict_to_period": restrict_to_period,
"sheets_info": sheets_info,
"sheet_name": selected,
"target_fields": TARGET_FIELDS,
"kind": ctx.get("kind") or "excel",
"mode": ctx.get("mode") or "department",
},
)
@router.post("/preview-mapped", response_class=HTMLResponse)
async def importer_preview_mapped(
request: Request,
map_slug: str = Form(...),
timesheet_id: int = Form(...),
sheet_name: str = Form(...),
restrict_to_period: int = Form(1),
mode: str = Form("department"),
db: Session = Depends(get_session),
employee: Optional[str] = Form(None),
work_date: Optional[str] = Form(None),
clock_in: Optional[str] = Form(None),
clock_out: Optional[str] = Form(None),
break_hours: Optional[str] = Form(None),
total_hours: Optional[str] = Form(None),
total_minus_break: Optional[str] = Form(None), # hidden in UI; may be None
pto_hours: Optional[str] = Form(None),
pto_type: Optional[str] = Form(None),
holiday_hours: Optional[str] = Form(None),
bereavement_hours: Optional[str] = Form(None),
):
if not request.session.get("is_admin"):
raise HTTPException(status_code=403, detail="Admin access required")
ctx_path = os.path.join("uploads", map_slug)
if not os.path.exists(ctx_path):
raise HTTPException(status_code=400, detail="Mapping context expired. Please re-upload.")
with open(ctx_path, "r", encoding="utf-8") as f:
ctx = json.load(f)
kind = ctx.get("kind") or "excel"
src_path = ctx.get("path")
sheets_info = ctx.get("sheets_info") or []
sheet_info = next((s for s in sheets_info if s.get("sheet_name") == sheet_name), None)
if not sheet_info or not sheet_info.get("header_row_idx"):
raise HTTPException(status_code=400, detail="Selected sheet has no recognizable header row.")
def to_idx(v: Optional[str]) -> Optional[int]:
if v is None:
return None
v = v.strip()
if not v or v.lower() == "none":
return None
try:
return int(v)
except Exception:
return None
mapping = {
"employee": to_idx(employee),
"work_date": to_idx(work_date),
"clock_in": to_idx(clock_in),
"clock_out": to_idx(clock_out),
"break_hours": to_idx(break_hours),
"total_hours": to_idx(total_hours),
"total_minus_break": to_idx(total_minus_break), # may be None
"pto_hours": to_idx(pto_hours),
"pto_type": to_idx(pto_type),
"holiday_hours": to_idx(holiday_hours),
"bereavement_hours": to_idx(bereavement_hours),
}
if mapping["employee"] is None or mapping["work_date"] is None:
raise HTTPException(status_code=400, detail="Please select both Employee and Work Date columns.")
if kind == "excel":
norm_rows = _parse_rows_with_mapping(src_path, sheet_name, int(sheet_info["header_row_idx"]), mapping)
else:
norm_rows = _parse_csv_with_mapping(src_path, mapping)
ts = db.query(TimesheetPeriod).get(timesheet_id)
if not ts:
raise HTTPException(status_code=404, detail="Time Period not found")
total_before_filter = len(norm_rows)
filtered_rows = norm_rows
if restrict_to_period:
filtered_rows = [r for r in norm_rows if _within_period(r["work_date"], ts)]
if not filtered_rows and total_before_filter > 0:
filtered_rows = norm_rows
by_emp: Dict[str, List[Dict]] = {}
for r in filtered_rows:
by_emp.setdefault(r["employee_name"], []).append(r)
preview = []
for name, rows in sorted(by_emp.items(), key=lambda kv: kv[0].lower()):
emp = db.query(Employee).filter(func.lower(Employee.name) == func.lower(name)).first()
has_any_in_period = False
if emp:
has_any_in_period = db.query(TimeEntry).filter(TimeEntry.timesheet_id == timesheet_id, TimeEntry.employee_id == emp.id).first() is not None
preview.append({
"employee_name": name,
"status": ("Existing in period" if has_any_in_period else ("Existing employee" if emp else "New employee")),
"existing_employee_id": emp.id if emp else None,
"row_count": len(rows),
})
os.makedirs("uploads", exist_ok=True)
slug = f"dept-import-{datetime.utcnow().strftime('%Y%m%d%H%M%S')}-{os.getpid()}.json"
path = os.path.join("uploads", slug)
def enc(o):
if isinstance(o, (datetime, date_type)):
return o.isoformat()
return str(o)
with open(path, "w", encoding="utf-8") as f:
json.dump({"timesheet_id": timesheet_id, "rows": filtered_rows}, f, default=enc)
return request.app.state.templates.TemplateResponse(
"dept_importer_preview.html",
{"request": request, "slug": slug, "timesheet_id": timesheet_id, "preview": preview, "mode": mode},
)
@router.post("/execute")
async def importer_execute(
request: Request,
slug: str = Form(...),
timesheet_id: int = Form(...),
selected_names: str = Form(...),
mode: str = Form("department"),
db: Session = Depends(get_session),
):
if not request.session.get("is_admin"):
raise HTTPException(status_code=403, detail="Admin access required")
path = os.path.join("uploads", slug)
if not os.path.exists(path):
raise HTTPException(status_code=400, detail="Import context expired. Please re-upload.")
with open(path, "r", encoding="utf-8") as f:
payload = json.load(f)
if int(payload.get("timesheet_id")) != int(timesheet_id):
raise HTTPException(status_code=400, detail="Timesheet mismatch. Please re-upload.")
ts = db.query(TimesheetPeriod).get(timesheet_id)
if not ts:
raise HTTPException(status_code=404, detail="Time Period not found")
def conv(r: Dict) -> Dict:
ci = r.get("clock_in")
co = r.get("clock_out")
wd = r.get("work_date")
return {
"employee_name": r.get("employee_name"),
"work_date": date_type.fromisoformat(wd) if isinstance(wd, str) else wd,
"clock_in": (datetime.fromisoformat(ci) if isinstance(ci, str) else ci) if ci else None,
"clock_out": (datetime.fromisoformat(co) if isinstance(co, str) else co) if co else None,
"break_hours": float(r.get("break_hours") or 0),
"total_hours": float(r.get("total_hours") or 0),
"scheduled_hours": float(r.get("scheduled_hours") or 0),
"pto_hours": float(r.get("pto_hours") or 0),
"pto_type": (r.get("pto_type") or None),
"holiday_hours": float(r.get("holiday_hours") or 0),
"bereavement_hours": float(r.get("bereavement_hours") or 0),
}
rows = [conv(r) for r in (payload.get("rows") or [])]
selected_set = {s.strip() for s in (selected_names or "").split(",") if s.strip()}
rows = [r for r in rows if r["employee_name"] in selected_set]
week_rows = db.execute(text("SELECT day_date, week_number FROM week_assignments WHERE timesheet_id = :tid"), {"tid": timesheet_id}).fetchall()
week_map: Dict[date_type, int] = {row[0]: int(row[1]) for row in week_rows}
batch = ImportBatch(timesheet_id=timesheet_id, source_name=f"Department import {slug}", created_at=datetime.utcnow())
db.add(batch)
db.flush()
inserted_count = 0
by_emp: Dict[str, List[Dict]] = {}
for r in rows:
by_emp.setdefault(r["employee_name"], []).append(r)
min_date = None
max_date = None
for name, erows in by_emp.items():
emp = db.query(Employee).filter(func.lower(Employee.name) == func.lower(name)).first()
if not emp:
emp = Employee(name=name)
db.add(emp)
db.flush()
for r in erows:
wd: date_type = r["work_date"]
if min_date is None or wd < min_date:
min_date = wd
if max_date is None or wd > max_date:
max_date = wd
if wd not in week_map and mode == "department":
continue
ci = r["clock_in"]
co = r["clock_out"]
# Keep rows that indicate "scheduled but no show": scheduled_hours > 0 and no clocks or special hours
if (ci is None and co is None) and (r["pto_hours"] <= 0) and (r["holiday_hours"] <= 0) and (r["bereavement_hours"] <= 0):
if r.get("scheduled_hours", 0) <= 0:
# No clocks, no scheduled hours, nothing else -> skip
continue
# Otherwise, keep the row (total stays as provided/computed; paid will be zero)
# Incomplete clocks: skip unless PTO/holiday/bereavement-only
if (ci is None) ^ (co is None):
if (r["pto_hours"] <= 0) and (r["holiday_hours"] <= 0) and (r["bereavement_hours"] <= 0):
continue
if ci and co and co <= ci:
co = co + timedelta(days=1)
if (r.get("holiday_hours") or 0) > 0:
ci = None
co = None
if _dedup_exists(db, emp.id, timesheet_id, wd, ci, co):
continue
total_hours = q2(D(r["total_hours"] or 0))
brk = q2(D(r["break_hours"] or 0))
pto = q2(D(r["pto_hours"] or 0))
hol = q2(D(r["holiday_hours"] or 0))
ber = q2(D(r["bereavement_hours"] or 0))
worked = q2(D(total_hours) - D(brk))
if worked < D(0):
worked = q2(D(0))
hours_paid = q2(worked + D(pto) + D(hol) + D(ber))
te = TimeEntry(
employee_id=emp.id,
timesheet_id=timesheet_id,
work_date=wd,
clock_in=ci,
clock_out=co,
break_hours=brk,
total_hours=total_hours,
pto_hours=pto,
pto_type=(r["pto_type"] or None),
holiday_hours=hol,
bereavement_hours=ber,
hours_paid=hours_paid,
)
db.add(te)
db.flush()
db.add(ImportBatchItem(batch_id=batch.id, time_entry_id=te.id))
inserted_count += 1
db.commit()
try:
os.remove(path)
except Exception:
pass
if mode == "initial":
if min_date is None or max_date is None:
row = db.query(func.min(TimeEntry.work_date), func.max(TimeEntry.work_date)).filter(TimeEntry.timesheet_id == timesheet_id).one()
min_date, max_date = row[0], row[1]
if not min_date:
return RedirectResponse(url=f"/upload?error=No+rows+found+after+import", status_code=303)
from .utils import _semi_monthly_period_for_date as semi
ps1, pe1 = semi(min_date)
ps2, pe2 = semi(max_date or min_date)
ps, pe = (ps2, pe2) if (ps1, pe1) != (ps2, pe2) else (ps1, pe1)
ts = db.query(TimesheetPeriod).get(timesheet_id)
ts.period_start = ps
ts.period_end = pe
db.commit()
return RedirectResponse(url=f"/assign-weeks?timesheet_id={timesheet_id}", status_code=303)
msg = f"Imported {inserted_count} time entries from department file."
return RedirectResponse(url=f"/viewer?timesheet_id={timesheet_id}&msg={msg}", status_code=303)
@router.post("/undo-last")
def importer_undo_last(request: Request, timesheet_id: int = Form(...), db: Session = Depends(get_session)):
if not request.session.get("is_admin"):
raise HTTPException(status_code=403, detail="Admin access required")
batch = db.query(ImportBatch).filter(ImportBatch.timesheet_id == timesheet_id).order_by(ImportBatch.created_at.desc()).first()
if not batch:
return RedirectResponse(url=f"/viewer?timesheet_id={timesheet_id}&msg=No+department+imports+to+undo", status_code=303)
items = db.query(ImportBatchItem).filter(ImportBatchItem.batch_id == batch.id).all()
ids = [it.time_entry_id for it in items]
if items:
db.query(ImportBatchItem).filter(ImportBatchItem.batch_id == batch.id).delete(synchronize_session=False)
if ids:
db.query(TimeEntry).filter(TimeEntry.id.in_(ids)).delete(synchronize_session=False)
db.delete(batch)
db.commit()
return RedirectResponse(url=f"/viewer?timesheet_id={timesheet_id}&msg=Undid+last+department+import", status_code=303)

2531
app/main.py Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,76 @@
import sys
from datetime import date
from sqlalchemy import text, inspect
from .db import engine, SessionLocal
from .models import TimesheetPeriod, TimeEntry, WeekAssignment, EmployeePeriodSetting, TimesheetStatus
from .utils import _semi_monthly_period_for_date
def column_exists(inspector, table, column):
return any(c["name"] == column for c in inspector.get_columns(table))
def run():
print("[migrate] Starting timesheet instances migration...")
insp = inspect(engine)
with engine.begin() as conn:
# add columns if missing
if not column_exists(insp, "time_entries", "timesheet_id"):
conn.execute(text("ALTER TABLE time_entries ADD COLUMN timesheet_id INTEGER"))
if not column_exists(insp, "week_assignments", "timesheet_id"):
conn.execute(text("ALTER TABLE week_assignments ADD COLUMN timesheet_id INTEGER"))
if not column_exists(insp, "employee_period_settings", "timesheet_id"):
conn.execute(text("ALTER TABLE employee_period_settings ADD COLUMN timesheet_id INTEGER"))
if not column_exists(insp, "timesheet_status", "timesheet_id"):
conn.execute(text("ALTER TABLE timesheet_status ADD COLUMN timesheet_id INTEGER"))
# NEW: add created_at to timesheet_periods so ordering can later use it safely
if not column_exists(insp, "timesheet_periods", "created_at"):
conn.execute(text("ALTER TABLE timesheet_periods ADD COLUMN created_at TIMESTAMP WITHOUT TIME ZONE DEFAULT NOW() NOT NULL"))
s = SessionLocal()
try:
# derive periods from entries
dates = [r[0] for r in s.query(TimeEntry.work_date).order_by(TimeEntry.work_date.asc()).all()]
period_keys = set()
for d in dates:
ps, pe = _semi_monthly_period_for_date(d)
period_keys.add((ps, pe))
# also from other tables
was = s.query(WeekAssignment.period_start, WeekAssignment.period_end).group_by(WeekAssignment.period_start, WeekAssignment.period_end).all()
for ps, pe in was:
period_keys.add((ps, pe))
sts = s.query(TimesheetStatus.period_start, TimesheetStatus.period_end).group_by(TimesheetStatus.period_start, TimesheetStatus.period_end).all()
for ps, pe in sts:
period_keys.add((ps, pe))
epss = s.query(EmployeePeriodSetting.period_start, EmployeePeriodSetting.period_end).group_by(EmployeePeriodSetting.period_start, EmployeePeriodSetting.period_end).all()
for ps, pe in epss:
period_keys.add((ps, pe))
created = {}
for ps, pe in sorted(period_keys):
ts = s.query(TimesheetPeriod).filter(TimesheetPeriod.period_start == ps, TimesheetPeriod.period_end == pe).first()
if not ts:
ts = TimesheetPeriod(period_start=ps, period_end=pe, name=f"{ps.isoformat()} .. {pe.isoformat()}")
s.add(ts)
s.flush()
created[(ps, pe)] = ts.id
# assign timesheet_id to all rows by their period
entries = s.query(TimeEntry).filter(TimeEntry.timesheet_id.is_(None)).all()
for e in entries:
ps, pe = _semi_monthly_period_for_date(e.work_date)
e.timesheet_id = created.get((ps, pe))
was = s.query(WeekAssignment).filter(WeekAssignment.timesheet_id.is_(None)).all()
for w in was:
w.timesheet_id = created.get((w.period_start, w.period_end))
epss = s.query(EmployeePeriodSetting).filter(EmployeePeriodSetting.timesheet_id.is_(None)).all()
for ep in epss:
ep.timesheet_id = created.get((ep.period_start, ep.period_end))
sts = s.query(TimesheetStatus).filter(TimesheetStatus.timesheet_id.is_(None)).all()
for st in sts:
st.timesheet_id = created.get((st.period_start, st.period_end))
s.commit()
print(f"[migrate] Done. Created {len(created)} timesheet_periods and backfilled timesheet_id.")
finally:
s.close()
if __name__ == "__main__":
run()

147
app/models.py Normal file
View File

@ -0,0 +1,147 @@
from datetime import datetime
from sqlalchemy import (
Column,
Integer,
String,
Date,
DateTime,
ForeignKey,
UniqueConstraint,
Numeric,
Boolean, # NEW
)
from sqlalchemy.orm import declarative_base
Base = declarative_base()
class User(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True, index=True)
username = Column(String(128), unique=True, nullable=False, index=True)
password_hash = Column(String(256), nullable=False)
class Employee(Base):
__tablename__ = "employees"
id = Column(Integer, primary_key=True, index=True)
name = Column(String(256), nullable=False, index=True)
# NEW: active status + optional termination date
# Default True ensures existing/new employees are treated as active.
is_active = Column(Boolean, nullable=False, default=True)
termination_date = Column(Date, nullable=True)
class TimesheetPeriod(Base):
__tablename__ = "timesheet_periods"
id = Column(Integer, primary_key=True, index=True)
period_start = Column(Date, nullable=False, index=True)
period_end = Column(Date, nullable=False, index=True)
name = Column(String(256), nullable=True)
created_at = Column(DateTime, nullable=False, default=datetime.utcnow, index=True)
class TimeEntry(Base):
__tablename__ = "time_entries"
id = Column(Integer, primary_key=True, index=True)
employee_id = Column(Integer, ForeignKey("employees.id"), nullable=False, index=True)
timesheet_id = Column(Integer, ForeignKey("timesheet_periods.id"), nullable=False, index=True)
work_date = Column(Date, nullable=False, index=True)
clock_in = Column(DateTime, nullable=True)
clock_out = Column(DateTime, nullable=True)
pto_clock_in_backup = Column(DateTime, nullable=True)
pto_clock_out_backup = Column(DateTime, nullable=True)
total_hours = Column(Numeric(12, 2), default=0, nullable=False)
break_hours = Column(Numeric(12, 2), default=0, nullable=False)
pto_hours = Column(Numeric(12, 2), default=0, nullable=False)
pto_type = Column(String(64), nullable=True)
holiday_hours = Column(Numeric(12, 2), default=0, nullable=False)
bereavement_hours = Column(Numeric(12, 2), default=0, nullable=False)
hours_paid = Column(Numeric(12, 2), default=0, nullable=False)
class WeekAssignment(Base):
__tablename__ = "week_assignments"
id = Column(Integer, primary_key=True, index=True)
timesheet_id = Column(Integer, ForeignKey("timesheet_periods.id"), nullable=False, index=True)
period_start = Column(Date, nullable=False)
period_end = Column(Date, nullable=False)
day_date = Column(Date, nullable=False, index=True)
week_number = Column(Integer, nullable=False)
class EmployeePeriodSetting(Base):
__tablename__ = "employee_period_settings"
id = Column(Integer, primary_key=True, index=True)
employee_id = Column(Integer, ForeignKey("employees.id"), nullable=False, index=True)
timesheet_id = Column(Integer, ForeignKey("timesheet_periods.id"), nullable=False, index=True)
period_start = Column(Date, nullable=False)
period_end = Column(Date, nullable=False)
carry_over_hours = Column(Numeric(12, 2), default=0, nullable=False)
class TimesheetStatus(Base):
__tablename__ = "timesheet_status"
id = Column(Integer, primary_key=True, index=True)
timesheet_id = Column(Integer, ForeignKey("timesheet_periods.id"), nullable=False, index=True)
employee_id = Column(Integer, ForeignKey("employees.id"), nullable=False, index=True)
period_start = Column(Date, nullable=False)
period_end = Column(Date, nullable=False)
status = Column(String(32), default="pending", nullable=False)
submitted_at = Column(DateTime, default=datetime.utcnow, nullable=True)
class DuplicateReview(Base):
__tablename__ = "duplicate_reviews"
id = Column(Integer, primary_key=True, index=True)
timesheet_id = Column(Integer, ForeignKey("timesheet_periods.id"), nullable=False, index=True)
employee_id = Column(Integer, ForeignKey("employees.id"), nullable=False, index=True)
work_date = Column(Date, nullable=False, index=True)
__table_args__ = (
UniqueConstraint("timesheet_id", "employee_id", "work_date", name="uix_dup_review"),
)
# PTO tracker (per-year starting balance + per-year manual adjustments)
class PTOAccount(Base):
__tablename__ = "pto_accounts"
id = Column(Integer, primary_key=True, index=True)
employee_id = Column(Integer, ForeignKey("employees.id"), nullable=False, index=True)
year = Column(Integer, nullable=True, index=True) # per-year balance; filled/required by app logic
starting_balance = Column(Numeric(12, 2), default=0, nullable=False)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
updated_at = Column(DateTime, default=datetime.utcnow, nullable=False)
class PTOAdjustment(Base):
__tablename__ = "pto_adjustments"
id = Column(Integer, primary_key=True, index=True)
employee_id = Column(Integer, ForeignKey("employees.id"), nullable=False, index=True)
year = Column(Integer, nullable=True, index=True) # per-year adjustment; filled/required by app logic
hours = Column(Numeric(12, 2), nullable=False) # positive or negative
note = Column(String(255), nullable=True)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)

96
app/payroll_export.py Normal file
View File

@ -0,0 +1,96 @@
from io import BytesIO
from openpyxl import Workbook
from openpyxl.styles import Alignment, Font
from openpyxl.utils import get_column_letter
def build_overview_xlsx(period_name: str, rows: list):
"""
Build an Excel workbook for the time period overview.
period_name: e.g. "Dec 1..Dec 15, 2025" (or your custom name)
rows: list of dicts per employee:
{
"employee_name": str,
"regular": float,
"overtime": float,
"pto": float,
"holiday": float,
"paid_total": float,
"reimbursement": float,
"additional_payroll": float,
"notes": str,
}
"""
wb = Workbook()
ws = wb.active
ws.title = "Overview"
# Title row
ws["A1"] = period_name
ws["A1"].font = Font(bold=True, size=14)
headers = [
"Employee",
"Regular Hours",
"Overtime Hours",
"PTO Hours",
"Holiday",
"Reimbursement",
"Additional Payroll Changes",
"Total hours for pay period",
"Notes",
]
# Header row at row 2 (keep space for title row)
for col, text in enumerate(headers, start=1):
cell = ws.cell(row=2, column=col, value=text)
cell.font = Font(bold=True)
# Freeze the header row (row 2)
ws.freeze_panes = "A3"
# Data
row_idx = 3
for r in rows:
ws.cell(row=row_idx, column=1, value=r.get("employee_name", ""))
ws.cell(row=row_idx, column=2, value=r.get("regular", 0.0))
ws.cell(row=row_idx, column=3, value=r.get("overtime", 0.0))
ws.cell(row=row_idx, column=4, value=r.get("pto", 0.0))
ws.cell(row=row_idx, column=5, value=r.get("holiday", 0.0))
ws.cell(row=row_idx, column=6, value=r.get("reimbursement", 0.0) or 0.0)
ws.cell(row=row_idx, column=7, value=r.get("additional_payroll", 0.0) or 0.0)
ws.cell(row=row_idx, column=8, value=r.get("paid_total", 0.0))
ws.cell(row=row_idx, column=9, value=r.get("notes", "") or "")
row_idx += 1
# Number formats and alignment
num_cols = [2, 3, 4, 5, 8] # hour columns and total hours
currency_cols = [6, 7] # reimbursement, additional payroll
wrap_cols = [9] # notes
max_row = ws.max_row
for r in range(3, max_row + 1):
for c in num_cols:
cell = ws.cell(row=r, column=c)
cell.number_format = "0.00"
cell.alignment = Alignment(horizontal="right")
for c in currency_cols:
cell = ws.cell(row=r, column=c)
cell.number_format = "$#,##0.00"
cell.alignment = Alignment(horizontal="right")
for c in wrap_cols:
cell = ws.cell(row=r, column=c)
cell.alignment = Alignment(wrap_text=True)
# Autosize columns (basic heuristic)
for col in range(1, len(headers) + 1):
letter = get_column_letter(col)
max_len = len(headers[col - 1])
for cell in ws[letter]:
val = "" if cell.value is None else str(cell.value)
max_len = max(max_len, len(val))
ws.column_dimensions[letter].width = min(max_len + 2, 40)
buf = BytesIO()
wb.save(buf)
buf.seek(0)
return buf.getvalue()

356
app/process_excel.py Normal file
View File

@ -0,0 +1,356 @@
import openpyxl
from datetime import datetime, time, date as date_type, timedelta
from typing import Optional, Dict, Any, List
from sqlalchemy.orm import Session
from decimal import Decimal
from .models import TimeEntry, Employee
from .utils import D, q2 # use shared Decimal helpers
def safe_decimal(value, default=Decimal("0")) -> Decimal:
if value is None or value == "":
return Decimal(default)
if isinstance(value, (int, float)):
# Convert via str to avoid binary float artifacts
return D(value)
if isinstance(value, datetime):
# numeric only
return Decimal(default)
if isinstance(value, str):
try:
return D(value.strip())
except Exception:
return Decimal(default)
return Decimal(default)
def parse_excel_serial_date(serial: float) -> Optional[date_type]:
try:
epoch = datetime(1899, 12, 30) # Excel epoch
return (epoch + timedelta(days=float(serial))).date()
except Exception:
return None
def parse_excel_serial_datetime(serial: float) -> Optional[datetime]:
try:
epoch = datetime(1899, 12, 30)
return epoch + timedelta(days=float(serial))
except Exception:
return None
def parse_date(value) -> Optional[date_type]:
if value is None:
return None
if isinstance(value, date_type) and not isinstance(value, datetime):
return value
if isinstance(value, datetime):
return value.date()
if isinstance(value, (int, float)):
return parse_excel_serial_date(value)
if isinstance(value, str):
v = value.strip()
for fmt in [
"%Y-%m-%d",
"%m/%d/%Y",
"%m/%d/%y",
"%d/%m/%Y",
"%d/%m/%y",
"%Y/%m/%d",
"%m-%d-%Y",
"%d-%m-%Y",
"%B %d, %Y",
"%b %d, %Y",
]:
try:
return datetime.strptime(v, fmt).date()
except ValueError:
continue
return None
def parse_time_value(value) -> Optional[time]:
if value is None or value == "":
return None
if isinstance(value, time):
return value
if isinstance(value, datetime):
return value.time()
if isinstance(value, (int, float)):
# Excel serial time: fraction of a day
try:
seconds = int(round(float(value) * 86400))
base = datetime(1970, 1, 1) + timedelta(seconds=seconds)
return base.time()
except Exception:
return None
if isinstance(value, str):
v = value.strip()
for fmt in ["%I:%M %p", "%H:%M", "%I:%M:%S %p", "%H:%M:%S"]:
try:
return datetime.strptime(v, fmt).time()
except ValueError:
continue
return None
def parse_datetime_value(value) -> Optional[datetime]:
"""
Parse a cell that may contain a full datetime (with date), handling:
- Python datetime objects
- Excel serial datetimes (float or int)
- Strings with date and time
Returns None if only time-of-day is present (use parse_time_value then bind to work_date).
"""
if value is None or value == "":
return None
if isinstance(value, datetime):
return value
if isinstance(value, (int, float)):
dt = parse_excel_serial_datetime(value)
return dt
if isinstance(value, str):
v = value.strip()
has_slash_date = "/" in v or "-" in v
if has_slash_date:
for fmt in [
"%m/%d/%Y %I:%M:%S %p",
"%m/%d/%Y %I:%M %p",
"%m/%d/%y %I:%M %p",
"%Y-%m-%d %H:%M:%S",
"%Y-%m-%d %H:%M",
"%m/%d/%Y %H:%M:%S",
"%m/%d/%Y %H:%M",
]:
try:
return datetime.strptime(v, fmt)
except ValueError:
continue
return None
def norm(cell_val) -> str:
return str(cell_val).strip().lower() if cell_val is not None else ""
def pick_index(header: List[str], include: List[str], exclude: List[str] = []) -> Optional[int]:
for i, h in enumerate(header):
if not h:
continue
if all(tok in h for tok in include) and all(tok not in h for tok in exclude):
return i
return None
def detect_header_map(row_values: List[Any]) -> Dict[str, int]:
h = [norm(v) for v in row_values]
idx: Dict[str, int] = {}
# Required
idx_emp = pick_index(h, ["employee", "name"], []) or pick_index(h, ["employee"], []) or pick_index(h, ["name"], [])
idx_date = pick_index(h, ["date"], [])
if idx_emp is None or idx_date is None:
return {}
idx["employee"] = idx_emp
idx["work_date"] = idx_date
# Clock in/out
ci = pick_index(h, ["clock", "in", "time"], []) or pick_index(h, ["clock", "in"], []) or pick_index(h, ["time", "in"], [])
co = pick_index(h, ["clock", "out", "time"], []) or pick_index(h, ["clock", "out"], []) or pick_index(h, ["time", "out"], [])
if ci is not None:
idx["clock_in"] = ci
if co is not None:
idx["clock_out"] = co
# Break
br = (
pick_index(h, ["breaks", "hours", "taken"], [])
or pick_index(h, ["break", "hours", "taken"], [])
or pick_index(h, ["breaks", "taken"], [])
or pick_index(h, ["unpaid", "hours"], [])
or pick_index(h, ["break"], [])
or pick_index(h, ["lunch"], [])
)
if br is not None:
idx["break_hours"] = br
# Total: prefer "hours worked"
tot = (
pick_index(h, ["hours", "worked"], ["minus"])
or pick_index(h, ["worked"], ["minus"])
)
if tot is not None:
idx["total_hours"] = tot
else:
alt = pick_index(h, ["hours", "worked", "minus", "break"], []) or pick_index(h, ["worked", "minus", "break"], [])
if alt is not None:
idx["total_minus_break"] = alt
# PTO hours
ptoh = (
pick_index(h, ["pto", "hours"], [])
or pick_index(h, ["sick", "hours"], [])
or pick_index(h, ["vacation", "hours"], [])
or pick_index(h, ["vacation"], [])
)
if ptoh is not None:
idx["pto_hours"] = ptoh
# PTO Type (optional)
ptot = pick_index(h, ["pto", "type"], [])
if ptot is not None:
idx["pto_type"] = ptot
# Holiday / Bereavement
hol = pick_index(h, ["holiday"], [])
ber = pick_index(h, ["bereavement"], [])
if hol is not None:
idx["holiday_hours"] = hol
if ber is not None:
idx["bereavement_hours"] = ber
return idx
def import_workbook(path: str, db: Session, timesheet_id: Optional[int] = None) -> Dict[str, Any]:
wb = openpyxl.load_workbook(path, data_only=True)
inserted_total = 0
employees_seen = set()
sheets_processed = 0
for ws in wb.worksheets:
# Find header row
header_map: Dict[str, int] = {}
header_row_idx = None
for r in range(1, min(ws.max_row, 30) + 1):
row_values = [cell.value for cell in ws[r]]
header_map = detect_header_map(row_values)
if header_map:
header_row_idx = r
break
if not header_map or not header_row_idx:
continue
sheets_processed += 1
# Parse rows
for r in range(header_row_idx + 1, ws.max_row + 1):
vals = [cell.value for cell in ws[r]]
emp_raw = vals[header_map["employee"]] if len(vals) > header_map["employee"] else None
date_raw = vals[header_map["work_date"]] if len(vals) > header_map["work_date"] else None
if not emp_raw or not date_raw:
continue
employee_name = str(emp_raw).strip()
work_date = parse_date(date_raw)
if not work_date:
continue
# Optional fields as Decimal
clock_in_dt: Optional[datetime] = None
clock_out_dt: Optional[datetime] = None
break_hours_taken = D(0)
pto_hours = D(0)
pto_type = None
holiday_hours = D(0)
bereavement_hours = D(0)
total_from_sheet: Optional[Decimal] = None
alt_total_minus_break: Optional[Decimal] = None
# Clock In/Out: prefer full datetime if present in the cell; else bind time to work_date
if "clock_in" in header_map and len(vals) > header_map["clock_in"]:
ci_raw = vals[header_map["clock_in"]]
clock_in_dt = parse_datetime_value(ci_raw)
if not clock_in_dt:
t = parse_time_value(ci_raw)
clock_in_dt = datetime.combine(work_date, t) if t else None
if "clock_out" in header_map and len(vals) > header_map["clock_out"]:
co_raw = vals[header_map["clock_out"]]
clock_out_dt = parse_datetime_value(co_raw)
if not clock_out_dt:
t = parse_time_value(co_raw)
clock_out_dt = datetime.combine(work_date, t) if t else None
# Overnight
if clock_in_dt and clock_out_dt and clock_out_dt <= clock_in_dt:
clock_out_dt = clock_out_dt + timedelta(days=1)
# Breaks Hours Taken
if "break_hours" in header_map and len(vals) > header_map["break_hours"]:
break_hours_taken = safe_decimal(vals[header_map["break_hours"]], D(0))
# Total = Hours Worked (preferred)
if "total_hours" in header_map and len(vals) > header_map["total_hours"]:
total_from_sheet = safe_decimal(vals[header_map["total_hours"]], None)
# Fallback: Hours Worked Minus Break Hours -> infer Hours Worked by adding back break
if "total_minus_break" in header_map and len(vals) > header_map["total_minus_break"]:
alt_total_minus_break = safe_decimal(vals[header_map["total_minus_break"]], None)
# PTO / Holiday / Bereavement
if "pto_hours" in header_map and len(vals) > header_map["pto_hours"]:
pto_hours = safe_decimal(vals[header_map["pto_hours"]], D(0))
if "pto_type" in header_map and len(vals) > header_map["pto_type"]:
v = vals[header_map["pto_type"]]
pto_type = (str(v).strip() if v is not None and not isinstance(v, (int, float, datetime)) else None)
if "holiday_hours" in header_map and len(vals) > header_map["holiday_hours"]:
holiday_hours = safe_decimal(vals[header_map["holiday_hours"]], D(0))
if "bereavement_hours" in header_map and len(vals) > header_map["bereavement_hours"]:
bereavement_hours = safe_decimal(vals[header_map["bereavement_hours"]], D(0))
# Determine Total Hours (Hours Worked)
if total_from_sheet is None:
if alt_total_minus_break is not None and break_hours_taken is not None:
total_from_sheet = q2(alt_total_minus_break + break_hours_taken)
else:
if clock_in_dt and clock_out_dt:
total_from_sheet = q2(D((clock_out_dt - clock_in_dt).total_seconds()) / D(3600))
else:
total_from_sheet = D(0)
else:
total_from_sheet = q2(total_from_sheet)
# Force PTO type review: if PTO hours are present (including Vacation), blank pto_type
if pto_hours > D(0):
pto_type = None
# Employee
emp = db.query(Employee).filter(Employee.name == employee_name).first()
if not emp:
emp = Employee(name=employee_name)
db.add(emp)
db.flush()
employees_seen.add(emp.id)
entry = TimeEntry(
employee_id=emp.id,
work_date=work_date,
clock_in=clock_in_dt,
clock_out=clock_out_dt,
break_hours=q2(break_hours_taken),
total_hours=q2(total_from_sheet),
pto_hours=q2(pto_hours),
pto_type=pto_type,
holiday_hours=q2(holiday_hours),
bereavement_hours=q2(bereavement_hours),
timesheet_id=timesheet_id,
)
# Hours Paid = (Total - Break) + PTO + Holiday + Bereavement
worked = (D(entry.total_hours or 0) - D(entry.break_hours or 0))
if worked < D(0):
worked = D(0)
entry.hours_paid = q2(worked + D(entry.pto_hours or 0) + D(entry.holiday_hours or 0) + D(entry.bereavement_hours or 0))
db.add(entry)
inserted_total += 1
db.commit()
print(f"[import_workbook] Sheets processed: {sheets_processed}, rows inserted: {inserted_total}, employees: {len(employees_seen)}")
return {"rows": inserted_total, "employees": len(employees_seen)}

98
app/routes/clock_edit.py Normal file
View File

@ -0,0 +1,98 @@
from datetime import datetime, timedelta
from typing import Optional
from fastapi import APIRouter, Depends, Form, Request
from fastapi.responses import RedirectResponse
from sqlalchemy.orm import Session
from ..db import get_db
from ..models import TimeEntry
router = APIRouter()
def _parse_dt_local(v: Optional[str]) -> Optional[datetime]:
"""
Parse HTML datetime-local values safely:
- 'YYYY-MM-DDTHH:MM'
- 'YYYY-MM-DDTHH:MM:SS'
Returns None for blank/None.
"""
if not v:
return None
v = v.strip()
if not v:
return None
try:
# Python 3.11+ supports fromisoformat for both shapes
return datetime.fromisoformat(v)
except Exception:
for fmt in ("%Y-%m-%dT%H:%M:%S", "%Y-%m-%dT%H:%M"):
try:
return datetime.strptime(v, fmt)
except ValueError:
continue
return None
def _safe_float(v, default=None):
if v is None or v == "":
return default
try:
return float(v)
except Exception:
return default
@router.post("/timesheet/update-clocks")
async def update_clocks(
request: Request,
entry_id: int = Form(...),
clock_in: Optional[str] = Form(None),
clock_out: Optional[str] = Form(None),
recalc_total: Optional[str] = Form(None),
redirect_to: Optional[str] = Form(None),
db: Session = Depends(get_db),
):
"""
Update only the clock_in/clock_out fields for a time entry.
Optionally recalculate total_hours from clocks when recalc_total=1.
Re-applies hours_paid = max(0, total - break) + PTO + Holiday + Bereavement.
"""
# Optional: enforce admin-only
is_admin = bool(getattr(request, "session", {}).get("is_admin")) if hasattr(request, "session") else True
if not is_admin:
# Fall back to normal redirect with no change
return RedirectResponse(url=redirect_to or "/", status_code=303)
entry: TimeEntry = db.query(TimeEntry).filter(TimeEntry.id == entry_id).first()
if not entry:
return RedirectResponse(url=redirect_to or "/", status_code=303)
new_ci = _parse_dt_local(clock_in)
new_co = _parse_dt_local(clock_out)
# Apply if provided; if one is provided and the other left blank, keep the existing other value
if new_ci is not None:
entry.clock_in = new_ci
if new_co is not None:
entry.clock_out = new_co
# Handle overnight: if both set and out <= in, assume next day
if entry.clock_in and entry.clock_out and entry.clock_out <= entry.clock_in:
entry.clock_out = entry.clock_out + timedelta(days=1)
# Optionally recompute total from clocks
if recalc_total == "1" and entry.clock_in and entry.clock_out:
entry.total_hours = round((entry.clock_out - entry.clock_in).total_seconds() / 3600.0, 2)
# Recompute hours_paid using the canonical rule
total = _safe_float(entry.total_hours, 0.0) or 0.0
brk = _safe_float(entry.break_hours, 0.0) or 0.0
pto = _safe_float(entry.pto_hours, 0.0) or 0.0
hol = _safe_float(entry.holiday_hours, 0.0) or 0.0
oth = _safe_float(entry.bereavement_hours, 0.0) or 0.0
worked = max(0.0, total - brk)
entry.hours_paid = round(worked + pto + hol + oth, 2)
db.add(entry)
db.commit()
return RedirectResponse(url=redirect_to or "/", status_code=303)

View File

@ -0,0 +1,32 @@
from fastapi import APIRouter, Depends, Form, Request, HTTPException
from sqlalchemy.orm import Session
from sqlalchemy import text
from ..db import get_session
from ..models import TimeEntry
router = APIRouter(prefix="/timesheet", tags=["Timesheet API"])
@router.post("/delete-entry")
def delete_entry(
request: Request,
entry_id: int = Form(...),
timesheet_id: int = Form(...),
db: Session = Depends(get_session),
):
# Require edit/admin permission (mirror your other viewers)
if not (request.session.get("is_admin") or request.session.get("can_edit")):
raise HTTPException(status_code=403, detail="Edit access required")
te = db.query(TimeEntry).get(entry_id)
if not te or int(te.timesheet_id) != int(timesheet_id):
raise HTTPException(status_code=404, detail="Time entry not found for this time period")
# Remove import-batch linkage first (safe if none exist)
db.execute(text("DELETE FROM import_batch_items WHERE time_entry_id = :eid"), {"eid": entry_id})
# Delete the time entry
db.delete(te)
db.commit()
return {"ok": True}

41
app/routes/viewer.py Normal file
View File

@ -0,0 +1,41 @@
from fastapi import APIRouter, Depends, Form, Request, HTTPException
from fastapi.responses import RedirectResponse
from sqlalchemy.orm import Session
from sqlalchemy import text
from ..db import get_session
router = APIRouter(tags=["Viewer"])
@router.post("/viewer/delete-period")
def delete_period(
request: Request,
timesheet_id: int = Form(...),
db: Session = Depends(get_session),
):
if not request.session.get("is_admin"):
raise HTTPException(status_code=403, detail="Admin access required")
tid = int(timesheet_id)
# Cascade delete department import artifacts first
db.execute(
text(
"DELETE FROM import_batch_items "
"WHERE batch_id IN (SELECT id FROM import_batches WHERE timesheet_id = :tid)"
),
{"tid": tid},
)
db.execute(text("DELETE FROM import_batches WHERE timesheet_id = :tid"), {"tid": tid})
# Delete all time entries for the period
db.execute(text("DELETE FROM time_entries WHERE timesheet_id = :tid"), {"tid": tid})
# Delete week assignments to fully reset the period
db.execute(text("DELETE FROM week_assignments WHERE timesheet_id = :tid"), {"tid": tid})
# If your original route removed the TimesheetPeriod record, keep that behavior:
# db.execute(text("DELETE FROM timesheet_periods WHERE id = :tid"), {"tid": tid})
db.commit()
return RedirectResponse(url="/viewer?msg=Time+period+deleted", status_code=303)

162
app/static/styles.css Normal file
View File

@ -0,0 +1,162 @@
/* Modern light theme with subtle shadows and good spacing */
:root{
--bg:#f5f7fb;
--text:#0f172a;
--muted:#64748b;
--card:#ffffff;
--line:#e2e8f0;
--brand:#0ea5e9;
--brand-600:#0284c7;
--primary:#2563eb;
--primary-600:#1d4ed8;
--success:#16a34a;
--warn:#f59e0b;
--danger:#ef4444;
--dup:#fff7e6;
--radius:12px;
--shadow: 0 12px 28px rgba(15,23,42,.08), 0 2px 6px rgba(15,23,42,.06);
--mono: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
--font: Inter, system-ui, -apple-system, Segoe UI, Roboto, "Helvetica Neue", Arial, sans-serif;
}
*{box-sizing:border-box}
html,body{height:100%}
body.tk{
margin:0;
background:var(--bg);
color:var(--text);
font-family:var(--font);
line-height:1.5;
}
/* Header / nav */
.tk-header{
position:sticky;top:0;z-index:10;
background:#ffffffc0; backdrop-filter:saturate(180%) blur(12px);
border-bottom:1px solid var(--line);
}
.tk-header-inner{
max-width:1200px;margin:0 auto;display:flex;align-items:center;justify-content:space-between;
padding:12px 20px;
}
.tk-brand{
font-weight:800;letter-spacing:.2px;text-decoration:none;color:var(--brand-600);
}
.tk-nav{display:flex;gap:12px}
.tk-nav-link{
color:var(--muted);text-decoration:none;padding:8px 10px;border-radius:8px;transition:.15s;
}
.tk-nav-link:hover{color:var(--text);background:#f1f5f9}
.tk-nav-link.danger{color:var(--danger)}
/* Main page container */
.tk-container{
max-width:100%;
margin:18px auto;
padding:0 20px;
display:flex;
justify-content:center; /* default for most pages */
align-items:flex-start;
}
/* Viewer: make the editor span full width and cancel left/right padding so the sidebar sits at the window edge */
.page-wide{
flex:1 1 auto; /* occupy full width of the container */
margin:0 -20px; /* negate tk-container left/right padding */
width:calc(100% + 40px); /* keep content full width after negative margins */
}
/* Layout */
.editor-grid{
display:grid;
grid-template-columns:280px minmax(0,1fr); /* fixed sidebar + fluid editor */
gap:18px;
width:100%;
}
@media (max-width:960px){.editor-grid{grid-template-columns:1fr}}
.panel{
background:var(--card);
border:1px solid var(--line);
border-radius:var(--radius);
box-shadow:var(--shadow);
padding:14px;
}
.panel.sidebar{position:sticky;top:84px;height:fit-content}
.panel.main{min-height:480px}
.panel.toolbar{display:flex;align-items:center;gap:10px;flex-wrap:wrap}
.panel-title{font-weight:700;margin-bottom:8px}
.divider{height:1px;background:var(--line);margin:12px 0}
/* Sidebar */
.emp-list{list-style:none;margin:0;padding:0;max-height:52vh;overflow:auto}
.emp-list li{margin:0}
.emp-list li a{display:block;padding:8px 10px;border-radius:10px;text-decoration:none;color:var(--text)}
.emp-list li.active a{background:#eaf2ff;border:1px solid #c9dbff}
.emp-list li a:hover{background:#f7fafc}
.emp-list .empty{color:var(--muted);padding:6px 8px}
.actions .btn{margin-bottom:8px}
.w-full{width:100%}
/* Controls */
.label{color:var(--muted);margin-right:8px}
.input,.select{
background:#fff;border:1px solid var(--line);color:var(--text);
padding:8px 10px;border-radius:10px;outline:none;min-width:90px;
transition:.15s border-color, .15s box-shadow;
}
.input:focus,.select:focus{border-color:#93c5fd;box-shadow:0 0 0 4px rgba(147,197,253,.35)}
.inline{display:inline-flex;gap:10px;align-items:center}
/* Buttons */
.btn{
appearance:none;border:1px solid var(--line);background:#f8fafc;color:var(--text);
padding:10px 14px;border-radius:12px;text-decoration:none;cursor:pointer;
transition:.15s filter, .15s transform;
}
.btn:hover{filter:brightness(1.02)}
.btn.primary{background:linear-gradient(180deg,#60a5fa,#3b82f6);color:white;border-color:#3b82f6}
.btn.primary.sm{padding:8px 12px;border-radius:10px}
.btn.danger{background:linear-gradient(180deg,#fca5a5,#ef4444);color:white;border-color:#ef4444}
/* Alerts */
.alert{border-radius:12px;padding:12px}
.alert.success{background:#ecfdf5;border:1px solid #a7f3d0}
.alert.warn{background:#fff7ed;border:1px solid #fed7aa}
.mb-8{margin-bottom:8px}
.mt-8{margin-top:8px}
/* Totals */
.panel.totals{
display:grid;grid-template-columns:repeat(6,1fr);gap:12px;
}
.total{background:#f8fafc;border:1px solid var(--line);border-radius:10px;padding:10px}
.t-label{color:var(--muted);font-size:12px;margin-bottom:4px}
.t-val{font-weight:800}
/* Table */
.table-wrap{overflow:auto}
.table{
width:100%;border-collapse:separate;border-spacing:0;
font-size:14px;
}
.table thead th{
position:sticky;top:0;background:#f1f5f9;border-bottom:1px solid var(--line);
text-align:left;padding:10px;font-weight:700;
}
.table td{
border-bottom:1px solid var(--line);
padding:8px 10px;vertical-align:middle;
}
.table .num{text-align:right}
.table .mono{font-family:var(--mono)}
.table.compact td,.table.compact th{padding:8px}
.table tr.total td{font-weight:700;border-top:2px solid var(--line)}
.dup-row{background:var(--dup)}
.table input.input{width:110px}
.table .select{width:130px}
/* Utility */
.mr-8{margin-right:8px}

View File

@ -0,0 +1,72 @@
{% extends "layout.html" %}
{% block content %}
<div class="page-wide">
<div class="panel" style="width:100%;">
<div class="panel-title">Employee Management</div>
<form method="get" action="/admin/employees" class="panel toolbar" style="gap:12px; align-items:center;">
<label class="checkbox" style="display:flex; gap:6px; align-items:center;">
<input type="checkbox" name="include_inactive" value="1" {% if include_inactive %}checked{% endif %}>
Show inactive
</label>
<button class="btn" type="submit">Apply</button>
</form>
<div class="table-wrap">
<table class="table compact" style="width:100%;">
<thead>
<tr>
<th>Name</th>
<th>Status</th>
<th style="width:160px;">Termination Date</th>
<th style="width:260px;">Actions</th>
</tr>
</thead>
<tbody>
{% for e in employees %}
{% set active = (e.is_active is not none and e.is_active) %}
<tr>
<td>{{ e.name }}</td>
<td>
{% if active %}
<span class="badge success">Active</span>
{% else %}
<span class="badge danger">Inactive</span>
{% endif %}
</td>
<td class="mono">
{% if e.termination_date %}{{ e.termination_date.isoformat() }}{% endif %}
</td>
<td>
{% if active %}
<form method="post" action="/admin/employees/set-status" class="inline" style="gap:8px; align-items:center;"
onsubmit="return confirm('Mark {{ e.name }} inactive?');">
<input type="hidden" name="employee_id" value="{{ e.id }}">
<input type="hidden" name="is_active" value="0">
<label class="label" for="td_{{ e.id }}">Termination</label>
<input class="input" id="td_{{ e.id }}" name="termination_date" type="date" style="min-width:140px;">
<input type="hidden" name="redirect_to" value="/admin/employees?include_inactive={{ include_inactive }}">
<button class="btn danger" type="submit">Mark inactive</button>
</form>
{% else %}
<form method="post" action="/admin/employees/set-status" class="inline" style="gap:8px; align-items:center;"
onsubmit="return confirm('Reactivate {{ e.name }}?');">
<input type="hidden" name="employee_id" value="{{ e.id }}">
<input type="hidden" name="is_active" value="1">
<input type="hidden" name="redirect_to" value="/admin/employees?include_inactive={{ include_inactive }}">
<button class="btn" type="submit">Reactivate</button>
</form>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="muted" style="margin-top:8px;">
Marking an employee inactive hides them from PTO Tracker and Print All (unless “Show inactive” is checked). Reactivation restores visibility. Termination date is optional.
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,84 @@
{% extends "layout.html" %}
{% block content %}
<div class="page-wide">
<div class="panel">
<div class="panel-title">User Management</div>
<!-- Create user form -->
<form method="post" action="/admin/users/create" class="panel toolbar" style="gap:12px; flex-wrap:wrap; align-items:center;">
<label class="label">Full Name</label>
<input class="input" name="full_name" type="text" placeholder="Jane Smith" value="">
<label class="label">Username</label>
<input class="input" name="username" type="text" placeholder="jsmith" required>
<label class="label">Password</label>
<input class="input" name="password" type="password" placeholder="••••••" required>
<label class="label">Role</label>
<select class="select" name="role">
<option value="user">User</option>
<option value="admin">Admin</option>
</select>
<button class="btn primary" type="submit">Create</button>
</form>
<!-- Users table -->
<div class="table-wrap">
<table class="table compact" style="width:100%;">
<thead>
<tr>
<th>Full Name</th>
<th>Username</th>
<th>Role</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for u in users %}
<tr>
<td>{{ profiles.get(u.id, '') }}</td>
<td>{{ u.username }}</td>
<td>
{% if u.id in admin_ids %}
Admin
{% else %}
User
{% endif %}
</td>
<td>
<!-- Inline actions row: Reset Password first, then Delete on the right -->
<form method="post" action="/admin/users/reset-password" class="inline" style="gap:8px; align-items:center; display:inline-flex;">
<input type="hidden" name="user_id" value="{{ u.id }}">
<label class="label">New password</label>
<input class="input" name="new_password" type="password" placeholder="New password" required>
<button class="btn" type="submit">Reset Password</button>
</form>
{% if u.id not in admin_ids %}
<form method="post" action="/admin/users/update-role" class="inline" style="gap:8px; display:inline-flex; margin-left:8px;">
<input type="hidden" name="user_id" value="{{ u.id }}">
<input type="hidden" name="role" value="admin">
<button class="btn" type="submit">Make Admin</button>
</form>
{% endif %}
<form method="post" action="/admin/users/delete" class="inline" style="gap:8px; display:inline-flex; margin-left:8px;"
onsubmit="return confirm('Delete user {{ u.username }}?');">
<input type="hidden" name="user_id" value="{{ u.id }}">
<button class="btn danger" type="submit">Delete</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% if flash %}
<div class="panel" style="margin-top:8px;">{{ flash }}</div>
{% endif %}
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,71 @@
{% extends "layout.html" %}
{% block content %}
<div class="page-narrow">
<div class="panel">
<div class="panel-title">Assign Weeks</div>
<p>Assign a week number (1-3) for each date in {{ period }}. These settings compute weekly OT.</p>
{% if request.session.get('is_admin') %}
<form method="post" action="/assign-weeks">
<input type="hidden" name="timesheet_id" value="{{ timesheet_id }}">
<div class="form-row" style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;">
<label for="timesheet_name">Timesheet name</label>
<input class="input" type="text" id="timesheet_name" name="timesheet_name" value="{{ timesheet_name }}" placeholder="e.g. Dec 1-15">
<button type="submit" class="btn">Save Name</button>
</div>
<div class="table-wrap" style="max-width:720px;">
<table class="table compact">
<thead>
<tr>
<th style="text-align:center;">Date</th>
<th style="text-align:center;">Week</th>
</tr>
</thead>
<tbody>
{% for d in days %}
<tr>
<td>{{ d.strftime("%A, %B %d, %Y") }}</td>
<td>
<select class="select" name="week_{{ d.isoformat() }}">
{% for w in [1,2,3] %}
<option value="{{ w }}" {% if existing.get(d) == w %}selected{% endif %}>{{ w }}</option>
{% endfor %}
</select>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="form-row" style="margin-top:12px; display:flex; gap:8px; flex-wrap:wrap;">
<button type="submit" class="btn primary">OK</button>
<!-- Back without deleting -->
<button type="button" class="btn" onclick="window.location.href='/viewer?timesheet_id={{ timesheet_id }}'">Back to Viewer</button>
</div>
</form>
<!-- Cancel import: delete this time period and return to Viewer -->
<form method="post" action="/viewer/delete-period" onsubmit="return confirmCancel();" style="margin-top:8px;">
<input type="hidden" name="timesheet_id" value="{{ timesheet_id }}">
<button type="submit" class="btn danger">Cancel Import (Delete Period)</button>
</form>
<script>
function confirmCancel() {
return confirm('This will delete the entire time period and all imported entries. Continue?');
}
</script>
{% else %}
<div class="alert warn">
You have view/print-only access. Assigning weeks is restricted to administrators.
</div>
<div class="form-row" style="display:flex;gap:8px;margin-top:8px;">
<a class="btn" href="/viewer">Back to Timesheet Editor</a>
<a class="btn" href="/review">Review Timesheets</a>
</div>
{% endif %}
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,106 @@
{% extends "layout.html" %}
{% block content %}
<style>
.att-toolbar { display:flex; gap:12px; align-items:center; flex-wrap:wrap; }
.att-grid { display:flex; flex-direction:column; gap:14px; }
.att-row { display:grid; grid-template-columns: 260px 1fr; gap:12px; align-items:flex-start; }
.att-name { font-weight:600; }
/* Bigger cells for easier on-screen viewing */
.cells { display:grid; grid-auto-flow: column; grid-auto-columns: 24px; gap:4px; overflow-x:auto; padding-bottom:6px; }
.cell { width:24px; height:24px; border-radius:4px; border:1px solid #e5e7eb; }
.c-worked { background:#22c55e33; border-color:#16a34a66; }
.c-off { background:#06b6d433; border-color:#0ea5a566; } /* Off PTO and skipped workdays */
.c-sick { background:#f43f5e33; border-color:#e11d4866; } /* Sick PTO (distinct color) */
.c-pto { background:#3b82f633; border-color:#2563eb66; } /* PTO only when type is 'PTO' */
.c-holiday { background:#f59e0b33; border-color:#d9770666; }
.c-other { background:#a855f733; border-color:#7e22ce66; } /* Was bereavement */
.c-weekend { background:#e5e7eb; border-color:#d1d5db; }
.c-nodata { background:#fafafa; border-color:#eeeeee; } /* No submissions in range */
.legend { display:flex; gap:12px; flex-wrap:wrap; margin-top:6px; }
.legend .item { display:flex; gap:6px; align-items:center; }
.legend .swatch { width:16px; height:16px; border-radius:3px; border:1px solid #e5e7eb; }
</style>
<div class="page-wide">
<div class="panel">
<div class="panel-title">Attendance Tracker</div>
<form class="panel att-toolbar" method="get" action="/attendance">
<label class="label">Start</label>
<input class="input" type="date" name="start" value="{{ start.isoformat() }}">
<label class="label">End</label>
<input class="input" type="date" name="end" value="{{ end.isoformat() }}">
<label class="label">Employee</label>
<select class="select" name="employee_id" id="employee_id" style="min-width:220px;">
<option value="all" {% if not selected_employee_id %}selected{% endif %}>All employees</option>
{% for e in employees %}
<option value="{{ e.id }}" {% if selected_employee_id and e.id == selected_employee_id %}selected{% endif %}>{{ e.name }}</option>
{% endfor %}
</select>
<label class="checkbox" style="display:flex; gap:6px; align-items:center;">
<input type="checkbox" name="include_weekends" value="1" {% if include_weekends %}checked{% endif %}>
Include weekends
</label>
<button class="btn primary" type="submit">Run</button>
<a class="btn" target="_blank"
href="/attendance/export.csv?start={{ start.isoformat() }}&end={{ end.isoformat() }}&employee_id={{ selected_employee_id if selected_employee_id else 'all' }}&include_weekends={{ 1 if include_weekends else 0 }}">
Export CSV
</a>
</form>
<div class="legend">
<div class="item"><span class="swatch" style="background:#22c55e33;border-color:#16a34a66;"></span> Worked</div>
<div class="item"><span class="swatch" style="background:#06b6d433;border-color:#0ea5a566;"></span> Off</div>
<div class="item"><span class="swatch" style="background:#f43f5e33;border-color:#e11d4866;"></span> Sick</div>
<div class="item"><span class="swatch" style="background:#3b82f633;border-color:#2563eb66;"></span> PTO</div>
<div class="item"><span class="swatch" style="background:#f59e0b33;border-color:#d9770666;"></span> Holiday</div>
<div class="item"><span class="swatch" style="background:#a855f733;border-color:#7e22ce66;"></span> Other</div>
<div class="item"><span class="swatch" style="background:#e5e7eb;border-color:#d1d5db;"></span> Weekend</div>
<div class="item"><span class="swatch" style="background:#fafafa;border-color:#eeeeee;"></span> No data (no submissions in range)</div>
</div>
</div>
<div class="panel">
<div class="panel-title">Period Overview</div>
<div class="att-grid">
{% for row in visual %}
<div class="att-row">
<div class="att-name">{{ row.employee.name }}</div>
<div class="cells" title="Scroll horizontally for full range">
{% for c in row.cells %}
<div class="cell c-{{ c.status }}" title="{{ c.date.isoformat() }} — {{ c.status }}"></div>
{% endfor %}
</div>
</div>
<div class="att-row" style="grid-template-columns:260px 1fr;">
<div></div>
<div style="display:flex; gap:16px; flex-wrap:wrap; margin-bottom:10px;">
<div><strong>Worked days:</strong> {{ row.totals.worked_days }} ({{ row.hours.worked|round(2) }} hrs)</div>
<div><strong>Off days:</strong> {{ row.totals.off_days }} ({{ row.hours.off|round(2) }} hrs)</div>
<div><strong>Sick days:</strong> {{ row.totals.sick_days }} ({{ row.hours.sick|round(2) }} hrs)</div>
<div><strong>PTO days:</strong> {{ row.totals.pto_days }} ({{ row.hours.pto|round(2) }} hrs)</div>
<div><strong>Holiday days:</strong> {{ row.totals.holiday_days }} ({{ row.hours.holiday|round(2) }} hrs)</div>
<div><strong>Other days:</strong> {{ row.totals.other_days }} ({{ row.hours.other|round(2) }} hrs)</div>
</div>
</div>
<hr style="border:none; border-top:1px solid #eee; margin:10px 0;">
{% endfor %}
</div>
</div>
</div>
<script>
// Auto-submit when employee dropdown changes
(function () {
var sel = document.getElementById('employee_id');
if (!sel) return;
sel.addEventListener('change', function () { sel.form.submit(); });
})();
</script>
{% endblock %}

View File

@ -0,0 +1,94 @@
{% extends "layout.html" %}
{% block content %}
<div class="page-wide">
<div class="panel">
<div class="panel-title">Map Columns {% if kind == 'excel' %}& Select Sheet{% endif %}</div>
<form method="post" action="/import/department/preview-mapped" style="display:flex; flex-direction:column; gap:12px;">
<input type="hidden" name="map_slug" value="{{ map_slug }}">
<input type="hidden" name="timesheet_id" value="{{ timesheet_id }}">
<input type="hidden" name="mode" value="{{ mode|default('department') }}">
<div class="panel toolbar" style="gap:12px; flex-wrap:wrap; align-items:center;">
{% if kind == 'excel' and sheets_info|length > 1 %}
<label class="label">Sheet</label>
<select class="select" name="sheet_name" onchange="onSheetChange(this)" required>
{% for s in sheets_info %}
<option value="{{ s.sheet_name }}" {% if s.sheet_name == sheet_name %}selected{% endif %}>{{ s.sheet_name }}</option>
{% endfor %}
</select>
{% else %}
<input type="hidden" name="sheet_name" value="{{ sheet_name }}">
<div class="muted">Source: {{ sheet_name }}</div>
{% endif %}
<label class="checkbox" style="display:flex; gap:6px; align-items:center;">
<input type="checkbox" name="restrict_to_period" value="1" {% if restrict_to_period %}checked{% endif %}>
Only include rows within the selected time period
</label>
<a class="btn" href="/import/department?timesheet_id={{ timesheet_id }}">Start over</a>
<a class="btn" href="/viewer?timesheet_id={{ timesheet_id }}">Back to Viewer</a>
</div>
<div class="muted">
Choose which columns from the {{ 'sheet' if kind == 'excel' else 'file' }} map to your fields. Defaults are auto-detected.
</div>
{% set selected = (sheets_info | selectattr('sheet_name', 'equalto', sheet_name) | list | first) %}
{% set headers = selected.header_vals %}
{% set auto = selected.auto_map %}
<div class="table-wrap">
<table class="table">
<thead>
<tr>
<th>Field</th>
<th>Column</th>
</tr>
</thead>
<tbody>
{% for key, label, required in target_fields %}
<tr>
<td>
{{ label }}
{% if required %}<span class="badge warn" title="Required field">Required</span>{% endif %}
</td>
<td>
<select class="select" name="{{ key }}" {% if required %}required{% endif %}>
<option value="None">— None —</option>
{% for i in range(headers|length) %}
{% set hv = headers[i] %}
{% set hv_disp = (hv|string) if hv is not none else '' %}
<option value="{{ i }}"
{% if auto and auto.get(key) is not none and auto.get(key) == i %}selected{% endif %}>
{{ i }} — {{ hv_disp }}
</option>
{% endfor %}
</select>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div style="display:flex; gap:8px; align-items:center; flex-wrap:wrap;">
<button class="btn primary" type="submit">Build Preview</button>
<button class="btn" type="button" onclick="window.location.href='/import/department/map?map_slug={{ map_slug }}&sheet_name={{ sheet_name }}'">Reset defaults</button>
</div>
</form>
<div class="muted" style="margin-top:8px;">
</div>
</div>
</div>
<script>
function onSheetChange(sel) {
const sheet = sel && sel.value ? sel.value : '';
const params = new URLSearchParams({ map_slug: '{{ map_slug }}', sheet_name: sheet });
window.location.href = '/import/department/map?' + params.toString();
}
</script>
{% endblock %}

View File

@ -0,0 +1,69 @@
{% extends "layout.html" %}
{% block content %}
<div class="page-wide">
<div class="panel">
<div class="panel-title">Preview Import</div>
<form method="post" action="/import/department/execute" style="display:flex; flex-direction:column; gap:12px;">
<input type="hidden" name="slug" value="{{ slug }}">
<input type="hidden" name="timesheet_id" value="{{ timesheet_id }}">
<input type="hidden" name="mode" value="{{ mode|default('department') }}">
<div class="table-wrap">
<table class="table">
<thead>
<tr>
<th style="width:36px;"><input type="checkbox" id="chkAll"></th>
<th>Employee</th>
<th>Status</th>
<th class="num">Rows</th>
</tr>
</thead>
<tbody>
{% for row in preview %}
<tr>
<td style="text-align:center;">
<input type="checkbox" class="chkRow" data-name="{{ row.employee_name }}">
</td>
<td>{{ row.employee_name }}</td>
<td>{{ row.status }}</td>
<td class="num">{{ row.row_count }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<input type="hidden" name="selected_names" id="selected_names" value="">
<div style="display:flex; gap:8px; align-items:center; flex-wrap:wrap;">
<button class="btn primary" type="submit" onclick="return gatherSelection();">Import Selected</button>
<a class="btn" href="/import/department?timesheet_id={{ timesheet_id }}">Start over</a>
<a class="btn" href="/viewer?timesheet_id={{ timesheet_id }}">Back to Viewer</a>
</div>
</form>
<div class="muted" style="margin-top:8px;">
Existing employees will have new rows appended (duplicates are skipped). New employees will be created automatically.
{% if mode == 'initial' %}
After import, the time period dates will be derived from the imported rows and youll assign weeks.
{% else %}
Dates not previously assigned to a week in this time period will be skipped.
{% endif %}
</div>
</div>
</div>
<script>
document.getElementById('chkAll').addEventListener('change', function() {
var checked = this.checked;
document.querySelectorAll('.chkRow').forEach(function(chk) { chk.checked = checked; });
});
function gatherSelection() {
var names = [];
document.querySelectorAll('.chkRow:checked').forEach(function(chk) { names.push(chk.getAttribute('data-name')); });
if (names.length === 0) { alert('Please select at least one employee.'); return false; }
document.getElementById('selected_names').value = names.join(',');
return true;
}
</script>
{% endblock %}

View File

@ -0,0 +1,33 @@
{% extends "layout.html" %}
{% block content %}
<div class="page-wide">
<div class="panel">
<div class="panel-title">Import Another Department</div>
<form method="post" action="/import/department/upload" enctype="multipart/form-data"
class="panel toolbar" style="gap:12px; flex-wrap:wrap; align-items:center;">
<label class="label">Time Period</label>
<select class="select" name="timesheet_id" required>
{% for p in period_options %}
<option value="{{ p.timesheet_id }}" {% if active_ts == p.timesheet_id %}selected{% endif %}>{{ p.display }}</option>
{% endfor %}
</select>
<label class="label">File</label>
<input class="input" type="file" name="file" accept=".csv,.xlsx,.xlsm,.txt" required>
<label class="checkbox" style="display:flex; gap:6px; align-items:center;">
<input type="checkbox" name="restrict_to_period" value="1" checked>
Only import rows within this time periods date range
</label>
<button class="btn primary" type="submit">Upload</button>
<a class="btn" href="/viewer?timesheet_id={{ active_ts or '' }}">Back to Viewer</a>
</form>
<div class="muted" style="margin-top:8px;">
Supported files: CSV, XLSX. Expected columns include Employee Name, Date, Clock In, Clock Out, Break Hours, PTO Hours, PTO Type.
Well normalize whatever headers you have.
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,25 @@
{% extends "layout.html" %}
{% block content %}
<h2>Employees</h2>
<form method="get" action="/employees" class="inline">
<input name="q" value="{{ q }}" placeholder="Search employees">
<button type="submit">Search</button>
</form>
<table class="table">
<thead><tr><th>Name</th><th>Actions</th></tr></thead>
<tbody>
{% for e in employees %}
<tr>
<td>{{ e.name }}</td>
<td>
<a href="/timesheet/{{ e.id }}">Timesheet</a>
<a href="/overview/{{ e.id }}">Overview</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% if not employees %}
<p>No employees yet. Import an Excel file first.</p>
{% endif %}
{% endblock %}

164
app/templates/layout.html Normal file
View File

@ -0,0 +1,164 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>TimeKeeper</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="/static/styles.css?v=20251223-03">
<script>
// Expose edit capability to client scripts (true only for admins)
window.can_edit = {{ 'true' if request.session.get('is_admin') else 'false' }};
</script>
<style>
/* Admin dropdown: inline in nav, no outline/border; ASCII-safe caret */
.tk-nav { display: flex; gap: 12px; align-items: center; }
.tk-dropdown { position: relative; display: inline-flex; align-items: center; }
.tk-dropdown-toggle.tk-nav-link {
border: 0;
outline: none;
box-shadow: none;
background: transparent;
display: inline-flex;
align-items: center;
padding: 6px 10px;
}
.tk-dropdown-toggle.tk-nav-link:focus { outline: none; box-shadow: none; }
.tk-dropdown-toggle.tk-nav-link::after {
content: "\25BE"; /* small down triangle */
margin-left: 4px;
font-size: 0.9em;
}
.tk-dropdown-menu {
position: absolute;
right: 0;
top: calc(100% + 6px);
min-width: 200px;
background: #fff;
border: 1px solid #e5e7eb;
box-shadow: 0 6px 18px rgba(0,0,0,0.08);
border-radius: 8px;
padding: 6px;
display: none;
z-index: 1001;
}
.tk-dropdown.open .tk-dropdown-menu { display: block; }
.tk-dropdown-menu a { display: block; padding: 8px 10px; border-radius: 6px; }
.tk-dropdown-menu a:hover { background: #f3f4f6; }
</style>
</head>
<body class="tk">
<header class="tk-header">
<div class="tk-header-inner">
<a href="/viewer" class="tk-brand">TimeKeeper</a>
{% if not hide_nav_links %}
<nav class="tk-nav">
<a href="/viewer" class="tk-nav-link">Timesheet Editor</a>
<a href="/review" class="tk-nav-link">Review Timesheets</a>
{% if request.session.get('is_admin') %}
<a href="/pto-tracker" class="tk-nav-link">PTO Tracker</a>
<a href="/upload" class="tk-nav-link">Import</a>
<div class="tk-dropdown" id="adminDropdown">
<button type="button" class="tk-nav-link tk-dropdown-toggle" aria-haspopup="true" aria-expanded="false">Admin</button>
<div class="tk-dropdown-menu" role="menu" aria-label="Admin menu">
<a href="/admin/users" class="tk-nav-link">User Management</a>
<a href="/admin/employees" class="tk-nav-link">Employee Management</a>
<a href="/attendance" class="tk-nav-link">Attendance</a>
</div>
</div>
{% endif %}
<a href="/logout" class="tk-nav-link danger">Logout</a>
</nav>
{% endif %}
</div>
</header>
<main class="tk-container">
{% block content %}{% endblock %}
</main>
<script>
// Admin dropdown toggle
(function () {
var dd = document.getElementById('adminDropdown');
if (!dd) return;
var btn = dd.querySelector('.tk-dropdown-toggle');
function closeAll() {
dd.classList.remove('open');
if (btn) btn.setAttribute('aria-expanded', 'false');
}
btn.addEventListener('click', function (e) {
e.stopPropagation();
var willOpen = !dd.classList.contains('open');
if (willOpen) {
dd.classList.add('open');
btn.setAttribute('aria-expanded', 'true');
} else {
closeAll();
}
});
document.addEventListener('click', function (e) {
if (!dd.contains(e.target)) closeAll();
});
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape') closeAll();
});
})();
</script>
<script>
// Preserve and restore scroll position across same-origin navigations and refreshes.
(function () {
var key = 'scrollpos:' + location.pathname + location.search;
function headerOffset() {
var hdr = document.querySelector('.tk-header');
return hdr ? hdr.getBoundingClientRect().height : 0;
}
function restore() {
// If URL has a hash, let the browser handle anchor scrolling.
if (location.hash) return;
try {
var v = sessionStorage.getItem(key);
if (v !== null) {
var y = parseInt(v, 10) || 0;
var off = headerOffset();
window.scrollTo({ top: Math.max(0, y - off), left: 0, behavior: 'auto' });
}
} catch (e) {}
}
function persist() {
try {
var y = window.scrollY || window.pageYOffset || 0;
sessionStorage.setItem(key, String(y));
} catch (e) {}
}
if (document.readyState === 'complete' || document.readyState === 'interactive') {
restore();
} else {
document.addEventListener('DOMContentLoaded', restore, { once: true });
}
window.addEventListener('beforeunload', persist);
// Persist on same-origin link clicks and form submits
document.addEventListener('click', function (ev) {
var a = ev.target && ev.target.closest ? ev.target.closest('a[href]') : null;
if (!a) return;
try {
var u = new URL(a.href, location.href);
if (u.origin === location.origin) persist();
} catch (e) {}
}, true);
document.addEventListener('submit', function () { persist(); }, true);
})();
</script>
</body>
</html>

39
app/templates/login.html Normal file
View File

@ -0,0 +1,39 @@
{% extends "layout.html" %}
{% block content %}
<style>
.auth-wrap { min-height: calc(100vh - 120px); display: grid; place-items: center; }
.auth-card { width: 100%; max-width: 420px; padding: 24px; }
.auth-title { font-weight: 800; font-size: 22px; margin-bottom: 6px; color: var(--brand-600); }
.auth-sub { color: var(--muted); margin-bottom: 16px; }
.form-row { margin-bottom: 12px; display: grid; gap: 6px; }
</style>
<div class="auth-wrap">
<div class="panel auth-card">
<div class="auth-title">Welcome back</div>
<div class="auth-sub">Sign in to continue</div>
{% if error %}
<div class="alert warn" style="margin-bottom:12px;">{{ error }}</div>
{% endif %}
<form method="post" action="/login">
<div class="form-row">
<label class="label" for="username">Username</label>
<input id="username" name="username" type="text" class="input" autofocus required>
</div>
<div class="form-row">
<label class="label" for="password">Password</label>
<input id="password" name="password" type="password" class="input" required>
</div>
<div class="form-row" style="margin-top:8px;">
<button type="submit" class="btn primary" style="width:100%;">Sign in</button>
</div>
</form>
<div class="form-row" style="margin-top:8px;">
<small class="label">Tip: I hope you enjoy all of your free time! P.S. Trevor</small>
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,93 @@
{% extends "layout.html" %}
{% block content %}
<div class="page-wide">
<div class="panel" style="width:100%;margin:0;">
<div class="panel toolbar" style="justify-content:space-between;flex-wrap:wrap;">
<div>
<div class="panel-title">Time Period Overview</div>
<div class="label">Period: <strong>{{ period_name }}</strong></div>
</div>
<div class="inline" style="gap:8px;">
<a class="btn" href="/review?timesheet_id={{ timesheet_id }}">Back to Review</a>
<button class="btn" type="button" onclick="openPrint('/overview/print?timesheet_id={{ timesheet_id }}')">Print Overview</button>
</div>
</div>
<div class="panel" style="width:100%;margin:0;">
<div class="table-wrap" style="width:100%;">
<table class="table" style="width:100%;">
<thead>
<tr>
<th>Employee</th>
<th class="num">Regular</th>
<th class="num">Overtime</th>
<th class="num">PTO</th>
<th class="num">Holiday</th>
<th class="num">Other</th>
<th class="num">Paid Total</th>
</tr>
</thead>
<tbody>
{% for b in bundles %}
<tr>
<td>{{ b.employee.name }}</td>
<td class="num mono">{{ b.grouped.totals.regular|fmt2 }}</td>
<td class="num mono">{{ b.grouped.totals.overtime|fmt2 }}</td>
<td class="num mono">{{ b.grouped.totals.pto|fmt2 }}</td>
<td class="num mono">{{ b.grouped.totals.holiday|fmt2 }}</td>
<td class="num mono">{{ b.grouped.totals.bereavement|fmt2 }}</td>
<td class="num mono">{{ b.grouped.totals.paid_total|fmt2 }}</td>
</tr>
{% endfor %}
</tbody>
<tfoot>
<tr class="total">
<td>Total</td>
<td class="num mono">{{ totals.regular|fmt2 }}</td>
<td class="num mono">{{ totals.overtime|fmt2 }}</td>
<td class="num mono">{{ totals.pto|fmt2 }}</td>
<td class="num mono">{{ totals.holiday|fmt2 }}</td>
<td class="num mono">{{ totals.bereavement|fmt2 }}</td>
<td class="num mono">{{ totals.paid_total|fmt2 }}</td>
</tr>
</tfoot>
</table>
</div>
</div>
</div>
</div>
<!-- Hidden iframe used for printing without leaving the overview page -->
<iframe id="print-iframe" style="position:fixed; inset:0; width:0; height:0; border:0; visibility:hidden;"></iframe>
<script>
function openPrint(url) {
const iframe = document.getElementById('print-iframe');
iframe.src = url;
}
window.addEventListener('message', function (e) {
if (e && e.data && e.data.type === 'close-print') {
const iframe = document.getElementById('print-iframe');
if (iframe) {
iframe.removeAttribute('src'); // unload
}
}
});
</script>
<style>
/* Uniform button sizing across this page */
.btn, .panel .btn {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 36px;
padding: 0 14px;
font-size: 14px;
line-height: 1;
box-sizing: border-box;
}
.table thead th { text-align: left; }
.table .num { text-align: right; }
</style>
{% endblock %}

View File

@ -0,0 +1,169 @@
{% extends "layout.html" %}
{% block content %}
<div class="sheet">
<div class="header">
<div class="brand">Time Period Overview</div>
<div class="row">
<div class="period"><strong>Period:</strong> {{ period_name }}</div>
</div>
</div>
<table class="grid">
<thead>
<tr>
<th>Employee</th>
<th>Regular</th>
<th>Overtime</th>
<th>PTO</th>
<th>Holiday</th>
<th>Other</th>
<th>Paid Total</th>
</tr>
</thead>
<tbody>
{% for b in bundles %}
<tr>
<td>{{ b.employee.name }}</td>
<td class="num mono">{{ b.grouped.totals.regular|fmt2 }}</td>
<td class="num mono">{{ b.grouped.totals.overtime|fmt2 }}</td>
<td class="num mono">{{ b.grouped.totals.pto|fmt2 }}</td>
<td class="num mono">{{ b.grouped.totals.holiday|fmt2 }}</td>
<td class="num mono">{{ b.grouped.totals.bereavement|fmt2 }}</td>
<td class="num mono">{{ b.grouped.totals.paid_total|fmt2 }}</td>
</tr>
{% endfor %}
</tbody>
<tfoot>
<tr>
<td colspan="7" class="no-pad">
<div class="totals-footer">
<span><strong>Regular:</strong> {{ totals.regular|fmt2 }}</span>
<span><strong>Overtime:</strong> {{ totals.overtime|fmt2 }}</span>
<span><strong>PTO:</strong> {{ totals.pto|fmt2 }}</span>
<span><strong>Holiday:</strong> {{ totals.holiday|fmt2 }}</span>
<span><strong>Other:</strong> {{ totals.bereavement|fmt2 }}</span>
<span><strong>Paid Total:</strong> {{ totals.paid_total|fmt2 }}</span>
</div>
</td>
</tr>
</tfoot>
</table>
</div>
<style>
header, nav, .navbar, .topbar, .site-header, .app-nav { display:none !important; }
@media print {
body { margin: 0 !important; -webkit-print-color-adjust: exact; print-color-adjust: exact; }
.tk-container { display:block !important; margin:0 !important; padding:0 !important; }
}
:root { --ink:#222; --grid:#cdd3da; --head:#f4f6f9; }
@page { size: Letter landscape; margin: 0.30in; }
.sheet {
width: 10.4in;
margin: 0;
color: var(--ink);
font-family: Inter, system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif;
}
.header { margin-bottom: 0.08in; }
.brand { font-size: 20pt; font-weight: 800; line-height: 1.1; margin-bottom: 0.03in; }
.row { display: grid; grid-template-columns: auto 1fr; align-items: end; column-gap: 0.16in; }
.period { font-size: 10.6pt; }
table.grid { width:100%; border-collapse:collapse; }
thead { display: table-header-group; }
tfoot { display: table-footer-group; }
.no-pad { padding: 0; }
/* Main body scaled down slightly to fit more employees */
table.grid th, table.grid td {
border: 1pt solid var(--grid);
padding: 0.052in 0.072in;
font-size: 10.4pt;
background: #fff;
}
table.grid thead th {
background: var(--head);
text-transform: uppercase;
font-size: 9.6pt;
letter-spacing: .02em;
color: #3a4856;
}
.num { text-align: right; }
.mono { font-family: Menlo, Monaco, Consolas, "Courier New", monospace; }
/* Footer totals: slightly larger and bold, fixed to footer area on each page */
.totals-footer {
display: flex; flex-wrap: wrap; gap: 0.12in;
border-top: 1.5pt solid #333; margin-top: 0.06in; padding-top: 0.08in;
font-size: 11.6pt; /* a little bigger than body */
}
/* Keep rows intact; natural pagination */
table.grid tr { page-break-inside: avoid; }
/* Prevent extra whitespace creating another page */
.sheet, .grid { margin-bottom: 0; padding-bottom: 0; }
/* Nudge trims vertical spacing slightly when needed */
.nudge table.grid th, .nudge table.grid td { padding: 0.049in 0.069in; font-size: 10.2pt; }
.nudge .brand { font-size: 19pt; }
.nudge .totals-footer { gap: 0.11in; font-size: 11.2pt; padding-top: 0.07in; }
/* Clip: apply when remaining overflow is tiny (prevents page 2) */
.clip { overflow: hidden; max-block-size: calc(8.5in - 0.60in); }
</style>
<script>
(function(){
// Ensure it fits on one page by tightening spacing when needed.
const DPI = 96;
const pageHeightInches = 8.5;
const marginInches = 0.30; // sync with @page
const availablePx = (pageHeightInches - (marginInches * 2)) * DPI;
function fitToOnePage() {
const sheet = document.querySelector('.sheet');
if (!sheet) return;
sheet.classList.remove('nudge', 'clip');
const h = sheet.scrollHeight;
const nearThreshold = 28; // px
const tinyOverflow = 14; // px
if (h > availablePx) {
sheet.classList.add('nudge');
const h2 = sheet.scrollHeight;
if (h2 > availablePx && (h2 - availablePx) <= tinyOverflow) {
sheet.classList.add('clip');
}
}
}
// After print/cancel, return to Overview page (not Review)
const backUrl = "/overview?timesheet_id={{ timesheet_id }}";
function finish() {
if (window.parent && window.parent !== window) {
try { window.parent.postMessage({ type: 'close-print' }, '*'); } catch(e){}
} else {
window.location.href = backUrl;
}
}
window.addEventListener('load', function(){
fitToOnePage();
setTimeout(function(){ window.focus(); window.print(); }, 80);
});
if (typeof window.onbeforeprint !== 'undefined') {
window.addEventListener('beforeprint', fitToOnePage);
}
if (window.matchMedia) {
window.matchMedia('print').addEventListener('change', e => { if (e.matches) fitToOnePage(); });
}
window.addEventListener('afterprint', finish);
})();
</script>
{% endblock %}

View File

@ -0,0 +1,214 @@
{% extends "layout.html" %}
{% block content %}
<div class="sheet">
<div class="header">
<div class="brand">Timesheet</div>
<div class="row">
<div class="emp"><strong>Employee:</strong> {{ employee.name }}</div>
<div class="period"><strong>Period:</strong> {{ period_name }}</div>
</div>
<!-- Totals at the top (kept) -->
<div class="totals-top">
<span><strong>Regular:</strong> {{ grouped.totals.regular|fmt2 }}</span>
<span><strong>Overtime:</strong> {{ grouped.totals.overtime|fmt2 }}</span>
<span><strong>PTO:</strong> {{ grouped.totals.pto|fmt2 }}</span>
<span><strong>Holiday:</strong> {{ grouped.totals.holiday|fmt2 }}</span>
<span><strong>Other:</strong> {{ grouped.totals.bereavement|fmt2 }}</span>
<span><strong>Paid Total:</strong> {{ grouped.totals.paid_total|fmt2 }}</span>
</div>
</div>
<table class="grid">
<thead>
<tr>
<th>Date</th>
<th>Clock In</th>
<th>Clock Out</th>
<th class="num">Break</th>
<th class="num">Total</th>
<!-- PTO amount before PTO Type (kept) -->
<th class="num">PTO</th>
<th>PTO Type</th>
<th class="num">Holiday</th>
<th class="num">Other</th>
<th class="num">Paid Total</th>
</tr>
</thead>
<tbody>
{% for r in grouped.rows %}
<tr>
<td class="mono">{{ r.work_date }}</td>
<!-- Holiday takes precedence; then PTO type; else times -->
<td class="mono">
{% if r.holiday_hours and r.holiday_hours > 0 %}
Holiday
{% elif r.pto_type %}
{{ r.pto_type }}
{% else %}
{{ r.clock_in|fmt_excel_dt }}
{% endif %}
</td>
<td class="mono">
{% if r.holiday_hours and r.holiday_hours > 0 %}
Holiday
{% elif r.pto_type %}
{{ r.pto_type }}
{% else %}
{{ r.clock_out|fmt_excel_dt }}
{% endif %}
</td>
<td class="num mono">{{ r.break_hours|fmt2 }}</td>
<td class="num mono">{{ r.total_hours|fmt2 }}</td>
<td class="num mono">{{ r.pto_hours|fmt2 }}</td>
<td>{{ r.pto_type or "" }}</td>
<td class="num mono">{{ r.holiday_hours|fmt2 }}</td>
<td class="num mono">{{ r.bereavement_hours|fmt2 }}</td>
<td class="num mono">{{ r.hours_paid|fmt2 }}</td>
</tr>
{% endfor %}
</tbody>
</table>
<!-- Footer UNDER all content (no overlay) -->
<div class="footer-block">
<div class="comments">
<div class="comments-title">Comments</div>
<div class="comments-box">
{% if grouped.comments %}{{ grouped.comments }}{% endif %}
</div>
</div>
<div class="signatures">
<div class="sig-block">
<div class="signature-line"></div>
<div class="sig-label">Employee Signature</div>
</div>
<div class="sig-block">
<div class="signature-line"></div>
<div class="sig-label">Reviewer Signature</div>
</div>
<div class="sig-block">
<div class="signature-line"></div>
<div class="sig-label">Date</div>
</div>
</div>
</div>
</div>
<style>
header, nav, .navbar, .topbar, .site-header, .app-nav, .tk-header, .tk-nav, .tk-brand { display:none !important; }
@media print {
body { margin: 0 !important; -webkit-print-color-adjust: exact; print-color-adjust: exact; }
.tk-container { display:block !important; margin:0 !important; padding:0 !important; }
}
:root {
--ink:#222; --grid:#cdd3da; --head:#f4f6f9;
--margin: 0.30in; /* sync with @page */
}
@page { size: Letter landscape; margin: var(--margin); }
.sheet {
width: calc(11in - (2 * var(--margin)));
margin: 0;
color: var(--ink);
font-family: Inter, system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif;
}
.header { margin-bottom: 0.06in; }
.brand { font-size: 19.5pt; font-weight: 800; line-height: 1.1; margin-bottom: 0.03in; }
.row { display: grid; grid-template-columns: 1fr auto; align-items: end; column-gap: 0.16in; }
.emp, .period { font-size: 10.4pt; }
.totals-top {
display:flex; flex-wrap:wrap; gap:0.12in;
font-size: 11pt; margin-top: 0.04in; margin-bottom: 0.08in;
border: none; padding: 0;
}
/* Body smaller for more fit */
table.grid { width:100%; border-collapse: separate; border-spacing: 0; }
thead { display: table-header-group; }
table.grid th, table.grid td {
border: 1pt solid var(--grid);
padding: 0.042in 0.058in; /* smaller cell padding */
font-size: 9.8pt; /* smaller body font */
background: #fff;
}
table.grid thead th {
background: var(--head);
text-transform: uppercase;
font-size: 9.2pt;
letter-spacing: .02em;
color: #3a4856;
}
.num { text-align: right; }
.mono { font-family: Menlo, Monaco, Consolas, "Courier New", monospace; }
table.grid tr { page-break-inside: avoid; }
/* Footer underneath */
.footer-block { margin-top: 0.14in; page-break-inside: avoid; }
.comments-title { font-size: 10.2pt; font-weight: 600; margin-bottom: 0.06in; }
.comments-box {
border: none;
min-height: 0.60in; /* smaller default height */
padding: 0.06in;
font-size: 9.8pt;
background: transparent;
}
.signatures {
display: grid;
grid-template-columns: 1fr 1fr 0.8fr;
column-gap: 0.16in;
align-items: end;
margin-top: 0.12in;
}
.signature-line { border-bottom: 1.3pt solid #333; height: 0.20in; }
.sig-label { font-size: 9.4pt; color:#555; text-align: center; margin-top: 0.04in; }
/* Tightening classes if needed */
.compact table.grid th, .compact table.grid td { padding: 0.038in 0.054in; font-size: 9.5pt; }
.compact .comments-box { min-height: 0.50in; }
.micro table.grid th, .micro table.grid td { padding: 0.034in 0.050in; font-size: 9.2pt; }
.micro .comments-box { min-height: 0.45in; }
</style>
<script>
(function(){
// Make sure everything fits on one page; if not, progressively shrink paddings/font sizes.
const DPI = 96;
const pageHeightInches = 8.5;
const styles = getComputedStyle(document.documentElement);
const marginInches = parseFloat(styles.getPropertyValue('--margin')) || 0.30;
const availablePx = (pageHeightInches - (2 * marginInches)) * DPI;
function fit() {
const sheet = document.querySelector('.sheet');
if (!sheet) return;
sheet.classList.remove('compact','micro');
let h = sheet.scrollHeight;
if (h > availablePx) {
sheet.classList.add('compact');
h = sheet.scrollHeight;
}
if (h > availablePx) {
sheet.classList.add('micro');
}
}
window.addEventListener('load', function(){
fit();
setTimeout(function(){ window.focus(); window.print(); }, 60);
});
if (typeof window.onbeforeprint !== 'undefined') window.addEventListener('beforeprint', fit);
if (window.matchMedia) {
const mq = window.matchMedia('print');
if (mq && mq.addEventListener) mq.addEventListener('change', e => { if (e.matches) fit(); });
}
})();
</script>
{% endblock %}

View File

@ -0,0 +1,218 @@
{% extends "layout.html" %}
{% block content %}
<div class="bundle">
{% for b in bundles %}
<section class="timesheet">
<div class="header">
<div class="brand">Timesheet</div>
<div class="row">
<div class="emp"><strong>Employee:</strong> {{ b.employee.name }}</div>
<div class="period"><strong>Period:</strong> {{ period_name }}</div>
</div>
<!-- Totals at the top (kept) -->
<div class="totals-top">
<span><strong>Regular:</strong> {{ b.grouped.totals.regular|fmt2 }}</span>
<span><strong>Overtime:</strong> {{ b.grouped.totals.overtime|fmt2 }}</span>
<span><strong>PTO:</strong> {{ b.grouped.totals.pto|fmt2 }}</span>
<span><strong>Holiday:</strong> {{ b.grouped.totals.holiday|fmt2 }}</span>
<span><strong>Other:</strong> {{ b.grouped.totals.bereavement|fmt2 }}</span>
<span><strong>Paid Total:</strong> {{ b.grouped.totals.paid_total|fmt2 }}</span>
</div>
</div>
<table class="grid">
<thead>
<tr>
<th>Date</th>
<th>Clock In</th>
<th>Clock Out</th>
<th class="num">Break</th>
<th class="num">Total</th>
<!-- PTO before PTO Type (kept) -->
<th class="num">PTO</th>
<th>PTO Type</th>
<th class="num">Holiday</th>
<th class="num">Other</th>
<th class="num">Paid Total</th>
</tr>
</thead>
<tbody>
{% for r in b.grouped.rows %}
<tr>
<td class="mono">{{ r.work_date }}</td>
<!-- Holiday takes precedence; then PTO type; else times -->
<td class="mono">
{% if r.holiday_hours and r.holiday_hours > 0 %}
Holiday
{% elif r.pto_type %}
{{ r.pto_type }}
{% else %}
{{ r.clock_in|fmt_excel_dt }}
{% endif %}
</td>
<td class="mono">
{% if r.holiday_hours and r.holiday_hours > 0 %}
Holiday
{% elif r.pto_type %}
{{ r.pto_type }}
{% else %}
{{ r.clock_out|fmt_excel_dt }}
{% endif %}
</td>
<td class="num mono">{{ r.break_hours|fmt2 }}</td>
<td class="num mono">{{ r.total_hours|fmt2 }}</td>
<td class="num mono">{{ r.pto_hours|fmt2 }}</td>
<td>{{ r.pto_type or "" }}</td>
<td class="num mono">{{ r.holiday_hours|fmt2 }}</td>
<td class="num mono">{{ r.bereavement_hours|fmt2 }}</td>
<td class="num mono">{{ r.hours_paid|fmt2 }}</td>
</tr>
{% endfor %}
</tbody>
</table>
<!-- Footer underneath, per timesheet -->
<div class="footer-block">
<div class="comments">
<div class="comments-title">Comments</div>
<div class="comments-box">
{% if b.grouped.comments %}{{ b.grouped.comments }}{% endif %}
</div>
</div>
<div class="signatures">
<div class="sig-block">
<div class="signature-line"></div>
<div class="sig-label">Employee Signature</div>
</div>
<div class="sig-block">
<div class="signature-line"></div>
<div class="sig-label">Reviewer Signature</div>
</div>
<div class="sig-block">
<div class="signature-line"></div>
<div class="sig-label">Date</div>
</div>
</div>
</div>
</section>
{% endfor %}
</div>
<style>
header, nav, .navbar, .topbar, .site-header, .app-nav, .tk-header, .tk-nav, .tk-brand { display:none !important; }
@media print {
body { margin: 0 !important; -webkit-print-color-adjust: exact; print-color-adjust: exact; }
.tk-container { display:block !important; margin:0 !important; padding:0 !important; }
}
:root { --ink:#222; --grid:#cdd3da; --head:#f4f6f9; --margin: 0.30in; }
@page { size: Letter landscape; margin: var(--margin); }
.bundle {
width: calc(11in - (2 * var(--margin)));
margin: 0;
color: var(--ink);
font-family: Inter, system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif;
}
.timesheet { break-after: auto; page-break-after: auto; margin-bottom: 0.16in; }
.timesheet + .timesheet { break-before: page; page-break-before: always; }
.timesheet:last-child { break-after: avoid; page-break-after: avoid; }
.header { margin-bottom: 0.06in; }
.brand { font-size: 19.5pt; font-weight: 800; line-height: 1.1; margin-bottom: 0.03in; }
.row { display: grid; grid-template-columns: 1fr auto; align-items: end; column-gap: 0.16in; }
.emp, .period { font-size: 10.4pt; }
.totals-top {
display:flex; flex-wrap:wrap; gap:0.12in;
font-size: 11pt; margin-top: 0.04in; margin-bottom: 0.08in;
border: none; padding: 0;
}
/* Smaller body */
table.grid { width:100%; border-collapse: separate; border-spacing: 0; }
thead { display: table-header-group; }
table.grid th, table.grid td {
border: 1pt solid var(--grid);
padding: 0.042in 0.058in;
font-size: 9.8pt;
background: #fff;
}
table.grid thead th {
background: var(--head);
text-transform: uppercase;
font-size: 9.2pt;
letter-spacing: .02em;
color: #3a4856;
}
.num { text-align: right; }
.mono { font-family: Menlo, Monaco, Consolas, "Courier New", monospace; }
table.grid tr { page-break-inside: avoid; }
.grid { margin-bottom: 0; padding-bottom: 0; }
/* Footer underneath each section */
.footer-block { margin-top: 0.14in; page-break-inside: avoid; }
.comments-title { font-size: 10.2pt; font-weight: 600; margin-bottom: 0.06in; }
.comments-box {
border: none;
min-height: 0.60in;
padding: 0.06in;
font-size: 9.8pt;
background: transparent;
}
.signatures {
display: grid;
grid-template-columns: 1fr 1fr 0.8fr;
column-gap: 0.16in;
align-items: end;
margin-top: 0.12in;
}
.signature-line { border-bottom: 1.3pt solid #333; height: 0.20in; }
.sig-label { font-size: 9.4pt; color:#555; text-align: center; margin-top: 0.04in; }
/* Tighten if needed */
.compact table.grid th, .compact table.grid td { padding: 0.038in 0.054in; font-size: 9.5pt; }
.compact .comments-box { min-height: 0.50in; }
.micro table.grid th, .micro table.grid td { padding: 0.034in 0.050in; font-size: 9.2pt; }
.micro .comments-box { min-height: 0.45in; }
</style>
<script>
(function(){
// Fit each section to a page by shrinking body/footers if necessary
const DPI = 96;
const pageHeightInches = 8.5;
const marginInches = parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--margin')) || 0.30;
const availablePx = (pageHeightInches - (2 * marginInches)) * DPI;
function fitSection(sec) {
sec.classList.remove('compact','micro');
let h = sec.scrollHeight;
if (h > availablePx) {
sec.classList.add('compact');
h = sec.scrollHeight;
}
if (h > availablePx) {
sec.classList.add('micro');
}
}
function fitAll() {
document.querySelectorAll('.timesheet').forEach(fitSection);
}
window.addEventListener('load', function(){
fitAll();
setTimeout(function(){ window.focus(); window.print(); }, 60);
});
if (typeof window.onbeforeprint !== 'undefined') window.addEventListener('beforeprint', fitAll);
if (window.matchMedia) {
const mq = window.matchMedia('print');
if (mq && mq.addEventListener) mq.addEventListener('change', e => { if (e.matches) fitAll(); });
}
})();
</script>
{% endblock %}

View File

@ -0,0 +1,147 @@
{% extends "layout.html" %}
{% block content %}
<div class="page-wide">
<div class="panel" style="width:100%;">
<div class="panel-title">PTO Balance Tracker</div>
<form id="ptoForm" method="get" action="/pto-tracker" class="panel toolbar" style="gap:12px;flex-wrap:wrap;align-items:center;">
<label class="label">Employee</label>
<select class="select" id="employeeSelect" name="employee_id">
{% for e in employees %}
<option value="{{ e.id }}" {% if selected_employee and e.id == selected_employee.id %}selected{% endif %}>
{{ e.name }}{% if include_inactive and (inactive_ids and e.id in inactive_ids) %} (inactive){% endif %}
</option>
{% endfor %}
</select>
<label class="label">Year</label>
<select class="select" id="yearSelect" name="year">
{% for y in years %}
<option value="{{ y }}" {% if selected_year == y %}selected{% endif %}>{{ y }}</option>
{% endfor %}
</select>
<label class="checkbox" style="display:flex; gap:6px; align-items:center;">
<input id="includeInactive" type="checkbox" name="include_inactive" value="1" {% if include_inactive %}checked{% endif %}>
Show inactive
</label>
<button type="submit" class="btn primary">Load</button>
{% if selected_employee %}
<a class="btn" target="_blank"
href="/pto-tracker/print?employee_id={{ selected_employee.id }}&year={{ selected_year }}">
Print
</a>
<a class="btn" target="_blank"
href="/pto-tracker/print-all?year={{ selected_year }}{% if include_inactive %}&include_inactive=1{% endif %}">
Print All
</a>
{% endif %}
</form>
{% if selected_employee %}
<div class="panel" style="margin-top:12px;">
<div class="panel-title">Balances</div>
<div style="display:flex;gap:16px;align-items:flex-end;flex-wrap:wrap;">
<form method="post" action="/pto-tracker/set-starting" class="inline" onsubmit="return confirm('Update starting balance?')">
<input type="hidden" name="employee_id" value="{{ selected_employee.id }}">
<input type="hidden" name="year" value="{{ selected_year }}">
<label class="label" for="starting_balance">Starting balance ({{ selected_year }})</label>
<input class="input" id="starting_balance" name="starting_balance" type="number" step="0.01" value="{{ starting_balance|fmt2 }}">
<button class="btn" type="submit">Save</button>
</form>
<div class="inline" style="gap:8px;">
<div class="label">Remaining</div>
<div class="total" style="min-width:140px; display:flex; align-items:center; justify-content:center;">
<div class="t-val">{{ remaining_balance|fmt2 }}</div>
</div>
</div>
</div>
</div>
<div class="panel" style="margin-top:12px;">
<div class="panel-title">Add Adjustment</div>
<form method="post" action="/pto-tracker/add-adjustment" class="inline" style="gap:8px;flex-wrap:wrap;">
<input type="hidden" name="employee_id" value="{{ selected_employee.id }}">
<input type="hidden" name="year" value="{{ selected_year }}">
<label class="label" for="hours">Hours (+/)</label>
<input class="input" id="hours" name="hours" type="number" step="0.01" placeholder="e.g. 8.00 or -4.00" required>
<label class="label" for="note">Note</label>
<input class="input" id="note" name="note" type="text" placeholder="e.g. Grant, carryover correction">
<button class="btn" type="submit">Add</button>
</form>
<div class="muted" style="margin-top:6px;">Adjustments apply to the selected year.</div>
</div>
<div class="panel" style="margin-top:12px;">
<div class="panel-title">PTO Ledger ({{ selected_year }}, submitted timesheets only)</div>
<div class="table-wrap">
<table class="table compact" style="width:100%;">
<thead>
<tr>
<th style="text-align:center;">Date</th>
<th>Description</th>
<th class="num">Hours (±)</th>
<th class="num">Running Balance</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for row in ledger %}
<tr>
<td class="mono" style="text-align:center;">{% if row.date %}{{ row.date.strftime("%b %d, %Y") }}{% endif %}</td>
<td>{{ row.desc }}</td>
<td class="num mono">{% if row.delta != '' %}{{ row.delta|fmt2 }}{% endif %}</td>
<td class="num mono">{{ row.balance|fmt2 }}</td>
<td>
{% if row.kind == 'adjustment' and row.adj_id %}
<form method="post" action="/pto-tracker/delete-adjustment" onsubmit="return confirm('Delete this adjustment?')" style="display:inline;">
<input type="hidden" name="employee_id" value="{{ selected_employee.id }}">
<input type="hidden" name="year" value="{{ selected_year }}">
<input type="hidden" name="adjustment_id" value="{{ row.adj_id }}">
<button class="btn danger" type="submit">Delete</button>
</form>
{% elif row.kind == 'usage' and row.u_date is defined %}
<form method="post" action="/pto-tracker/exclude-usage" onsubmit="return confirm('Exclude this usage row from the ledger?')" style="display:inline;">
<input type="hidden" name="employee_id" value="{{ selected_employee.id }}">
<input type="hidden" name="year" value="{{ selected_year }}">
<input type="hidden" name="work_date" value="{{ row.u_date }}">
<input type="hidden" name="pto_type" value="{{ row.u_type }}">
<button class="btn" type="submit">Exclude</button>
</form>
{% else %}
<!-- starting balance row -->
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="muted" style="margin-top:8px;">
Usage rows reflect PTO on submitted timesheets within the selected year. “Exclude” hides a specific date/type without altering timesheets. Adjustments and starting balances are year-specific.
</div>
</div>
{% endif %}
</div>
</div>
<script>
// Auto-submit on employee change and Show inactive toggle
(function () {
var form = document.getElementById('ptoForm');
if (!form) return;
var emp = document.getElementById('employeeSelect');
var chk = document.getElementById('includeInactive');
if (emp) {
emp.addEventListener('change', function () { form.submit(); });
}
if (chk) {
chk.addEventListener('change', function () { form.submit(); });
}
})();
</script>
{% endblock %}

View File

@ -0,0 +1,87 @@
{% extends "layout.html" %}
{% block content %}
<style>
/* Tighter top margin to shift content higher on page */
@page { size: A4 portrait; margin: 0.35in 0.5in 0.5in 0.5in; }
html, body { background: #fff; }
/* Hide base layout chrome during print */
@media print {
body * { visibility: hidden !important; }
.print-root, .print-root * { visibility: visible !important; }
.print-root { position: static !important; width: auto !important; margin: 0 !important; box-shadow: none !important; border: 0 !important; }
}
.print-root { width: 100% !important; max-width: 100% !important; margin: 0 !important; padding: 0 !important; }
.title { font-weight: 700; font-size: 18px; text-align: center; margin: 0 0 6px 0; }
.topline { display: flex; justify-content: space-between; gap: 16px; margin-bottom: 6px; }
.topline .lbl { font-weight: 700; }
.summary { display: flex; gap: 16px; margin: 4px 0 10px 0; flex-wrap: wrap; }
.summary strong { font-weight: 700; }
.table-wrap { width: 100% !important; }
table.print-table { width: 100%; border-collapse: collapse; font-size: 12px; }
table.print-table thead th { text-align: left; border-bottom: 2px solid #333; padding: 6px 8px; }
table.print-table tbody td { padding: 6px 8px; border-bottom: 1px solid #ddd; }
table.print-table tbody tr:nth-child(even) td { background: #fafafa; }
.num { text-align: right; }
.center { text-align: center; }
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; }
.footer { margin-top: 8px; font-size: 11px; color: #555; }
.panel, .panel-title { width: 100% !important; max-width: 100% !important; margin: 0 !important; padding: 0 !important; border: 0 !important; box-shadow: none !important; background: transparent !important; }
</style>
<div class="print-root">
<div class="title">PTO Ledger</div>
<div class="topline">
<div><span class="lbl">Employee:</span> {{ employee.name }}</div>
<div><span class="lbl">Year:</span> {{ selected_year }}</div>
</div>
<div class="summary">
<div><strong>Starting balance:</strong> {{ starting_balance|fmt2 }}</div>
<div><strong>Remaining:</strong> {{ remaining_balance|fmt2 }}</div>
</div>
<div class="table-wrap">
<table class="print-table">
<thead>
<tr>
<th class="center" style="width:130px;">Date</th>
<th>Description</th>
<th class="num" style="width:120px;">Hours (±)</th>
<th class="num" style="width:140px;">Running Balance</th>
</tr>
</thead>
<tbody>
{% for row in ledger %}
<tr>
<td class="mono center">{% if row.date %}{{ row.date.strftime("%b %d, %Y") }}{% endif %}</td>
<td>{% if row.kind == 'start' %}<em>{{ row.desc }}</em>{% else %}{{ row.desc }}{% endif %}</td>
<td class="num mono">{% if row.delta != '' %}{{ row.delta|fmt2 }}{% endif %}</td>
<td class="num mono">{{ row.balance|fmt2 }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="footer">
Generated on {{ (request.state.now or None) and request.state.now.strftime("%b %d, %Y %I:%M %p") or "" }}
</div>
</div>
<script>
window.addEventListener('load', function () {
setTimeout(function () { window.print(); }, 150);
});
window.addEventListener('afterprint', function () { window.close(); });
</script>
{% endblock %}

View File

@ -0,0 +1,89 @@
{% extends "layout.html" %}
{% block content %}
<style>
@page { size: A4 portrait; margin: 0.35in 0.5in 0.5in 0.5in; }
html, body { background: #fff; }
@media print {
body * { visibility: hidden !important; }
.print-root, .print-root * { visibility: visible !important; }
.print-root { position: static !important; width: auto !important; margin: 0 !important; box-shadow: none !important; border: 0 !important; }
}
.print-root { width: 100% !important; max-width: 100% !important; margin: 0 !important; padding: 0 !important; }
.bundle { page-break-after: always; }
.bundle:last-child { page-break-after: auto; }
.title { font-weight: 700; font-size: 18px; text-align: center; margin: 0 0 6px 0; }
.topline { display: flex; justify-content: space-between; gap: 16px; margin-bottom: 6px; }
.topline .lbl { font-weight: 700; }
.summary { display: flex; gap: 16px; margin: 4px 0 10px 0; flex-wrap: wrap; }
.summary strong { font-weight: 700; }
table.print-table { width: 100%; border-collapse: collapse; font-size: 12px; }
table.print-table thead th { text-align: left; border-bottom: 2px solid #333; padding: 6px 8px; }
table.print-table tbody td { padding: 6px 8px; border-bottom: 1px solid #ddd; }
table.print-table tbody tr:nth-child(even) td { background: #fafafa; }
.num { text-align: right; }
.center { text-align: center; }
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; }
.footer { margin-top: 8px; font-size: 11px; color: #555; }
.panel, .panel-title { width: 100% !important; max-width: 100% !important; margin: 0 !important; padding: 0 !important; border: 0 !important; box-shadow: none !important; background: transparent !important; }
</style>
<div class="print-root">
{% for b in bundles %}
<div class="bundle">
<div class="title">PTO Ledger</div>
<div class="topline">
<div><span class="lbl">Employee:</span> {{ b.employee.name }}</div>
<div><span class="lbl">Year:</span> {{ selected_year }}</div>
</div>
<div class="summary">
<div><strong>Starting balance:</strong> {{ b.starting_balance|fmt2 }}</div>
<div><strong>Remaining:</strong> {{ b.remaining_balance|fmt2 }}</div>
</div>
<table class="print-table">
<thead>
<tr>
<th class="center" style="width:130px;">Date</th>
<th>Description</th>
<th class="num" style="width:120px;">Hours (±)</th>
<th class="num" style="width:140px;">Running Balance</th>
</tr>
</thead>
<tbody>
{% for row in b.ledger %}
<tr>
<td class="mono center">{% if row.date %}{{ row.date.strftime("%b %d, %Y") }}{% endif %}</td>
<td>{% if row.kind == 'start' %}<em>{{ row.desc }}</em>{% else %}{{ row.desc }}{% endif %}</td>
<td class="num mono">{% if row.delta != '' %}{{ row.delta|fmt2 }}{% endif %}</td>
<td class="num mono">{{ row.balance|fmt2 }}</td>
</tr>
{% endfor %}
</tbody>
</table>
<div class="footer">
Generated on {{ (request.state.now or None) and request.state.now.strftime("%b %d, %Y %I:%M %p") or "" }}
</div>
</div>
{% endfor %}
</div>
<script>
window.addEventListener('load', function () {
setTimeout(function () { window.print(); }, 150);
});
window.addEventListener('afterprint', function () { window.close(); });
</script>
{% endblock %}

83
app/templates/review.html Normal file
View File

@ -0,0 +1,83 @@
{% extends "layout.html" %}
{% block content %}
<div class="page-wide">
<div class="panel">
<div class="panel-title">Submitted Timesheets</div>
{% if flash %}<div class="alert success">{{ flash }}</div>{% endif %}
<form method="get" action="/review" style="display:flex;gap:8px;align-items:center;margin-bottom:12px;flex-wrap:wrap;">
<label class="label">Time Period</label>
<select class="select" name="timesheet_id">
{% for p in period_options %}
<option value="{{ p.timesheet_id }}" {% if p.timesheet_id == active_ts %}selected{% endif %}>{{ p.display }}</option>
{% endfor %}
</select>
<button class="btn primary" type="submit">Load</button>
{% if active_ts %}
<a class="btn" href="/overview?timesheet_id={{ active_ts }}">Time Period Overview</a>
<!-- NEW: Export all submitted employees for this period to Excel (includes Payroll Extras) -->
<a class="btn primary" href="/overview/export-xlsx?timesheet_id={{ active_ts }}">Export to BA</a>
{% endif %}
<button class="btn" type="button" onclick="openPrint('/review/print-all?timesheet_id={{ active_ts }}')">Print All Submitted</button>
</form>
{% if submitted and submitted|length > 0 %}
<table class="table">
<thead>
<tr><th>Employee</th><th>Submitted At</th><th>Actions</th></tr>
</thead>
<tbody>
{% for row in submitted %}
<tr>
<td>{{ row.employee_name }}</td>
<td class="mono">{% if row.submitted_at %}{{ row.submitted_at|fmt_dt }}{% endif %}</td>
<td style="display:flex;gap:8px;flex-wrap:wrap;">
<a class="btn" href="/review/edit?timesheet_id={{ active_ts }}&employee_id={{ row.employee_id }}">View/Edit</a>
<button class="btn" type="button" onclick="openPrint('/review/print?timesheet_id={{ active_ts }}&employee_id={{ row.employee_id }}')">Print</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="empty">No submitted timesheets for this period.</div>
{% endif %}
</div>
</div>
<!-- Hidden iframe used for printing without leaving the review page -->
<iframe id="print-iframe" style="position:fixed; inset:0; width:0; height:0; border:0; visibility:hidden;"></iframe>
<script>
function openPrint(url) {
const iframe = document.getElementById('print-iframe');
iframe.src = url;
}
window.addEventListener('message', function (e) {
if (e && e.data && e.data.type === 'close-print') {
const iframe = document.getElementById('print-iframe');
if (iframe) {
iframe.removeAttribute('src'); // unload
}
}
});
</script>
<style>
/* Uniform button sizing across this page */
.btn, .panel .btn {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 36px;
padding: 0 14px;
font-size: 14px;
line-height: 1;
box-sizing: border-box;
}
.label { font-size:13px; color:#475569; }
.select { min-height:36px; padding:8px 10px; border:1px solid var(--line); border-radius:8px; }
.table .mono { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; }
</style>
{% endblock %}

View File

@ -0,0 +1,659 @@
{% extends "layout.html" %}
{% block content %}
<div class="page-wide">
<div class="panel" style="width:100%;">
<div class="panel-title">Review & Edit</div>
{% if flash %}<div class="alert success">{{ flash }}</div>{% endif %}
<div style="display:flex;gap:8px;align-items:center;justify-content:space-between;margin-bottom:10px;flex-wrap:wrap;">
<div class="muted"><strong>{{ employee.name }}</strong> &ndash; {{ period_name }}</div>
<div style="display:flex;gap:8px;flex-wrap:wrap;">
<a class="btn" href="/review?timesheet_id={{ timesheet_id }}">Return to Review Timesheets</a>
<button class="btn" type="button" onclick="openPrint('/review/print?timesheet_id={{ timesheet_id }}&employee_id={{ employee.id }}')">Print</button>
</div>
</div>
<form id="carry-over-form-edit" method="post" action="/viewer/update-employee-period"
style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-bottom:12px;"
onsubmit="return saveCarryOverEdit(event)">
<input type="hidden" name="employee_id" value="{{ employee.id }}">
<input type="hidden" name="timesheet_id" value="{{ timesheet_id }}">
<input type="hidden" name="redirect_to" value="/review/edit?timesheet_id={{ timesheet_id }}&employee_id={{ employee.id }}">
<label class="label" for="carry_over_hours">Carry over hours</label>
<input class="input" id="carry_over_hours" name="carry_over_hours" type="number" step="0.01" value="{{ carry_over_hours|fmt2 }}" {% if not can_edit %}disabled{% endif %}>
<button class="btn" type="submit" {% if not can_edit %}disabled{% endif %}>Save</button>
<span class="muted">Applied to Week 1 regular cap (40 minus carry over).</span>
</form>
<!-- NEW: Payroll extras panel (included in Excel export) -->
<div class="panel" style="margin-bottom:12px;">
<div class="panel-title">Payroll Extras</div>
<form method="post" action="/review/update-payroll-note" class="inline" style="display:flex;gap:12px;align-items:flex-end;flex-wrap:wrap;">
<input type="hidden" name="timesheet_id" value="{{ timesheet_id }}">
<input type="hidden" name="employee_id" value="{{ employee.id }}">
<div style="display:grid;gap:6px;min-width:220px;">
<label class="label">Reimbursement (optional)</label>
<input class="input" name="reimbursement_amount" type="text" placeholder="$0.00"
value="{% if payroll_note and payroll_note.reimbursement_amount is not none %}{{ '%.2f'|format(payroll_note.reimbursement_amount) }}{% endif %}"
{% if not can_edit %}disabled{% endif %}>
</div>
<div style="display:grid;gap:6px;min-width:220px;">
<label class="label">Additional Payroll Changes (optional)</label>
<input class="input" name="additional_payroll_amount" type="text" placeholder="$0.00"
value="{% if payroll_note and payroll_note.additional_payroll_amount is not none %}{{ '%.2f'|format(payroll_note.additional_payroll_amount) }}{% endif %}"
{% if not can_edit %}disabled{% endif %}>
</div>
<div style="display:grid;gap:6px;min-width:320px;flex:1;">
<label class="label">Notes</label>
<textarea class="input" name="notes" rows="2" placeholder="Notes for payroll team..." {% if not can_edit %}disabled{% endif %}>{{ payroll_note.notes if payroll_note and payroll_note.notes else '' }}</textarea>
</div>
<div>
<button type="submit" class="btn primary" {% if not can_edit %}disabled{% endif %}>Save Extras</button>
</div>
</form>
<div class="muted" style="margin-top:8px;">Saved values appear in the Overview Excel export.</div>
</div>
<!-- /Payroll extras -->
{% if holiday_needs is defined and holiday_needs|length > 0 %}
<div class="alert warn" style="margin-bottom:12px;">
<div class="mb-8"><strong>Holiday review required{% if employee %} for {{ employee.name }}{% endif %}:</strong> The dates below include Holiday hours.</div>
<ul class="dup-list">
{% for h in holiday_needs %}
<li>{{ h.work_date.strftime("%A, %B %d, %Y") }} &ndash; Holiday {{ h.holiday_hours|fmt2 }} hr(s)</li>
{% endfor %}
</ul>
{% if timesheet_id and employee and can_edit %}
<div class="mt-8 inline" style="gap:8px;">
<form method="post" action="/review/review-holiday-needs">
<input type="hidden" name="employee_id" value="{{ employee.id }}">
<input type="hidden" name="timesheet_id" value="{{ timesheet_id }}">
<button class="btn" type="submit">Reviewed</button>
</form>
</div>
{% endif %}
<div class="mt-8">Holiday rows are highlighted in the grid until you click Reviewed. Saving rows does not clear highlights.</div>
</div>
{% endif %}
<div class="table-wrap">
<table class="table" style="width:100%;">
<thead>
<tr>
<th>DATE</th>
<th>CLOCK IN</th>
<th>CLOCK OUT</th>
<th>BREAK</th>
<th>TOTAL</th>
<th>PTO</th>
<th>PTO TYPE</th>
<th>HOLIDAY</th>
<th>OTHER</th>
<th>PAID</th>
<th></th>
</tr>
</thead>
<tbody>
{% set has_holiday_flags = flagged_holiday_dates is defined and reviewed_holiday_dates is defined %}
{% for r in grouped.rows %}
<form id="row-{{ r.entry_id }}" action="/timesheet/update-entry" method="post"></form>
{% set highlight_holiday = (has_holiday_flags and (r.work_date in flagged_holiday_dates and r.work_date not in reviewed_holiday_dates)) or (not has_holiday_flags and (r.holiday_hours and r.holiday_hours > 0)) %}
<tr id="row-tr-{{ r.entry_id }}" class="{% if highlight_holiday %}row-holiday{% endif %}" data-entry-id="{{ r.entry_id }}" tabindex="0">
<td class="mono">
{{ r.work_date.strftime("%b %d, %Y") }}
{% if highlight_holiday %}
<span class="badge holiday" title="This entry has Holiday hours and needs review.">Holiday &ndash; Needs review</span>
{% endif %}
</td>
<td id="ci-{{ r.entry_id }}" class="mono clock-cell"
title="{{ r.clock_in|fmt_dt }}"
data-ci="{{ r.clock_in|fmt_excel_dt }}"
data-date="{{ r.work_date.strftime('%Y-%m-%d') }}">
<div class="clock-row">
<span class="clock-text">
{% if r.holiday_hours and r.holiday_hours > 0 %}
Holiday
{% elif r.pto_type %}
{{ r.pto_type }}
{% else %}
{{ r.clock_in|fmt_excel_dt if r.clock_in else '-' }}
{% endif %}
</span>
<button type="button" class="icon-btn calendar" title="Pick date & time"
onclick="if(window.can_edit){openClockPicker(this, {{ r.entry_id }}, 'clock_in')}"
{% if not can_edit %}disabled{% endif %}>
<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true">
<rect x="3" y="4" width="18" height="18" rx="3" ry="3" fill="none" stroke="currentColor" stroke-width="2"/>
<line x1="3" y1="10" x2="21" y2="10" stroke="currentColor" stroke-width="2"/>
<line x1="8" y1="2" x2="8" y2="6" stroke="currentColor" stroke-width="2"/>
<line x1="16" y1="2" x2="16" y2="6" stroke="currentColor" stroke-width="2"/>
</svg>
</button>
</div>
<input class="clock-input" id="ci-input-{{ r.entry_id }}" type="datetime-local"
value="{{ r.clock_in.strftime('%Y-%m-%dT%H:%M') if r.clock_in else '' }}"
onchange="onClockChanged({{ r.entry_id }}, 'clock_in', this.value)" />
</td>
<td id="co-{{ r.entry_id }}" class="mono clock-cell"
title="{{ r.clock_out|fmt_dt }}"
data-co="{{ r.clock_out|fmt_excel_dt }}"
data-date="{{ r.work_date.strftime('%Y-%m-%d') }}">
<div class="clock-row">
<span class="clock-text">
{% if r.holiday_hours and r.holiday_hours > 0 %}
Holiday
{% elif r.pto_type %}
{{ r.pto_type }}
{% else %}
{{ r.clock_out|fmt_excel_dt if r.clock_out else '-' }}
{% endif %}
</span>
<button type="button" class="icon-btn calendar" title="Pick date & time"
onclick="if(window.can_edit){openClockPicker(this, {{ r.entry_id }}, 'clock_out')}"
{% if not can_edit %}disabled{% endif %}>
<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true">
<rect x="3" y="4" width="18" height="18" rx="3" ry="3" fill="none" stroke="currentColor" stroke-width="2"/>
<line x1="3" y1="10" x2="21" y2="10" stroke="currentColor" stroke-width="2"/>
<line x1="8" y1="2" x2="8" y2="6" stroke="currentColor" stroke-width="2"/>
<line x1="16" y1="2" x2="16" y2="6" stroke="currentColor" stroke-width="2"/>
</svg>
</button>
</div>
<input class="clock-input" id="co-input-{{ r.entry_id }}" type="datetime-local"
value="{{ r.clock_out.strftime('%Y-%m-%dT%H:%M') if r.clock_out else '' }}"
onchange="onClockChanged({{ r.entry_id }}, 'clock_out', this.value)" />
</td>
<td class="num">
<input form="row-{{ r.entry_id }}" type="hidden" name="entry_id" value="{{ r.entry_id }}">
<input form="row-{{ r.entry_id }}" type="hidden" name="timesheet_id" value="{{ timesheet_id }}">
<input form="row-{{ r.entry_id }}" type="hidden" name="employee_id" value="{{ employee.id }}">
<input class="input" form="row-{{ r.entry_id }}" type="number" step="0.01" name="break_hours" value="{{ r.break_hours|fmt2 }}" {% if not can_edit %}readonly disabled{% endif %}>
</td>
<td class="num">
<input class="input total-hours" form="row-{{ r.entry_id }}" type="number" step="0.01" name="total_hours" value="{{ r.total_hours|fmt2 }}" {% if not can_edit %}readonly disabled{% endif %}>
</td>
<td class="num">
<input class="input" form="row-{{ r.entry_id }}" type="number" step="0.01" name="pto_hours" value="{{ r.pto_hours|fmt2 }}" {% if not can_edit %}readonly disabled{% endif %}>
</td>
<td>
<select class="select" form="row-{{ r.entry_id }}" name="pto_type"
onchange="onPtoTypeChange(this, {{ r.entry_id }})" {% if not can_edit %}disabled{% endif %}>
<option value="" {% if not r.pto_type %}selected{% endif %}></option>
<option value="PTO" {% if r.pto_type == 'PTO' %}selected{% endif %}>PTO</option>
<option value="Sick" {% if r.pto_type == 'Sick' %}selected{% endif %}>Sick</option>
<option value="Off" {% if r.pto_type == 'Off' %}selected{% endif %}>Off</option>
</select>
</td>
<td class="num">
<input class="input" form="row-{{ r.entry_id }}" type="number" step="0.01" name="holiday_hours" value="{{ r.holiday_hours|fmt2 }}" {% if not can_edit %}readonly disabled{% endif %}>
</td>
<td class="num">
<input class="input" form="row-{{ r.entry_id }}" type="number" step="0.01" name="bereavement_hours" value="{{ r.bereavement_hours|fmt2 }}" {% if not can_edit %}readonly disabled{% endif %}>
</td>
<td class="num mono paid-cell">{{ r.hours_paid|fmt2 }}</td>
<td>
<input form="row-{{ r.entry_id }}" type="hidden" name="redirect_to" value="/review/edit?timesheet_id={{ timesheet_id }}&employee_id={{ employee.id }}">
{% if can_edit %}
<button type="button" class="btn primary sm" onclick="saveEntry(event, {{ r.entry_id }})">Save</button>
{% else %}
<button type="button" class="btn primary sm" disabled>Save</button>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="panel totals" id="summary-tiles" style="width:100%;">
<div class="total"><div class="t-label">Regular</div><div class="t-val">{{ grouped.totals.regular|fmt2 }}</div></div>
<div class="total"><div class="t-label">Overtime</div><div class="t-val">{{ grouped.totals.overtime|fmt2 }}</div></div>
<div class="total"><div class="t-label">PTO</div><div class="t-val">{{ grouped.totals.pto|fmt2 }}</div></div>
<div class="total"><div class="t-label">Holiday</div><div class="t-val">{{ grouped.totals.holiday|fmt2 }}</div></div>
<div class="total"><div class="t-label">Other</div><div class="t-val">{{ grouped.totals.bereavement|fmt2 }}</div></div>
<div class="total"><div class="t-label">Paid Total</div><div class="t-val">{{ grouped.totals.paid_total|fmt2 }}</div></div>
</div>
</div>
</div>
<iframe id="print-iframe" style="position:fixed; inset:0; width:0; height:0; border:0; visibility:hidden;"></iframe>
<div id="clock-picker" class="picker-popover" hidden>
<div class="picker-header">
<div class="picker-title">Set date & time</div>
<div class="picker-actions">
<button class="btn sm" type="button" onclick="closeClockPicker()">Cancel</button>
<button id="picker-apply" class="btn primary sm" type="button" onclick="applyClockPicker()">Apply</button>
</div>
</div>
<div class="picker-body">
<div class="picker-row">
<label>Date</label>
<input id="picker-date" type="date">
</div>
<div class="picker-row">
<label>Time</label>
<input id="picker-time" type="time" step="60">
</div>
</div>
</div>
<div id="row-menu" class="ctx-menu" role="menu" hidden>
{% if can_edit %}
<button type="button" id="ctx-delete" class="ctx-item danger" role="menuitem">Delete row</button>
{% else %}
<button type="button" class="ctx-item danger" role="menuitem" disabled>Delete row</button>
{% endif %}
</div>
<div id="toast" class="toast" hidden>Time entry saved</div>
<style>
.table thead th { text-align: center; }
.table thead th.num { text-align: center; }
.table tbody td.num, .table tfoot td.num { text-align: right; }
.row-holiday { background: #fff6e6; }
.badge.holiday { display:inline-block; padding:2px 6px; font-size:11px; border-radius:10px; border:1px solid #ffc470; color:#8a5200; background:#fff4e0; margin-left: 6px; }
.clock-cell { white-space: nowrap; overflow: visible; }
.clock-row { display: grid; grid-template-columns: 1fr 28px; align-items: center; column-gap: 6px; }
.clock-text { display: block; width: 100%; min-width: 0; text-align: center; overflow: hidden; text-overflow: ellipsis; }
.icon-btn.calendar { border:0; background:#f8fafc; color:#64748b; cursor:pointer; width:28px; height:28px; border-radius:8px; display:inline-flex; align-items:center; justify-content:center; border:1px solid var(--line); }
.icon-btn.calendar:hover { color:#0f172a; filter:brightness(1.02); }
.icon-btn.calendar[disabled] { opacity: 0.5; cursor: not-allowed; }
.clock-input { display:none; }
.picker-popover { position:fixed; z-index:1000; width:280px; background:#fff; border:1px solid var(--line); border-radius:8px; box-shadow:0 8px 24px rgba(2,6,23,0.15); overflow:hidden; }
.picker-header { display:flex; align-items:center; justify-content:space-between; gap:8px; position:sticky; top:0; background:#fff; border-bottom:1px solid var(--line); padding:8px; }
.picker-title { font-size:12px; color:#475569; font-weight:600; }
.picker-actions { display:flex; gap:8px; }
.picker-body { padding:10px; }
.picker-row input { flex:1; min-height:32px; }
.toast { position:fixed; right:16px; bottom:16px; background:#10b981; color:#fff; padding:10px 14px; border-radius:8px; box-shadow:0 8px 24px rgba(2,6,23,0.25); opacity:0; transform:translateY(6px); transition:opacity .2s ease, transform .2s ease; z-index:1500; }
.toast.show { opacity:1; transform:translateY(0); }
.table-wrap { overflow-x:auto; }
</style>
<script>
function openPrint(url) {
const iframe = document.getElementById('print-iframe');
iframe.src = url;
}
window.addEventListener('message', function (e) {
if (e && e.data && e.data.type === 'close-print') {
const iframe = document.getElementById('print-iframe');
if (iframe) iframe.removeAttribute('src');
}
});
function onPtoTypeChange(selectEl, entryId) {
const ciCell = document.getElementById('ci-' + entryId);
const coCell = document.getElementById('co-' + entryId);
if (!ciCell || !coCell) return;
const type = (selectEl.value || '').trim();
const ciTextEl = ciCell.querySelector('.clock-text');
const coTextEl = coCell.querySelector('.clock-text');
const row = document.getElementById('row-tr-' + entryId);
const holInput = row ? row.querySelector('input[name="holiday_hours"]') : null;
const holVal = holInput ? parseFloat(holInput.value || '0') : 0;
if (holVal > 0) {
if (ciTextEl) ciTextEl.textContent = 'Holiday';
if (coTextEl) coTextEl.textContent = 'Holiday';
return;
}
if (type) {
if (ciTextEl) ciTextEl.textContent = type;
if (coTextEl) coTextEl.textContent = type;
} else {
const ci = ciCell.getAttribute('data-ci') || '-';
const co = coCell.getAttribute('data-co') || '-';
if (ciTextEl) ciTextEl.textContent = ci;
if (coTextEl) coTextEl.textContent = co;
}
}
function num(v){const n=parseFloat(v);return isFinite(n)?n:0;}
function fmt2(v){return (Math.round(num(v)*100)/100).toFixed(2);}
// FIX: read PTO type from the table row (not the empty <form>)
function getPtoType(entryId){
const row = document.getElementById('row-tr-' + entryId);
const sel = row ? row.querySelector('select[name="pto_type"]') : null;
return (sel && sel.value ? sel.value.trim() : '');
}
let toastTimer = null;
function showToast(msg) {
const t = document.getElementById('toast');
if (!t) return;
t.textContent = msg || 'Saved';
t.hidden = false;
t.classList.add('show');
clearTimeout(toastTimer);
toastTimer = setTimeout(() => {
t.classList.remove('show');
setTimeout(() => { t.hidden = true; }, 200);
}, 1800);
}
async function refreshSummary(timesheetId, employeeId) {
try {
const url = `/review/edit?timesheet_id=${encodeURIComponent(timesheetId)}&employee_id=${encodeURIComponent(employeeId)}`;
const res = await fetch(url, { credentials: 'same-origin' });
if (!res.ok) return;
const html = await res.text();
const doc = new DOMParser().parseFromString(html, 'text/html');
const newTiles = doc.querySelector('#summary-tiles');
const curTiles = document.querySelector('#summary-tiles');
if (newTiles && curTiles) curTiles.innerHTML = newTiles.innerHTML;
} catch (e) {
console.warn('refreshSummary failed', e);
}
}
let pickerState = { anchor: null, entryId: null, field: null };
let pickerOutsideHandler = null;
function placePopover(anchorBtn) {
const pop = document.getElementById('clock-picker');
const rect = anchorBtn.getBoundingClientRect();
const vpW = window.innerWidth || document.documentElement.clientWidth;
const vpH = window.innerHeight || document.documentElement.clientHeight;
pop.style.visibility = 'hidden';
pop.hidden = false;
const measure = pop.getBoundingClientRect();
const popW = measure.width || 280;
const popH = measure.height || 220;
const belowTop = rect.bottom + 8;
const aboveTop = rect.top - popH - 8;
const top = (belowTop + popH + 16 <= vpH) ? belowTop : Math.max(8, aboveTop);
const left = Math.min(vpW - popW - 8, rect.right + 8);
pop.style.left = left + 'px';
pop.style.top = top + 'px';
pop.style.visibility = 'visible';
}
function openClockPicker(anchorBtn, entryId, field) {
const pop = document.getElementById('clock-picker');
const dateInput = document.getElementById('picker-date');
const timeInput = document.getElementById('picker-time');
pickerState.anchor = anchorBtn;
pickerState.entryId = entryId;
pickerState.field = field;
const cellId = (field === 'clock_in' ? 'ci-' : 'co-') + entryId;
const theHiddenId = (field === 'clock_in' ? 'ci-input-' : 'co-input-') + entryId;
const cell = document.getElementById(cellId);
const hidden = document.getElementById(theHiddenId);
const baseDate = cell ? (cell.getAttribute('data-date') || '') : '';
let dval = '', tval = '';
if (hidden && hidden.value) {
const parts = hidden.value.split('T');
dval = parts[0] || baseDate || '';
tval = parts[1] || '';
} else {
dval = baseDate || '';
tval = '08:00';
}
dateInput.value = dval;
timeInput.value = tval;
placePopover(anchorBtn);
setTimeout(() => {
pickerOutsideHandler = (e) => {
const popNow = document.getElementById('clock-picker');
if (!popNow.hidden && !popNow.contains(e.target) && e.target !== anchorBtn) {
closeClockPicker();
document.removeEventListener('mousedown', pickerOutsideHandler);
pickerOutsideHandler = null;
}
};
document.addEventListener('mousedown', pickerOutsideHandler);
}, 0);
}
function closeClockPicker() {
const pop = document.getElementById('clock-picker');
pop.hidden = true;
pickerState = { anchor: null, entryId: null, field: null };
if (pickerOutsideHandler) {
document.removeEventListener('mousedown', pickerOutsideHandler);
pickerOutsideHandler = null;
}
}
function applyClockPicker() {
const dateInput = document.getElementById('picker-date');
const timeInput = document.getElementById('picker-time');
const dval = (dateInput.value || '').trim();
const tval = (timeInput.value || '').trim();
if (!dval || !tval || !pickerState.entryId || !pickerState.field) {
closeClockPicker();
return;
}
const newIso = dval + 'T' + tval;
const otherHiddenId = (pickerState.field === 'clock_in' ? 'co-input-' : 'ci-input-') + pickerState.entryId;
const otherHidden = document.getElementById(otherHiddenId);
const otherVal = otherHidden ? (otherHidden.value || '').trim() : '';
if (otherVal) {
const newDt = new Date(newIso);
const otherDt = new Date(otherVal);
if (pickerState.field === 'clock_in' && newDt >= otherDt) { alert('Clock In must be before Clock Out for the same entry.'); return; }
if (pickerState.field === 'clock_out' && newDt <= otherDt) { alert('Clock Out must be after Clock In for the same entry.'); return; }
}
const hiddenId = (pickerState.field === 'clock_in' ? 'ci-input-' : 'co-input-') + pickerState.entryId;
const hidden = document.getElementById(hiddenId);
if (hidden) hidden.value = newIso;
onClockChanged(pickerState.entryId, pickerState.field, newIso);
closeClockPicker();
}
async function onClockChanged(entryId, field, value) {
if (!window.can_edit) { showToast('Read-only mode'); return; }
const row = document.getElementById('row-tr-' + entryId);
const breakVal = row ? ((row.querySelector('input[name="break_hours"]') || {}).value || '') : '';
const ptoVal = row ? ((row.querySelector('input[name="pto_hours"]') || {}).value || '') : '';
const holVal = row ? ((row.querySelector('input[name="holiday_hours"]') || {}).value || '') : '';
const othVal = row ? ((row.querySelector('input[name="bereavement_hours"]') || {}).value || '') : '';
const fd = new FormData();
fd.append('entry_id', entryId);
if (field === 'clock_in') fd.append('clock_in', value);
if (field === 'clock_out') fd.append('clock_out', value);
fd.append('break_hours', breakVal);
fd.append('pto_hours', ptoVal);
fd.append('holiday_hours', holVal);
fd.append('bereavement_hours', othVal);
try {
const res = await fetch('/timesheet/update-clocks', {
method: 'POST',
headers: { 'Accept': 'application/json' },
credentials: 'same-origin',
body: fd
});
if (!res.ok) {
let err = '';
try { const e = await res.json(); err = e && e.error ? e.error : JSON.stringify(e); } catch {}
alert('Unable to update clock time (' + res.status + '). ' + (err || 'Please try again.'));
return;
}
const data = await res.json();
const ciCell = document.getElementById('ci-' + entryId);
const coCell = document.getElementById('co-' + entryId);
if (parseFloat(holVal || '0') > 0) {
const ciText = ciCell ? ciCell.querySelector('.clock-text') : null;
const coText = coCell ? coCell.querySelector('.clock-text') : null;
if (ciText) ciText.textContent = 'Holiday';
if (coText) coText.textContent = 'Holiday';
row.classList.add('row-holiday');
const dateCell = row.querySelector('td:first-child');
if (dateCell && !dateCell.querySelector('.badge.holiday')) {
const b = document.createElement('span');
b.className = 'badge holiday';
b.title = 'This entry has Holiday hours and needs review.';
b.textContent = 'Holiday - Needs review';
dateCell.appendChild(document.createTextNode(' '));
dateCell.appendChild(b);
}
} else {
const ptoType = getPtoType(entryId);
if (ptoType) {
const ciText = ciCell ? ciCell.querySelector('.clock-text') : null;
const coText = coCell ? coCell.querySelector('.clock-text') : null;
if (ciText) ciText.textContent = ptoType;
if (coText) coText.textContent = ptoType;
} else {
if (data.clock_in_fmt && ciCell) {
const t = ciCell.querySelector('.clock-text'); if (t) t.textContent = data.clock_in_fmt;
ciCell.setAttribute('data-ci', data.clock_in_fmt);
}
if (data.clock_out_fmt && coCell) {
const t = coCell.querySelector('.clock-text'); if (t) t.textContent = data.clock_out_fmt;
coCell.setAttribute('data-co', data.clock_out_fmt);
}
}
row.classList.remove('row-holiday');
const dateCell = row.querySelector('td:first-child');
if (dateCell) {
const badge = dateCell.querySelector('.badge.holiday');
if (badge) badge.remove();
}
}
if (row) {
const totalInput = row.querySelector('input.total-hours');
if (totalInput && typeof data.total_hours_fmt === 'string') totalInput.value = data.total_hours_fmt;
const paidCell = row.querySelector('.paid-cell');
if (paidCell && typeof data.hours_paid_fmt === 'string') paidCell.textContent = data.hours_paid_fmt;
}
await refreshSummary('{{ timesheet_id }}', '{{ employee.id }}');
showToast('Time updated');
} catch (e) {
console.error('Clock update failed', e);
alert('Unable to update clock time. Please try again.');
}
}
async function saveEntry(ev, entryId) {
if (ev) ev.preventDefault();
if (!window.can_edit) { showToast('Read-only mode'); return; }
const form = document.getElementById('row-' + entryId);
if (!form) return;
const fd = new FormData(form);
try {
const res = await fetch('/timesheet/update-entry', {
method: 'POST',
credentials: 'same-origin',
body: fd
});
if (!res.ok) { showToast('Save failed (' + res.status + ')'); return; }
const row = document.getElementById('row-tr-' + entryId);
if (row) {
['break_hours','total_hours','pto_hours','holiday_hours','bereavement_hours'].forEach(name=>{
const inp = row.querySelector('input[name="'+name+'"]');
if (inp) inp.value = fmt2(inp.value);
});
const total = num((row.querySelector('input[name="total_hours"]')||{}).value);
const brk = num((row.querySelector('input[name="break_hours"]')||{}).value);
const pto = num((row.querySelector('input[name="pto_hours"]')||{}).value);
const hol = num((row.querySelector('input[name="holiday_hours"]')||{}).value);
const oth = num((row.querySelector('input[name="bereavement_hours"]')||{}).value);
const paid = Math.max(0, total - brk) + pto + hol + oth;
const paidCell = row.querySelector('.paid-cell');
if (paidCell) paidCell.textContent = fmt2(paid);
const ciCell = document.getElementById('ci-' + entryId);
const coCell = document.getElementById('co-' + entryId);
const ciText = ciCell ? ciCell.querySelector('.clock-text') : null;
const coText = coCell ? coCell.querySelector('.clock-text') : null;
const ptoType = getPtoType(entryId);
if (hol > 0) {
if (ciText) ciText.textContent = 'Holiday';
if (coText) coText.textContent = 'Holiday';
row.classList.add('row-holiday');
const dateCell = row.querySelector('td:first-child');
if (dateCell && !dateCell.querySelector('.badge.holiday')) {
const b = document.createElement('span');
b.className = 'badge holiday';
b.title = 'This entry has Holiday hours and needs review.';
b.textContent = 'Holiday - Needs review';
dateCell.appendChild(document.createTextNode(' '));
dateCell.appendChild(b);
}
} else if (ptoType) {
if (ciText) ciText.textContent = ptoType;
if (coText) coText.textContent = ptoType;
row.classList.remove('row-holiday');
const dateCell = row.querySelector('td:first-child');
if (dateCell) { const badge = dateCell.querySelector('.badge.holiday'); if (badge) badge.remove(); }
} else {
if (ciText) ciText.textContent = (ciCell ? (ciCell.getAttribute('data-ci') || '-') : '-');
if (coText) coText.textContent = (coCell ? (coCell.getAttribute('data-co') || '-') : '-');
row.classList.remove('row-holiday');
const dateCell = row.querySelector('td:first-child');
if (dateCell) { const badge = dateCell.querySelector('.badge.holiday'); if (badge) badge.remove(); }
}
}
await refreshSummary('{{ timesheet_id }}', '{{ employee.id }}');
showToast('Time entry saved');
} catch (e) {
console.error('saveEntry error', e);
showToast('Save failed');
}
}
function saveCarryOverEdit(ev) {
if (ev) ev.preventDefault();
if (!window.can_edit) { showToast('Read-only mode'); return false; }
const form = document.getElementById('carry-over-form-edit');
if (!form) return false;
const fd = new FormData(form);
fetch('/viewer/update-employee-period', {
method: 'POST',
credentials: 'same-origin',
body: fd
}).then(async (res) => {
if (!res.ok) { showToast('Save failed ('+res.status+')'); return; }
await refreshSummary(fd.get('timesheet_id'), fd.get('employee_id'));
showToast('Carry over saved');
}).catch((e) => {
console.error('carry-over save error', e);
showToast('Save failed');
});
return false;
}
</script>
{% endblock %}

View File

@ -0,0 +1,209 @@
from collections import defaultdict
from dataclasses import dataclass
from datetime import date, timedelta
from typing import Dict, List, Tuple, Optional
from sqlalchemy.orm import Session
from sqlalchemy import func
from .models import TimeEntry
@dataclass
class DayRow:
entry_id: int
work_date: date
clock_in: str
clock_out: str
break_hours: float
total_hours: float
pto_hours: float
pto_type: str | None
holiday_hours: float
bereavement_hours: float
hours_paid: float
def parse_period_selector(selector: Optional[str]) -> Dict:
if not selector:
return {"type": "current_pay_period", "label": "Current Pay Period"}
if ".." in selector:
s, e = selector.split("..", 1)
return {"type": "range", "start": date.fromisoformat(s), "end": date.fromisoformat(e), "label": f"{s}..{e}"}
if len(selector) == 7:
y, m = selector.split("-")
return {"type": "month", "year": int(y), "month": int(m), "label": selector}
if len(selector) == 4:
return {"type": "year", "year": int(selector), "label": selector}
try:
d = date.fromisoformat(selector)
return {"type": "single", "date": d, "label": selector}
except Exception:
return {"type": "current_pay_period", "label": "Current Pay Period"}
def _start_of_week(d: date, start_weekday: int) -> date:
return d - timedelta(days=(d.weekday() - start_weekday) % 7)
def compute_period_bounds(selector: Dict, pay_period_type: str, start_weekday: int) -> Tuple[date, date]:
today = date.today()
if selector["type"] == "range":
return selector["start"], selector["end"]
if selector["type"] == "month":
y, m = selector["year"], selector["month"]
start = date(y, m, 1)
if m == 12:
end = date(y, 12, 31)
else:
end = date(y, m + 1, 1) - timedelta(days=1)
return start, end
if selector["type"] == "year":
y = selector["year"]
return date(y, 1, 1), date(y, 12, 31)
start_week = _start_of_week(today, start_weekday)
if pay_period_type.upper() == "WEEKLY":
return start_week, start_week + timedelta(days=6)
if pay_period_type.upper() == "SEMI_MONTHLY":
if today.day <= 15:
return date(today.year, today.month, 1), date(today.year, today.month, 15)
else:
if today.month == 12:
eom = date(today.year, 12, 31)
else:
eom = date(today.year, today.month + 1, 1) - timedelta(days=1)
return date(today.year, today.month, 16), eom
# BIWEEKLY default
start = start_week
epoch = date(2020, 1, 6)
delta_weeks = ((start - epoch).days // 7)
if delta_weeks % 2 != 0:
start = start - timedelta(days=7)
return start, start + timedelta(days=13)
def default_week_ranges(start: date, end: date, start_weekday: int) -> List[Tuple[date, date]]:
"""
Produce contiguous week ranges inside [start, end] using start_weekday.
This often yields 23 weeks for semi-monthly periods.
"""
ranges: List[Tuple[date, date]] = []
cursor = start
while cursor <= end:
week_start = cursor
# end of week is based on the configured start_weekday
end_of_week = _start_of_week(cursor, start_weekday) + timedelta(days=6)
week_end = min(end, end_of_week)
ranges.append((week_start, week_end))
cursor = week_end + timedelta(days=1)
return ranges
def group_entries_for_timesheet(
entries: List[TimeEntry],
start: date,
end: date,
pay_period_type: str,
start_weekday: int,
week_ranges: Optional[List[Tuple[date, date]]] = None,
carry_over_hours: float = 0.0,
):
rows: List[DayRow] = []
for e in entries:
rows.append(
DayRow(
entry_id=e.id,
work_date=e.work_date,
clock_in=e.clock_in.strftime("%I:%M %p") if e.clock_in else "",
clock_out=e.clock_out.strftime("%I:%M %p") if e.clock_out else "",
break_hours=round(e.break_hours or 0.0, 2),
total_hours=round(e.total_hours or 0.0, 2),
pto_hours=round(e.pto_hours or 0.0, 2),
pto_type=e.pto_type or "",
holiday_hours=round(e.holiday_hours or 0.0, 2),
bereavement_hours=round(e.bereavement_hours or 0.0, 2),
hours_paid=round(e.hours_paid or (e.total_hours or 0.0), 2),
)
)
rows.sort(key=lambda r: (r.work_date, r.clock_in or ""))
# Week ranges: explicit or default
week_ranges = week_ranges or default_week_ranges(start, end, start_weekday)
# Map date -> week index
def week_idx(d: date) -> int:
for idx, (ws, we) in enumerate(week_ranges, start=1):
if ws <= d <= we:
return idx
return len(week_ranges)
weekly_hours = defaultdict(float)
for r in rows:
weekly_hours[week_idx(r.work_date)] += r.total_hours
weekly_summary = []
for i, (ws, we) in enumerate(week_ranges, start=1):
base_hours = round(weekly_hours[i], 2)
carry = carry_over_hours if i == 1 else 0.0
all_with_carry = base_hours + carry
ot = max(0.0, all_with_carry - 40.0)
reg = max(0.0, base_hours - ot)
weekly_summary.append({
"label": f"Week {i}",
"start": ws,
"end": we,
"all": round(base_hours, 2),
"reg": round(reg, 2),
"ot": round(ot, 2),
})
totals = {
"regular": round(sum(ws["reg"] for ws in weekly_summary), 2),
"pto": round(sum(r.pto_hours for r in rows), 2),
"holiday": round(sum(r.holiday_hours for r in rows), 2),
"bereavement": round(sum(r.bereavement_hours for r in rows), 2),
"overtime": round(sum(ws["ot"] for ws in weekly_summary), 2),
"paid_total": round(sum(r.hours_paid for r in rows), 2),
}
return {"rows": rows, "weekly_summary": weekly_summary, "totals": totals, "week_ranges": week_ranges}
def compute_yearly_stats(db: Session, employee_id: int, scope: str = "year", year: Optional[int] = None, month: Optional[int] = None):
q = db.query(
func.date_part("year", TimeEntry.work_date).label("y"),
func.date_part("month", TimeEntry.work_date).label("m"),
func.sum(TimeEntry.total_hours).label("total"),
func.sum(TimeEntry.pto_hours).label("pto"),
func.sum(TimeEntry.holiday_hours).label("holiday"),
func.sum(TimeEntry.bereavement_hours).label("bereavement"),
func.sum(TimeEntry.hours_paid).label("paid"),
).filter(TimeEntry.employee_id == employee_id)
if scope == "month" and year and month:
q = q.filter(func.date_part("year", TimeEntry.work_date) == year)
q = q.filter(func.date_part("month", TimeEntry.work_date) == month)
elif scope == "year" and year:
q = q.filter(func.date_part("year", TimeEntry.work_date) == year)
q = q.group_by("y", "m").order_by("y", "m")
rows = q.all()
data = []
for y, m, total, pto, holiday, bereavement, paid in rows:
data.append({
"year": int(y),
"month": int(m),
"total_hours": float(total or 0.0),
"average_daily": round(float(total or 0.0) / 20.0, 2),
"pto": float(pto or 0.0),
"holiday": float(holiday or 0.0),
"bereavement": float(bereavement or 0.0),
"paid": float(paid or 0.0),
})
return {"rows": data}
def available_years_for_employee(db: Session, employee_id: int) -> List[int]:
rows = (
db.query(func.min(TimeEntry.work_date), func.max(TimeEntry.work_date))
.filter(TimeEntry.employee_id == employee_id)
.first()
)
if not rows or not rows[0]:
return []
start, end = rows
return list(range(start.year, end.year + 1))

35
app/templates/upload.html Normal file
View File

@ -0,0 +1,35 @@
{% extends "layout.html" %}
{% block content %}
<div class="page-narrow">
<div class="panel">
<div class="panel-title">Import time period workbook</div>
{% if error %}<div class="alert error">{{ error }}</div>{% endif %}
{% if request.session.get('is_admin') %}
<form method="post" action="/upload" enctype="multipart/form-data" class="form">
<div class="form-row">
<label for="timesheet_name">Time Period Name</label>
<input class="input" type="text" id="timesheet_name" name="timesheet_name" placeholder="e.g. Dec 1-15, 2025" required>
</div>
<div class="form-row">
<label for="file">Excel file <small>(.xlsx, .xlsm, .xls, .csv)</small></label>
<input class="input" type="file" id="file" name="file" accept=".xlsx,.xlsm,.xls" required>
</div>
<div class="form-row" style="display:flex;gap:8px;">
<button type="submit" class="btn primary">Upload & Import</button>
<!-- Cancel: always route directly to Viewer (no history/back dependency) -->
<a class="btn" href="/viewer">Cancel</a>
</div>
</form>
{% else %}
<div class="alert warn" role="status">
You have view/print-only access. Importing new time periods is restricted to administrators.
</div>
<div class="form-row" style="display:flex;gap:8px;margin-top:8px;">
<a class="btn" href="/viewer">Back to Timesheet Editor</a>
<a class="btn" href="/review">Review Timesheets</a>
</div>
{% endif %}
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,30 @@
{% extends "layout.html" %}
{% block content %}
<div class="page-narrow">
<div class="panel">
<div class="panel-title">Select Worksheet to Import</div>
<div class="mb-8">File: <strong>{{ filename }}</strong></div>
{% if error %}<div class="alert error">{{ error }}</div>{% endif %}
<form method="post" action="/upload/select" class="form">
<div class="form-row">
<label for="sheet_name">Worksheet</label>
<select class="select" id="sheet_name" name="sheet_name" required>
{% for s in sheet_names %}
<option value="{{ s }}">{{ s }}</option>
{% endfor %}
</select>
</div>
<div class="form-row" style="display:flex;gap:8px;">
<button type="submit" class="btn primary">Continue</button>
<button type="button" class="btn" onclick="window.location.href='/upload'">Cancel</button>
</div>
<div class="mt-12 muted">
After import, CI/CO will be date-bound and Total will be calculated in software from CI/CO; Break will sync from "Break Hours Taken" or break start/end.
</div>
</form>
</div>
</div>
{% endblock %}

1146
app/templates/viewer.html Normal file

File diff suppressed because it is too large Load Diff

298
app/utils.py Normal file
View File

@ -0,0 +1,298 @@
from dataclasses import dataclass
from datetime import date, datetime, time
from calendar import monthrange
from typing import Dict, List, Optional, Tuple
from sqlalchemy import func
from sqlalchemy.orm import Session
from decimal import Decimal, ROUND_HALF_UP, getcontext
from .models import TimeEntry, TimesheetPeriod
# Decimal settings for consistent rounding
getcontext().prec = 28
def D(x) -> Decimal:
return Decimal(str(x or 0))
def q2(x: Decimal) -> Decimal:
return x.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
# ---------------------------
# Period helpers and listing
# ---------------------------
def _semi_monthly_period_for_date(d: date) -> Tuple[date, date]:
if d.day <= 15:
start = date(d.year, d.month, 1)
end = date(d.year, d.month, 15)
else:
start = date(d.year, d.month, 16)
end = date(d.year, d.month, monthrange(d.year, d.month)[1])
return start, end
def enumerate_timesheets_global(db: Session) -> List[Tuple[int, date, date, str]]:
rows: List[TimesheetPeriod] = (
db.query(TimesheetPeriod)
.order_by(TimesheetPeriod.period_start.asc(), TimesheetPeriod.period_end.asc(), TimesheetPeriod.id.asc())
.all()
)
out: List[Tuple[int, date, date, str]] = []
for ts in rows:
name = ts.name or f"{ts.period_start.isoformat()}..{ts.period_end.isoformat()}"
out.append((ts.id, ts.period_start, ts.period_end, name))
return out
# ---------------------------
# Viewer/print data shaping
# ---------------------------
@dataclass
class RowOut:
entry_id: int
work_date: date
clock_in: Optional[datetime]
clock_out: Optional[datetime]
break_hours: Decimal
total_hours: Decimal
pto_hours: Decimal
pto_type: Optional[str]
holiday_hours: Decimal
bereavement_hours: Decimal
hours_paid: Decimal
needs_pto_review: bool = False
needs_long_shift_review: bool = False
@dataclass
class Totals:
regular: Decimal
pto: Decimal
holiday: Decimal
bereavement: Decimal
overtime: Decimal
paid_total: Decimal
@dataclass
class WeekSummary:
label: str
all: Decimal
reg: Decimal
ot: Decimal
@dataclass
class Grouped:
rows: List[RowOut]
totals: Totals
weekly_summary: List[WeekSummary]
def _to_datetime(d: date, t) -> Optional[datetime]:
if t is None:
return None
if isinstance(t, datetime):
return t
if isinstance(t, time):
return datetime.combine(d, t)
s = str(t).strip()
for fmt in ("%Y-%m-%d %H:%M:%S.%f", "%Y-%m-%d %H:%M:%S", "%I:%M:%S %p", "%H:%M:%S", "%H:%M"):
try:
parsed = datetime.strptime(s, fmt)
return parsed.replace(year=d.year, month=d.month, day=d.day)
except Exception:
continue
return None
def group_entries_for_timesheet(
entries: List[TimeEntry],
period_start: date,
period_end: date,
week_map: Optional[Dict[date, int]] = None,
carry_over_hours: float = 0.0,
) -> Grouped:
rows: List[RowOut] = []
week_totals: Dict[int, Decimal] = {}
sum_worked = D(0)
sum_pto = D(0)
sum_holiday = D(0)
sum_bereavement = D(0)
for e in sorted(entries, key=lambda x: (x.work_date, _to_datetime(x.work_date, x.clock_in) or datetime.min)):
total = D(e.total_hours)
brk = D(e.break_hours)
pto = D(e.pto_hours)
hol = D(e.holiday_hours)
ber = D(e.bereavement_hours)
worked = total - brk
if worked < D(0):
worked = D(0)
hours_paid_row = q2(worked + pto + hol + ber)
rows.append(
RowOut(
entry_id=e.id,
work_date=e.work_date,
clock_in=e.clock_in,
clock_out=e.clock_out,
break_hours=q2(brk),
total_hours=q2(total),
pto_hours=q2(pto),
pto_type=(e.pto_type or None),
holiday_hours=q2(hol),
bereavement_hours=q2(ber),
hours_paid=hours_paid_row,
needs_pto_review=(pto > D(0) and not (e.pto_type or "").strip()),
needs_long_shift_review=(total > D(10)),
)
)
sum_worked += worked
sum_pto += pto
sum_holiday += hol
sum_bereavement += ber
wk = (week_map or {}).get(e.work_date)
if wk is not None:
week_totals[wk] = week_totals.get(wk, D(0)) + worked
# Weekly summary
weekly_summary: List[WeekSummary] = []
carry = D(carry_over_hours)
for wk in sorted(week_totals.keys()):
worked_w = week_totals[wk]
reg_cap = D(40) - (carry if wk == 1 else D(0))
if reg_cap < D(0):
reg_cap = D(0)
reg_w = worked_w if worked_w <= reg_cap else reg_cap
ot_w = worked_w - reg_w
weekly_summary.append(
WeekSummary(
label=f"Week {wk}",
all=q2(worked_w),
reg=q2(reg_w),
ot=q2(ot_w),
)
)
# Totals
worked_total = q2(sum((w.all for w in weekly_summary), D(0)))
regular_total = q2(sum((w.reg for w in weekly_summary), D(0)))
overtime_total = q2(sum((w.ot for w in weekly_summary), D(0)))
paid_total = q2(worked_total + sum_pto + sum_holiday + sum_bereavement)
totals = Totals(
regular=regular_total,
pto=q2(sum_pto),
holiday=q2(sum_holiday),
bereavement=q2(sum_bereavement),
overtime=overtime_total,
paid_total=paid_total,
)
return Grouped(rows=rows, totals=totals, weekly_summary=weekly_summary)
# ---------------------------
# Duplicate merging
# ---------------------------
def _sum_gaps(intervals: List[Tuple[datetime, datetime]]) -> Decimal:
if not intervals:
return D(0)
intervals = sorted(intervals, key=lambda x: x[0])
gaps_hours = D(0)
current_end = intervals[0][1]
for i in range(1, len(intervals)):
start_i, end_i = intervals[i]
if start_i > current_end:
gaps_hours += D((start_i - current_end).total_seconds()) / D(3600)
if end_i > current_end:
current_end = end_i
return q2(gaps_hours)
def merge_duplicates_for_timesheet(db: Session, employee_id: int, timesheet_id: int) -> int:
dup_dates = [
r[0]
for r in (
db.query(TimeEntry.work_date)
.filter(TimeEntry.timesheet_id == timesheet_id, TimeEntry.employee_id == employee_id)
.group_by(TimeEntry.work_date)
.having(func.count(TimeEntry.id) > 1)
.all()
)
]
merged_count = 0
for d in dup_dates:
entries: List[TimeEntry] = (
db.query(TimeEntry)
.filter(TimeEntry.timesheet_id == timesheet_id, TimeEntry.employee_id == employee_id, TimeEntry.work_date == d)
.order_by(TimeEntry.clock_in.asc())
.all()
)
if len(entries) < 2:
continue
intervals: List[Tuple[datetime, datetime]] = []
for e in entries:
ci = _to_datetime(d, e.clock_in)
co = _to_datetime(d, e.clock_out)
if ci and co and co > ci:
intervals.append((ci, co))
earliest_in: Optional[datetime] = min((s for s, _ in intervals), default=None)
latest_out: Optional[datetime] = max((e for _, e in intervals), default=None)
span_hours = D(0)
if earliest_in and latest_out and latest_out > earliest_in:
span_hours = q2(D((latest_out - earliest_in).total_seconds()) / D(3600))
break_hours = _sum_gaps(intervals)
pto_hours = q2(sum(D(e.pto_hours) for e in entries))
holiday_hours = q2(sum(D(e.holiday_hours) for e in entries))
bereavement_hours = q2(sum(D(e.bereavement_hours) for e in entries))
pto_type = next((e.pto_type for e in entries if e.pto_type), None)
worked_hours = span_hours - break_hours
if worked_hours < D(0):
worked_hours = D(0)
hours_paid = q2(worked_hours + pto_hours + holiday_hours + bereavement_hours)
def keeper_key(e: TimeEntry):
ci = _to_datetime(d, e.clock_in)
return ci or datetime.combine(d, time(0, 0))
keeper = min(entries, key=keeper_key)
# Persist Decimal directly (models use Numeric now)
keeper.total_hours = span_hours
keeper.break_hours = break_hours
keeper.pto_hours = pto_hours
keeper.pto_type = pto_type
keeper.holiday_hours = holiday_hours
keeper.bereavement_hours = bereavement_hours
keeper.hours_paid = hours_paid
if earliest_in:
keeper.clock_in = earliest_in
if latest_out:
keeper.clock_out = latest_out
for e in entries:
if e.id != keeper.id:
db.delete(e)
merged_count += 1
if merged_count:
db.commit()
return merged_count

14
requirements.txt Normal file
View File

@ -0,0 +1,14 @@
fastapi==0.115.5
uvicorn[standard]==0.32.1
jinja2==3.1.4
python-multipart==0.0.12
itsdangerous==2.2.0
SQLAlchemy==2.0.36
psycopg[binary]==3.2.3
alembic==1.14.0
passlib==1.7.4
argon2-cffi==23.1.0
pandas==2.2.3
openpyxl==3.1.5
xlrd==1.2.0
python-dateutil==2.9.0.post0