190 lines
6.1 KiB
Python
190 lines
6.1 KiB
Python
"""Time Budgets: weekly hour allocations per domain with actual vs budgeted comparison."""
|
|
|
|
from fastapi import APIRouter, Request, Depends, Form
|
|
from fastapi.templating import Jinja2Templates
|
|
from fastapi.responses import RedirectResponse
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
from sqlalchemy import text
|
|
from typing import Optional
|
|
|
|
from core.database import get_db
|
|
from core.base_repository import BaseRepository
|
|
from core.sidebar import get_sidebar_data
|
|
|
|
router = APIRouter(prefix="/time-budgets", tags=["time_budgets"])
|
|
templates = Jinja2Templates(directory="templates")
|
|
|
|
|
|
@router.get("/")
|
|
async def list_time_budgets(
|
|
request: Request,
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
sidebar = await get_sidebar_data(db)
|
|
|
|
# Get current budget per domain (most recent effective_from <= today)
|
|
result = await db.execute(text("""
|
|
SELECT DISTINCT ON (tb.domain_id)
|
|
tb.*, d.name as domain_name, d.color as domain_color
|
|
FROM time_budgets tb
|
|
JOIN domains d ON tb.domain_id = d.id
|
|
WHERE tb.is_deleted = false AND d.is_deleted = false
|
|
AND tb.effective_from <= CURRENT_DATE
|
|
ORDER BY tb.domain_id, tb.effective_from DESC
|
|
"""))
|
|
current_budgets = [dict(r._mapping) for r in result]
|
|
|
|
# Get actual hours per domain this week (Mon-Sun)
|
|
result = await db.execute(text("""
|
|
SELECT t.domain_id,
|
|
COALESCE(SUM(
|
|
CASE
|
|
WHEN te.duration_minutes IS NOT NULL THEN te.duration_minutes
|
|
WHEN te.end_at IS NOT NULL THEN EXTRACT(EPOCH FROM (te.end_at - te.start_at)) / 60
|
|
ELSE 0
|
|
END
|
|
), 0) / 60.0 as actual_hours
|
|
FROM time_entries te
|
|
JOIN tasks t ON te.task_id = t.id
|
|
WHERE te.is_deleted = false
|
|
AND te.start_at >= date_trunc('week', CURRENT_DATE)
|
|
AND te.start_at < date_trunc('week', CURRENT_DATE) + INTERVAL '7 days'
|
|
AND t.domain_id IS NOT NULL
|
|
GROUP BY t.domain_id
|
|
"""))
|
|
actual_map = {str(r._mapping["domain_id"]): float(r._mapping["actual_hours"]) for r in result}
|
|
|
|
# Attach actual hours to budgets
|
|
for b in current_budgets:
|
|
b["actual_hours"] = round(actual_map.get(str(b["domain_id"]), 0), 1)
|
|
b["weekly_hours_float"] = float(b["weekly_hours"])
|
|
if b["weekly_hours_float"] > 0:
|
|
b["pct"] = round(b["actual_hours"] / b["weekly_hours_float"] * 100)
|
|
else:
|
|
b["pct"] = 0
|
|
|
|
total_budgeted = sum(float(b["weekly_hours"]) for b in current_budgets)
|
|
overcommitted = total_budgeted > 168
|
|
|
|
# Also get all budgets (including future / historical) for full list
|
|
result = await db.execute(text("""
|
|
SELECT tb.*, d.name as domain_name, d.color as domain_color
|
|
FROM time_budgets tb
|
|
JOIN domains d ON tb.domain_id = d.id
|
|
WHERE tb.is_deleted = false AND d.is_deleted = false
|
|
ORDER BY tb.effective_from DESC, d.name
|
|
"""))
|
|
all_budgets = [dict(r._mapping) for r in result]
|
|
|
|
return templates.TemplateResponse("time_budgets.html", {
|
|
"request": request,
|
|
"sidebar": sidebar,
|
|
"current_budgets": current_budgets,
|
|
"all_budgets": all_budgets,
|
|
"total_budgeted": total_budgeted,
|
|
"overcommitted": overcommitted,
|
|
"count": len(all_budgets),
|
|
"page_title": "Time Budgets",
|
|
"active_nav": "time_budgets",
|
|
})
|
|
|
|
|
|
@router.get("/create")
|
|
async def create_form(
|
|
request: Request,
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
sidebar = await get_sidebar_data(db)
|
|
|
|
result = await db.execute(text("""
|
|
SELECT id, name, color FROM domains
|
|
WHERE is_deleted = false ORDER BY sort_order, name
|
|
"""))
|
|
domains = [dict(r._mapping) for r in result]
|
|
|
|
return templates.TemplateResponse("time_budgets_form.html", {
|
|
"request": request,
|
|
"sidebar": sidebar,
|
|
"budget": None,
|
|
"domains": domains,
|
|
"page_title": "New Time Budget",
|
|
"active_nav": "time_budgets",
|
|
})
|
|
|
|
|
|
@router.post("/create")
|
|
async def create_budget(
|
|
request: Request,
|
|
domain_id: str = Form(...),
|
|
weekly_hours: str = Form(...),
|
|
effective_from: str = Form(...),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
repo = BaseRepository("time_budgets", db)
|
|
data = {
|
|
"domain_id": domain_id,
|
|
"weekly_hours": float(weekly_hours),
|
|
"effective_from": effective_from,
|
|
}
|
|
budget = await repo.create(data)
|
|
return RedirectResponse(url="/time-budgets", status_code=303)
|
|
|
|
|
|
@router.get("/{budget_id}/edit")
|
|
async def edit_form(
|
|
request: Request,
|
|
budget_id: str,
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
sidebar = await get_sidebar_data(db)
|
|
repo = BaseRepository("time_budgets", db)
|
|
budget = await repo.get(budget_id)
|
|
|
|
if not budget:
|
|
return RedirectResponse(url="/time-budgets", status_code=303)
|
|
|
|
result = await db.execute(text("""
|
|
SELECT id, name, color FROM domains
|
|
WHERE is_deleted = false ORDER BY sort_order, name
|
|
"""))
|
|
domains = [dict(r._mapping) for r in result]
|
|
|
|
return templates.TemplateResponse("time_budgets_form.html", {
|
|
"request": request,
|
|
"sidebar": sidebar,
|
|
"budget": budget,
|
|
"domains": domains,
|
|
"page_title": "Edit Time Budget",
|
|
"active_nav": "time_budgets",
|
|
})
|
|
|
|
|
|
@router.post("/{budget_id}/edit")
|
|
async def update_budget(
|
|
request: Request,
|
|
budget_id: str,
|
|
domain_id: str = Form(...),
|
|
weekly_hours: str = Form(...),
|
|
effective_from: str = Form(...),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
repo = BaseRepository("time_budgets", db)
|
|
data = {
|
|
"domain_id": domain_id,
|
|
"weekly_hours": float(weekly_hours),
|
|
"effective_from": effective_from,
|
|
}
|
|
await repo.update(budget_id, data)
|
|
return RedirectResponse(url="/time-budgets", status_code=303)
|
|
|
|
|
|
@router.post("/{budget_id}/delete")
|
|
async def delete_budget(
|
|
request: Request,
|
|
budget_id: str,
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
repo = BaseRepository("time_budgets", db)
|
|
await repo.soft_delete(budget_id)
|
|
return RedirectResponse(url="/time-budgets", status_code=303)
|