Customer 360: extended Client fields, auto-migrate, updated Rolodex CRUD/templates, QDRO routes/views, importer mapping

QDRO links appear in rolodex_view.html case rows and case.html header when QDRO data exists, matching legacy flows.
This commit is contained in:
HotSwapp
2025-10-13 14:04:35 -05:00
parent 4cd35c66fd
commit 2e2380552e
32 changed files with 194632 additions and 9 deletions

View File

@@ -105,6 +105,40 @@ def create_tables() -> None:
except Exception:
pass
# Lightweight migration: ensure new client columns exist (SQLite safe)
try:
inspector = inspect(engine)
client_cols = {col['name'] for col in inspector.get_columns('clients')}
client_required_sql = {
'prefix': 'ALTER TABLE clients ADD COLUMN prefix VARCHAR(20)',
'middle_name': 'ALTER TABLE clients ADD COLUMN middle_name VARCHAR(50)',
'suffix': 'ALTER TABLE clients ADD COLUMN suffix VARCHAR(20)',
'title': 'ALTER TABLE clients ADD COLUMN title VARCHAR(100)',
'group': 'ALTER TABLE clients ADD COLUMN "group" VARCHAR(50)',
'email': 'ALTER TABLE clients ADD COLUMN email VARCHAR(255)',
'dob': 'ALTER TABLE clients ADD COLUMN dob DATE',
'ssn': 'ALTER TABLE clients ADD COLUMN ssn VARCHAR(20)',
'legal_status': 'ALTER TABLE clients ADD COLUMN legal_status VARCHAR(50)',
'memo': 'ALTER TABLE clients ADD COLUMN memo TEXT'
}
client_alters = []
for col_name, ddl in client_required_sql.items():
if col_name not in client_cols:
client_alters.append(ddl)
if client_alters:
with engine.begin() as conn:
for ddl in client_alters:
conn.execute(text(ddl))
except Exception as e:
try:
from .logging_config import setup_logging
import structlog
setup_logging()
_logger = structlog.get_logger(__name__)
_logger.warning("sqlite_migration_clients_failed", error=str(e))
except Exception:
pass
# Seed default admin user after creating tables
try:
from .auth import seed_admin_user

View File

@@ -29,7 +29,7 @@ import structlog
from structlog import contextvars as structlog_contextvars
from .database import create_tables, get_db, get_database_url
from .models import User, Case, Client, Phone, Transaction, Document, Payment, ImportLog
from .models import User, Case, Client, Phone, Transaction, Document, Payment, ImportLog, Qdros
from .auth import authenticate_user, get_current_user_from_session
from .reporting import (
build_phone_book_pdf,
@@ -633,16 +633,37 @@ def import_rolodex_data(db: Session, file_path: str) -> Dict[str, Any]:
result['errors'].append(f"Row {row_num}: Client with ID '{rolodex_id}' already exists")
continue
# Parse DOB (YYYY-MM-DD or MM/DD/YY variants)
dob_raw = row.get('DOB', '').strip()
dob_val = None
for fmt in ("%Y-%m-%d", "%m/%d/%Y", "%m/%d/%y"):
if not dob_raw:
break
try:
dob_val = datetime.strptime(dob_raw, fmt).date()
break
except ValueError:
continue
client = Client(
rolodex_id=rolodex_id,
prefix=row.get('Prefix', '').strip() or None,
first_name=row.get('First', '').strip() or None,
middle_initial=row.get('Middle', '').strip() or None,
middle_name=row.get('Middle', '').strip() or None,
last_name=row.get('Last', '').strip() or None,
suffix=row.get('Suffix', '').strip() or None,
title=row.get('Title', '').strip() or None,
company=row.get('Title', '').strip() or None,
address=row.get('A1', '').strip() or None,
city=row.get('City', '').strip() or None,
state=row.get('St', '').strip() or None,
zip_code=row.get('Zip', '').strip() or None
state=(row.get('Abrev', '').strip() or row.get('St', '').strip() or None),
zip_code=row.get('Zip', '').strip() or None,
group=row.get('Group', '').strip() or None,
email=row.get('Email', '').strip() or None,
dob=dob_val,
ssn=row.get('SS#', '').strip() or None,
legal_status=row.get('Legal_Status', '').strip() or None,
memo=row.get('Memo', '').strip() or None,
)
db.add(client)
@@ -2298,6 +2319,9 @@ async def case_detail(
logger.info("case_detail", case_id=case_obj.id, file_no=case_obj.file_no)
# Determine if QDRO entries exist for this case's file number
has_qdro = db.query(Qdros).filter(Qdros.file_no == case_obj.file_no).count() > 0
# Get any errors from session and clear them
errors = request.session.pop("case_update_errors", None)
@@ -2322,6 +2346,7 @@ async def case_detail(
"saved": saved,
"errors": errors or [],
"totals": totals,
"has_qdro": has_qdro,
},
)
@@ -2658,13 +2683,23 @@ async def rolodex_view(client_id: int, request: Request, db: Session = Depends(g
@app.post("/rolodex/create")
async def rolodex_create(
request: Request,
prefix: str = Form(None),
first_name: str = Form(None),
middle_name: str = Form(None),
last_name: str = Form(None),
suffix: str = Form(None),
title: str = Form(None),
company: str = Form(None),
address: str = Form(None),
city: str = Form(None),
state: str = Form(None),
zip_code: str = Form(None),
group: str = Form(None),
email: str = Form(None),
dob: str = Form(None),
ssn: str = Form(None),
legal_status: str = Form(None),
memo: str = Form(None),
rolodex_id: str = Form(None),
db: Session = Depends(get_db),
):
@@ -2672,14 +2707,32 @@ async def rolodex_create(
if not user:
return RedirectResponse(url="/login", status_code=302)
# Parse date
dob_dt = None
if dob and dob.strip():
try:
dob_dt = datetime.strptime(dob.strip(), "%Y-%m-%d").date()
except ValueError:
dob_dt = None
client = Client(
prefix=(prefix or "").strip() or None,
first_name=(first_name or "").strip() or None,
middle_name=(middle_name or "").strip() or None,
last_name=(last_name or "").strip() or None,
suffix=(suffix or "").strip() or None,
title=(title 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,
group=(group or "").strip() or None,
email=(email or "").strip() or None,
dob=dob_dt,
ssn=(ssn or "").strip() or None,
legal_status=(legal_status or "").strip() or None,
memo=(memo or "").strip() or None,
rolodex_id=(rolodex_id or "").strip() or None,
)
db.add(client)
@@ -2693,13 +2746,23 @@ async def rolodex_create(
async def rolodex_update(
client_id: int,
request: Request,
prefix: str = Form(None),
first_name: str = Form(None),
middle_name: str = Form(None),
last_name: str = Form(None),
suffix: str = Form(None),
title: str = Form(None),
company: str = Form(None),
address: str = Form(None),
city: str = Form(None),
state: str = Form(None),
zip_code: str = Form(None),
group: str = Form(None),
email: str = Form(None),
dob: str = Form(None),
ssn: str = Form(None),
legal_status: str = Form(None),
memo: str = Form(None),
rolodex_id: str = Form(None),
db: Session = Depends(get_db),
):
@@ -2711,13 +2774,27 @@ async def rolodex_update(
if not client:
raise HTTPException(status_code=404, detail="Client not found")
client.prefix = (prefix or "").strip() or None
client.first_name = (first_name or "").strip() or None
client.middle_name = (middle_name or "").strip() or None
client.last_name = (last_name or "").strip() or None
client.suffix = (suffix or "").strip() or None
client.title = (title 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.group = (group or "").strip() or None
client.email = (email or "").strip() or None
if dob and dob.strip():
try:
client.dob = datetime.strptime(dob.strip(), "%Y-%m-%d").date()
except ValueError:
pass
client.ssn = (ssn or "").strip() or None
client.legal_status = (legal_status or "").strip() or None
client.memo = (memo or "").strip() or None
client.rolodex_id = (rolodex_id or "").strip() or None
db.commit()
@@ -3625,3 +3702,67 @@ async def api_list_ledger(
items=items,
pagination=Pagination(page=page, page_size=page_size, total=total, total_pages=total_pages),
)
# ------------------------------
# QDRO Views
# ------------------------------
@app.get("/qdro/{file_no}")
async def qdro_versions(
request: Request,
file_no: str,
db: Session = Depends(get_db),
):
user = get_current_user_from_session(request.session)
if not user:
return RedirectResponse(url="/login", status_code=302)
versions = (
db.query(Qdros)
.filter(Qdros.file_no == file_no)
.order_by(Qdros.version.asc())
.all()
)
return templates.TemplateResponse(
"qdro.html",
{
"request": request,
"user": user,
"file_no": file_no,
"versions": versions,
"qdro": None,
},
)
@app.get("/qdro/{file_no}/{version}")
async def qdro_detail(
request: Request,
file_no: str,
version: str,
db: Session = Depends(get_db),
):
user = get_current_user_from_session(request.session)
if not user:
return RedirectResponse(url="/login", status_code=302)
q = db.query(Qdros).filter(Qdros.file_no == file_no, Qdros.version == version).first()
if not q:
return RedirectResponse(url=f"/qdro/{file_no}", status_code=302)
versions = (
db.query(Qdros)
.filter(Qdros.file_no == file_no)
.order_by(Qdros.version.asc())
.all()
)
return templates.TemplateResponse(
"qdro.html",
{
"request": request,
"user": user,
"file_no": file_no,
"versions": versions,
"qdro": q,
},
)

View File

@@ -42,9 +42,20 @@ class Client(Base):
id = Column(Integer, primary_key=True, index=True)
rolodex_id = Column(String(20), unique=True, index=True)
# Name and identity fields (modernized)
prefix = Column(String(20))
last_name = Column(String(50))
first_name = Column(String(50))
middle_initial = Column(String(10))
middle_name = Column(String(50))
suffix = Column(String(20)) # Jr, Sr, etc.
title = Column(String(100)) # Job/role title
group = Column(String(50)) # Legacy rolodex group
email = Column(String(255))
dob = Column(Date)
ssn = Column(String(20))
legal_status = Column(String(50))
memo = Column(Text)
company = Column(String(100))
address = Column(String(255))
city = Column(String(50))

View File

@@ -130,6 +130,11 @@ Case {{ case.file_no if case else '' }} · Delphi Database
</button>
</form>
{% endif %}
{% if has_qdro %}
<a class="btn btn-sm btn-outline-secondary" href="/qdro/{{ case.file_no }}">
QDRO
</a>
{% endif %}
</div>
</div>
<div class="card-body">

75
app/templates/qdro.html Normal file
View File

@@ -0,0 +1,75 @@
{% extends "base.html" %}
{% block title %}QDRO · {{ file_no }}{% if qdro %} · {{ qdro.version }}{% endif %} · 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="/dashboard">
<i class="bi bi-arrow-left"></i>
Back
</a>
<h2 class="mb-0">QDRO</h2>
<div class="ms-auto">
<a class="btn btn-sm btn-outline-secondary" href="/qdro/{{ file_no }}">All Versions</a>
</div>
</div>
<div class="col-md-4">
<div class="card h-100">
<div class="card-header">Versions</div>
<div class="card-body p-0">
<div class="list-group list-group-flush">
{% if versions and versions|length > 0 %}
{% for v in versions %}
<a class="list-group-item list-group-item-action d-flex justify-content-between align-items-center" href="/qdro/{{ v.file_no }}/{{ v.version }}">
<span>Version {{ v.version }}</span>
<i class="bi bi-chevron-right"></i>
</a>
{% endfor %}
{% else %}
<div class="list-group-item text-muted">No QDRO versions for this file.</div>
{% endif %}
</div>
</div>
</div>
</div>
<div class="col-md-8">
<div class="card h-100">
<div class="card-header">Details</div>
<div class="card-body">
{% if qdro %}
<div class="row mb-3">
<div class="col-md-6"><div class="text-muted small">File #</div><div class="fw-semibold">{{ qdro.file_no }}</div></div>
<div class="col-md-6"><div class="text-muted small">Version</div><div class="fw-semibold">{{ qdro.version }}</div></div>
</div>
<div class="row mb-3">
<div class="col-md-6"><div class="text-muted small">Plan Id</div><div>{{ qdro.plan_id or '' }}</div></div>
<div class="col-md-6"><div class="text-muted small">Case Number</div><div>{{ qdro.case_number or '' }}</div></div>
</div>
<div class="row mb-3">
<div class="col-md-6"><div class="text-muted small">Case Type</div><div>{{ qdro.case_type or '' }}</div></div>
<div class="col-md-6"><div class="text-muted small">Section</div><div>{{ qdro.section or '' }}</div></div>
</div>
<div class="row mb-3">
<div class="col-md-6"><div class="text-muted small">Judgment Date</div><div>{{ qdro.judgment_date if qdro.judgment_date else '' }}</div></div>
<div class="col-md-6"><div class="text-muted small">Valuation Date</div><div>{{ qdro.valuation_date if qdro.valuation_date else '' }}</div></div>
</div>
<div class="row mb-3">
<div class="col-md-6"><div class="text-muted small">Married On</div><div>{{ qdro.married_on if qdro.married_on else '' }}</div></div>
<div class="col-md-6"><div class="text-muted small">Percent Awarded</div><div>{{ qdro.percent_awarded or '' }}</div></div>
</div>
<div class="row mb-3">
<div class="col-md-12"><div class="text-muted small">Judge</div><div>{{ qdro.judge or '' }}</div></div>
</div>
{% else %}
<div class="text-muted">Select a version on the left to view details.</div>
{% endif %}
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -18,6 +18,10 @@
<form method="post" action="{{ '/rolodex/create' if not client else '/rolodex/' ~ client.id ~ '/update' }}">
<div class="mb-2 text-muted small" id="fieldHelp" aria-live="polite">Focus a field to see help.</div>
<div class="row g-3">
<div class="col-md-2">
<label for="prefix" class="form-label">Prefix</label>
<input type="text" class="form-control" id="prefix" name="prefix" value="{{ client.prefix if client else '' }}">
</div>
<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" data-help="Client last name (surname)." value="{{ client.last_name if client else '' }}">
@@ -26,6 +30,18 @@
<label for="first_name" class="form-label">First Name</label>
<input type="text" class="form-control" id="first_name" name="first_name" data-help="Client given name." value="{{ client.first_name if client else '' }}">
</div>
<div class="col-md-4">
<label for="middle_name" class="form-label">Middle</label>
<input type="text" class="form-control" id="middle_name" name="middle_name" value="{{ client.middle_name if client else '' }}">
</div>
<div class="col-md-2">
<label for="suffix" class="form-label">Suffix</label>
<input type="text" class="form-control" id="suffix" name="suffix" placeholder="Jr/Sr" value="{{ client.suffix if client else '' }}">
</div>
<div class="col-md-4">
<label for="title" class="form-label">Title</label>
<input type="text" class="form-control" id="title" name="title" value="{{ client.title 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" data-help="Organization or employer (optional)." value="{{ client.company if client else '' }}">
@@ -48,6 +64,30 @@
<input type="text" class="form-control" id="zip_code" name="zip_code" data-help="5-digit ZIP or ZIP+4." value="{{ client.zip_code if client else '' }}">
</div>
<div class="col-md-3">
<label for="group" class="form-label">Group</label>
<input type="text" class="form-control" id="group" name="group" value="{{ client.group if client else '' }}">
</div>
<div class="col-md-4">
<label for="email" class="form-label">Email</label>
<input type="email" class="form-control" id="email" name="email" value="{{ client.email if client else '' }}">
</div>
<div class="col-md-2">
<label for="dob" class="form-label">DOB</label>
<input type="date" class="form-control" id="dob" name="dob" value="{{ client.dob.strftime('%Y-%m-%d') if client and client.dob else '' }}">
</div>
<div class="col-md-3">
<label for="ssn" class="form-label">SS#</label>
<input type="text" class="form-control" id="ssn" name="ssn" value="{{ client.ssn if client else '' }}">
</div>
<div class="col-md-4">
<label for="legal_status" class="form-label">Legal Status</label>
<input type="text" class="form-control" id="legal_status" name="legal_status" value="{{ client.legal_status if client else '' }}">
</div>
<div class="col-12">
<label for="memo" class="form-label">Memo / Notes</label>
<textarea class="form-control" id="memo" name="memo" rows="3">{{ client.memo if client else '' }}</textarea>
</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" data-help="Legacy ID used for migration and lookup; may be alphanumeric." value="{{ client.rolodex_id if client else '' }}">

View File

@@ -23,7 +23,7 @@
<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 class="fw-semibold">{{ client.prefix or '' }} {{ client.first_name or '' }} {{ client.middle_name or '' }} {{ client.last_name or '' }} {{ client.suffix or '' }}</div>
</div>
<div class="col-md-4">
<div class="text-muted small">Company</div>
@@ -52,6 +52,34 @@
<div>{{ client.zip_code or '' }}</div>
</div>
</div>
<div class="row mb-3">
<div class="col-md-3">
<div class="text-muted small">Group</div>
<div>{{ client.group or '' }}</div>
</div>
<div class="col-md-3">
<div class="text-muted small">Email</div>
<div>{{ client.email or '' }}</div>
</div>
<div class="col-md-3">
<div class="text-muted small">DOB</div>
<div>{{ client.dob.strftime('%Y-%m-%d') if client.dob else '' }}</div>
</div>
<div class="col-md-3">
<div class="text-muted small">SS#</div>
<div>{{ client.ssn or '' }}</div>
</div>
</div>
<div class="row mb-3">
<div class="col-md-3">
<div class="text-muted small">Legal Status</div>
<div>{{ client.legal_status or '' }}</div>
</div>
<div class="col-md-9">
<div class="text-muted small">Memo / Notes</div>
<div>{{ client.memo or '' }}</div>
</div>
</div>
<div class="d-flex gap-2">
<a class="btn btn-primary" href="/rolodex/{{ client.id }}/edit">
@@ -121,7 +149,15 @@
<div class="col-lg-6">
<div class="card h-100">
<div class="card-header">Related Cases</div>
<div class="card-header d-flex justify-content-between align-items-center">
<span>Related Cases</span>
<div class="btn-group btn-group-sm" role="group" aria-label="Case filters">
{% set status_filter = request.query_params.get('status') or 'all' %}
<a class="btn btn-outline-secondary {% if status_filter == 'all' %}active{% endif %}" href="?status=all">All</a>
<a class="btn btn-outline-secondary {% if status_filter == 'open' %}active{% endif %}" href="?status=open">Open</a>
<a class="btn btn-outline-secondary {% if status_filter == 'closed' %}active{% endif %}" href="?status=closed">Closed</a>
</div>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-sm mb-0 align-middle">
@@ -131,23 +167,34 @@
<th>Description</th>
<th style="width: 90px;">Status</th>
<th style="width: 110px;">Opened</th>
<th class="text-end" style="width: 110px;">Actions</th>
<th class="text-end" style="width: 150px;">Actions</th>
</tr>
</thead>
<tbody>
{% if client.cases and client.cases|length > 0 %}
{% for c in client.cases %}
{% if status_filter == 'all' or (status_filter == 'open' and (c.status != 'closed')) or (status_filter == 'closed' and c.status == 'closed') %}
<tr>
<td>{{ c.file_no }}</td>
<td>{{ c.description or '' }}</td>
<td>{{ c.status or '' }}</td>
<td>
{% if c.status == 'closed' %}
<span class="badge bg-secondary">Closed</span>
{% else %}
<span class="badge bg-success">Open</span>
{% endif %}
</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>
<a class="btn btn-sm btn-outline-secondary" href="/qdro/{{ c.file_no }}">
QDRO
</a>
</td>
</tr>
{% endif %}
{% endfor %}
{% else %}
<tr><td colspan="5" class="text-center text-muted py-3">No related cases.</td></tr>