diff --git a/app/__pycache__/main.cpython-313.pyc b/app/__pycache__/main.cpython-313.pyc
index 8386186..66e16c1 100644
Binary files a/app/__pycache__/main.cpython-313.pyc and b/app/__pycache__/main.cpython-313.pyc differ
diff --git a/app/main.py b/app/main.py
index c8cc057..0135bbd 100644
--- a/app/main.py
+++ b/app/main.py
@@ -16,13 +16,13 @@ from typing import Optional, List, Dict, Any
from io import StringIO
from fastapi import FastAPI, Depends, Request, Query, HTTPException, UploadFile, File, Form
-from fastapi.responses import RedirectResponse
+from fastapi.responses import RedirectResponse, Response
from starlette.middleware.sessions import SessionMiddleware
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from sqlalchemy.orm import Session, joinedload
-from sqlalchemy import or_
+from sqlalchemy import or_, and_
from dotenv import load_dotenv
from starlette.middleware.base import BaseHTTPMiddleware
import structlog
@@ -794,9 +794,9 @@ def process_csv_import(db: Session, import_type: str, file_path: str) -> Dict[st
@app.get("/")
async def root():
"""
- Root endpoint - health check.
+ Root endpoint - serves login form for web interface.
"""
- return {"message": "Delphi Database API is running"}
+ return RedirectResponse(url="/login", status_code=302)
@app.get("/health")
@@ -1478,3 +1478,406 @@ async def case_reopen(
request.session["case_update_errors"] = ["Failed to reopen case. Please try again."]
return RedirectResponse(url=f"/case/{case_id}", status_code=302)
+
+
+@app.get("/rolodex")
+async def rolodex_list(
+ request: Request,
+ q: str | None = Query(None, description="Search by name or company"),
+ phone: str | None = Query(None, description="Search by phone contains"),
+ page: int = Query(1, ge=1, description="Page number (1-indexed)"),
+ page_size: int = Query(20, ge=1, le=100, description="Results per page"),
+ db: Session = Depends(get_db),
+):
+ """
+ Rolodex list with simple search and pagination.
+
+ Filters clients by name/company and optional phone substring.
+ """
+ user = get_current_user_from_session(request.session)
+ if not user:
+ return RedirectResponse(url="/login", status_code=302)
+
+ # Eager-load phones to avoid N+1 in template
+ query = db.query(Client).options(joinedload(Client.phones))
+
+ if q:
+ like = f"%{q}%"
+ query = query.filter(
+ or_(
+ Client.first_name.ilike(like),
+ Client.last_name.ilike(like),
+ Client.company.ilike(like),
+ )
+ )
+
+ if phone:
+ like_phone = f"%{phone}%"
+ # Use EXISTS over join to avoid duplicate rows
+ query = query.filter(Client.phones.any(Phone.phone_number.ilike(like_phone)))
+
+ # Order by last then first for stable display
+ query = query.order_by(Client.last_name.asc().nulls_last(), Client.first_name.asc().nulls_last())
+
+ total: int = query.count()
+ total_pages: int = (total + page_size - 1) // page_size if total > 0 else 1
+ if page > total_pages:
+ page = total_pages
+
+ offset = (page - 1) * page_size
+ clients = query.offset(offset).limit(page_size).all()
+
+ start_page = max(1, page - 2)
+ end_page = min(total_pages, page + 2)
+ page_numbers = list(range(start_page, end_page + 1))
+
+ logger.info(
+ "rolodex_render",
+ query=q,
+ phone=phone,
+ page=page,
+ page_size=page_size,
+ total=total,
+ )
+
+ return templates.TemplateResponse(
+ "rolodex.html",
+ {
+ "request": request,
+ "user": user,
+ "clients": clients,
+ "q": q,
+ "phone": phone,
+ "page": page,
+ "page_size": page_size,
+ "total": total,
+ "total_pages": total_pages,
+ "page_numbers": page_numbers,
+ "start_index": (offset + 1) if total > 0 else 0,
+ "end_index": min(offset + len(clients), total),
+ "enable_bulk": True,
+ },
+ )
+
+
+@app.get("/rolodex/new")
+async def rolodex_new(request: Request):
+ user = get_current_user_from_session(request.session)
+ if not user:
+ return RedirectResponse(url="/login", status_code=302)
+
+ return templates.TemplateResponse("rolodex_edit.html", {"request": request, "user": user, "client": None})
+
+
+@app.get("/rolodex/{client_id}")
+async def rolodex_view(client_id: int, request: Request, db: Session = Depends(get_db)):
+ user = get_current_user_from_session(request.session)
+ if not user:
+ return RedirectResponse(url="/login", status_code=302)
+
+ client = (
+ db.query(Client)
+ .options(joinedload(Client.phones), joinedload(Client.cases))
+ .filter(Client.id == client_id)
+ .first()
+ )
+ if not client:
+ raise HTTPException(status_code=404, detail="Client not found")
+
+ return templates.TemplateResponse("rolodex_view.html", {"request": request, "user": user, "client": client})
+
+
+@app.post("/rolodex/create")
+async def rolodex_create(
+ request: Request,
+ first_name: str = Form(None),
+ last_name: str = Form(None),
+ company: str = Form(None),
+ address: str = Form(None),
+ city: str = Form(None),
+ state: str = Form(None),
+ zip_code: str = Form(None),
+ rolodex_id: str = Form(None),
+ db: Session = Depends(get_db),
+):
+ user = get_current_user_from_session(request.session)
+ if not user:
+ return RedirectResponse(url="/login", status_code=302)
+
+ client = Client(
+ first_name=(first_name or "").strip() or None,
+ last_name=(last_name or "").strip() or None,
+ company=(company or "").strip() or None,
+ address=(address or "").strip() or None,
+ city=(city or "").strip() or None,
+ state=(state or "").strip() or None,
+ zip_code=(zip_code or "").strip() or None,
+ rolodex_id=(rolodex_id or "").strip() or None,
+ )
+ db.add(client)
+ db.commit()
+ db.refresh(client)
+ logger.info("rolodex_create", client_id=client.id, rolodex_id=client.rolodex_id)
+ return RedirectResponse(url=f"/rolodex/{client.id}", status_code=302)
+
+
+@app.post("/rolodex/{client_id}/update")
+async def rolodex_update(
+ client_id: int,
+ request: Request,
+ first_name: str = Form(None),
+ last_name: str = Form(None),
+ company: str = Form(None),
+ address: str = Form(None),
+ city: str = Form(None),
+ state: str = Form(None),
+ zip_code: str = Form(None),
+ rolodex_id: str = Form(None),
+ db: Session = Depends(get_db),
+):
+ user = get_current_user_from_session(request.session)
+ if not user:
+ return RedirectResponse(url="/login", status_code=302)
+
+ client = db.query(Client).filter(Client.id == client_id).first()
+ if not client:
+ raise HTTPException(status_code=404, detail="Client not found")
+
+ client.first_name = (first_name or "").strip() or None
+ client.last_name = (last_name or "").strip() or None
+ client.company = (company or "").strip() or None
+ client.address = (address or "").strip() or None
+ client.city = (city or "").strip() or None
+ client.state = (state or "").strip() or None
+ client.zip_code = (zip_code or "").strip() or None
+ client.rolodex_id = (rolodex_id or "").strip() or None
+
+ db.commit()
+ logger.info(
+ "rolodex_update",
+ client_id=client.id,
+ fields={
+ "first_name": client.first_name,
+ "last_name": client.last_name,
+ "company": client.company,
+ "rolodex_id": client.rolodex_id,
+ },
+ )
+ return RedirectResponse(url=f"/rolodex/{client.id}", status_code=302)
+
+
+@app.post("/rolodex/{client_id}/delete")
+async def rolodex_delete(client_id: int, request: Request, db: Session = Depends(get_db)):
+ user = get_current_user_from_session(request.session)
+ if not user:
+ return RedirectResponse(url="/login", status_code=302)
+
+ client = db.query(Client).filter(Client.id == client_id).first()
+ if not client:
+ raise HTTPException(status_code=404, detail="Client not found")
+
+ db.delete(client)
+ db.commit()
+ logger.info("rolodex_delete", client_id=client_id)
+ return RedirectResponse(url="/rolodex", status_code=302)
+
+
+@app.post("/rolodex/{client_id}/phone/add")
+async def rolodex_add_phone(
+ client_id: int,
+ request: Request,
+ phone_number: str = Form(...),
+ phone_type: str = Form(None),
+ db: Session = Depends(get_db),
+):
+ user = get_current_user_from_session(request.session)
+ if not user:
+ return RedirectResponse(url="/login", status_code=302)
+
+ client = db.query(Client).filter(Client.id == client_id).first()
+ if not client:
+ raise HTTPException(status_code=404, detail="Client not found")
+
+ phone = Phone(
+ client_id=client.id,
+ phone_number=(phone_number or "").strip(),
+ phone_type=(phone_type or "").strip() or None,
+ )
+ db.add(phone)
+ db.commit()
+ logger.info("rolodex_phone_add", client_id=client.id, phone_id=phone.id, number=phone.phone_number)
+ return RedirectResponse(url=f"/rolodex/{client.id}", status_code=302)
+
+
+@app.post("/rolodex/{client_id}/phone/{phone_id}/delete")
+async def rolodex_delete_phone(client_id: int, phone_id: int, request: Request, db: Session = Depends(get_db)):
+ user = get_current_user_from_session(request.session)
+ if not user:
+ return RedirectResponse(url="/login", status_code=302)
+
+ phone = db.query(Phone).filter(Phone.id == phone_id, Phone.client_id == client_id).first()
+ if not phone:
+ raise HTTPException(status_code=404, detail="Phone not found")
+
+ db.delete(phone)
+ db.commit()
+ logger.info("rolodex_phone_delete", client_id=client_id, phone_id=phone_id)
+ return RedirectResponse(url=f"/rolodex/{client_id}", status_code=302)
+
+
+@app.get("/payments")
+async def payments_search(
+ request: Request,
+ from_date: str | None = Query(None, description="YYYY-MM-DD"),
+ to_date: str | None = Query(None, description="YYYY-MM-DD"),
+ file_no: str | None = Query(None, description="Case file number"),
+ rolodex_id: str | None = Query(None, description="Legacy client Id"),
+ q: str | None = Query(None, description="Description contains"),
+ page: int = Query(1, ge=1),
+ page_size: int = Query(50, ge=1, le=200),
+ db: Session = Depends(get_db),
+):
+ user = get_current_user_from_session(request.session)
+ if not user:
+ return RedirectResponse(url="/login", status_code=302)
+
+ query = (
+ db.query(Payment)
+ .join(Case, Payment.case_id == Case.id)
+ .join(Client, Case.client_id == Client.id)
+ .order_by(Payment.payment_date.desc().nulls_last(), Payment.id.desc())
+ )
+
+ filters = []
+ if from_date:
+ try:
+ dt = datetime.strptime(from_date, "%Y-%m-%d")
+ filters.append(Payment.payment_date >= dt)
+ except ValueError:
+ pass
+ if to_date:
+ try:
+ dt = datetime.strptime(to_date, "%Y-%m-%d")
+ filters.append(Payment.payment_date <= dt)
+ except ValueError:
+ pass
+ if file_no:
+ filters.append(Case.file_no.ilike(f"%{file_no}%"))
+ if rolodex_id:
+ filters.append(Client.rolodex_id.ilike(f"%{rolodex_id}%"))
+ if q:
+ filters.append(Payment.description.ilike(f"%{q}%"))
+
+ if filters:
+ query = query.filter(and_(*filters))
+
+ total = query.count()
+ total_pages = (total + page_size - 1) // page_size if total > 0 else 1
+ if page > total_pages:
+ page = total_pages
+ offset = (page - 1) * page_size
+ payments = query.offset(offset).limit(page_size).all()
+
+ # Totals for current result page
+ page_total_amount = sum(p.amount or 0 for p in payments)
+
+ logger.info(
+ "payments_render",
+ from_date=from_date,
+ to_date=to_date,
+ file_no=file_no,
+ rolodex_id=rolodex_id,
+ q=q,
+ total=total,
+ )
+
+ return templates.TemplateResponse(
+ "payments.html",
+ {
+ "request": request,
+ "user": user,
+ "payments": payments,
+ "from_date": from_date,
+ "to_date": to_date,
+ "file_no": file_no,
+ "rolodex_id": rolodex_id,
+ "q": q,
+ "page": page,
+ "page_size": page_size,
+ "total": total,
+ "total_pages": total_pages,
+ "start_index": (offset + 1) if total > 0 else 0,
+ "end_index": min(offset + len(payments), total),
+ "page_total_amount": page_total_amount,
+ },
+ )
+
+
+@app.post("/reports/phone-book")
+async def phone_book_report_post(request: Request):
+ """Accepts selected client IDs from forms and redirects to GET for rendering."""
+ user = get_current_user_from_session(request.session)
+ if not user:
+ return RedirectResponse(url="/login", status_code=302)
+
+ form = await request.form()
+ client_ids = form.getlist("client_ids")
+ if not client_ids:
+ return RedirectResponse(url="/rolodex", status_code=302)
+
+ ids_param = "&".join([f"client_ids={cid}" for cid in client_ids])
+ return RedirectResponse(url=f"/reports/phone-book?{ids_param}", status_code=302)
+
+
+@app.get("/reports/phone-book")
+async def phone_book_report(
+ request: Request,
+ client_ids: List[int] | None = Query(None),
+ q: str | None = Query(None, description="Filter by name/company"),
+ format: str | None = Query(None, description="csv for CSV output"),
+ db: Session = Depends(get_db),
+):
+ user = get_current_user_from_session(request.session)
+ if not user:
+ return RedirectResponse(url="/login", status_code=302)
+
+ query = db.query(Client).options(joinedload(Client.phones))
+ if client_ids:
+ query = query.filter(Client.id.in_(client_ids))
+ elif q:
+ like = f"%{q}%"
+ query = query.filter(
+ or_(Client.first_name.ilike(like), Client.last_name.ilike(like), Client.company.ilike(like))
+ )
+
+ clients = query.order_by(Client.last_name.asc().nulls_last(), Client.first_name.asc().nulls_last()).all()
+
+ if format == "csv":
+ # Build CSV output
+ output = StringIO()
+ writer = csv.writer(output)
+ writer.writerow(["Last", "First", "Company", "Phone Type", "Phone Number"])
+ for c in clients:
+ if c.phones:
+ for p in c.phones:
+ writer.writerow([
+ c.last_name or "",
+ c.first_name or "",
+ c.company or "",
+ p.phone_type or "",
+ p.phone_number or "",
+ ])
+ else:
+ writer.writerow([c.last_name or "", c.first_name or "", c.company or "", "", ""])
+ csv_bytes = output.getvalue().encode("utf-8")
+ return Response(
+ content=csv_bytes,
+ media_type="text/csv",
+ headers={"Content-Disposition": "attachment; filename=phone_book.csv"},
+ )
+
+ logger.info("phone_book_render", count=len(clients))
+ return templates.TemplateResponse(
+ "report_phone_book.html",
+ {"request": request, "user": user, "clients": clients, "q": q, "client_ids": client_ids or []},
+ )
diff --git a/app/templates/base.html b/app/templates/base.html
index 98a50da..d8a7da9 100644
--- a/app/templates/base.html
+++ b/app/templates/base.html
@@ -37,6 +37,12 @@
Dashboard
+
+ Rolodex
+
+
+ Payments
+
Admin
diff --git a/app/templates/payments.html b/app/templates/payments.html
new file mode 100644
index 0000000..551592b
--- /dev/null
+++ b/app/templates/payments.html
@@ -0,0 +1,106 @@
+{% extends "base.html" %}
+
+{% block title %}Payments · Delphi Database{% endblock %}
+
+{% block content %}
+
+
+
Payments
+
+
+
+ {% if total and total > 0 %}
+ Showing {{ start_index }}–{{ end_index }} of {{ total }} | Page total: ${{ '%.2f'|format(page_total_amount) }}
+ {% else %}
+ No results
+ {% endif %}
+
+
+
+
+
+
+
+ | Date |
+ File # |
+ Client |
+ Type |
+ Description |
+ Amount |
+
+
+
+ {% if payments and payments|length > 0 %}
+ {% for p in payments %}
+
+ | {{ p.payment_date.strftime('%Y-%m-%d') if p.payment_date else '' }} |
+ {{ p.case.file_no if p.case else '' }} |
+
+ {% set client = p.case.client if p.case else None %}
+ {% if client %}{{ client.last_name }}, {{ client.first_name }}{% else %}—{% endif %}
+ |
+ {{ p.payment_type or '' }} |
+ {{ p.description or '' }} |
+ {{ '%.2f'|format(p.amount) if p.amount is not none else '' }} |
+
+ {% endfor %}
+ {% else %}
+ | No payments found. |
+ {% endif %}
+
+
+
+
+
+
+ {% if total_pages and total_pages > 1 %}
+
+ {% endif %}
+
+
+{% endblock %}
+
+
diff --git a/app/templates/report_phone_book.html b/app/templates/report_phone_book.html
new file mode 100644
index 0000000..d35a990
--- /dev/null
+++ b/app/templates/report_phone_book.html
@@ -0,0 +1,61 @@
+{% extends "base.html" %}
+
+{% block title %}Phone Book · Delphi Database{% endblock %}
+
+{% block content %}
+
+
+
+
+
+
+
+
+ | Name |
+ Company |
+ Phone Type |
+ Phone Number |
+
+
+
+ {% if clients and clients|length > 0 %}
+ {% for c in clients %}
+ {% if c.phones and c.phones|length > 0 %}
+ {% for p in c.phones %}
+
+ | {{ c.last_name or '' }}, {{ c.first_name or '' }} |
+ {{ c.company or '' }} |
+ {{ p.phone_type or '' }} |
+ {{ p.phone_number or '' }} |
+
+ {% endfor %}
+ {% else %}
+
+ | {{ c.last_name or '' }}, {{ c.first_name or '' }} |
+ {{ c.company or '' }} |
+ — |
+ — |
+
+ {% endif %}
+ {% endfor %}
+ {% else %}
+ | No data. |
+ {% endif %}
+
+
+
+
+
+{% endblock %}
+
+
diff --git a/app/templates/rolodex.html b/app/templates/rolodex.html
new file mode 100644
index 0000000..105378c
--- /dev/null
+++ b/app/templates/rolodex.html
@@ -0,0 +1,153 @@
+{% extends "base.html" %}
+
+{% block title %}Rolodex · Delphi Database{% endblock %}
+
+{% block content %}
+
+
+
Rolodex
+
+
+
+ {% if total and total > 0 %}
+ Showing {{ start_index }}–{{ end_index }} of {{ total }}
+ {% else %}
+ No results
+ {% endif %}
+
+
+
+ {% if total_pages and total_pages > 1 %}
+
+ {% endif %}
+
+
+{% block extra_scripts %}
+
+{% endblock %}
+{% endblock %}
+
+
diff --git a/app/templates/rolodex_edit.html b/app/templates/rolodex_edit.html
new file mode 100644
index 0000000..cd30b9a
--- /dev/null
+++ b/app/templates/rolodex_edit.html
@@ -0,0 +1,71 @@
+{% extends "base.html" %}
+
+{% block title %}{{ 'New Client' if not client else 'Edit Client' }} · Delphi Database{% endblock %}
+
+{% block content %}
+
+
+
+
+ Back
+
+
{{ 'New Client' if not client else 'Edit Client' }}
+
+
+
+
+{% endblock %}
+
+
diff --git a/app/templates/rolodex_view.html b/app/templates/rolodex_view.html
new file mode 100644
index 0000000..4f80704
--- /dev/null
+++ b/app/templates/rolodex_view.html
@@ -0,0 +1,170 @@
+{% extends "base.html" %}
+
+{% block title %}Client · {{ client.last_name }}, {{ client.first_name }} · Delphi Database{% endblock %}
+
+{% block content %}
+
+
+
+
+
+
+
+
+
Name
+
{{ client.last_name or '' }}, {{ client.first_name or '' }}
+
+
+
Company
+
{{ client.company or '' }}
+
+
+
Legacy Rolodex Id
+
{{ client.rolodex_id or '' }}
+
+
+
+
+
Address
+
{{ client.address or '' }}
+
+
+
City
+
{{ client.city or '' }}
+
+
+
State
+
{{ client.state or '' }}
+
+
+
ZIP
+
{{ client.zip_code or '' }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | Number |
+ Type |
+ Actions |
+
+
+
+ {% if client.phones and client.phones|length > 0 %}
+ {% for p in client.phones %}
+
+ | {{ p.phone_number }} |
+ {{ p.phone_type or '' }} |
+
+
+ |
+
+ {% endfor %}
+ {% else %}
+ | No phones. |
+ {% endif %}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | File # |
+ Description |
+ Status |
+ Opened |
+ Actions |
+
+
+
+ {% if client.cases and client.cases|length > 0 %}
+ {% for c in client.cases %}
+
+ | {{ c.file_no }} |
+ {{ c.description or '' }} |
+ {{ c.status or '' }} |
+ {{ c.open_date.strftime('%Y-%m-%d') if c.open_date else '' }} |
+
+
+
+
+ |
+
+ {% endfor %}
+ {% else %}
+ | No related cases. |
+ {% endif %}
+
+
+
+
+
+
+
+{% endblock %}
+
+
diff --git a/docker-compose.yml b/docker-compose.yml
index 922e6dc..05390c8 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -1,5 +1,3 @@
-version: '3.8'
-
services:
delphi-db:
build: .