initial
This commit is contained in:
commit
5a29141d9b
22
Dockerfile
Normal file
22
Dockerfile
Normal 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
330
app/attendance.py
Normal 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
62
app/auth.py
Normal 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
19
app/db.py
Normal 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
950
app/dept_importer.py
Normal 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
2531
app/main.py
Normal file
File diff suppressed because it is too large
Load Diff
76
app/migrate_to_timesheet_instances.py
Normal file
76
app/migrate_to_timesheet_instances.py
Normal 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
147
app/models.py
Normal 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
96
app/payroll_export.py
Normal 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
356
app/process_excel.py
Normal 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
98
app/routes/clock_edit.py
Normal 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)
|
||||||
32
app/routes/timesheet_api.py
Normal file
32
app/routes/timesheet_api.py
Normal 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
41
app/routes/viewer.py
Normal 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
162
app/static/styles.css
Normal 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}
|
||||||
72
app/templates/admin_employees.html
Normal file
72
app/templates/admin_employees.html
Normal 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 %}
|
||||||
84
app/templates/admin_users.html
Normal file
84
app/templates/admin_users.html
Normal 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 %}
|
||||||
71
app/templates/assign_weeks.html
Normal file
71
app/templates/assign_weeks.html
Normal 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 %}
|
||||||
106
app/templates/attendance.html
Normal file
106
app/templates/attendance.html
Normal 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 %}
|
||||||
94
app/templates/dept_importer_map.html
Normal file
94
app/templates/dept_importer_map.html
Normal 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 %}
|
||||||
69
app/templates/dept_importer_preview.html
Normal file
69
app/templates/dept_importer_preview.html
Normal 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 you’ll 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 %}
|
||||||
33
app/templates/dept_importer_upload.html
Normal file
33
app/templates/dept_importer_upload.html
Normal 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 period’s 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.
|
||||||
|
We’ll normalize whatever headers you have.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
25
app/templates/employees.html
Normal file
25
app/templates/employees.html
Normal 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
164
app/templates/layout.html
Normal 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
39
app/templates/login.html
Normal 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 %}
|
||||||
93
app/templates/overview.html
Normal file
93
app/templates/overview.html
Normal 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 %}
|
||||||
169
app/templates/print_overview.html
Normal file
169
app/templates/print_overview.html
Normal 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 %}
|
||||||
214
app/templates/print_timesheet.html
Normal file
214
app/templates/print_timesheet.html
Normal 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 %}
|
||||||
218
app/templates/print_timesheet_bundle.html
Normal file
218
app/templates/print_timesheet_bundle.html
Normal 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 %}
|
||||||
147
app/templates/pto_tracker.html
Normal file
147
app/templates/pto_tracker.html
Normal 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 %}
|
||||||
87
app/templates/pto_tracker_print.html
Normal file
87
app/templates/pto_tracker_print.html
Normal 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 %}
|
||||||
89
app/templates/pto_tracker_print_all.html
Normal file
89
app/templates/pto_tracker_print_all.html
Normal 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
83
app/templates/review.html
Normal 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 %}
|
||||||
659
app/templates/review_edit.html
Normal file
659
app/templates/review_edit.html
Normal 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> – {{ 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") }} – 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 – 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 %}
|
||||||
209
app/templates/timesheet.html
Normal file
209
app/templates/timesheet.html
Normal 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 2–3 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
35
app/templates/upload.html
Normal 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 %}
|
||||||
30
app/templates/upload_select.html
Normal file
30
app/templates/upload_select.html
Normal 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
1146
app/templates/viewer.html
Normal file
File diff suppressed because it is too large
Load Diff
298
app/utils.py
Normal file
298
app/utils.py
Normal 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
14
requirements.txt
Normal 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
|
||||||
Loading…
Reference in New Issue
Block a user