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 io import StringIO
|
||||||
|
|
||||||
from fastapi import FastAPI, Depends, Request, Query, HTTPException, UploadFile, File, Form
|
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 starlette.middleware.sessions import SessionMiddleware
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
from sqlalchemy.orm import Session, joinedload
|
from sqlalchemy.orm import Session, joinedload
|
||||||
from sqlalchemy import or_
|
from sqlalchemy import or_, and_
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
from starlette.middleware.base import BaseHTTPMiddleware
|
from starlette.middleware.base import BaseHTTPMiddleware
|
||||||
import structlog
|
import structlog
|
||||||
@@ -794,9 +794,9 @@ def process_csv_import(db: Session, import_type: str, file_path: str) -> Dict[st
|
|||||||
@app.get("/")
|
@app.get("/")
|
||||||
async def root():
|
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")
|
@app.get("/health")
|
||||||
@@ -1478,3 +1478,406 @@ async def case_reopen(
|
|||||||
request.session["case_update_errors"] = ["Failed to reopen case. Please try again."]
|
request.session["case_update_errors"] = ["Failed to reopen case. Please try again."]
|
||||||
|
|
||||||
return RedirectResponse(url=f"/case/{case_id}", status_code=302)
|
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">
|
<li class="nav-item">
|
||||||
<a class="nav-link {% if 'dashboard' in request.url.path %}active{% endif %}" href="/dashboard">Dashboard</a>
|
<a class="nav-link {% if 'dashboard' in request.url.path %}active{% endif %}" href="/dashboard">Dashboard</a>
|
||||||
</li>
|
</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">
|
<li class="nav-item">
|
||||||
<a class="nav-link {% if 'admin' in request.url.path %}active{% endif %}" href="/admin">Admin</a>
|
<a class="nav-link {% if 'admin' in request.url.path %}active{% endif %}" href="/admin">Admin</a>
|
||||||
</li>
|
</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 %}
|
||||||
|
|
||||||
|
|
||||||
@@ -1,5 +1,3 @@
|
|||||||
version: '3.8'
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
delphi-db:
|
delphi-db:
|
||||||
build: .
|
build: .
|
||||||
|
|||||||
Reference in New Issue
Block a user