MVP legacy features: payments search page, phone book report (HTML+CSV), Rolodex bulk selection + actions; audit logging for Rolodex/Phone CRUD; nav updates

This commit is contained in:
HotSwapp
2025-10-06 23:31:02 -05:00
parent d456ae4f39
commit f9c3b3cc9c
9 changed files with 974 additions and 6 deletions

Binary file not shown.

View File

@@ -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 []},
)

View File

@@ -37,6 +37,12 @@
<li class="nav-item">
<a class="nav-link {% if 'dashboard' in request.url.path %}active{% endif %}" href="/dashboard">Dashboard</a>
</li>
<li class="nav-item">
<a class="nav-link {% if 'rolodex' in request.url.path %}active{% endif %}" href="/rolodex">Rolodex</a>
</li>
<li class="nav-item">
<a class="nav-link {% if 'payments' in request.url.path %}active{% endif %}" href="/payments">Payments</a>
</li>
<li class="nav-item">
<a class="nav-link {% if 'admin' in request.url.path %}active{% endif %}" href="/admin">Admin</a>
</li>

106
app/templates/payments.html Normal file
View File

@@ -0,0 +1,106 @@
{% extends "base.html" %}
{% block title %}Payments · Delphi Database{% endblock %}
{% block content %}
<div class="row g-3 align-items-center mb-3">
<div class="col-auto">
<h2 class="mb-0">Payments</h2>
</div>
<div class="col ms-auto">
<form class="row g-2" method="get">
<div class="col-md-2">
<input type="date" class="form-control" name="from_date" value="{{ from_date or '' }}" placeholder="From">
</div>
<div class="col-md-2">
<input type="date" class="form-control" name="to_date" value="{{ to_date or '' }}" placeholder="To">
</div>
<div class="col-md-2">
<input type="text" class="form-control" name="file_no" value="{{ file_no or '' }}" placeholder="File #">
</div>
<div class="col-md-2">
<input type="text" class="form-control" name="rolodex_id" value="{{ rolodex_id or '' }}" placeholder="Rolodex Id">
</div>
<div class="col-md-3">
<input type="text" class="form-control" name="q" value="{{ q or '' }}" placeholder="Description contains">
</div>
<div class="col-auto">
<input type="hidden" name="page_size" value="{{ page_size }}">
<button class="btn btn-outline-primary" type="submit"><i class="bi bi-search me-1"></i>Search</button>
</div>
<div class="col-auto">
<a class="btn btn-outline-secondary" href="/payments"><i class="bi bi-x-circle me-1"></i>Clear</a>
</div>
</form>
</div>
<div class="col-12 text-muted small">
{% if total and total > 0 %}
Showing {{ start_index }}{{ end_index }} of {{ total }} | Page total: ${{ '%.2f'|format(page_total_amount) }}
{% else %}
No results
{% endif %}
</div>
<div class="col-12">
<div class="table-responsive">
<table class="table table-hover align-middle">
<thead class="table-light">
<tr>
<th style="width: 120px;">Date</th>
<th style="width: 140px;">File #</th>
<th>Client</th>
<th>Type</th>
<th>Description</th>
<th class="text-end" style="width: 140px;">Amount</th>
</tr>
</thead>
<tbody>
{% if payments and payments|length > 0 %}
{% for p in payments %}
<tr>
<td>{{ p.payment_date.strftime('%Y-%m-%d') if p.payment_date else '' }}</td>
<td>{{ p.case.file_no if p.case else '' }}</td>
<td>
{% set client = p.case.client if p.case else None %}
{% if client %}{{ client.last_name }}, {{ client.first_name }}{% else %}<span class="text-muted"></span>{% endif %}
</td>
<td>{{ p.payment_type or '' }}</td>
<td>{{ p.description or '' }}</td>
<td class="text-end">{{ '%.2f'|format(p.amount) if p.amount is not none else '' }}</td>
</tr>
{% endfor %}
{% else %}
<tr><td colspan="6" class="text-center text-muted py-4">No payments found.</td></tr>
{% endif %}
</tbody>
</table>
</div>
</div>
<div class="col-12">
{% if total_pages and total_pages > 1 %}
<nav aria-label="Payments pagination">
<ul class="pagination mb-0">
<li class="page-item {% if page <= 1 %}disabled{% endif %}">
<a class="page-link" href="/payments?page={{ page - 1 if page > 1 else 1 }}&page_size={{ page_size }}{% if from_date %}&from_date={{ from_date }}{% endif %}{% if to_date %}&to_date={{ to_date }}{% endif %}{% if file_no %}&file_no={{ file_no | urlencode }}{% endif %}{% if rolodex_id %}&rolodex_id={{ rolodex_id | urlencode }}{% endif %}{% if q %}&q={{ q | urlencode }}{% endif %}">
&laquo;
</a>
</li>
{% for p in range(1, total_pages + 1) %}
<li class="page-item {% if p == page %}active{% endif %}">
<a class="page-link" href="/payments?page={{ p }}&page_size={{ page_size }}{% if from_date %}&from_date={{ from_date }}{% endif %}{% if to_date %}&to_date={{ to_date }}{% endif %}{% if file_no %}&file_no={{ file_no | urlencode }}{% endif %}{% if rolodex_id %}&rolodex_id={{ rolodex_id | urlencode }}{% endif %}{% if q %}&q={{ q | urlencode }}{% endif %}">{{ p }}</a>
</li>
{% endfor %}
<li class="page-item {% if page >= total_pages %}disabled{% endif %}">
<a class="page-link" href="/payments?page={{ page + 1 if page < total_pages else total_pages }}&page_size={{ page_size }}{% if from_date %}&from_date={{ from_date }}{% endif %}{% if to_date %}&to_date={{ to_date }}{% endif %}{% if file_no %}&file_no={{ file_no | urlencode }}{% endif %}{% if rolodex_id %}&rolodex_id={{ rolodex_id | urlencode }}{% endif %}{% if q %}&q={{ q | urlencode }}{% endif %}">
&raquo;
</a>
</li>
</ul>
</nav>
{% endif %}
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,61 @@
{% extends "base.html" %}
{% block title %}Phone Book · Delphi Database{% endblock %}
{% block content %}
<div class="row g-3">
<div class="col-12 d-flex align-items-center">
<a class="btn btn-sm btn-outline-secondary me-2" href="/rolodex">
<i class="bi bi-arrow-left"></i> Back
</a>
<h2 class="mb-0">Phone Book</h2>
<div class="ms-auto d-flex gap-2">
<a class="btn btn-outline-secondary btn-sm" href="/reports/phone-book?format=csv{% for id in client_ids %}&client_ids={{ id }}{% endfor %}{% if q %}&q={{ q | urlencode }}{% endif %}">
<i class="bi bi-filetype-csv me-1"></i>Download CSV
</a>
</div>
</div>
<div class="col-12">
<div class="table-responsive">
<table class="table table-sm align-middle">
<thead class="table-light">
<tr>
<th style="width: 220px;">Name</th>
<th>Company</th>
<th style="width: 160px;">Phone Type</th>
<th style="width: 220px;">Phone Number</th>
</tr>
</thead>
<tbody>
{% if clients and clients|length > 0 %}
{% for c in clients %}
{% if c.phones and c.phones|length > 0 %}
{% for p in c.phones %}
<tr>
<td><span class="fw-semibold">{{ c.last_name or '' }}, {{ c.first_name or '' }}</span></td>
<td>{{ c.company or '' }}</td>
<td>{{ p.phone_type or '' }}</td>
<td>{{ p.phone_number or '' }}</td>
</tr>
{% endfor %}
{% else %}
<tr>
<td><span class="fw-semibold">{{ c.last_name or '' }}, {{ c.first_name or '' }}</span></td>
<td>{{ c.company or '' }}</td>
<td class="text-muted"></td>
<td class="text-muted"></td>
</tr>
{% endif %}
{% endfor %}
{% else %}
<tr><td colspan="4" class="text-center text-muted py-4">No data.</td></tr>
{% endif %}
</tbody>
</table>
</div>
</div>
</div>
{% endblock %}

153
app/templates/rolodex.html Normal file
View File

@@ -0,0 +1,153 @@
{% extends "base.html" %}
{% block title %}Rolodex · Delphi Database{% endblock %}
{% block content %}
<div class="row g-3 align-items-center mb-3">
<div class="col-auto">
<h2 class="mb-0">Rolodex</h2>
</div>
<div class="col ms-auto">
<form class="row g-2" method="get" action="/rolodex">
<div class="col-md">
<input class="form-control" type="search" name="q" placeholder="Search name or company" aria-label="Search" value="{{ q or '' }}">
</div>
<div class="col-md">
<input class="form-control" type="search" name="phone" placeholder="Phone contains" aria-label="Phone" value="{{ phone or '' }}">
</div>
<div class="col-auto">
<input type="hidden" name="page_size" value="{{ page_size }}">
<button class="btn btn-outline-primary" type="submit">
<i class="bi bi-search me-1"></i>Search
</button>
</div>
<div class="col-auto">
<a class="btn btn-outline-secondary" href="/rolodex">
<i class="bi bi-x-circle me-1"></i>Clear
</a>
</div>
<div class="col-auto">
<a class="btn btn-primary" href="/rolodex/new">
<i class="bi bi-plus-lg me-1"></i>New Client
</a>
</div>
</form>
</div>
<div class="col-12 text-muted small">
{% if total and total > 0 %}
Showing {{ start_index }}{{ end_index }} of {{ total }}
{% else %}
No results
{% endif %}
</div>
<div class="col-12">
<div class="table-responsive">
<form method="post" action="/reports/phone-book" id="bulkForm">
<table class="table table-hover align-middle">
<thead class="table-light">
<tr>
{% if enable_bulk %}
<th style="width: 40px;"><input class="form-check-input" type="checkbox" id="selectAll"></th>
{% endif %}
<th style="width: 220px;">Name</th>
<th>Company</th>
<th>Address</th>
<th>City</th>
<th style="width: 80px;">State</th>
<th style="width: 110px;">ZIP</th>
<th style="width: 200px;">Phones</th>
<th class="text-end" style="width: 140px;">Actions</th>
</tr>
</thead>
<tbody>
{% if clients and clients|length > 0 %}
{% for c in clients %}
<tr>
{% if enable_bulk %}
<td>
<input class="form-check-input" type="checkbox" name="client_ids" value="{{ c.id }}">
</td>
{% endif %}
<td>
<span class="fw-semibold">{{ c.last_name or '' }}, {{ c.first_name or '' }}</span>
</td>
<td>{{ c.company or '' }}</td>
<td>{{ c.address or '' }}</td>
<td>{{ c.city or '' }}</td>
<td>{{ c.state or '' }}</td>
<td>{{ c.zip_code or '' }}</td>
<td>
{% if c.phones and c.phones|length > 0 %}
{% for p in c.phones[:3] %}
<span class="badge bg-light text-dark me-1">{{ p.phone_number }}</span>
{% endfor %}
{% else %}
<span class="text-muted"></span>
{% endif %}
</td>
<td class="text-end">
<a class="btn btn-sm btn-outline-primary" href="/rolodex/{{ c.id }}">
<i class="bi bi-person-lines-fill me-1"></i>View
</a>
</td>
</tr>
{% endfor %}
{% else %}
<tr>
<td colspan="8" class="text-center text-muted py-4">No clients found.</td>
</tr>
{% endif %}
</tbody>
</table>
{% if enable_bulk %}
<div class="d-flex gap-2">
<button type="submit" class="btn btn-outline-secondary">
<i class="bi bi-journal-text me-1"></i>Phone Book (Selected)
</button>
<a class="btn btn-outline-secondary" href="/reports/phone-book?format=csv{% if q %}&q={{ q | urlencode }}{% endif %}">
<i class="bi bi-filetype-csv me-1"></i>Phone Book CSV (Current Filter)
</a>
</div>
{% endif %}
</form>
</div>
</div>
<div class="col-12">
{% if total_pages and total_pages > 1 %}
<nav aria-label="Rolodex pagination">
<ul class="pagination mb-0">
<li class="page-item {% if page <= 1 %}disabled{% endif %}">
<a class="page-link" href="/rolodex?page={{ page - 1 if page > 1 else 1 }}&page_size={{ page_size }}{% if q %}&q={{ q | urlencode }}{% endif %}{% if phone %}&phone={{ phone | urlencode }}{% endif %}" aria-label="Previous">
<span aria-hidden="true">&laquo;</span>
</a>
</li>
{% for p in page_numbers %}
<li class="page-item {% if p == page %}active{% endif %}">
<a class="page-link" href="/rolodex?page={{ p }}&page_size={{ page_size }}{% if q %}&q={{ q | urlencode }}{% endif %}{% if phone %}&phone={{ phone | urlencode }}{% endif %}">{{ p }}</a>
</li>
{% endfor %}
<li class="page-item {% if page >= total_pages %}disabled{% endif %}">
<a class="page-link" href="/rolodex?page={{ page + 1 if page < total_pages else total_pages }}&page_size={{ page_size }}{% if q %}&q={{ q | urlencode }}{% endif %}{% if phone %}&phone={{ phone | urlencode }}{% endif %}" aria-label="Next">
<span aria-hidden="true">&raquo;</span>
</a>
</li>
</ul>
</nav>
{% endif %}
</div>
</div>
{% block extra_scripts %}
<script>
document.addEventListener('DOMContentLoaded', function() {
const selectAll = document.getElementById('selectAll');
if (selectAll) {
selectAll.addEventListener('change', function() {
document.querySelectorAll('input[name="client_ids"]').forEach(cb => cb.checked = selectAll.checked);
});
}
});
</script>
{% endblock %}
{% endblock %}

View File

@@ -0,0 +1,71 @@
{% extends "base.html" %}
{% block title %}{{ 'New Client' if not client else 'Edit Client' }} · Delphi Database{% endblock %}
{% block content %}
<div class="row g-3">
<div class="col-12 d-flex align-items-center">
<a class="btn btn-sm btn-outline-secondary me-2" href="/rolodex">
<i class="bi bi-arrow-left"></i>
Back
</a>
<h2 class="mb-0">{{ 'New Client' if not client else 'Edit Client' }}</h2>
</div>
<div class="col-12">
<div class="card">
<div class="card-body">
<form method="post" action="{{ '/rolodex/create' if not client else '/rolodex/' ~ client.id ~ '/update' }}">
<div class="row g-3">
<div class="col-md-4">
<label for="last_name" class="form-label">Last Name</label>
<input type="text" class="form-control" id="last_name" name="last_name" value="{{ client.last_name if client else '' }}">
</div>
<div class="col-md-4">
<label for="first_name" class="form-label">First Name</label>
<input type="text" class="form-control" id="first_name" name="first_name" value="{{ client.first_name if client else '' }}">
</div>
<div class="col-md-4">
<label for="company" class="form-label">Company</label>
<input type="text" class="form-control" id="company" name="company" value="{{ client.company if client else '' }}">
</div>
<div class="col-md-6">
<label for="address" class="form-label">Address</label>
<input type="text" class="form-control" id="address" name="address" value="{{ client.address if client else '' }}">
</div>
<div class="col-md-3">
<label for="city" class="form-label">City</label>
<input type="text" class="form-control" id="city" name="city" value="{{ client.city if client else '' }}">
</div>
<div class="col-md-1">
<label for="state" class="form-label">State</label>
<input type="text" class="form-control" id="state" name="state" value="{{ client.state if client else '' }}">
</div>
<div class="col-md-2">
<label for="zip_code" class="form-label">ZIP</label>
<input type="text" class="form-control" id="zip_code" name="zip_code" value="{{ client.zip_code if client else '' }}">
</div>
<div class="col-md-4">
<label for="rolodex_id" class="form-label">Legacy Rolodex Id</label>
<input type="text" class="form-control" id="rolodex_id" name="rolodex_id" value="{{ client.rolodex_id if client else '' }}">
</div>
<div class="col-12">
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">
<i class="bi bi-check-lg me-1"></i>Save
</button>
<a href="{{ '/rolodex/' ~ client.id if client else '/rolodex' }}" class="btn btn-outline-secondary">Cancel</a>
</div>
</div>
</div>
</form>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,170 @@
{% extends "base.html" %}
{% block title %}Client · {{ client.last_name }}, {{ client.first_name }} · Delphi Database{% endblock %}
{% block content %}
<div class="row g-3">
<div class="col-12 d-flex align-items-center">
<a class="btn btn-sm btn-outline-secondary me-2" href="/rolodex">
<i class="bi bi-arrow-left"></i>
Back
</a>
<h2 class="mb-0">Client</h2>
<div class="ms-auto">
<a class="btn btn-sm btn-outline-primary" href="/rolodex/{{ client.id }}/edit" onclick="event.preventDefault(); document.getElementById('editFormLink').submit();">
<i class="bi bi-pencil-square me-1"></i>Edit
</a>
<form id="editFormLink" method="get" action="/rolodex/new" class="d-none">
<input type="hidden" name="_" value="1">
</form>
</div>
</div>
<div class="col-12">
<div class="card">
<div class="card-body">
<div class="row mb-3">
<div class="col-md-4">
<div class="text-muted small">Name</div>
<div class="fw-semibold">{{ client.last_name or '' }}, {{ client.first_name or '' }}</div>
</div>
<div class="col-md-4">
<div class="text-muted small">Company</div>
<div>{{ client.company or '' }}</div>
</div>
<div class="col-md-4">
<div class="text-muted small">Legacy Rolodex Id</div>
<div>{{ client.rolodex_id or '' }}</div>
</div>
</div>
<div class="row mb-3">
<div class="col-md-6">
<div class="text-muted small">Address</div>
<div>{{ client.address or '' }}</div>
</div>
<div class="col-md-3">
<div class="text-muted small">City</div>
<div>{{ client.city or '' }}</div>
</div>
<div class="col-md-1">
<div class="text-muted small">State</div>
<div>{{ client.state or '' }}</div>
</div>
<div class="col-md-2">
<div class="text-muted small">ZIP</div>
<div>{{ client.zip_code or '' }}</div>
</div>
</div>
<div class="d-flex gap-2">
<a class="btn btn-primary" href="/rolodex/new" onclick="event.preventDefault(); document.getElementById('editClientForm').submit();">
<i class="bi bi-pencil-square me-1"></i>Edit Client
</a>
<form id="editClientForm" method="get" action="/rolodex/new" class="d-none">
<input type="hidden" name="_prefill" value="{{ client.id }}">
</form>
<form method="post" action="/rolodex/{{ client.id }}/delete" onsubmit="return confirm('Delete this client? This cannot be undone.');">
<button type="submit" class="btn btn-outline-danger">
<i class="bi bi-trash me-1"></i>Delete
</button>
</form>
</div>
</div>
</div>
</div>
<div class="col-lg-6">
<div class="card h-100">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0">Phones</h5>
</div>
<div class="card-body">
<form class="row g-2 mb-3" method="post" action="/rolodex/{{ client.id }}/phone/add">
<div class="col-md-6">
<input type="text" class="form-control" name="phone_number" placeholder="Phone number" required>
</div>
<div class="col-md-4">
<input type="text" class="form-control" name="phone_type" placeholder="Type (home, work)">
</div>
<div class="col-md-2">
<button type="submit" class="btn btn-primary w-100"><i class="bi bi-plus"></i></button>
</div>
</form>
<div class="table-responsive">
<table class="table table-sm align-middle">
<thead class="table-light">
<tr>
<th>Number</th>
<th>Type</th>
<th class="text-end" style="width: 100px;">Actions</th>
</tr>
</thead>
<tbody>
{% if client.phones and client.phones|length > 0 %}
{% for p in client.phones %}
<tr>
<td>{{ p.phone_number }}</td>
<td>{{ p.phone_type or '' }}</td>
<td class="text-end">
<form method="post" action="/rolodex/{{ client.id }}/phone/{{ p.id }}/delete" onsubmit="return confirm('Delete this phone?');">
<button type="submit" class="btn btn-sm btn-outline-danger">
<i class="bi bi-trash"></i>
</button>
</form>
</td>
</tr>
{% endfor %}
{% else %}
<tr><td colspan="3" class="text-center text-muted py-3">No phones.</td></tr>
{% endif %}
</tbody>
</table>
</div>
</div>
</div>
</div>
<div class="col-lg-6">
<div class="card h-100">
<div class="card-header">Related Cases</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-sm mb-0 align-middle">
<thead class="table-light">
<tr>
<th style="width: 140px;">File #</th>
<th>Description</th>
<th style="width: 90px;">Status</th>
<th style="width: 110px;">Opened</th>
<th class="text-end" style="width: 110px;">Actions</th>
</tr>
</thead>
<tbody>
{% if client.cases and client.cases|length > 0 %}
{% for c in client.cases %}
<tr>
<td>{{ c.file_no }}</td>
<td>{{ c.description or '' }}</td>
<td>{{ c.status or '' }}</td>
<td>{{ c.open_date.strftime('%Y-%m-%d') if c.open_date else '' }}</td>
<td class="text-end">
<a class="btn btn-sm btn-outline-primary" href="/case/{{ c.id }}">
<i class="bi bi-folder2-open"></i>
</a>
</td>
</tr>
{% endfor %}
{% else %}
<tr><td colspan="5" class="text-center text-muted py-3">No related cases.</td></tr>
{% endif %}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
{% endblock %}