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:
Binary file not shown.
411
app/main.py
411
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 []},
|
||||
)
|
||||
|
||||
@@ -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
106
app/templates/payments.html
Normal 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 %}">
|
||||
«
|
||||
</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 %}">
|
||||
»
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
|
||||
61
app/templates/report_phone_book.html
Normal file
61
app/templates/report_phone_book.html
Normal 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
153
app/templates/rolodex.html
Normal 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">«</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">»</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 %}
|
||||
|
||||
|
||||
71
app/templates/rolodex_edit.html
Normal file
71
app/templates/rolodex_edit.html
Normal 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 %}
|
||||
|
||||
|
||||
170
app/templates/rolodex_view.html
Normal file
170
app/templates/rolodex_view.html
Normal 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 %}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user