2531 lines
88 KiB
Python
2531 lines
88 KiB
Python
import os
|
|
import csv
|
|
from collections import defaultdict
|
|
from uuid import uuid4
|
|
from datetime import datetime, date, timedelta
|
|
from typing import Optional, List, Set
|
|
from decimal import Decimal, ROUND_HALF_UP
|
|
from urllib.parse import quote
|
|
from io import BytesIO
|
|
|
|
from fastapi import FastAPI, Request, Depends, Form, UploadFile, File, HTTPException, Query
|
|
from fastapi.responses import RedirectResponse, HTMLResponse, JSONResponse, StreamingResponse
|
|
from fastapi.staticfiles import StaticFiles
|
|
from fastapi.templating import Jinja2Templates
|
|
from starlette.middleware.sessions import SessionMiddleware
|
|
from sqlalchemy import func, text, Column, Integer, Date, String, DateTime, Numeric
|
|
from sqlalchemy.orm import Session
|
|
|
|
from .db import engine, SessionLocal, get_session, ping_db
|
|
from .models import (
|
|
Base,
|
|
User,
|
|
Employee,
|
|
TimeEntry,
|
|
WeekAssignment,
|
|
TimesheetStatus,
|
|
EmployeePeriodSetting,
|
|
TimesheetPeriod,
|
|
DuplicateReview,
|
|
PTOAccount,
|
|
PTOAdjustment,
|
|
)
|
|
from .auth import hash_password, verify_and_update_password, login_required
|
|
from .utils import (
|
|
D,
|
|
q2,
|
|
group_entries_for_timesheet,
|
|
enumerate_timesheets_global,
|
|
_semi_monthly_period_for_date,
|
|
)
|
|
from .payroll_export import build_overview_xlsx
|
|
|
|
# Attendance router (keeps this file small)
|
|
from .attendance import router as attendance_router
|
|
# Department upload router (moved out of main.py)
|
|
from .dept_importer import router as dept_router
|
|
|
|
SECRET_KEY = os.getenv("SECRET_KEY", "please-change-me")
|
|
DEFAULT_ADMIN_USER = os.getenv("DEFAULT_ADMIN_USER", "Admin")
|
|
DEFAULT_ADMIN_PASSWORD = os.getenv("DEFAULT_ADMIN_PASSWORD", "1Senior!")
|
|
PORT = int(os.getenv("PORT", "5070"))
|
|
APP_TZ = os.getenv("APP_TZ", "America/New_York")
|
|
|
|
# -----------------------
|
|
# Templates / app bootstrap
|
|
# -----------------------
|
|
app = FastAPI(title="TimeKeeper")
|
|
app.add_middleware(SessionMiddleware, secret_key=SECRET_KEY)
|
|
|
|
app.mount("/static", StaticFiles(directory="app/static"), name="static")
|
|
templates = Jinja2Templates(directory="app/templates")
|
|
app.state.templates = templates
|
|
|
|
# Smart loader: prefer UTF-8, fallback to cp1252 for Windows-saved files
|
|
try:
|
|
from jinja2.loaders import FileSystemLoader
|
|
|
|
class SmartLoader(FileSystemLoader):
|
|
def get_source(self, environment, template):
|
|
try:
|
|
self.encoding = "utf-8"
|
|
return super().get_source(environment, template)
|
|
except UnicodeDecodeError:
|
|
self.encoding = "cp1252"
|
|
return super().get_source(environment, template)
|
|
|
|
templates.env.loader = SmartLoader("app/templates")
|
|
except Exception:
|
|
pass
|
|
|
|
# -----------------------
|
|
# Jinja filters (formatting)
|
|
# -----------------------
|
|
def fmt2(x):
|
|
if x is None:
|
|
return "0.00"
|
|
try:
|
|
d = Decimal(str(x))
|
|
except Exception:
|
|
return str(x)
|
|
return str(d.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP))
|
|
|
|
|
|
def fmt_time(x):
|
|
if not x:
|
|
return ""
|
|
try:
|
|
if isinstance(x, datetime):
|
|
return x.strftime("%I:%M:%S %p")
|
|
s = str(x)
|
|
for fmt in ("%Y-%m-%d %H:%M:%S.%f", "%Y-%m-%d %H:%M:%S", "%H:%M:%S.%f", "%H:%M:%S"):
|
|
try:
|
|
dt = datetime.strptime(s, fmt)
|
|
return dt.strftime("%I:%M:%S %p")
|
|
except Exception:
|
|
continue
|
|
base = s.split(".")[0]
|
|
parts = base.split()
|
|
tpart = parts[-1] if parts else base
|
|
try:
|
|
dt = datetime.strptime(tpart, "%H:%M:%S")
|
|
return dt.strftime("%I:%M:%S %p")
|
|
except Exception:
|
|
return tpart
|
|
except Exception:
|
|
return str(x)
|
|
|
|
|
|
def fmt_dt(x):
|
|
if not x:
|
|
return ""
|
|
try:
|
|
if isinstance(x, datetime):
|
|
return x.strftime("%b %d, %Y %I:%M:%S %p")
|
|
s = str(x)
|
|
for fmt in ("%Y-%m-%d %H:%M:%S.%f", "%Y-%m-%d %H:%M:%S", "%m/%d/%Y %H:%M:%S"):
|
|
try:
|
|
dt = datetime.strptime(s, fmt)
|
|
return dt.strftime("%b %d, %Y %I:%M:%S %p")
|
|
except Exception:
|
|
continue
|
|
return s
|
|
except Exception:
|
|
return str(x)
|
|
|
|
|
|
def fmt_excel_dt(x):
|
|
# Excel style "MM/DD/YYYY hh:mm AM/PM"
|
|
if not x:
|
|
return ""
|
|
try:
|
|
if isinstance(x, datetime):
|
|
return x.strftime("%m/%d/%Y %I:%M %p")
|
|
s = str(x).strip()
|
|
fmts = [
|
|
"%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",
|
|
]
|
|
for fmt in fmts:
|
|
try:
|
|
dt = datetime.strptime(s, fmt)
|
|
return dt.strftime("%m/%d/%Y %I:%M %p")
|
|
except Exception:
|
|
continue
|
|
return s
|
|
except Exception:
|
|
return str(x)
|
|
|
|
|
|
templates.env.filters["fmt2"] = fmt2
|
|
templates.env.filters["fmt_time"] = fmt_time
|
|
templates.env.filters["fmt_dt"] = fmt_dt
|
|
templates.env.filters["fmt_excel_dt"] = fmt_excel_dt
|
|
|
|
# -----------------------
|
|
# Timezone helpers
|
|
# -----------------------
|
|
try:
|
|
from zoneinfo import ZoneInfo
|
|
LOCAL_TZ = ZoneInfo(APP_TZ)
|
|
except Exception:
|
|
LOCAL_TZ = None
|
|
|
|
def to_local(dt: Optional[datetime]) -> Optional[datetime]:
|
|
if not dt:
|
|
return None
|
|
try:
|
|
if LOCAL_TZ is None:
|
|
return dt
|
|
if dt.tzinfo is None:
|
|
dt = dt.replace(tzinfo=ZoneInfo("UTC"))
|
|
return dt.astimezone(LOCAL_TZ)
|
|
except Exception:
|
|
return dt
|
|
|
|
# -----------------------
|
|
# Review tables (dismiss banners without altering entries)
|
|
# -----------------------
|
|
class LongShiftFlag(Base):
|
|
__tablename__ = "long_shift_flags"
|
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
|
timesheet_id = Column(Integer, nullable=False, index=True)
|
|
employee_id = Column(Integer, nullable=False, index=True)
|
|
work_date = Column(Date, nullable=False, index=True)
|
|
|
|
|
|
class LongShiftReview(Base):
|
|
__tablename__ = "long_shift_reviews"
|
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
|
timesheet_id = Column(Integer, nullable=False, index=True)
|
|
employee_id = Column(Integer, nullable=False, index=True)
|
|
work_date = Column(Date, nullable=False, index=True)
|
|
|
|
|
|
class PtoNeedFlag(Base):
|
|
__tablename__ = "pto_need_flags"
|
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
|
timesheet_id = Column(Integer, nullable=False, index=True)
|
|
employee_id = Column(Integer, nullable=False, index=True)
|
|
work_date = Column(Date, nullable=False, index=True)
|
|
|
|
|
|
class PtoReviewFlag(Base):
|
|
__tablename__ = "pto_review_flags"
|
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
|
timesheet_id = Column(Integer, nullable=False, index=True)
|
|
employee_id = Column(Integer, nullable=False, index=True)
|
|
work_date = Column(Date, nullable=False, index=True)
|
|
|
|
|
|
class HolidayReviewFlag(Base):
|
|
__tablename__ = "holiday_review_flags"
|
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
|
timesheet_id = Column(Integer, nullable=False, index=True)
|
|
employee_id = Column(Integer, nullable=False, index=True)
|
|
work_date = Column(Date, nullable=False, index=True)
|
|
|
|
# -----------------------
|
|
# Admin role + profile tables
|
|
# -----------------------
|
|
class AdminUser(Base):
|
|
__tablename__ = "admin_users"
|
|
user_id = Column(Integer, primary_key=True, index=True)
|
|
|
|
|
|
class UserProfile(Base):
|
|
__tablename__ = "user_profiles"
|
|
user_id = Column(Integer, primary_key=True, index=True)
|
|
full_name = Column(String(255), nullable=True)
|
|
|
|
# -----------------------
|
|
# PTO usage exclusions
|
|
# -----------------------
|
|
class PTOUsageExclusion(Base):
|
|
__tablename__ = "pto_usage_exclusions"
|
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
|
employee_id = Column(Integer, nullable=False, index=True)
|
|
work_date = Column(Date, nullable=False, index=True)
|
|
pto_type = Column(String(64), nullable=True)
|
|
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
|
|
|
# -----------------------
|
|
# NEW: Payroll notes per employee per time period (for export)
|
|
# -----------------------
|
|
class PayrollNote(Base):
|
|
__tablename__ = "payroll_notes"
|
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
|
timesheet_id = Column(Integer, nullable=False, index=True)
|
|
employee_id = Column(Integer, nullable=False, index=True)
|
|
reimbursement_amount = Column(Numeric(10, 2), nullable=True)
|
|
additional_payroll_amount = Column(Numeric(10, 2), nullable=True)
|
|
notes = Column(String(2000), nullable=True)
|
|
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
|
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
|
|
|
|
# -----------------------
|
|
# Holiday helpers (top-level)
|
|
# -----------------------
|
|
def _flagged_holiday_dates(db: Session, timesheet_id: int, employee_id: int) -> set:
|
|
return {
|
|
r[0]
|
|
for r in db.query(TimeEntry.work_date)
|
|
.filter(
|
|
TimeEntry.timesheet_id == timesheet_id,
|
|
TimeEntry.employee_id == employee_id,
|
|
TimeEntry.holiday_hours > 0,
|
|
)
|
|
.group_by(TimeEntry.work_date)
|
|
.all()
|
|
}
|
|
|
|
def _reviewed_holiday_dates(db: Session, timesheet_id: int, employee_id: int) -> set:
|
|
return {
|
|
r.work_date
|
|
for r in db.query(HolidayReviewFlag)
|
|
.filter(
|
|
HolidayReviewFlag.timesheet_id == timesheet_id,
|
|
HolidayReviewFlag.employee_id == employee_id,
|
|
)
|
|
.all()
|
|
}
|
|
|
|
def _holiday_needs_rows(db: Session, timesheet_id: int, employee_id: int):
|
|
reviewed = _reviewed_holiday_dates(db, timesheet_id, employee_id)
|
|
return (
|
|
db.query(TimeEntry)
|
|
.filter(
|
|
TimeEntry.timesheet_id == timesheet_id,
|
|
TimeEntry.employee_id == employee_id,
|
|
TimeEntry.holiday_hours > 0,
|
|
~TimeEntry.work_date.in_(reviewed),
|
|
)
|
|
.order_by(TimeEntry.work_date.asc())
|
|
.all()
|
|
)
|
|
|
|
# -----------------------
|
|
# Startup / schema
|
|
# -----------------------
|
|
def ensure_schema():
|
|
with engine.connect() as conn:
|
|
# Backup columns for times hidden by PTO status in print view
|
|
conn.execute(text("ALTER TABLE time_entries ADD COLUMN IF NOT EXISTS pto_clock_in_backup TIMESTAMP NULL"))
|
|
conn.execute(text("ALTER TABLE time_entries ADD COLUMN IF NOT EXISTS pto_clock_out_backup TIMESTAMP NULL"))
|
|
|
|
# Year columns for per-year PTO tracking
|
|
conn.execute(text("ALTER TABLE pto_accounts ADD COLUMN IF NOT EXISTS year INTEGER"))
|
|
conn.execute(text("ALTER TABLE pto_adjustments ADD COLUMN IF NOT EXISTS year INTEGER"))
|
|
|
|
# Backfill existing rows to current year if null
|
|
conn.execute(text("UPDATE pto_accounts SET year = EXTRACT(YEAR FROM NOW())::INTEGER WHERE year IS NULL"))
|
|
conn.execute(text("UPDATE pto_adjustments SET year = EXTRACT(YEAR FROM NOW())::INTEGER WHERE year IS NULL"))
|
|
|
|
# SAFEGUARD: Drop incorrect unique index that enforces only one row per employee
|
|
conn.execute(text("DROP INDEX IF EXISTS ix_pto_accounts_employee_id"))
|
|
|
|
# Enforce one starting balance per employee per year
|
|
conn.execute(text("CREATE UNIQUE INDEX IF NOT EXISTS idx_pto_accounts_emp_year ON pto_accounts (employee_id, year)"))
|
|
|
|
# Employee active status + termination date
|
|
conn.execute(text("ALTER TABLE employees ADD COLUMN IF NOT EXISTS is_active BOOLEAN NOT NULL DEFAULT TRUE"))
|
|
conn.execute(text("ALTER TABLE employees ADD COLUMN IF NOT EXISTS termination_date DATE NULL"))
|
|
conn.execute(text("UPDATE employees SET is_active = TRUE WHERE IS_ACTIVE IS NULL"))
|
|
|
|
# Payroll notes table created by SQLAlchemy metadata (no raw DDL here)
|
|
|
|
conn.commit()
|
|
|
|
|
|
@app.on_event("startup")
|
|
def on_startup():
|
|
try:
|
|
ping_db()
|
|
Base.metadata.create_all(bind=engine)
|
|
ensure_schema()
|
|
with SessionLocal() as s:
|
|
default = s.query(User).filter(User.username == DEFAULT_ADMIN_USER).first()
|
|
if not default:
|
|
default = User(username=DEFAULT_ADMIN_USER, password_hash=hash_password(DEFAULT_ADMIN_PASSWORD))
|
|
s.add(default)
|
|
s.flush()
|
|
if not s.query(AdminUser).filter(AdminUser.user_id == default.id).first():
|
|
s.add(AdminUser(user_id=default.id))
|
|
s.commit()
|
|
print("[startup] Timekeeper ready.")
|
|
except Exception as ex:
|
|
print(f"[startup] Database initialization failed: {ex}")
|
|
|
|
# -----------------------
|
|
# Admin helpers
|
|
# -----------------------
|
|
def current_is_admin(db: Session, user_id: Optional[int]) -> bool:
|
|
if not user_id:
|
|
return False
|
|
return bool(db.query(AdminUser).filter(AdminUser.user_id == user_id).first())
|
|
|
|
|
|
def require_admin_edit(request: Request, db: Session):
|
|
if not current_is_admin(db, request.session.get("user_id")):
|
|
raise HTTPException(status_code=403, detail="Read-only user: edit action not permitted")
|
|
|
|
# -----------------------
|
|
# Basic routes (login, viewer, etc.)
|
|
# -----------------------
|
|
@app.get("/health")
|
|
def health():
|
|
try:
|
|
ping_db()
|
|
return JSONResponse({"status": "ok"})
|
|
except Exception as ex:
|
|
return JSONResponse({"status": "error", "detail": str(ex)}, status_code=500)
|
|
|
|
|
|
@app.get("/", response_class=HTMLResponse)
|
|
def index(request: Request):
|
|
if request.session.get("user_id"):
|
|
return RedirectResponse(url="/viewer")
|
|
return RedirectResponse(url="/login")
|
|
|
|
|
|
@app.get("/login", response_class=HTMLResponse)
|
|
def login_page(request: Request):
|
|
return templates.TemplateResponse("login.html", {"request": request, "hide_nav_links": True})
|
|
|
|
|
|
@app.post("/login")
|
|
def login(
|
|
request: Request,
|
|
username: str = Form(...),
|
|
password: str = Form(...),
|
|
db: Session = Depends(get_session),
|
|
):
|
|
try:
|
|
user = db.query(User).filter(User.username == username).first()
|
|
if not user:
|
|
return templates.TemplateResponse("login.html", {"request": request, "hide_nav_links": True, "error": "Invalid credentials"}, status_code=401)
|
|
|
|
verified, new_hash = verify_and_update_password(password, user.password_hash)
|
|
if not verified:
|
|
return templates.TemplateResponse("login.html", {"request": request, "hide_nav_links": True, "error": "Invalid credentials"}, status_code=401)
|
|
|
|
if new_hash:
|
|
user.password_hash = new_hash
|
|
db.commit()
|
|
|
|
request.session["user_id"] = user.id
|
|
request.session["username"] = user.username
|
|
request.session["is_admin"] = current_is_admin(db, user.id)
|
|
return RedirectResponse(url="/viewer", status_code=303)
|
|
except Exception as ex:
|
|
print(f"[login] error: {ex}")
|
|
return templates.TemplateResponse(
|
|
"login.html",
|
|
{"request": request, "hide_nav_links": True, "error": f"Login failed due to server/database error: {ex}"},
|
|
status_code=500,
|
|
)
|
|
|
|
|
|
@app.get("/logout")
|
|
def logout(request: Request):
|
|
request.session.clear()
|
|
return RedirectResponse(url="/login", status_code=303)
|
|
|
|
# -------- Upload --------
|
|
@app.get("/upload", response_class=HTMLResponse)
|
|
@login_required
|
|
def upload_page(request: Request):
|
|
with SessionLocal() as db:
|
|
if not current_is_admin(db, request.session.get("user_id")):
|
|
raise HTTPException(status_code=403, detail="Admin access required")
|
|
return templates.TemplateResponse("upload.html", {"request": request})
|
|
|
|
|
|
@app.post("/upload")
|
|
@login_required
|
|
async def upload_file(
|
|
request: Request,
|
|
file: UploadFile = File(...),
|
|
timesheet_name: str = Form(...),
|
|
db: Session = Depends(get_session),
|
|
):
|
|
require_admin_edit(request, db)
|
|
|
|
allowed_ext = (".xlsx", ".xlsm", ".xls", ".csv", ".txt")
|
|
if not file.filename.lower().endswith(allowed_ext):
|
|
return templates.TemplateResponse(
|
|
"upload.html",
|
|
{"request": request, "error": f"Unsupported file type. Please upload one of: {', '.join(allowed_ext)}"},
|
|
status_code=400,
|
|
)
|
|
|
|
name = (timesheet_name or "").strip()
|
|
if not name:
|
|
return templates.TemplateResponse(
|
|
"upload.html", {"request": request, "error": "Time Period Name is required."}, status_code=400
|
|
)
|
|
|
|
try:
|
|
contents = await file.read()
|
|
os.makedirs("uploads", exist_ok=True)
|
|
saved_path = os.path.join("uploads", f"{datetime.utcnow().strftime('%Y%m%d%H%M%S')}-{file.filename}")
|
|
with open(saved_path, "wb") as f:
|
|
f.write(contents)
|
|
|
|
placeholder_date = date.today()
|
|
ts = TimesheetPeriod(period_start=placeholder_date, period_end=placeholder_date, name=name)
|
|
db.add(ts)
|
|
db.flush()
|
|
new_id = ts.id
|
|
db.commit()
|
|
|
|
return RedirectResponse(
|
|
url=f"/import/department/start-initial?timesheet_id={new_id}&src={quote(saved_path, safe='')}",
|
|
status_code=303,
|
|
)
|
|
|
|
except Exception as ex:
|
|
print(f"[upload] staging failed: {ex}")
|
|
return templates.TemplateResponse("upload.html", {"request": request, "error": f"Staging failed: {ex}"}, status_code=500)
|
|
|
|
# -------- Assign weeks --------
|
|
@app.get("/assign-weeks", response_class=HTMLResponse)
|
|
@login_required
|
|
def assign_weeks_page(
|
|
request: Request,
|
|
timesheet_id: int = Query(...),
|
|
db: Session = Depends(get_session),
|
|
):
|
|
if not current_is_admin(db, request.session.get("user_id")):
|
|
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")
|
|
|
|
days = [
|
|
r[0]
|
|
for r in (
|
|
db.query(TimeEntry.work_date)
|
|
.filter(TimeEntry.timesheet_id == timesheet_id)
|
|
.group_by(TimeEntry.work_date)
|
|
.order_by(TimeEntry.work_date.asc())
|
|
.all()
|
|
)
|
|
]
|
|
existing = {
|
|
wa.day_date: wa.week_number
|
|
for wa in db.query(WeekAssignment).filter(WeekAssignment.timesheet_id == timesheet_id).all()
|
|
}
|
|
|
|
return templates.TemplateResponse(
|
|
"assign_weeks.html",
|
|
{
|
|
"request": request,
|
|
"timesheet_id": timesheet_id,
|
|
"period": f"{ts.period_start.isoformat()}..{ts.period_end.isoformat()}",
|
|
"days": days,
|
|
"existing": existing,
|
|
"timesheet_name": ts.name or "",
|
|
},
|
|
)
|
|
|
|
|
|
@app.post("/assign-weeks")
|
|
@login_required
|
|
async def assign_weeks_submit(
|
|
request: Request,
|
|
timesheet_id: int = Form(...),
|
|
timesheet_name: Optional[str] = Form(None),
|
|
db: Session = Depends(get_session),
|
|
):
|
|
require_admin_edit(request, db)
|
|
|
|
ts = db.query(TimesheetPeriod).get(timesheet_id)
|
|
if not ts:
|
|
raise HTTPException(status_code=404, detail="Time Period not found")
|
|
|
|
form = await request.form()
|
|
for k, v in form.items():
|
|
if not k.startswith("week_"):
|
|
continue
|
|
day_str = k[len("week_"):]
|
|
try:
|
|
day = date.fromisoformat(day_str)
|
|
week = int(v)
|
|
except Exception:
|
|
continue
|
|
|
|
wa = (
|
|
db.query(WeekAssignment)
|
|
.filter(WeekAssignment.timesheet_id == timesheet_id, WeekAssignment.day_date == day)
|
|
.first()
|
|
)
|
|
if not wa:
|
|
db.add(
|
|
WeekAssignment(
|
|
timesheet_id=timesheet_id,
|
|
period_start=ts.period_start,
|
|
period_end=ts.period_end,
|
|
day_date=day,
|
|
week_number=week,
|
|
)
|
|
)
|
|
else:
|
|
wa.week_number = week
|
|
|
|
if timesheet_name is not None:
|
|
ts.name = (timesheet_name or "").strip() or None
|
|
|
|
db.commit()
|
|
return RedirectResponse(url=f"/viewer?timesheet_id={timesheet_id}", status_code=303)
|
|
|
|
# -------- Decimal-safe helpers --------
|
|
def _to_dec_opt(v: Optional[str]) -> Optional[Decimal]:
|
|
if v is None:
|
|
return None
|
|
try:
|
|
return q2(D(v))
|
|
except Exception:
|
|
return None
|
|
|
|
def _parse_money(v: Optional[str]) -> Optional[Decimal]:
|
|
"""
|
|
Parse currency-like input (e.g., "$370", "370.00", "370") to Decimal(0.01).
|
|
Returns None for blank.
|
|
"""
|
|
if v is None:
|
|
return None
|
|
s = (str(v) or "").strip()
|
|
if not s:
|
|
return None
|
|
# strip $ and commas
|
|
s = s.replace("$", "").replace(",", "")
|
|
try:
|
|
d = Decimal(s)
|
|
return d.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
|
|
except Exception:
|
|
return None
|
|
|
|
# -------- Entry editing --------
|
|
@app.post("/timesheet/update-entry")
|
|
@login_required
|
|
def update_entry(
|
|
request: Request,
|
|
entry_id: int = Form(...),
|
|
timesheet_id: int = Form(...),
|
|
employee_id: Optional[int] = Form(None),
|
|
total_hours: Optional[str] = Form(None),
|
|
break_hours: Optional[str] = Form(None),
|
|
pto_hours: Optional[str] = Form("0"),
|
|
pto_type: Optional[str] = Form(""),
|
|
holiday_hours: Optional[str] = Form(None),
|
|
bereavement_hours: Optional[str] = Form(None),
|
|
redirect_to: Optional[str] = Form(None),
|
|
db: Session = Depends(get_session),
|
|
):
|
|
require_admin_edit(request, db)
|
|
|
|
entry = db.query(TimeEntry).get(entry_id)
|
|
if not entry:
|
|
raise HTTPException(status_code=404, detail="Entry not found")
|
|
|
|
th = _to_dec_opt(total_hours)
|
|
bh = _to_dec_opt(break_hours)
|
|
ph = _to_dec_opt(pto_hours)
|
|
hh = _to_dec_opt(holiday_hours)
|
|
oh = _to_dec_opt(bereavement_hours)
|
|
|
|
if th is not None:
|
|
entry.total_hours = th
|
|
if bh is not None:
|
|
entry.break_hours = bh
|
|
if hh is not None:
|
|
entry.holiday_hours = hh
|
|
if oh is not None:
|
|
entry.bereavement_hours = oh
|
|
|
|
new_pto_type = (pto_type or "").strip() or None
|
|
|
|
if new_pto_type and not entry.pto_type:
|
|
if entry.clock_in:
|
|
entry.pto_clock_in_backup = entry.clock_in
|
|
if entry.clock_out:
|
|
entry.pto_clock_out_backup = entry.clock_out
|
|
entry.clock_in = None
|
|
entry.clock_out = None
|
|
|
|
if not new_pto_type and entry.pto_type:
|
|
if entry.pto_clock_in_backup:
|
|
entry.clock_in = entry.pto_clock_in_backup
|
|
if entry.pto_clock_out_backup:
|
|
entry.clock_out = entry.pto_clock_out_backup
|
|
|
|
entry.pto_type = new_pto_type
|
|
if ph is not None:
|
|
entry.pto_hours = ph
|
|
|
|
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.commit()
|
|
|
|
if redirect_to:
|
|
return RedirectResponse(url=redirect_to, status_code=303)
|
|
|
|
suffix = f"?timesheet_id={timesheet_id}"
|
|
if employee_id:
|
|
suffix += f"&employee_id={employee_id}"
|
|
return RedirectResponse(url=f"/viewer{suffix}", status_code=303)
|
|
|
|
# -------- Routers --------
|
|
app.include_router(attendance_router)
|
|
app.include_router(dept_router)
|
|
|
|
# -------- Clock editing (JSON for instant UI updates) --------
|
|
def _parse_dt_local(v: Optional[str]) -> Optional[datetime]:
|
|
if not v:
|
|
return None
|
|
v = v.strip()
|
|
if not v:
|
|
return None
|
|
try:
|
|
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
|
|
|
|
@app.post("/timesheet/update-clocks")
|
|
@login_required
|
|
async def update_clocks(
|
|
request: Request,
|
|
entry_id: int = Form(...),
|
|
clock_in: Optional[str] = Form(None),
|
|
clock_out: Optional[str] = Form(None),
|
|
break_hours: Optional[str] = Form(None),
|
|
pto_hours: Optional[str] = Form(None),
|
|
holiday_hours: Optional[str] = Form(None),
|
|
bereavement_hours: Optional[str] = Form(None),
|
|
redirect_to: Optional[str] = Form(None),
|
|
db: Session = Depends(get_session),
|
|
):
|
|
accept = (request.headers.get("accept") or "").lower()
|
|
|
|
if not current_is_admin(db, request.session.get("user_id")):
|
|
if "application/json" in accept:
|
|
return JSONResponse({"ok": False, "error": "admin_required"}, status_code=403)
|
|
raise HTTPException(status_code=403, detail="Read-only user: edit action not permitted")
|
|
|
|
entry = db.query(TimeEntry).get(entry_id)
|
|
if not entry:
|
|
if "application/json" in accept:
|
|
return JSONResponse({"ok": False, "error": "not_found"}, status_code=404)
|
|
return RedirectResponse(url=redirect_to or "/", status_code=303)
|
|
|
|
new_ci = _parse_dt_local(clock_in)
|
|
new_co = _parse_dt_local(clock_out)
|
|
|
|
if new_ci is not None:
|
|
entry.clock_in = new_ci
|
|
if new_co is not None:
|
|
entry.clock_out = new_co
|
|
|
|
if entry.clock_in and entry.clock_out and entry.clock_out <= entry.clock_in:
|
|
entry.clock_out = entry.clock_out + timedelta(days=1)
|
|
|
|
if entry.clock_in and entry.clock_out:
|
|
entry.total_hours = q2(D((entry.clock_out - entry.clock_in).total_seconds()) / D(3600))
|
|
|
|
bh = _to_dec_opt(break_hours)
|
|
ph = _to_dec_opt(pto_hours)
|
|
hh = _to_dec_opt(holiday_hours)
|
|
oh = _to_dec_opt(bereavement_hours)
|
|
|
|
if bh is not None:
|
|
entry.break_hours = bh
|
|
if ph is not None:
|
|
entry.pto_hours = ph
|
|
if hh is not None:
|
|
entry.holiday_hours = hh
|
|
if oh is not None:
|
|
entry.bereavement_hours = oh
|
|
|
|
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.commit()
|
|
|
|
if "application/json" in accept:
|
|
return JSONResponse({
|
|
"ok": True,
|
|
"clock_in_fmt": fmt_excel_dt(entry.clock_in) if entry.clock_in else None,
|
|
"clock_out_fmt": fmt_excel_dt(entry.clock_out) if entry.clock_out else None,
|
|
"total_hours": float(D(entry.total_hours or 0)),
|
|
"total_hours_fmt": fmt2(entry.total_hours or 0),
|
|
"hours_paid": float(D(entry.hours_paid or 0)),
|
|
"hours_paid_fmt": fmt2(entry.hours_paid or 0),
|
|
})
|
|
|
|
return RedirectResponse(url=redirect_to or "/", status_code=303)
|
|
|
|
# -------- Delete a single time entry --------
|
|
@app.post("/timesheet/delete-entry")
|
|
@login_required
|
|
def delete_entry_row(
|
|
request: Request,
|
|
entry_id: int = Form(...),
|
|
timesheet_id: int = Form(...),
|
|
db: Session = Depends(get_session),
|
|
):
|
|
require_admin_edit(request, db)
|
|
|
|
entry = db.query(TimeEntry).get(entry_id)
|
|
if not entry or int(entry.timesheet_id) != int(timesheet_id):
|
|
raise HTTPException(status_code=404, detail="Time entry not found for this time period")
|
|
|
|
db.execute(text("DELETE FROM import_batch_items WHERE time_entry_id = :eid"), {"eid": entry_id})
|
|
|
|
db.delete(entry)
|
|
db.commit()
|
|
return JSONResponse({"ok": True})
|
|
|
|
# -------- Bulk delete (multi-select) --------
|
|
@app.post("/timesheet/delete-entries")
|
|
@login_required
|
|
def delete_entries(
|
|
request: Request,
|
|
timesheet_id: int = Form(...),
|
|
entry_ids: str = Form(...),
|
|
db: Session = Depends(get_session),
|
|
):
|
|
require_admin_edit(request, db)
|
|
|
|
ids = []
|
|
for tok in (entry_ids or "").split(","):
|
|
tok = tok.strip()
|
|
if not tok:
|
|
continue
|
|
try:
|
|
ids.append(int(tok))
|
|
except Exception:
|
|
continue
|
|
ids = list({i for i in ids})
|
|
if not ids:
|
|
return JSONResponse({"ok": True, "deleted": 0})
|
|
|
|
valid_ids = [
|
|
rid
|
|
for (rid,) in db.query(TimeEntry.id)
|
|
.filter(TimeEntry.id.in_(ids), TimeEntry.timesheet_id == timesheet_id)
|
|
.all()
|
|
]
|
|
if not valid_ids:
|
|
return JSONResponse({"ok": True, "deleted": 0})
|
|
|
|
for eid in valid_ids:
|
|
db.execute(text("DELETE FROM import_batch_items WHERE time_entry_id = :eid"), {"eid": eid})
|
|
|
|
db.query(TimeEntry).filter(TimeEntry.id.in_(valid_ids)).delete(synchronize_session=False)
|
|
db.commit()
|
|
return JSONResponse({"ok": True, "deleted": len(valid_ids)})
|
|
|
|
# -------- Employee-period settings --------
|
|
@app.post("/viewer/update-employee-period")
|
|
@login_required
|
|
def update_employee_period(
|
|
request: Request,
|
|
employee_id: int = Form(...),
|
|
timesheet_id: int = Form(...),
|
|
carry_over_hours: Optional[str] = Form("0"),
|
|
redirect_to: Optional[str] = Form(None),
|
|
db: Session = Depends(get_session),
|
|
):
|
|
require_admin_edit(request, db)
|
|
|
|
ts = db.query(TimesheetPeriod).get(timesheet_id)
|
|
if not ts:
|
|
raise HTTPException(status_code=404, detail="Time Period not found")
|
|
|
|
eps = (
|
|
db.query(EmployeePeriodSetting)
|
|
.filter(EmployeePeriodSetting.timesheet_id == timesheet_id, EmployeePeriodSetting.employee_id == employee_id)
|
|
.first()
|
|
)
|
|
if not eps:
|
|
eps = EmployeePeriodSetting(
|
|
employee_id=employee_id,
|
|
period_start=ts.period_start,
|
|
period_end=ts.period_end,
|
|
timesheet_id=timesheet_id,
|
|
)
|
|
db.add(eps)
|
|
|
|
eps.carry_over_hours = q2(D(carry_over_hours or "0"))
|
|
|
|
db.commit()
|
|
if redirect_to:
|
|
return RedirectResponse(url=redirect_to, status_code=303)
|
|
return RedirectResponse(url=f"/viewer?timesheet_id={timesheet_id}&employee_id={employee_id}", status_code=303)
|
|
|
|
# -------- Submit timesheet --------
|
|
@app.post("/viewer/submit")
|
|
@login_required
|
|
def submit_timesheet(
|
|
request: Request,
|
|
employee_id: int = Form(...),
|
|
timesheet_id: int = Form(...),
|
|
db: Session = Depends(get_session),
|
|
):
|
|
require_admin_edit(request, db)
|
|
|
|
ts = db.query(TimesheetPeriod).get(timesheet_id)
|
|
if not ts:
|
|
raise HTTPException(status_code=404, detail="Time Period not found")
|
|
|
|
row = (
|
|
db.query(TimesheetStatus)
|
|
.filter(TimesheetStatus.timesheet_id == timesheet_id, TimesheetStatus.employee_id == employee_id)
|
|
.first()
|
|
)
|
|
if not row:
|
|
db.add(
|
|
TimesheetStatus(
|
|
timesheet_id=timesheet_id,
|
|
employee_id=employee_id,
|
|
period_start=ts.period_start,
|
|
period_end=ts.period_end,
|
|
status="submitted",
|
|
)
|
|
)
|
|
else:
|
|
row.status = "submitted"
|
|
db.commit()
|
|
|
|
employees_all = db.query(Employee).order_by(Employee.name.asc()).all()
|
|
emp_ids_with_rows = [
|
|
r[0]
|
|
for r in db.query(TimeEntry.employee_id)
|
|
.filter(TimeEntry.timesheet_id == timesheet_id)
|
|
.group_by(TimeEntry.employee_id)
|
|
.all()
|
|
]
|
|
submitted = {
|
|
r[0]: r[1]
|
|
for r in db.query(TimesheetStatus.employee_id, TimesheetStatus.status)
|
|
.filter(TimesheetStatus.timesheet_id == timesheet_id)
|
|
.all()
|
|
}
|
|
pending_ids = [eid for eid in emp_ids_with_rows if eid not in submitted or submitted[eid] != "submitted"]
|
|
|
|
next_employee_id: Optional[int] = None
|
|
current_index = next((i for i, e in enumerate(employees_all) if e.id == employee_id), None)
|
|
if current_index is not None:
|
|
for e in employees_all[current_index + 1 :]:
|
|
if e.id in pending_ids:
|
|
next_employee_id = e.id
|
|
break
|
|
|
|
if next_employee_id:
|
|
return RedirectResponse(url=f"/viewer?timesheet_id={timesheet_id}&employee_id={next_employee_id}", status_code=303)
|
|
|
|
if len(pending_ids) == 0:
|
|
msg = "You are all done with this timeperiod, See you next time!"
|
|
return RedirectResponse(url=f"/viewer?timesheet_id={timesheet_id}&include_submitted=1&msg={msg}", status_code=303)
|
|
|
|
first_pending = next((e.id for e in employees_all if e.id in pending_ids), None)
|
|
if first_pending:
|
|
return RedirectResponse(url=f"/viewer?timesheet_id={timesheet_id}&employee_id={first_pending}", status_code=303)
|
|
|
|
return RedirectResponse(url=f"/viewer?timesheet_id={timesheet_id}", status_code=303)
|
|
|
|
# -------- Keep duplicates / review actions (unchanged) --------
|
|
@app.post("/viewer/keep-duplicates")
|
|
@login_required
|
|
def viewer_keep_duplicates(
|
|
request: Request,
|
|
employee_id: int = Form(...),
|
|
timesheet_id: int = Form(...),
|
|
db: Session = Depends(get_session),
|
|
):
|
|
require_admin_edit(request, db)
|
|
|
|
dup_rows = (
|
|
db.query(TimeEntry.work_date, func.count(TimeEntry.id))
|
|
.filter(TimeEntry.timesheet_id == timesheet_id, TimeEntry.employee_id == employee_id)
|
|
.group_by(TimeEntry.work_date)
|
|
.having(func.count(TimeEntry.id) > 1)
|
|
.order_by(TimeEntry.work_date.asc())
|
|
.all()
|
|
)
|
|
dup_dates = [d for d, _ in dup_rows]
|
|
|
|
created = 0
|
|
for d in dup_dates:
|
|
exists = (
|
|
db.query(DuplicateReview)
|
|
.filter(
|
|
DuplicateReview.timesheet_id == timesheet_id,
|
|
DuplicateReview.employee_id == employee_id,
|
|
DuplicateReview.work_date == d,
|
|
)
|
|
.first()
|
|
)
|
|
if not exists:
|
|
db.add(DuplicateReview(timesheet_id=timesheet_id, employee_id=employee_id, work_date=d))
|
|
created += 1
|
|
if created:
|
|
db.commit()
|
|
|
|
msg = "Duplicate dates marked as reviewed."
|
|
return RedirectResponse(
|
|
url=f"/viewer?timesheet_id={timesheet_id}&employee_id={employee_id}&msg={msg}",
|
|
status_code=303,
|
|
)
|
|
|
|
@app.post("/viewer/review-long-shifts")
|
|
@login_required
|
|
def viewer_review_long_shifts(
|
|
request: Request,
|
|
employee_id: int = Form(...),
|
|
timesheet_id: int = Form(...),
|
|
db: Session = Depends(get_session),
|
|
):
|
|
require_admin_edit(request, db)
|
|
|
|
flagged_dates = {
|
|
r.work_date
|
|
for r in db.query(LongShiftFlag)
|
|
.filter(LongShiftFlag.timesheet_id == timesheet_id, LongShiftFlag.employee_id == employee_id)
|
|
.all()
|
|
}
|
|
|
|
created = 0
|
|
for d in flagged_dates:
|
|
exists = (
|
|
db.query(LongShiftReview)
|
|
.filter(
|
|
LongShiftReview.timesheet_id == timesheet_id,
|
|
LongShiftReview.employee_id == employee_id,
|
|
LongShiftReview.work_date == d,
|
|
)
|
|
.first()
|
|
)
|
|
if not exists:
|
|
db.add(LongShiftReview(timesheet_id=timesheet_id, employee_id=employee_id, work_date=d))
|
|
created += 1
|
|
if created:
|
|
db.commit()
|
|
|
|
msg = "Long shifts marked as reviewed."
|
|
return RedirectResponse(
|
|
url=f"/viewer?timesheet_id={timesheet_id}&employee_id={employee_id}&msg={msg}",
|
|
status_code=303,
|
|
)
|
|
|
|
@app.post("/viewer/review-pto-needs")
|
|
@login_required
|
|
def viewer_review_pto_needs(
|
|
request: Request,
|
|
employee_id: int = Form(...),
|
|
timesheet_id: int = Form(...),
|
|
db: Session = Depends(get_session),
|
|
):
|
|
require_admin_edit(request, db)
|
|
|
|
flagged_dates = {
|
|
r.work_date
|
|
for r in db.query(PtoNeedFlag)
|
|
.filter(PtoNeedFlag.timesheet_id == timesheet_id, PtoNeedFlag.employee_id == employee_id)
|
|
.all()
|
|
}
|
|
|
|
created = 0
|
|
for d in flagged_dates:
|
|
exists = (
|
|
db.query(PtoReviewFlag)
|
|
.filter(
|
|
PtoReviewFlag.timesheet_id == timesheet_id,
|
|
PtoReviewFlag.employee_id == employee_id,
|
|
PtoReviewFlag.work_date == d,
|
|
)
|
|
.first()
|
|
)
|
|
if not exists:
|
|
db.add(PtoReviewFlag(timesheet_id=timesheet_id, employee_id=employee_id, work_date=d))
|
|
created += 1
|
|
if created:
|
|
db.commit()
|
|
|
|
msg = "PTO review marked as reviewed."
|
|
return RedirectResponse(
|
|
url=f"/viewer?timesheet_id={timesheet_id}&employee_id={employee_id}&msg={msg}",
|
|
status_code=303,
|
|
)
|
|
|
|
@app.post("/viewer/review-holiday-needs")
|
|
@login_required
|
|
def viewer_review_holiday_needs(
|
|
request: Request,
|
|
employee_id: int = Form(...),
|
|
timesheet_id: int = Form(...),
|
|
db: Session = Depends(get_session),
|
|
):
|
|
require_admin_edit(request, db)
|
|
|
|
flagged_dates = _flagged_holiday_dates(db, timesheet_id, employee_id)
|
|
|
|
created = 0
|
|
for d in flagged_dates:
|
|
exists = (
|
|
db.query(HolidayReviewFlag)
|
|
.filter(
|
|
HolidayReviewFlag.timesheet_id == timesheet_id,
|
|
HolidayReviewFlag.employee_id == employee_id,
|
|
HolidayReviewFlag.work_date == d,
|
|
)
|
|
.first()
|
|
)
|
|
if not exists:
|
|
db.add(HolidayReviewFlag(timesheet_id=timesheet_id, employee_id=employee_id, work_date=d))
|
|
created += 1
|
|
if created:
|
|
db.commit()
|
|
|
|
msg = "Holiday rows marked as reviewed."
|
|
return RedirectResponse(
|
|
url=f"/viewer?timesheet_id={timesheet_id}&employee_id={employee_id}&msg={msg}",
|
|
status_code=303,
|
|
)
|
|
|
|
@app.post("/review/review-holiday-needs")
|
|
@login_required
|
|
def review_review_holiday_needs(
|
|
request: Request,
|
|
employee_id: int = Form(...),
|
|
timesheet_id: int = Form(...),
|
|
db: Session = Depends(get_session),
|
|
):
|
|
require_admin_edit(request, db)
|
|
|
|
flagged_dates = _flagged_holiday_dates(db, timesheet_id, employee_id)
|
|
|
|
created = 0
|
|
for d in flagged_dates:
|
|
exists = (
|
|
db.query(HolidayReviewFlag)
|
|
.filter(
|
|
HolidayReviewFlag.timesheet_id == timesheet_id,
|
|
HolidayReviewFlag.employee_id == employee_id,
|
|
HolidayReviewFlag.work_date == d,
|
|
)
|
|
.first()
|
|
)
|
|
if not exists:
|
|
db.add(HolidayReviewFlag(timesheet_id=timesheet_id, employee_id=employee_id, work_date=d))
|
|
created += 1
|
|
if created:
|
|
db.commit()
|
|
|
|
msg = "Holiday rows marked as reviewed."
|
|
return RedirectResponse(
|
|
url=f"/review/edit?timesheet_id={timesheet_id}&employee_id={employee_id}&flash={msg.replace(' ', '+')}",
|
|
status_code=303,
|
|
)
|
|
|
|
# -------- Delete time period --------
|
|
@app.post("/viewer/delete-period")
|
|
@login_required
|
|
def delete_period_post(
|
|
request: Request,
|
|
timesheet_id: int = Form(...),
|
|
db: Session = Depends(get_session),
|
|
):
|
|
require_admin_edit(request, db)
|
|
|
|
ts = db.query(TimesheetPeriod).get(timesheet_id)
|
|
if not ts:
|
|
raise HTTPException(status_code=404, detail="Time Period not found")
|
|
|
|
db.execute(
|
|
text("""
|
|
DELETE FROM import_batch_items
|
|
USING time_entries
|
|
WHERE import_batch_items.time_entry_id = time_entries.id
|
|
AND time_entries.timesheet_id = :tid
|
|
"""),
|
|
{"tid": timesheet_id},
|
|
)
|
|
|
|
db.query(TimeEntry).filter(TimeEntry.timesheet_id == timesheet_id).delete(synchronize_session=False)
|
|
db.query(WeekAssignment).filter(WeekAssignment.timesheet_id == timesheet_id).delete(synchronize_session=False)
|
|
db.query(TimesheetStatus).filter(TimesheetStatus.timesheet_id == timesheet_id).delete(synchronize_session=False)
|
|
db.query(EmployeePeriodSetting).filter(EmployeePeriodSetting.timesheet_id == timesheet_id).delete(synchronize_session=False)
|
|
db.query(DuplicateReview).filter(DuplicateReview.timesheet_id == timesheet_id).delete(synchronize_session=False)
|
|
db.query(LongShiftFlag).filter(LongShiftFlag.timesheet_id == timesheet_id).delete(synchronize_session=False)
|
|
db.query(LongShiftReview).filter(LongShiftReview.timesheet_id == timesheet_id).delete(synchronize_session=False)
|
|
db.query(PtoNeedFlag).filter(PtoNeedFlag.timesheet_id == timesheet_id).delete(synchronize_session=False)
|
|
db.query(PtoReviewFlag).filter(PtoReviewFlag.timesheet_id == timesheet_id).delete(synchronize_session=False)
|
|
db.query(HolidayReviewFlag).filter(HolidayReviewFlag.timesheet_id == timesheet_id).delete(synchronize_session=False)
|
|
db.query(PayrollNote).filter(PayrollNote.timesheet_id == timesheet_id).delete(synchronize_session=False)
|
|
|
|
db.delete(ts)
|
|
db.commit()
|
|
|
|
return RedirectResponse(url="/viewer?msg=Time+Period+deleted", status_code=303)
|
|
|
|
# -------- NEW: Remove an employee from a single time period --------
|
|
@app.post("/viewer/remove-employee")
|
|
@login_required
|
|
def viewer_remove_employee(
|
|
request: Request,
|
|
employee_id: int = Form(...),
|
|
timesheet_id: int = Form(...),
|
|
db: Session = Depends(get_session),
|
|
):
|
|
"""
|
|
Remove an employee's data from a single timesheet:
|
|
- Deletes their time entries for the period
|
|
- Cleans up import batch item references
|
|
- Clears status, settings, duplicate reviews, long-shift flags/reviews,
|
|
PTO need/review flags, holiday review flags, and payroll notes
|
|
"""
|
|
require_admin_edit(request, db)
|
|
|
|
ts = db.query(TimesheetPeriod).get(timesheet_id)
|
|
if not ts:
|
|
raise HTTPException(status_code=404, detail="Time Period not found")
|
|
emp = db.query(Employee).get(employee_id)
|
|
if not emp:
|
|
raise HTTPException(status_code=404, detail="Employee not found")
|
|
|
|
# Delete import batch item links for this employee's entries in this period
|
|
db.execute(
|
|
text("""
|
|
DELETE FROM import_batch_items
|
|
USING time_entries
|
|
WHERE import_batch_items.time_entry_id = time_entries.id
|
|
AND time_entries.timesheet_id = :tid
|
|
AND time_entries.employee_id = :eid
|
|
"""),
|
|
{"tid": timesheet_id, "eid": employee_id},
|
|
)
|
|
|
|
# Delete time entries
|
|
deleted_entries = db.query(TimeEntry).filter(
|
|
TimeEntry.timesheet_id == timesheet_id,
|
|
TimeEntry.employee_id == employee_id,
|
|
).delete(synchronize_session=False)
|
|
|
|
# Delete ancillary per-employee/per-period rows
|
|
db.query(TimesheetStatus).filter(
|
|
TimesheetStatus.timesheet_id == timesheet_id,
|
|
TimesheetStatus.employee_id == employee_id,
|
|
).delete(synchronize_session=False)
|
|
|
|
db.query(EmployeePeriodSetting).filter(
|
|
EmployeePeriodSetting.timesheet_id == timesheet_id,
|
|
EmployeePeriodSetting.employee_id == employee_id,
|
|
).delete(synchronize_session=False)
|
|
|
|
db.query(DuplicateReview).filter(
|
|
DuplicateReview.timesheet_id == timesheet_id,
|
|
DuplicateReview.employee_id == employee_id,
|
|
).delete(synchronize_session=False)
|
|
|
|
db.query(LongShiftFlag).filter(
|
|
LongShiftFlag.timesheet_id == timesheet_id,
|
|
LongShiftFlag.employee_id == employee_id,
|
|
).delete(synchronize_session=False)
|
|
|
|
db.query(LongShiftReview).filter(
|
|
LongShiftReview.timesheet_id == timesheet_id,
|
|
LongShiftReview.employee_id == employee_id,
|
|
).delete(synchronize_session=False)
|
|
|
|
db.query(PtoNeedFlag).filter(
|
|
PtoNeedFlag.timesheet_id == timesheet_id,
|
|
PtoNeedFlag.employee_id == employee_id,
|
|
).delete(synchronize_session=False)
|
|
|
|
db.query(PtoReviewFlag).filter(
|
|
PtoReviewFlag.timesheet_id == timesheet_id,
|
|
PtoReviewFlag.employee_id == employee_id,
|
|
).delete(synchronize_session=False)
|
|
|
|
db.query(HolidayReviewFlag).filter(
|
|
HolidayReviewFlag.timesheet_id == timesheet_id,
|
|
HolidayReviewFlag.employee_id == employee_id,
|
|
).delete(synchronize_session=False)
|
|
|
|
db.query(PayrollNote).filter(
|
|
PayrollNote.timesheet_id == timesheet_id,
|
|
PayrollNote.employee_id == employee_id,
|
|
).delete(synchronize_session=False)
|
|
|
|
db.commit()
|
|
|
|
return JSONResponse({"ok": True, "deleted_entries": int(deleted_entries)})
|
|
|
|
# -----------------------
|
|
# Admin pages (users/employees) - unchanged
|
|
# -----------------------
|
|
@app.get("/admin/employees", response_class=HTMLResponse)
|
|
@login_required
|
|
def admin_employees_page(
|
|
request: Request,
|
|
include_inactive: int = Query(1),
|
|
db: Session = Depends(get_session),
|
|
):
|
|
if not current_is_admin(db, request.session.get("user_id")):
|
|
raise HTTPException(status_code=403, detail="Admin access required")
|
|
|
|
if include_inactive:
|
|
employees = db.query(Employee).order_by(Employee.name.asc()).all()
|
|
else:
|
|
employees = db.query(Employee).filter(text("COALESCE(is_active, TRUE) = TRUE")).order_by(Employee.name.asc()).all()
|
|
|
|
inactive_ids = {r[0] for r in db.execute(text("SELECT id FROM employees WHERE COALESCE(is_active, TRUE) = FALSE")).fetchall()}
|
|
|
|
return templates.TemplateResponse(
|
|
"admin_employees.html",
|
|
{"request": request, "employees": employees, "include_inactive": include_inactive, "inactive_ids": inactive_ids},
|
|
)
|
|
|
|
@app.post("/admin/employees/set-status")
|
|
@login_required
|
|
def admin_employees_set_status(
|
|
request: Request,
|
|
employee_id: int = Form(...),
|
|
is_active: int = Form(...),
|
|
termination_date: Optional[str] = Form(None),
|
|
redirect_to: Optional[str] = Form(None),
|
|
db: Session = Depends(get_session),
|
|
):
|
|
if not current_is_admin(db, request.session.get("user_id")):
|
|
raise HTTPException(status_code=403, detail="Admin access required")
|
|
|
|
emp = db.query(Employee).get(employee_id)
|
|
if not emp:
|
|
raise HTTPException(status_code=404, detail="Employee not found")
|
|
|
|
emp.is_active = bool(int(is_active))
|
|
if not emp.is_active:
|
|
td = (termination_date or "").strip()
|
|
if td:
|
|
try:
|
|
emp.termination_date = date.fromisoformat(td)
|
|
except Exception:
|
|
raise HTTPException(status_code=400, detail="Invalid termination_date")
|
|
else:
|
|
emp.termination_date = None
|
|
else:
|
|
emp.termination_date = None
|
|
|
|
db.commit()
|
|
return RedirectResponse(url=(redirect_to or "/admin/employees"), status_code=303)
|
|
|
|
@app.get("/admin/users", response_class=HTMLResponse)
|
|
@login_required
|
|
def admin_users_page(
|
|
request: Request,
|
|
msg: Optional[str] = None,
|
|
db: Session = Depends(get_session),
|
|
):
|
|
if not current_is_admin(db, request.session.get("user_id")):
|
|
raise HTTPException(status_code=403, detail="Admin access required")
|
|
|
|
users = db.query(User).order_by(User.username.asc()).all()
|
|
admin_ids = {r.user_id for r in db.query(AdminUser).all()}
|
|
profiles = {r.user_id: (r.full_name or "") for r in db.query(UserProfile).all()}
|
|
admin_count = len(admin_ids)
|
|
|
|
return templates.TemplateResponse(
|
|
"admin_users.html",
|
|
{
|
|
"request": request,
|
|
"users": users,
|
|
"admin_ids": admin_ids,
|
|
"profiles": profiles,
|
|
"admin_count": admin_count,
|
|
"me_id": request.session.get("user_id"),
|
|
"flash": msg,
|
|
},
|
|
)
|
|
|
|
@app.post("/admin/users/create")
|
|
@login_required
|
|
def admin_users_create(
|
|
request: Request,
|
|
full_name: str = Form(""),
|
|
username: str = Form(...),
|
|
password: str = Form(...),
|
|
role: str = Form("user"),
|
|
db: Session = Depends(get_session),
|
|
):
|
|
if not current_is_admin(db, request.session.get("user_id")):
|
|
raise HTTPException(status_code=403, detail="Admin access required")
|
|
|
|
uname = (username or "").strip()
|
|
pwd = (password or "").strip()
|
|
role = (role or "user").strip().lower()
|
|
fname = (full_name or "").strip()
|
|
|
|
if not uname or not pwd:
|
|
return RedirectResponse(url="/admin/users?msg=Username+and+password+required", status_code=303)
|
|
if db.query(User).filter(User.username == uname).first():
|
|
return RedirectResponse(url="/admin/users?msg=Username+already+exists", status_code=303)
|
|
|
|
u = User(username=uname, password_hash=hash_password(pwd))
|
|
db.add(u)
|
|
db.flush()
|
|
db.add(UserProfile(user_id=u.id, full_name=fname or None))
|
|
if role == "admin":
|
|
db.add(AdminUser(user_id=u.id))
|
|
db.commit()
|
|
return RedirectResponse(url="/admin/users?msg=User+created", status_code=303)
|
|
|
|
@app.post("/admin/users/reset-password")
|
|
@login_required
|
|
def admin_users_reset_password(
|
|
request: Request,
|
|
user_id: int = Form(...),
|
|
new_password: str = Form(...),
|
|
db: Session = Depends(get_session),
|
|
):
|
|
if not current_is_admin(db, request.session.get("user_id")):
|
|
raise HTTPException(status_code=403, detail="Admin access required")
|
|
|
|
u = db.query(User).get(user_id)
|
|
if not u:
|
|
return RedirectResponse(url="/admin/users?msg=User+not+found", status_code=303)
|
|
pwd = (new_password or "").strip()
|
|
if not pwd:
|
|
return RedirectResponse(url="/admin/users?msg=Password+required", status_code=303)
|
|
u.password_hash = hash_password(pwd)
|
|
db.commit()
|
|
return RedirectResponse(url="/admin/users?msg=Password+reset", status_code=303)
|
|
|
|
@app.post("/admin/users/update-role")
|
|
@login_required
|
|
def admin_users_update_role(
|
|
request: Request,
|
|
user_id: int = Form(...),
|
|
role: str = Form(...),
|
|
db: Session = Depends(get_session),
|
|
):
|
|
if not current_is_admin(db, request.session.get("user_id")):
|
|
raise HTTPException(status_code=403, detail="Admin access required")
|
|
|
|
role = (role or "user").strip().lower()
|
|
target = db.query(User).get(user_id)
|
|
if not target:
|
|
return RedirectResponse(url="/admin/users?msg=User+not+found", status_code=303)
|
|
|
|
is_admin_now = bool(db.query(AdminUser).filter(AdminUser.user_id == user_id).first())
|
|
|
|
if role == "user":
|
|
return RedirectResponse(url="/admin/users?msg=Demotion+disabled", status_code=303)
|
|
|
|
if not is_admin_now:
|
|
db.add(AdminUser(user_id=user_id))
|
|
db.commit()
|
|
return RedirectResponse(url="/admin/users?msg=Promoted+to+admin", status_code=303)
|
|
|
|
return RedirectResponse(url="/admin/users?msg=Already+admin", status_code=303)
|
|
|
|
@app.post("/admin/users/delete")
|
|
@login_required
|
|
def admin_users_delete(
|
|
request: Request,
|
|
user_id: int = Form(...),
|
|
db: Session = Depends(get_session),
|
|
):
|
|
if not current_is_admin(db, request.session.get("user_id")):
|
|
raise HTTPException(status_code=403, detail="Admin access required")
|
|
|
|
if user_id == request.session.get("user_id"):
|
|
return RedirectResponse(url="/admin/users?msg=Cannot+delete+the+current+user", status_code=303)
|
|
|
|
target = db.query(User).get(user_id)
|
|
if not target:
|
|
return RedirectResponse(url="/admin/users?msg=User+not+found", status_code=303)
|
|
|
|
db.query(AdminUser).filter(AdminUser.user_id == user_id).delete(synchronize_session=False)
|
|
db.query(UserProfile).filter(UserProfile.user_id == user_id).delete(synchronize_session=False)
|
|
db.delete(target)
|
|
db.commit()
|
|
return RedirectResponse(url="/admin/users?msg=User+deleted", status_code=303)
|
|
|
|
# -------- Viewer (Timesheet Editor)
|
|
@app.get("/viewer", response_class=HTMLResponse)
|
|
@login_required
|
|
def viewer_page(
|
|
request: Request,
|
|
timesheet_id: Optional[int] = Query(None),
|
|
employee_id: Optional[int] = Query(None),
|
|
include_submitted: int = 0,
|
|
msg: Optional[str] = None,
|
|
db: Session = Depends(get_session),
|
|
):
|
|
employees_all = db.query(Employee).order_by(Employee.name.asc()).all()
|
|
|
|
sheets = enumerate_timesheets_global(db)
|
|
period_options = [{"timesheet_id": tid, "start": ps, "end": pe, "display": name} for tid, ps, pe, name in sheets]
|
|
if not period_options:
|
|
return templates.TemplateResponse(
|
|
"viewer.html",
|
|
{
|
|
"request": request,
|
|
"employees": [],
|
|
"selected_employee": None,
|
|
"period_options": [],
|
|
"active_ts": None,
|
|
"grouped": None,
|
|
"employee_setting": {"carry_over_hours": 0.0},
|
|
"duplicates": [],
|
|
"dup_dates": set(),
|
|
"flash": "No timesheet instances found.",
|
|
"all_done": False,
|
|
"can_edit": bool(request.session.get("is_admin")),
|
|
},
|
|
)
|
|
|
|
ts = db.query(TimesheetPeriod).get(timesheet_id) if timesheet_id else db.query(TimesheetPeriod).get(period_options[-1]["timesheet_id"])
|
|
if not ts:
|
|
ts = db.query(TimesheetPeriod).get(period_options[-1]["timesheet_id"])
|
|
active_ts_id = ts.id
|
|
|
|
done_message = "You are all done with this timeperiod, See you next time!"
|
|
if msg and done_message in msg:
|
|
return templates.TemplateResponse(
|
|
"viewer.html",
|
|
{
|
|
"request": request,
|
|
"employees": [],
|
|
"selected_employee": None,
|
|
"period_options": period_options,
|
|
"active_ts": active_ts_id,
|
|
"grouped": None,
|
|
"employee_setting": {"carry_over_hours": 0.0},
|
|
"duplicates": [],
|
|
"dup_dates": set(),
|
|
"flash": msg,
|
|
"all_done": True,
|
|
"can_edit": bool(request.session.get("is_admin")),
|
|
},
|
|
)
|
|
|
|
week_rows = db.query(WeekAssignment).filter(WeekAssignment.timesheet_id == active_ts_id).all()
|
|
week_map = {wr.day_date: wr.week_number for wr in week_rows}
|
|
if not week_map:
|
|
return RedirectResponse(url=f"/assign-weeks?timesheet_id={active_ts_id}", status_code=303)
|
|
|
|
emp_ids_with_rows = [
|
|
r[0] for r in db.query(TimeEntry.employee_id).filter(TimeEntry.timesheet_id == active_ts_id).group_by(TimeEntry.employee_id).all()
|
|
]
|
|
submitted = {
|
|
r[0]: r[1]
|
|
for r in db.query(TimesheetStatus.employee_id, TimesheetStatus.status).filter(TimesheetStatus.timesheet_id == active_ts_id).all()
|
|
}
|
|
pending_ids = [eid for eid in emp_ids_with_rows if eid not in submitted or submitted[eid] != "submitted"]
|
|
employees = [e for e in employees_all if (e.id in pending_ids) or (include_submitted and e.id in submitted)]
|
|
selected_employee = db.query(Employee).get(employee_id) if employee_id else (employees[0] if employees else (employees_all[0] if employees_all else None))
|
|
|
|
carry = 0.0
|
|
if selected_employee:
|
|
eps = (
|
|
db.query(EmployeePeriodSetting)
|
|
.filter(EmployeePeriodSetting.timesheet_id == active_ts_id, EmployeePeriodSetting.employee_id == selected_employee.id)
|
|
.first()
|
|
)
|
|
carry = float(eps.carry_over_hours if eps else 0.0)
|
|
|
|
entries = []
|
|
if selected_employee:
|
|
entries = (
|
|
db.query(TimeEntry)
|
|
.filter(TimeEntry.timesheet_id == active_ts_id, TimeEntry.employee_id == selected_employee.id)
|
|
.order_by(TimeEntry.work_date.asc())
|
|
.all()
|
|
)
|
|
grouped = group_entries_for_timesheet(entries, ts.period_start, ts.period_end, week_map=week_map, carry_over_hours=carry)
|
|
|
|
dups_rows_raw = []
|
|
dup_dates = set()
|
|
dups = []
|
|
if selected_employee:
|
|
dups_rows_raw = (
|
|
db.query(TimeEntry.work_date, func.count(TimeEntry.id))
|
|
.filter(TimeEntry.timesheet_id == active_ts_id, TimeEntry.employee_id == selected_employee.id)
|
|
.group_by(TimeEntry.work_date)
|
|
.having(func.count(TimeEntry.id) > 1)
|
|
.order_by(TimeEntry.work_date.asc())
|
|
.all()
|
|
)
|
|
reviewed_dates = {
|
|
r.work_date
|
|
for r in db.query(DuplicateReview)
|
|
.filter(DuplicateReview.timesheet_id == active_ts_id, DuplicateReview.employee_id == selected_employee.id)
|
|
.all()
|
|
}
|
|
dups_rows = [(d, c) for d, c in dups_rows_raw if d not in reviewed_dates]
|
|
dup_dates = {d for d, c in dups_rows}
|
|
dups = [{"date": d, "count": int(c)} for d, c in dups_rows]
|
|
|
|
reviewed_long_dates = set()
|
|
flagged_long_dates = set()
|
|
if selected_employee:
|
|
reviewed_long_dates = {
|
|
r.work_date
|
|
for r in db.query(LongShiftReview)
|
|
.filter(LongShiftReview.timesheet_id == active_ts_id, LongShiftReview.employee_id == selected_employee.id)
|
|
.all()
|
|
}
|
|
flagged_long_dates = {
|
|
r.work_date
|
|
for r in db.query(LongShiftFlag)
|
|
.filter(LongShiftFlag.timesheet_id == active_ts_id, LongShiftFlag.employee_id == selected_employee.id)
|
|
.all()
|
|
}
|
|
newly_flagged = 0
|
|
for r in grouped.rows:
|
|
if float(r.total_hours or 0.0) >= 10.0 and r.work_date not in flagged_long_dates:
|
|
db.add(LongShiftFlag(timesheet_id=active_ts_id, employee_id=selected_employee.id, work_date=r.work_date))
|
|
flagged_long_dates.add(r.work_date)
|
|
newly_flagged += 1
|
|
if newly_flagged:
|
|
db.commit()
|
|
|
|
reviewed_pto_dates = set()
|
|
flagged_pto_dates = set()
|
|
if selected_employee:
|
|
reviewed_pto_dates = {
|
|
r.work_date
|
|
for r in db.query(PtoReviewFlag)
|
|
.filter(PtoReviewFlag.timesheet_id == active_ts_id, PtoReviewFlag.employee_id == selected_employee.id)
|
|
.all()
|
|
}
|
|
flagged_pto_dates = {
|
|
r.work_date
|
|
for r in db.query(PtoNeedFlag)
|
|
.filter(PtoNeedFlag.timesheet_id == active_ts_id, PtoNeedFlag.employee_id == selected_employee.id)
|
|
.all()
|
|
}
|
|
newly_flagged_pto = 0
|
|
for r in grouped.rows:
|
|
if float(r.pto_hours or 0.0) > 0.0 and not (r.pto_type or "").strip() and r.work_date not in flagged_pto_dates:
|
|
db.add(PtoNeedFlag(timesheet_id=active_ts_id, employee_id=selected_employee.id, work_date=r.work_date))
|
|
flagged_pto_dates.add(r.work_date)
|
|
newly_flagged_pto += 1
|
|
if newly_flagged_pto:
|
|
db.commit()
|
|
|
|
flagged_holiday_dates = set()
|
|
reviewed_holiday_dates = set()
|
|
holiday_needs = []
|
|
if selected_employee:
|
|
flagged_holiday_dates = _flagged_holiday_dates(db, active_ts_id, selected_employee.id)
|
|
reviewed_holiday_dates = _reviewed_holiday_dates(db, active_ts_id, selected_employee.id)
|
|
holiday_needs = _holiday_needs_rows(db, active_ts_id, selected_employee.id)
|
|
|
|
long_shift_needs = [r for r in grouped.rows if (r.work_date in flagged_long_dates and r.work_date not in reviewed_long_dates)]
|
|
pto_needs = [r for r in grouped.rows if (r.work_date in flagged_pto_dates and r.work_date not in reviewed_pto_dates)]
|
|
|
|
return templates.TemplateResponse(
|
|
"viewer.html",
|
|
{
|
|
"request": request,
|
|
"employees": employees,
|
|
"selected_employee": selected_employee,
|
|
"period_options": period_options,
|
|
"active_ts": active_ts_id,
|
|
"grouped": grouped,
|
|
"employee_setting": {"carry_over_hours": carry},
|
|
"duplicates": dups,
|
|
"dup_dates": dup_dates,
|
|
"pto_needs": pto_needs,
|
|
"holiday_needs": holiday_needs,
|
|
"long_shift_needs": long_shift_needs,
|
|
"reviewed_long_dates": reviewed_long_dates,
|
|
"flagged_long_dates": flagged_long_dates,
|
|
"reviewed_pto_dates": reviewed_pto_dates,
|
|
"flagged_pto_dates": flagged_pto_dates,
|
|
"reviewed_holiday_dates": reviewed_holiday_dates,
|
|
"flagged_holiday_dates": flagged_holiday_dates,
|
|
"flash": msg,
|
|
"all_done": False,
|
|
"can_edit": bool(request.session.get("is_admin")),
|
|
},
|
|
)
|
|
|
|
# -------- Review list --------
|
|
@app.get("/review", response_class=HTMLResponse)
|
|
@login_required
|
|
def review_page(
|
|
request: Request,
|
|
timesheet_id: Optional[int] = Query(None),
|
|
msg: Optional[str] = None,
|
|
db: Session = Depends(get_session),
|
|
):
|
|
sheets = enumerate_timesheets_global(db)
|
|
period_options = [{"timesheet_id": tid, "display": name} for tid, ps, pe, name in sheets]
|
|
if not period_options:
|
|
return templates.TemplateResponse(
|
|
"review.html",
|
|
{"request": request, "submitted": [], "active_ts": None, "period_options": [], "flash": "No submitted timesheets yet."},
|
|
)
|
|
|
|
active_ts = timesheet_id or period_options[-1]["timesheet_id"]
|
|
|
|
rows = (
|
|
db.query(TimesheetStatus, Employee)
|
|
.join(Employee, Employee.id == TimesheetStatus.employee_id)
|
|
.filter(TimesheetStatus.timesheet_id == active_ts)
|
|
.order_by(Employee.name.asc())
|
|
.all()
|
|
)
|
|
submitted_rows = [
|
|
{"employee_id": emp.id, "employee_name": emp.name, "submitted_at": to_local(tsrow.submitted_at)}
|
|
for tsrow, emp in rows
|
|
if tsrow.status == "submitted"
|
|
]
|
|
|
|
return templates.TemplateResponse(
|
|
"review.html",
|
|
{"request": request, "submitted": submitted_rows, "active_ts": active_ts, "period_options": period_options, "flash": msg},
|
|
)
|
|
|
|
# -------- Review edit --------
|
|
@app.get("/review/edit", response_class=HTMLResponse)
|
|
@login_required
|
|
def review_edit_page(
|
|
request: Request,
|
|
timesheet_id: int = Query(...),
|
|
employee_id: int = Query(...),
|
|
msg: Optional[str] = None,
|
|
db: Session = Depends(get_session),
|
|
):
|
|
ts = db.query(TimesheetPeriod).get(timesheet_id)
|
|
if not ts:
|
|
raise HTTPException(status_code=404, detail="Time Period not found")
|
|
|
|
emp = db.query(Employee).get(employee_id)
|
|
if not emp:
|
|
raise HTTPException(status_code=404, detail="Employee not found")
|
|
|
|
eps = (
|
|
db.query(EmployeePeriodSetting)
|
|
.filter(EmployeePeriodSetting.timesheet_id == timesheet_id, EmployeePeriodSetting.employee_id == emp.id)
|
|
.first()
|
|
)
|
|
carry = float(eps.carry_over_hours if eps else 0.0)
|
|
week_rows = db.query(WeekAssignment).filter(WeekAssignment.timesheet_id == timesheet_id).all()
|
|
week_map = {wr.day_date: wr.week_number for wr in week_rows}
|
|
|
|
entries = (
|
|
db.query(TimeEntry)
|
|
.filter(TimeEntry.timesheet_id == timesheet_id, TimeEntry.employee_id == emp.id)
|
|
.order_by(TimeEntry.work_date.asc())
|
|
.all()
|
|
)
|
|
grouped = group_entries_for_timesheet(entries, ts.period_start, ts.period_end, week_map=week_map, carry_over_hours=carry)
|
|
|
|
dups_rows_raw = (
|
|
db.query(TimeEntry.work_date, func.count(TimeEntry.id))
|
|
.filter(TimeEntry.timesheet_id == timesheet_id, TimeEntry.employee_id == emp.id)
|
|
.group_by(TimeEntry.work_date)
|
|
.having(func.count(TimeEntry.id) > 1)
|
|
.order_by(TimeEntry.work_date.asc())
|
|
.all()
|
|
)
|
|
reviewed_dup_dates = {
|
|
r.work_date
|
|
for r in db.query(DuplicateReview)
|
|
.filter(DuplicateReview.timesheet_id == timesheet_id, DuplicateReview.employee_id == emp.id)
|
|
.all()
|
|
}
|
|
dups_rows = [(d, c) for d, c in dups_rows_raw if d not in reviewed_dup_dates]
|
|
dup_dates = {d for d, c in dups_rows}
|
|
duplicates = [{"date": d, "count": int(c)} for d, c in dups_rows]
|
|
|
|
reviewed_long_dates = {
|
|
r.work_date
|
|
for r in db.query(LongShiftReview)
|
|
.filter(LongShiftReview.timesheet_id == timesheet_id, LongShiftReview.employee_id == emp.id)
|
|
.all()
|
|
}
|
|
flagged_long_dates = {
|
|
r.work_date
|
|
for r in db.query(LongShiftFlag)
|
|
.filter(LongShiftFlag.timesheet_id == timesheet_id, LongShiftFlag.employee_id == emp.id)
|
|
.all()
|
|
}
|
|
newly_flagged = 0
|
|
for r in grouped.rows:
|
|
if float(r.total_hours or 0.0) >= 10.0 and r.work_date not in flagged_long_dates:
|
|
db.add(LongShiftFlag(timesheet_id=timesheet_id, employee_id=emp.id, work_date=r.work_date))
|
|
flagged_long_dates.add(r.work_date)
|
|
newly_flagged += 1
|
|
if newly_flagged:
|
|
db.commit()
|
|
long_shift_needs = [r for r in grouped.rows if (r.work_date in flagged_long_dates and r.work_date not in reviewed_long_dates)]
|
|
|
|
reviewed_pto_dates = {
|
|
r.work_date
|
|
for r in db.query(PtoReviewFlag)
|
|
.filter(PtoReviewFlag.timesheet_id == timesheet_id, PtoReviewFlag.employee_id == emp.id)
|
|
.all()
|
|
}
|
|
flagged_pto_dates = {
|
|
r.work_date
|
|
for r in db.query(PtoNeedFlag)
|
|
.filter(PtoNeedFlag.timesheet_id == timesheet_id, PtoNeedFlag.employee_id == emp.id)
|
|
.all()
|
|
}
|
|
newly_flagged_pto = 0
|
|
for r in grouped.rows:
|
|
if float(r.pto_hours or 0.0) > 0.0 and not (r.pto_type or "").strip() and r.work_date not in flagged_pto_dates:
|
|
db.add(PtoNeedFlag(timesheet_id=timesheet_id, employee_id=emp.id, work_date=r.work_date))
|
|
flagged_pto_dates.add(r.work_date)
|
|
newly_flagged_pto += 1
|
|
if newly_flagged_pto:
|
|
db.commit()
|
|
pto_needs = [r for r in grouped.rows if (r.work_date in flagged_pto_dates and r.work_date not in reviewed_pto_dates)]
|
|
|
|
flagged_holiday_dates = _flagged_holiday_dates(db, timesheet_id, emp.id)
|
|
reviewed_holiday_dates = _reviewed_holiday_dates(db, timesheet_id, emp.id)
|
|
holiday_needs = _holiday_needs_rows(db, timesheet_id, emp.id)
|
|
|
|
# NEW: payroll note (reimbursement/additional/notes)
|
|
payroll_note = (
|
|
db.query(PayrollNote)
|
|
.filter(PayrollNote.timesheet_id == timesheet_id, PayrollNote.employee_id == emp.id)
|
|
.first()
|
|
)
|
|
|
|
return templates.TemplateResponse(
|
|
"review_edit.html",
|
|
{
|
|
"request": request,
|
|
"employee": emp,
|
|
"timesheet_id": timesheet_id,
|
|
"period_name": ts.name or f"{ts.period_start}..{ts.period_end}",
|
|
"grouped": grouped,
|
|
"carry_over_hours": carry,
|
|
"flash": msg,
|
|
"duplicates": duplicates,
|
|
"dup_dates": dup_dates,
|
|
"long_shift_needs": long_shift_needs,
|
|
"flagged_long_dates": flagged_long_dates,
|
|
"reviewed_long_dates": reviewed_long_dates,
|
|
"pto_needs": pto_needs,
|
|
"flagged_pto_dates": flagged_pto_dates,
|
|
"reviewed_pto_dates": reviewed_pto_dates,
|
|
"holiday_needs": holiday_needs,
|
|
"flagged_holiday_dates": flagged_holiday_dates,
|
|
"reviewed_holiday_dates": reviewed_holiday_dates,
|
|
"can_edit": bool(request.session.get("is_admin")),
|
|
"payroll_note": payroll_note,
|
|
},
|
|
)
|
|
|
|
# -------- NEW: update payroll note (reimbursement/additional/notes)
|
|
@app.post("/review/update-payroll-note")
|
|
@login_required
|
|
def review_update_payroll_note(
|
|
request: Request,
|
|
timesheet_id: int = Form(...),
|
|
employee_id: int = Form(...),
|
|
reimbursement_amount: Optional[str] = Form(""),
|
|
additional_payroll_amount: Optional[str] = Form(""),
|
|
notes: Optional[str] = Form(""),
|
|
db: Session = Depends(get_session),
|
|
):
|
|
require_admin_edit(request, db)
|
|
|
|
ts = db.query(TimesheetPeriod).get(timesheet_id)
|
|
if not ts:
|
|
raise HTTPException(status_code=404, detail="Time Period not found")
|
|
emp = db.query(Employee).get(employee_id)
|
|
if not emp:
|
|
raise HTTPException(status_code=404, detail="Employee not found")
|
|
|
|
pn = (
|
|
db.query(PayrollNote)
|
|
.filter(PayrollNote.timesheet_id == timesheet_id, PayrollNote.employee_id == employee_id)
|
|
.first()
|
|
)
|
|
if not pn:
|
|
pn = PayrollNote(timesheet_id=timesheet_id, employee_id=employee_id)
|
|
db.add(pn)
|
|
|
|
pn.reimbursement_amount = _parse_money(reimbursement_amount)
|
|
pn.additional_payroll_amount = _parse_money(additional_payroll_amount)
|
|
pn.notes = (notes or "").strip() or None
|
|
db.commit()
|
|
|
|
msg = "Payroll extras saved."
|
|
return RedirectResponse(url=f"/review/edit?timesheet_id={timesheet_id}&employee_id={employee_id}&msg={msg.replace(' ', '+')}", status_code=303)
|
|
|
|
# -------- Print single / bundle (unchanged)
|
|
@app.get("/review/print", response_class=HTMLResponse)
|
|
@login_required
|
|
def review_print(
|
|
request: Request,
|
|
employee_id: int,
|
|
timesheet_id: int,
|
|
db: Session = Depends(get_session),
|
|
):
|
|
ts = db.query(TimesheetPeriod).get(timesheet_id)
|
|
if not ts:
|
|
raise HTTPException(status_code=404, detail="Time Period not found")
|
|
|
|
emp = db.query(Employee).get(employee_id)
|
|
if not emp:
|
|
raise HTTPException(status_code=404, detail="Employee not found")
|
|
|
|
eps = (
|
|
db.query(EmployeePeriodSetting)
|
|
.filter(EmployeePeriodSetting.timesheet_id == timesheet_id, EmployeePeriodSetting.employee_id == emp.id)
|
|
.first()
|
|
)
|
|
carry = float(eps.carry_over_hours if eps else 0.0)
|
|
week_rows = db.query(WeekAssignment).filter(WeekAssignment.timesheet_id == timesheet_id).all()
|
|
week_map = {wr.day_date: wr.week_number for wr in week_rows}
|
|
|
|
entries = (
|
|
db.query(TimeEntry)
|
|
.filter(TimeEntry.timesheet_id == timesheet_id, TimeEntry.employee_id == emp.id)
|
|
.order_by(TimeEntry.work_date.asc())
|
|
.all()
|
|
)
|
|
grouped = group_entries_for_timesheet(entries, ts.period_start, ts.period_end, week_map=week_map, carry_over_hours=carry)
|
|
|
|
return templates.TemplateResponse(
|
|
"print_timesheet.html",
|
|
{
|
|
"request": request,
|
|
"hide_nav_links": True,
|
|
"employee": emp,
|
|
"period_name": ts.name or f"{ts.period_start}..{ts.period_end}",
|
|
"grouped": grouped,
|
|
"timesheet_id": timesheet_id,
|
|
},
|
|
)
|
|
|
|
@app.get("/review/print-all", response_class=HTMLResponse)
|
|
@login_required
|
|
def review_print_all(
|
|
request: Request,
|
|
timesheet_id: int,
|
|
db: Session = Depends(get_session),
|
|
):
|
|
ts = db.query(TimesheetPeriod).get(timesheet_id)
|
|
if not ts:
|
|
raise HTTPException(status_code=404, detail="Time Period not found")
|
|
|
|
rows = (
|
|
db.query(TimesheetStatus, Employee)
|
|
.join(Employee, Employee.id == TimesheetStatus.employee_id)
|
|
.filter(TimesheetStatus.timesheet_id == timesheet_id, TimesheetStatus.status == "submitted")
|
|
.order_by(Employee.name.asc())
|
|
.all()
|
|
)
|
|
|
|
week_rows = db.query(WeekAssignment).filter(WeekAssignment.timesheet_id == timesheet_id).all()
|
|
week_map = {wr.day_date: wr.week_number for wr in week_rows}
|
|
|
|
bundles = []
|
|
for tsrow, emp in rows:
|
|
eps = (
|
|
db.query(EmployeePeriodSetting)
|
|
.filter(EmployeePeriodSetting.timesheet_id == timesheet_id, EmployeePeriodSetting.employee_id == emp.id)
|
|
.first()
|
|
)
|
|
carry = float(eps.carry_over_hours if eps else 0.0)
|
|
entries = (
|
|
db.query(TimeEntry)
|
|
.filter(TimeEntry.timesheet_id == timesheet_id, TimeEntry.employee_id == emp.id)
|
|
.order_by(TimeEntry.work_date.asc())
|
|
.all()
|
|
)
|
|
grouped = group_entries_for_timesheet(entries, ts.period_start, ts.period_end, week_map=week_map, carry_over_hours=carry)
|
|
bundles.append({"employee": emp, "grouped": grouped})
|
|
|
|
return templates.TemplateResponse(
|
|
"print_timesheet_bundle.html",
|
|
{
|
|
"request": request,
|
|
"hide_nav_links": True,
|
|
"timesheet_id": timesheet_id,
|
|
"period_name": ts.name or f"{ts.period_start}..{ts.period_end}",
|
|
"bundles": bundles,
|
|
},
|
|
)
|
|
|
|
# -------- Overview (HTML) --------
|
|
@app.get("/overview", response_class=HTMLResponse)
|
|
@login_required
|
|
def overview_page(
|
|
request: Request,
|
|
timesheet_id: int = Query(...),
|
|
db: Session = Depends(get_session),
|
|
):
|
|
ts = db.query(TimesheetPeriod).get(timesheet_id)
|
|
if not ts:
|
|
return templates.TemplateResponse(
|
|
"overview.html",
|
|
{"request": request, "bundles": [], "period_name": "", "timesheet_id": timesheet_id, "totals": None},
|
|
)
|
|
|
|
week_rows = db.query(WeekAssignment).filter(WeekAssignment.timesheet_id == timesheet_id).all()
|
|
week_map = {wr.day_date: wr.week_number for wr in week_rows}
|
|
|
|
employees = (
|
|
db.query(Employee)
|
|
.join(TimesheetStatus, TimesheetStatus.employee_id == Employee.id)
|
|
.filter(TimesheetStatus.timesheet_id == timesheet_id, TimesheetStatus.status == "submitted")
|
|
.order_by(Employee.name.asc())
|
|
.all()
|
|
)
|
|
|
|
bundles = []
|
|
sum_regular = Decimal("0")
|
|
sum_pto = Decimal("0")
|
|
sum_holiday = Decimal("0")
|
|
sum_bereavement = Decimal("0")
|
|
sum_ot = Decimal("0")
|
|
sum_paid = Decimal("0")
|
|
|
|
for emp in employees:
|
|
entries = (
|
|
db.query(TimeEntry)
|
|
.filter(TimeEntry.timesheet_id == timesheet_id, TimeEntry.employee_id == emp.id)
|
|
.order_by(TimeEntry.work_date.asc())
|
|
.all()
|
|
)
|
|
eps = (
|
|
db.query(EmployeePeriodSetting)
|
|
.filter(EmployeePeriodSetting.timesheet_id == timesheet_id, EmployeePeriodSetting.employee_id == emp.id)
|
|
.first()
|
|
)
|
|
carry = float(eps.carry_over_hours if eps else 0.0)
|
|
|
|
grouped = group_entries_for_timesheet(entries, ts.period_start, ts.period_end, week_map=week_map, carry_over_hours=carry)
|
|
t = grouped.totals
|
|
sum_regular += Decimal(str(t.regular))
|
|
sum_pto += Decimal(str(t.pto))
|
|
sum_holiday += Decimal(str(t.holiday))
|
|
sum_bereavement += Decimal(str(t.bereavement))
|
|
sum_ot += Decimal(str(t.overtime))
|
|
sum_paid += Decimal(str(t.paid_total))
|
|
|
|
bundles.append({"employee": emp, "grouped": grouped})
|
|
|
|
q = lambda d: float(Decimal(d).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP))
|
|
totals = {
|
|
"regular": q(sum_regular),
|
|
"pto": q(sum_pto),
|
|
"holiday": q(sum_holiday),
|
|
"bereavement": q(sum_bereavement),
|
|
"overtime": q(sum_ot),
|
|
"paid_total": q(sum_paid),
|
|
}
|
|
|
|
return templates.TemplateResponse(
|
|
"overview.html",
|
|
{
|
|
"request": request,
|
|
"bundles": bundles,
|
|
"period_name": ts.name or f"{ts.period_start}..{ts.period_end}",
|
|
"timesheet_id": timesheet_id,
|
|
"totals": totals,
|
|
},
|
|
)
|
|
|
|
# -------- NEW: Overview export to XLSX --------
|
|
@app.get("/overview/export-xlsx")
|
|
@login_required
|
|
def overview_export_xlsx(
|
|
request: Request,
|
|
timesheet_id: int = Query(...),
|
|
db: Session = Depends(get_session),
|
|
):
|
|
ts = db.query(TimesheetPeriod).get(timesheet_id)
|
|
if not ts:
|
|
raise HTTPException(status_code=404, detail="Time Period not found")
|
|
|
|
week_rows = db.query(WeekAssignment).filter(WeekAssignment.timesheet_id == timesheet_id).all()
|
|
week_map = {wr.day_date: wr.week_number for wr in week_rows}
|
|
|
|
employees = (
|
|
db.query(Employee)
|
|
.join(TimesheetStatus, TimesheetStatus.employee_id == Employee.id)
|
|
.filter(TimesheetStatus.timesheet_id == timesheet_id, TimesheetStatus.status == "submitted")
|
|
.order_by(Employee.name.asc())
|
|
.all()
|
|
)
|
|
|
|
# Payroll notes for extras
|
|
notes_by_emp = {
|
|
n.employee_id: n
|
|
for n in db.query(PayrollNote).filter(PayrollNote.timesheet_id == timesheet_id).all()
|
|
}
|
|
|
|
rows = []
|
|
for emp in employees:
|
|
entries = (
|
|
db.query(TimeEntry)
|
|
.filter(TimeEntry.timesheet_id == timesheet_id, TimeEntry.employee_id == emp.id)
|
|
.order_by(TimeEntry.work_date.asc())
|
|
.all()
|
|
)
|
|
eps = (
|
|
db.query(EmployeePeriodSetting)
|
|
.filter(EmployeePeriodSetting.timesheet_id == timesheet_id, EmployeePeriodSetting.employee_id == emp.id)
|
|
.first()
|
|
)
|
|
carry = float(eps.carry_over_hours if eps else 0.0)
|
|
grouped = group_entries_for_timesheet(entries, ts.period_start, ts.period_end, week_map=week_map, carry_over_hours=carry)
|
|
t = grouped.totals
|
|
|
|
pn = notes_by_emp.get(emp.id)
|
|
rows.append({
|
|
"employee_name": emp.name,
|
|
"regular": float(t.regular),
|
|
"overtime": float(t.overtime),
|
|
"pto": float(t.pto),
|
|
"holiday": float(t.holiday),
|
|
"paid_total": float(t.paid_total),
|
|
"reimbursement": float(pn.reimbursement_amount) if pn and pn.reimbursement_amount is not None else 0.0,
|
|
"additional_payroll": float(pn.additional_payroll_amount) if pn and pn.additional_payroll_amount is not None else 0.0,
|
|
"notes": pn.notes if pn and pn.notes else "",
|
|
})
|
|
|
|
period_name = ts.name or f"{ts.period_start}..{ts.period_end}"
|
|
xlsx_bytes = build_overview_xlsx(period_name, rows)
|
|
fname = f"overview-{timesheet_id}.xlsx"
|
|
return StreamingResponse(
|
|
BytesIO(xlsx_bytes),
|
|
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
headers={"Content-Disposition": f'attachment; filename=\"{fname}\"'}
|
|
)
|
|
|
|
# -------- Overview print (unchanged)
|
|
@app.get("/overview/print", response_class=HTMLResponse)
|
|
@login_required
|
|
def overview_print(
|
|
request: Request,
|
|
timesheet_id: int = Query(...),
|
|
db: Session = Depends(get_session),
|
|
):
|
|
ts = db.query(TimesheetPeriod).get(timesheet_id)
|
|
if not ts:
|
|
return templates.TemplateResponse(
|
|
"print_overview.html",
|
|
{"request": request, "bundles": [], "period_name": "", "timesheet_id": timesheet_id, "totals": None},
|
|
)
|
|
|
|
week_rows = db.query(WeekAssignment).filter(WeekAssignment.timesheet_id == timesheet_id).all()
|
|
week_map = {wr.day_date: wr.week_number for wr in week_rows}
|
|
|
|
employees = (
|
|
db.query(Employee)
|
|
.join(TimesheetStatus, TimesheetStatus.employee_id == Employee.id)
|
|
.filter(TimesheetStatus.timesheet_id == timesheet_id, TimesheetStatus.status == "submitted")
|
|
.order_by(Employee.name.asc())
|
|
.all()
|
|
)
|
|
|
|
bundles = []
|
|
sum_regular = Decimal("0")
|
|
sum_pto = Decimal("0")
|
|
sum_holiday = Decimal("0")
|
|
sum_bereavement = Decimal("0")
|
|
sum_ot = Decimal("0")
|
|
sum_paid = Decimal("0")
|
|
|
|
for emp in employees:
|
|
entries = (
|
|
db.query(TimeEntry)
|
|
.filter(TimeEntry.timesheet_id == timesheet_id, TimeEntry.employee_id == emp.id)
|
|
.order_by(TimeEntry.work_date.asc())
|
|
.all()
|
|
)
|
|
eps = (
|
|
db.query(EmployeePeriodSetting)
|
|
.filter(EmployeePeriodSetting.timesheet_id == timesheet_id, EmployeePeriodSetting.employee_id == emp.id)
|
|
.first()
|
|
)
|
|
carry = float(eps.carry_over_hours if eps else 0.0)
|
|
grouped = group_entries_for_timesheet(entries, ts.period_start, ts.period_end, week_map=week_map, carry_over_hours=carry)
|
|
|
|
t = grouped.totals
|
|
sum_regular += Decimal(str(t.regular))
|
|
sum_pto += Decimal(str(t.pto))
|
|
sum_holiday += Decimal(str(t.holiday))
|
|
sum_bereavement += Decimal(str(t.bereavement))
|
|
sum_ot += Decimal(str(t.overtime))
|
|
sum_paid += Decimal(str(t.paid_total))
|
|
|
|
bundles.append({"employee": emp, "grouped": grouped})
|
|
|
|
q = lambda d: float(Decimal(d).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP))
|
|
totals = {
|
|
"regular": q(sum_regular),
|
|
"pto": q(sum_pto),
|
|
"holiday": q(sum_holiday),
|
|
"bereavement": q(sum_bereavement),
|
|
"overtime": q(sum_ot),
|
|
"paid_total": q(sum_paid),
|
|
}
|
|
|
|
return templates.TemplateResponse(
|
|
"print_overview.html",
|
|
{
|
|
"request": request,
|
|
"hide_nav_links": True,
|
|
"bundles": bundles,
|
|
"period_name": ts.name or f"{ts.period_start}..{ts.period_end}",
|
|
"timesheet_id": timesheet_id,
|
|
"totals": totals,
|
|
},
|
|
)
|
|
|
|
# =======================
|
|
# PTO Tracker (Admin only, per-year)
|
|
# =======================
|
|
def _years_for_employee(db: Session, employee_id: int) -> List[int]:
|
|
cur = datetime.utcnow().year
|
|
years: Set[int] = set()
|
|
rows = (
|
|
db.query(func.extract("year", TimeEntry.work_date))
|
|
.join(
|
|
TimesheetStatus,
|
|
(TimesheetStatus.timesheet_id == TimeEntry.timesheet_id)
|
|
& (TimesheetStatus.employee_id == TimeEntry.employee_id),
|
|
)
|
|
.filter(TimeEntry.employee_id == employee_id, TimesheetStatus.status == "submitted")
|
|
.distinct()
|
|
.all()
|
|
)
|
|
years |= {int(float(r[0])) for r in rows if r[0] is not None}
|
|
years |= {
|
|
y for (y,) in db.query(func.distinct(PTOAccount.year)).filter(PTOAccount.employee_id == employee_id).all() if y is not None
|
|
}
|
|
years |= {
|
|
y for (y,) in db.query(func.distinct(PTOAdjustment.year)).filter(PTOAdjustment.employee_id == employee_id).all() if y is not None
|
|
}
|
|
years.add(cur)
|
|
years.add(cur - 1)
|
|
return sorted(years)
|
|
|
|
@app.get("/pto-tracker", response_class=HTMLResponse)
|
|
@login_required
|
|
def pto_tracker_page(
|
|
request: Request,
|
|
employee_id: Optional[int] = Query(None),
|
|
year: Optional[int] = Query(None),
|
|
include_inactive: int = Query(0),
|
|
db: Session = Depends(get_session),
|
|
):
|
|
if not current_is_admin(db, request.session.get("user_id")):
|
|
raise HTTPException(status_code=403, detail="Admin access required")
|
|
|
|
if include_inactive:
|
|
employees_all = db.query(Employee).order_by(Employee.name.asc()).all()
|
|
else:
|
|
employees_all = (
|
|
db.query(Employee)
|
|
.filter(text("COALESCE(is_active, TRUE) = TRUE"))
|
|
.order_by(Employee.name.asc())
|
|
.all()
|
|
)
|
|
|
|
inactive_rows = db.execute(text("SELECT id FROM employees WHERE COALESCE(is_active, TRUE) = FALSE")).fetchall()
|
|
inactive_ids = {r[0] for r in inactive_rows}
|
|
|
|
if not employees_all:
|
|
return templates.TemplateResponse(
|
|
"pto_tracker.html",
|
|
{
|
|
"request": request,
|
|
"employees": [],
|
|
"selected_employee": None,
|
|
"years": [datetime.utcnow().year],
|
|
"selected_year": datetime.utcnow().year,
|
|
"starting_balance": 0.0,
|
|
"remaining_balance": 0.0,
|
|
"ledger": [],
|
|
"include_inactive": include_inactive,
|
|
"inactive_ids": inactive_ids,
|
|
},
|
|
)
|
|
|
|
selected = db.query(Employee).get(employee_id) if employee_id else employees_all[0]
|
|
|
|
years = _years_for_employee(db, selected.id)
|
|
sel_year = int(year) if year else (years[-1] if years else datetime.utcnow().year)
|
|
if sel_year not in years:
|
|
years.append(sel_year)
|
|
years.sort()
|
|
|
|
y_start = date(sel_year, 1, 1)
|
|
y_end = date(sel_year, 12, 31)
|
|
|
|
acct = (
|
|
db.query(PTOAccount)
|
|
.filter(PTOAccount.employee_id == selected.id, PTOAccount.year == sel_year)
|
|
.first()
|
|
)
|
|
start_bal = D(acct.starting_balance if acct else 0)
|
|
|
|
adjustments = (
|
|
db.query(PTOAdjustment)
|
|
.filter(PTOAdjustment.employee_id == selected.id, PTOAdjustment.year == sel_year)
|
|
.order_by(PTOAdjustment.created_at.asc())
|
|
.all()
|
|
)
|
|
|
|
usage_rows = (
|
|
db.query(TimeEntry.work_date, TimeEntry.pto_type, func.sum(TimeEntry.pto_hours).label("hours"))
|
|
.join(
|
|
TimesheetStatus,
|
|
(TimesheetStatus.timesheet_id == TimeEntry.timesheet_id) & (TimesheetStatus.employee_id == TimeEntry.employee_id),
|
|
)
|
|
.outerjoin(
|
|
PTOUsageExclusion,
|
|
(PTOUsageExclusion.employee_id == selected.id)
|
|
& (PTOUsageExclusion.work_date == TimeEntry.work_date)
|
|
& (func.coalesce(PTOUsageExclusion.pto_type, "") == func.coalesce(TimeEntry.pto_type, "")),
|
|
)
|
|
.filter(
|
|
TimeEntry.employee_id == selected.id,
|
|
TimeEntry.pto_hours > 0,
|
|
TimesheetStatus.status == "submitted",
|
|
TimeEntry.work_date >= y_start,
|
|
TimeEntry.work_date <= y_end,
|
|
PTOUsageExclusion.id.is_(None),
|
|
)
|
|
.group_by(TimeEntry.work_date, TimeEntry.pto_type)
|
|
.order_by(TimeEntry.work_date.asc())
|
|
.all()
|
|
)
|
|
|
|
events = []
|
|
for a in adjustments:
|
|
events.append({
|
|
"kind": "adjustment",
|
|
"date": a.created_at.date(),
|
|
"desc": (a.note or "Adjustment"),
|
|
"delta": D(a.hours),
|
|
"adj_id": a.id,
|
|
})
|
|
for u_date, u_type, u_hours in usage_rows:
|
|
events.append({
|
|
"kind": "usage",
|
|
"date": u_date,
|
|
"desc": (u_type or "PTO"),
|
|
"delta": -D(u_hours),
|
|
"u_date": u_date.isoformat(),
|
|
"u_type": u_type or "",
|
|
})
|
|
|
|
events.sort(key=lambda e: (e["date"], 0 if e["kind"] == "adjustment" else 1))
|
|
|
|
running = q2(start_bal)
|
|
ledger = [{
|
|
"date": None,
|
|
"desc": f"Starting balance ({sel_year})",
|
|
"delta": "",
|
|
"balance": float(running),
|
|
"kind": "start",
|
|
}]
|
|
for ev in events:
|
|
running = q2(running + ev["delta"])
|
|
row = {
|
|
"date": ev["date"],
|
|
"desc": ev["desc"],
|
|
"delta": float(q2(ev["delta"])),
|
|
"balance": float(running),
|
|
"kind": ev["kind"],
|
|
}
|
|
if ev["kind"] == "adjustment":
|
|
row["adj_id"] = ev["adj_id"]
|
|
else:
|
|
row["u_date"] = ev["u_date"]
|
|
row["u_type"] = ev["u_type"]
|
|
ledger.append(row)
|
|
|
|
remaining = float(running)
|
|
|
|
return templates.TemplateResponse(
|
|
"pto_tracker.html",
|
|
{
|
|
"request": request,
|
|
"employees": employees_all,
|
|
"selected_employee": selected,
|
|
"years": years,
|
|
"selected_year": sel_year,
|
|
"starting_balance": float(q2(start_bal)),
|
|
"remaining_balance": remaining,
|
|
"ledger": ledger,
|
|
"include_inactive": include_inactive,
|
|
"inactive_ids": inactive_ids,
|
|
},
|
|
)
|
|
|
|
# -------- PTO tracker helpers/print (unchanged)
|
|
def _build_pto_ledger(db: Session, emp_id: int, sel_year: int):
|
|
y_start = date(sel_year, 1, 1)
|
|
y_end = date(sel_year, 12, 31)
|
|
|
|
acct = db.query(PTOAccount).filter(PTOAccount.employee_id == emp_id, PTOAccount.year == sel_year).first()
|
|
start_bal = D(acct.starting_balance if acct else 0)
|
|
|
|
adjustments = (
|
|
db.query(PTOAdjustment)
|
|
.filter(PTOAdjustment.employee_id == emp_id, PTOAdjustment.year == sel_year)
|
|
.order_by(PTOAdjustment.created_at.asc())
|
|
.all()
|
|
)
|
|
|
|
usage_rows = (
|
|
db.query(TimeEntry.work_date, TimeEntry.pto_type, func.sum(TimeEntry.pto_hours).label("hours"))
|
|
.join(
|
|
TimesheetStatus,
|
|
(TimesheetStatus.timesheet_id == TimeEntry.timesheet_id) & (TimesheetStatus.employee_id == TimeEntry.employee_id),
|
|
)
|
|
.outerjoin(
|
|
PTOUsageExclusion,
|
|
(PTOUsageExclusion.employee_id == emp_id)
|
|
& (PTOUsageExclusion.work_date == TimeEntry.work_date)
|
|
& (func.coalesce(PTOUsageExclusion.pto_type, "") == func.coalesce(TimeEntry.pto_type, "")),
|
|
)
|
|
.filter(
|
|
TimeEntry.employee_id == emp_id,
|
|
TimeEntry.pto_hours > 0,
|
|
TimesheetStatus.status == "submitted",
|
|
TimeEntry.work_date >= y_start,
|
|
TimeEntry.work_date <= y_end,
|
|
PTOUsageExclusion.id.is_(None),
|
|
)
|
|
.group_by(TimeEntry.work_date, TimeEntry.pto_type)
|
|
.order_by(TimeEntry.work_date.asc())
|
|
.all()
|
|
)
|
|
|
|
events = []
|
|
for a in adjustments:
|
|
events.append({"kind": "adjustment", "date": a.created_at.date(), "desc": (a.note or "Adjustment"), "delta": D(a.hours)})
|
|
for u_date, u_type, u_hours in usage_rows:
|
|
events.append({"kind": "usage", "date": u_date, "desc": (u_type or "PTO"), "delta": -D(u_hours)})
|
|
events.sort(key=lambda e: (e["date"], 0 if e["kind"] == "adjustment" else 1))
|
|
|
|
running = q2(start_bal)
|
|
ledger = [{"date": None, "desc": f"Starting balance ({sel_year})", "delta": "", "balance": float(running), "kind": "start"}]
|
|
for ev in events:
|
|
running = q2(running + ev["delta"])
|
|
ledger.append({
|
|
"date": ev["date"],
|
|
"desc": ev["desc"],
|
|
"delta": float(q2(ev["delta"])),
|
|
"balance": float(running),
|
|
"kind": ev["kind"],
|
|
})
|
|
return float(q2(start_bal)), float(running), ledger
|
|
|
|
@app.get("/pto-tracker/print", response_class=HTMLResponse)
|
|
@login_required
|
|
def pto_tracker_print(
|
|
request: Request,
|
|
employee_id: int = Query(...),
|
|
year: Optional[int] = Query(None),
|
|
db: Session = Depends(get_session),
|
|
):
|
|
if not current_is_admin(db, request.session.get("user_id")):
|
|
raise HTTPException(status_code=403, detail="Admin access required")
|
|
|
|
emp = db.query(Employee).get(employee_id)
|
|
if not emp:
|
|
raise HTTPException(status_code=404, detail="Employee not found")
|
|
|
|
years = _years_for_employee(db, emp.id)
|
|
sel_year = int(year) if year else (years[-1] if years else datetime.utcnow().year)
|
|
if sel_year not in years:
|
|
years.append(sel_year)
|
|
years.sort()
|
|
|
|
start_bal, remaining, ledger = _build_pto_ledger(db, emp.id, sel_year)
|
|
request.state.now = datetime.utcnow()
|
|
|
|
return templates.TemplateResponse(
|
|
"pto_tracker_print.html",
|
|
{
|
|
"request": request,
|
|
"hide_nav_links": True,
|
|
"employee": emp,
|
|
"selected_year": sel_year,
|
|
"starting_balance": start_bal,
|
|
"remaining_balance": remaining,
|
|
"ledger": ledger,
|
|
},
|
|
)
|
|
|
|
@app.get("/pto-tracker/print-all", response_class=HTMLResponse)
|
|
@login_required
|
|
def pto_tracker_print_all(
|
|
request: Request,
|
|
year: Optional[int] = Query(None),
|
|
include_inactive: int = Query(0),
|
|
db: Session = Depends(get_session),
|
|
):
|
|
if not current_is_admin(db, request.session.get("user_id")):
|
|
raise HTTPException(status_code=403, detail="Admin access required")
|
|
|
|
sel_year = int(year) if year else datetime.utcnow().year
|
|
|
|
if include_inactive:
|
|
employees = db.query(Employee).order_by(Employee.name.asc()).all()
|
|
else:
|
|
employees = (
|
|
db.query(Employee)
|
|
.filter(text("COALESCE(is_active, TRUE) = TRUE"))
|
|
.order_by(Employee.name.asc())
|
|
.all()
|
|
)
|
|
|
|
bundles = []
|
|
for emp in employees:
|
|
start_bal, remaining, ledger = _build_pto_ledger(db, emp.id, sel_year)
|
|
bundles.append({"employee": emp, "starting_balance": start_bal, "remaining_balance": remaining, "ledger": ledger})
|
|
|
|
request.state.now = datetime.utcnow()
|
|
return templates.TemplateResponse(
|
|
"pto_tracker_print_all.html",
|
|
{"request": request, "hide_nav_links": True, "selected_year": sel_year, "bundles": bundles},
|
|
)
|
|
|
|
# Mount the Attendance router again (safe)
|
|
app.include_router(attendance_router)
|
|
|
|
if __name__ == "__main__":
|
|
import uvicorn
|
|
uvicorn.run("app.main:app", host="0.0.0.0", port=PORT, log_level="info") |