Answer-table pattern: add reusable macros, integrate with Rolodex; bulk actions retained. Field prompts/help: generic focus-based help in forms (case, rolodex); add JS support. Rebuild Docker.

This commit is contained in:
HotSwapp
2025-10-07 17:00:54 -05:00
parent 748fe92565
commit e07a4fda1c
7 changed files with 201 additions and 117 deletions

View File

@@ -9,10 +9,10 @@ Purpose: Track features and workflows from legacy Paradox (.SC) scripts to port
- [x] CSV import: one-time and repeatable import from legacy CSVs
- Example: Import `old-database/Office/FILES.csv` into `files` table; validate required fields and normalize dates; log row count and any rejects.
- [ ] Answer table pattern for query results
- [x] Answer table pattern for query results
- Example: After searching `Rolodex`, show a paginated results view with bulk actions (reports, document assembly, return to full dataset).
- [ ] Field prompts/help (tooltips or side panel)
- [x] Field prompts/help (tooltips or side panel)
- Example: When focusing `Files.File_Type`, show: "F1 to select area of law" as inline help text.
- [ ] Structured logging/audit trail
@@ -94,7 +94,7 @@ Purpose: Track features and workflows from legacy Paradox (.SC) scripts to port
### Plan Info
- [ ] CRUD for plan catalog
- Example: Add a plan with `Plan_Id = \"ABC-123\"`, `Plan_Type = DB`, and memo details; searchable in Pensions and QDRO.
- Example: Add a plan with `Plan_Id = "ABC-123"`, `Plan_Type = DB`, and memo details; searchable in Pensions and QDRO.
### Pensions (annuity evaluator)
- [ ] Life Expectancy Method (uses `LifeTabl`)

Binary file not shown.

View File

@@ -133,11 +133,12 @@ Case {{ case.file_no if case else '' }} · Delphi Database
</div>
</div>
<div class="card-body">
<div class="mb-2 text-muted small" id="fieldHelp" aria-live="polite">Focus a field to see help.</div>
<form method="post" action="/case/{{ case.id }}/update">
<div class="row g-3">
<div class="col-md-6">
<label for="status" class="form-label">Status</label>
<select class="form-select" id="status" name="status">
<select class="form-select" id="status" name="status" data-help="Active or Closed. Closed cases appear in reports as closed and may restrict edits.">
<option value="active" {% if case.status == 'active' %}selected{% endif %}>Active</option>
<option value="closed" {% if case.status == 'closed' %}selected{% endif %}>Closed</option>
</select>
@@ -145,24 +146,24 @@ Case {{ case.file_no if case else '' }} · Delphi Database
<div class="col-md-6">
<label for="case_type" class="form-label">Case Type</label>
<input type="text" class="form-control" id="case_type" name="case_type"
<input type="text" class="form-control" id="case_type" name="case_type" data-help="F1 to select area of law; type to filter."
value="{{ case.case_type or '' }}">
</div>
<div class="col-12">
<label for="description" class="form-label">Description</label>
<textarea class="form-control" id="description" name="description" rows="3">{{ case.description or '' }}</textarea>
<textarea class="form-control" id="description" name="description" rows="3" data-help="Brief summary of the matter; appears on statements.">{{ case.description or '' }}</textarea>
</div>
<div class="col-md-6">
<label for="open_date" class="form-label">Open Date</label>
<input type="date" class="form-control" id="open_date" name="open_date"
<input type="date" class="form-control" id="open_date" name="open_date" data-help="Date the case was opened."
value="{{ case.open_date.strftime('%Y-%m-%d') if case.open_date else '' }}">
</div>
<div class="col-md-6">
<label for="close_date" class="form-label">Close Date</label>
<input type="date" class="form-control" id="close_date" name="close_date"
<input type="date" class="form-control" id="close_date" name="close_date" data-help="Set when the case is completed/closed."
value="{{ case.close_date.strftime('%Y-%m-%d') if case.close_date else '' }}">
</div>

View File

@@ -0,0 +1,96 @@
{#
Reusable macros for answer-table pattern: summary, table with selection,
bulk actions bar, and pagination controls.
Usage in a page:
{% from "partials/answer_table_macros.html" import results_summary, pagination, answer_table, bulk_actions_bar %}
<div class="col-12 text-muted small">{{ results_summary(start_index, end_index, total) }}</div>
{% set headers = [
{ 'title': 'Name', 'width': '220px' },
{ 'title': 'Company' },
{ 'title': 'Address' },
{ 'title': 'City' },
{ 'title': 'State', 'width': '80px' },
{ 'title': 'ZIP', 'width': '110px' },
{ 'title': 'Phones', 'width': '200px' },
{ 'title': 'Actions', 'width': '140px', 'align': 'end' },
] %}
{% call(answer_table(headers, form_action='/reports/phone-book', select_name='client_ids', enable_bulk=enable_bulk)) %}
{# render <tr>...</tr> rows here #}
{% endcall %}
{% if enable_bulk %}
{% call(bulk_actions_bar()) %}
{# render bulk action buttons/links here #}
{% endcall %}
{% endif %}
{{ pagination('/rolodex', page, total_pages, page_size, {'q': q, 'phone': phone}) }}
#}
{% macro results_summary(start_index, end_index, total) -%}
{% if total and total > 0 %}
Showing {{ start_index }}{{ end_index }} of {{ total }}
{% else %}
No results
{% endif %}
{%- endmacro %}
{% macro answer_table(headers, form_action=None, select_name='selected_ids', enable_bulk=False) -%}
<form method="post" action="{{ form_action or '' }}" class="js-answer-table">
<table class="table table-hover align-middle">
<thead class="table-light">
<tr>
{% if enable_bulk %}
<th style="width: 40px;"><input class="form-check-input js-select-all" type="checkbox"></th>
{% endif %}
{% for h in headers %}
<th{% if h.width %} style="width: {{ h.width }};"{% endif %}{% if h.align == 'end' %} class="text-end"{% endif %}>{{ h.title }}</th>
{% endfor %}
</tr>
</thead>
<tbody>
{{ caller() }}
</tbody>
</table>
</form>
{%- endmacro %}
{% macro bulk_actions_bar() -%}
<div class="d-flex gap-2 mb-2">
{{ caller() }}
</div>
{%- endmacro %}
{% macro pagination(base_url, page, total_pages, page_size, params=None) -%}
{% if total_pages and total_pages > 1 %}
<nav aria-label="Pagination">
<ul class="pagination mb-0">
<li class="page-item {% if page <= 1 %}disabled{% endif %}">
<a class="page-link" href="{{ base_url }}?page={{ 1 if page <= 1 else page - 1 }}&page_size={{ page_size }}{% if params %}{% for k, v in params.items() %}{% if v %}&{{ k }}={{ v | urlencode }}{% endif %}{% endfor %}{% endif %}" aria-label="Previous">
<span aria-hidden="true">&laquo;</span>
</a>
</li>
{% set start_page = 1 if page - 2 < 1 else page - 2 %}
{% set end_page = total_pages if page + 2 > total_pages else page + 2 %}
{% for p in range(start_page, end_page + 1) %}
<li class="page-item {% if p == page %}active{% endif %}">
<a class="page-link" href="{{ base_url }}?page={{ p }}&page_size={{ page_size }}{% if params %}{% for k, v in params.items() %}{% if v %}&{{ k }}={{ v | urlencode }}{% endif %}{% endfor %}{% endif %}">{{ p }}</a>
</li>
{% endfor %}
<li class="page-item {% if page >= total_pages %}disabled{% endif %}">
<a class="page-link" href="{{ base_url }}?page={{ total_pages if page >= total_pages else page + 1 }}&page_size={{ page_size }}{% if params %}{% for k, v in params.items() %}{% if v %}&{{ k }}={{ v | urlencode }}{% endif %}{% endfor %}{% endif %}" aria-label="Next">
<span aria-hidden="true">&raquo;</span>
</a>
</li>
</ul>
</nav>
{% endif %}
{%- endmacro %}

View File

@@ -2,6 +2,8 @@
{% block title %}Rolodex · Delphi Database{% endblock %}
{% from "partials/answer_table_macros.html" import results_summary, pagination, answer_table, bulk_actions_bar %}
{% block content %}
<div class="row g-3 align-items-center mb-3">
<div class="col-auto">
@@ -33,121 +35,72 @@
</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 text-muted small">{{ results_summary(start_index, end_index, total) }}</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">
{% set headers = [
{ 'title': 'Name', 'width': '220px' },
{ 'title': 'Company' },
{ 'title': 'Address' },
{ 'title': 'City' },
{ 'title': 'State', 'width': '80px' },
{ 'title': 'ZIP', 'width': '110px' },
{ 'title': 'Phones', 'width': '200px' },
{ 'title': 'Actions', 'width': '140px', 'align': 'end' },
] %}
{% call(answer_table(headers, form_action='/reports/phone-book', select_name='client_ids', enable_bulk=enable_bulk)) %}
{% if clients and clients|length > 0 %}
{% for c in clients %}
<tr>
{% if enable_bulk %}
<th style="width: 40px;"><input class="form-check-input" type="checkbox" id="selectAll"></th>
<td><input class="form-check-input" type="checkbox" name="client_ids" value="{{ c.id }}"></td>
{% 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>
<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>
<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>
</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 %}
{% endcall %}
{% 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>
{% call(bulk_actions_bar()) %}
<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>
{% endcall %}
{% endif %}
</form>
</div>
</div>
<div class="col-12">
{% if total_pages and total_pages > 1 %}
<nav aria-label="Rolodex pagination">
<ul class="pagination mb-0">
<li class="page-item {% if page <= 1 %}disabled{% endif %}">
<a class="page-link" href="/rolodex?page={{ page - 1 if page > 1 else 1 }}&page_size={{ page_size }}{% if q %}&q={{ q | urlencode }}{% endif %}{% if phone %}&phone={{ phone | urlencode }}{% endif %}" aria-label="Previous">
<span aria-hidden="true">&laquo;</span>
</a>
</li>
{% for p in page_numbers %}
<li class="page-item {% if p == page %}active{% endif %}">
<a class="page-link" href="/rolodex?page={{ p }}&page_size={{ page_size }}{% if q %}&q={{ q | urlencode }}{% endif %}{% if phone %}&phone={{ phone | urlencode }}{% endif %}">{{ p }}</a>
</li>
{% endfor %}
<li class="page-item {% if page >= total_pages %}disabled{% endif %}">
<a class="page-link" href="/rolodex?page={{ page + 1 if page < total_pages else total_pages }}&page_size={{ page_size }}{% if q %}&q={{ q | urlencode }}{% endif %}{% if phone %}&phone={{ phone | urlencode }}{% endif %}" aria-label="Next">
<span aria-hidden="true">&raquo;</span>
</a>
</li>
</ul>
</nav>
{% endif %}
{{ pagination('/rolodex', page, total_pages, page_size, {'q': q, 'phone': phone}) }}
</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 %}
{% block extra_scripts %}{% endblock %}
{% endblock %}

View File

@@ -16,18 +16,19 @@
<div class="card">
<div class="card-body">
<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-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 '' }}">
<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 '' }}">
</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 '' }}">
<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="company" class="form-label">Company</label>
<input type="text" class="form-control" id="company" name="company" value="{{ client.company if client else '' }}">
<input type="text" class="form-control" id="company" name="company" data-help="Organization or employer (optional)." value="{{ client.company if client else '' }}">
</div>
<div class="col-md-6">
@@ -36,20 +37,20 @@
</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 '' }}">
<input type="text" class="form-control" id="city" name="city" data-help="City or locality." 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 '' }}">
<input type="text" class="form-control" id="state" name="state" data-help="2-letter state code (e.g., NY)." 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 '' }}">
<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-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 '' }}">
<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 '' }}">
</div>
<div class="col-12">

View File

@@ -68,6 +68,39 @@ document.addEventListener('DOMContentLoaded', function() {
if (qtyInput) qtyInput.addEventListener('input', recomputeAmount);
if (rateInput) rateInput.addEventListener('input', recomputeAmount);
// Generic select-all for answer tables
document.querySelectorAll('.js-answer-table').forEach(function(form) {
var selectAll = form.querySelector('.js-select-all');
if (!selectAll) return;
selectAll.addEventListener('change', function() {
var checkboxes = form.querySelectorAll('input[type="checkbox"][name]');
checkboxes.forEach(function(cb) {
if (cb !== selectAll) cb.checked = selectAll.checked;
});
});
});
// Field help: show contextual help from data-help on focus
function attachFieldHelp(container) {
if (!container) return;
var helpEl = container.querySelector('#fieldHelp');
if (!helpEl) return;
container.querySelectorAll('input, select, textarea').forEach(function(field) {
field.addEventListener('focus', function() {
var text = field.getAttribute('data-help');
if (text) helpEl.textContent = text;
});
field.addEventListener('blur', function() {
// Optionally keep last help, or reset
});
});
}
// Attach help to known forms/sections
document.querySelectorAll('form').forEach(function(form) {
attachFieldHelp(form.closest('.card-body') || form);
});
});
// Utility functions