Compare commits
5 Commits
c68ba45ceb
...
a4f47fce4f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a4f47fce4f | ||
|
|
d3d89c7a5f | ||
|
|
e07a4fda1c | ||
|
|
748fe92565 | ||
|
|
1eb8ba8edd |
33
README.md
33
README.md
@@ -82,6 +82,39 @@ curl http://localhost:8000/health
|
||||
|
||||
4. Access the API at `http://localhost:8000`
|
||||
|
||||
## API JSON Lists and Sorting
|
||||
|
||||
The following list endpoints return standardized JSON with a shared `pagination` envelope and Pydantic models:
|
||||
|
||||
- `GET /api/rolodex` → items: `ClientOut[]`
|
||||
- `GET /api/files` → items: `CaseOut[]`
|
||||
- `GET /api/ledger` → items: `TransactionOut[]`
|
||||
|
||||
Common query params:
|
||||
- `page` (>=1), `page_size` (1..100 or 200 for ledger)
|
||||
- `sort_by` (endpoint-specific whitelist)
|
||||
- `sort_dir` (`asc` | `desc`)
|
||||
|
||||
If `sort_by` is invalid or `sort_dir` is not one of `asc|desc`, the API returns `400` with details. Dates are ISO-8601 strings, and nulls are preserved as `null`.
|
||||
|
||||
Authentication: Unauthenticated requests to `/api/*` return a JSON `401` with `{ "detail": "Unauthorized" }`.
|
||||
|
||||
### Sorting whitelists
|
||||
- `/api/rolodex`: `id, rolodex_id, last_name, first_name, company, created_at`
|
||||
- `/api/files`: `file_no, status, case_type, description, open_date, close_date, created_at, client_last_name, client_first_name, client_company, id`
|
||||
- `/api/ledger`: `transaction_date, item_no, id, amount, billed, t_code, t_type_l, employee_number, case_file_no, case_id`
|
||||
|
||||
## Docker smoke script
|
||||
|
||||
A simple curl-based smoke script is available:
|
||||
|
||||
```bash
|
||||
docker compose up -d --build
|
||||
docker compose exec delphi-db bash -lc "bash scripts/smoke.sh"
|
||||
```
|
||||
|
||||
Note: For authenticated API calls, log in at `/login` via the browser to create a session cookie, then copy your session cookie to a `cookies.txt` file for curl usage.
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
|
||||
@@ -3,19 +3,19 @@
|
||||
Purpose: Track features and workflows from legacy Paradox (.SC) scripts to port into the modern app. Each item includes a clear example to guide implementation and testing.
|
||||
|
||||
### Cross-cutting platform and data
|
||||
- [ ] Data model: create relational tables and relations
|
||||
- [x] Data model: create relational tables and relations
|
||||
- Example: Create tables `Rolodex`, `Phone`, `Files`, `Ledger`, `Deposits`, `Payments`, `Printers`, `Setup`, `Footers`, `FileStat`, `FileType`, `TrnsLkup`, `TrnsType`, `GrupLkup`, `PlanInfo`, `Pensions`, `Results`, `Output`, `Schedule`, `Separate`, `Death`, `Marriage`, `States`, `LifeTabl`, `NumberAl`. Foreign keys: `Phone.id -> Rolodex.id`, `Files.id -> Rolodex.id`, `Ledger.file_no -> Files.file_no`.
|
||||
|
||||
- [ ] CSV import: one-time and repeatable import from legacy CSVs
|
||||
- [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
|
||||
- [x] Structured logging/audit trail
|
||||
- Example: On `Ledger` create/update/delete, log user, action, record keys, and pre/post balance totals for the related file.
|
||||
|
||||
- [ ] Reporting infrastructure (screen preview, PDF, CSV)
|
||||
@@ -26,10 +26,10 @@ Purpose: Track features and workflows from legacy Paradox (.SC) scripts to port
|
||||
- Example: Home shows links to Rolodex, File Cabinet, Pensions, Plan Info, Deposits, Utilities, Printers/Customize, Tally Accounts.
|
||||
|
||||
### Rolodex (names/addresses/phones)
|
||||
- [ ] CRUD for `Rolodex` and child `Phone` entries
|
||||
- [x] CRUD for `Rolodex` and child `Phone` entries
|
||||
- Example: Add a phone number with `Location = Office` and format validation (e.g., `1-555-555-1234`).
|
||||
|
||||
- [ ] Advanced search dialog joining `Phone`
|
||||
- [x] Advanced search dialog joining `Phone`
|
||||
- Example: Search by `Last = "Smith"` and `Phone contains "555-12"`; results include records linked by `Phone.Id -> Rolodex.Id`.
|
||||
|
||||
- [ ] Reports: Envelope, Phone Book (2 variants), Rolodex Info
|
||||
@@ -39,7 +39,7 @@ Purpose: Track features and workflows from legacy Paradox (.SC) scripts to port
|
||||
- Example: Select one or more contacts, choose a form, and produce a merged document with their address data.
|
||||
|
||||
### File Cabinet (client files and billing)
|
||||
- [ ] Master-detail UI for `Files` with `Ledger` detail
|
||||
- [x] Master-detail UI for `Files` with `Ledger` detail
|
||||
- Example: Selecting a file shows ledger entries inline; adding a ledger line updates file totals.
|
||||
|
||||
- [ ] Ask/Search dialog over `Files`
|
||||
@@ -70,10 +70,10 @@ Purpose: Track features and workflows from legacy Paradox (.SC) scripts to port
|
||||
- [ ] Validations and defaults
|
||||
- Example: Require `Date`, `T_Code`, `Empl_Num`, `Amount`, `Billed`; if `T_Type = 2 (Hourly)`, default `Rate` from `Employee.Rate_Per_Hour` for the selected employee.
|
||||
|
||||
- [ ] Auto-compute `Amount = Quantity * Rate`
|
||||
- [x] Auto-compute `Amount = Quantity * Rate`
|
||||
- Example: Enter `Quantity = 2`, `Rate = 150.00` auto-sets `Amount = 300.00` when either field changes.
|
||||
|
||||
- [ ] Unique posting for `Item_No`
|
||||
- [x] Unique posting for `Item_No`
|
||||
- Example: On save, if `(File_No, Date, Item_No)` conflicts, increment `Item_No` until unique, then persist.
|
||||
|
||||
- [ ] Recompute file totals (Tally_Ledger) on change
|
||||
@@ -83,7 +83,7 @@ Purpose: Track features and workflows from legacy Paradox (.SC) scripts to port
|
||||
- Example: Keyboard toggle to set `Billed` Y/N; buttons to shift `Date` ±1 day.
|
||||
|
||||
### Deposits / Payments
|
||||
- [ ] Payments search (date range, File_No, Id, Regarding)
|
||||
- [x] Payments search (date range, File_No, Id, Regarding)
|
||||
- Example: `From_Date=2025-01-01`, `To_Date=2025-03-31` shows all payments in Q1 2025 with optional filters for file or id.
|
||||
|
||||
- [ ] Reports: Summary and Detailed payments
|
||||
@@ -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.
Binary file not shown.
BIN
app/__pycache__/schemas.cpython-313.pyc
Normal file
BIN
app/__pycache__/schemas.cpython-313.pyc
Normal file
Binary file not shown.
496
app/main.py
496
app/main.py
@@ -16,7 +16,7 @@ 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, Response
|
||||
from fastapi.responses import RedirectResponse, Response, JSONResponse
|
||||
from starlette.middleware.sessions import SessionMiddleware
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
@@ -32,6 +32,16 @@ from .database import create_tables, get_db, get_database_url
|
||||
from .models import User, Case, Client, Phone, Transaction, Document, Payment, ImportLog
|
||||
from .auth import authenticate_user, get_current_user_from_session
|
||||
from .logging_config import setup_logging
|
||||
from .schemas import (
|
||||
ClientOut,
|
||||
PhoneOut,
|
||||
CaseOut,
|
||||
TransactionOut,
|
||||
Pagination,
|
||||
RolodexListResponse,
|
||||
FilesListResponse,
|
||||
LedgerListResponse,
|
||||
)
|
||||
|
||||
# Load environment variables
|
||||
load_dotenv()
|
||||
@@ -73,6 +83,9 @@ class AuthMiddleware(BaseHTTPMiddleware):
|
||||
|
||||
# Enforce authentication for other paths
|
||||
if not request.session.get("user_id"):
|
||||
# Return JSON 401 for API routes, redirect for HTML routes
|
||||
if path.startswith("/api/"):
|
||||
return JSONResponse(status_code=401, content={"detail": "Unauthorized"})
|
||||
return RedirectResponse(url="/login", status_code=302)
|
||||
|
||||
return await call_next(request)
|
||||
@@ -927,6 +940,87 @@ def compute_case_totals_from_case(case_obj: Case) -> Dict[str, float]:
|
||||
}
|
||||
|
||||
|
||||
def compute_case_totals_for_case_id(db: Session, case_id: int) -> Dict[str, float]:
|
||||
"""
|
||||
Compute billed, unbilled, and overall totals for a case by ID.
|
||||
|
||||
This uses a simple in-Python aggregation over the case's transactions to
|
||||
avoid SQL portability issues and to keep the logic consistent with
|
||||
compute_case_totals_from_case.
|
||||
"""
|
||||
billed_total = 0.0
|
||||
unbilled_total = 0.0
|
||||
overall_total = 0.0
|
||||
|
||||
transactions: List[Transaction] = (
|
||||
db.query(Transaction).filter(Transaction.case_id == case_id).all()
|
||||
)
|
||||
for t in transactions:
|
||||
amt = float(t.amount) if t.amount is not None else 0.0
|
||||
overall_total += amt
|
||||
if ((t.billed or '').upper()) == 'Y':
|
||||
billed_total += amt
|
||||
else:
|
||||
unbilled_total += amt
|
||||
|
||||
return {
|
||||
'billed_total': round(billed_total, 2),
|
||||
'unbilled_total': round(unbilled_total, 2),
|
||||
'overall_total': round(overall_total, 2),
|
||||
}
|
||||
|
||||
|
||||
def _ledger_keys_from_tx(tx: Optional["Transaction"]) -> Dict[str, Any]:
|
||||
"""
|
||||
Extract identifying keys for a ledger transaction for audit logs.
|
||||
"""
|
||||
if tx is None:
|
||||
return {}
|
||||
return {
|
||||
'transaction_id': getattr(tx, 'id', None),
|
||||
'case_id': getattr(tx, 'case_id', None),
|
||||
'item_no': getattr(tx, 'item_no', None),
|
||||
'transaction_date': getattr(tx, 'transaction_date', None),
|
||||
't_code': getattr(tx, 't_code', None),
|
||||
't_type_l': getattr(tx, 't_type_l', None),
|
||||
'employee_number': getattr(tx, 'employee_number', None),
|
||||
'billed': getattr(tx, 'billed', None),
|
||||
'amount': getattr(tx, 'amount', None),
|
||||
}
|
||||
|
||||
|
||||
def _log_ledger_audit(
|
||||
*,
|
||||
action: str,
|
||||
user: "User",
|
||||
case_id: int,
|
||||
keys: Dict[str, Any],
|
||||
pre: Dict[str, float],
|
||||
post: Dict[str, float],
|
||||
) -> None:
|
||||
"""
|
||||
Emit a structured audit log line for ledger mutations including user, action,
|
||||
identifiers, and pre/post balances with deltas.
|
||||
"""
|
||||
delta = {
|
||||
'billed_total': round((post.get('billed_total', 0.0) - pre.get('billed_total', 0.0)), 2),
|
||||
'unbilled_total': round((post.get('unbilled_total', 0.0) - pre.get('unbilled_total', 0.0)), 2),
|
||||
'overall_total': round((post.get('overall_total', 0.0) - pre.get('overall_total', 0.0)), 2),
|
||||
}
|
||||
|
||||
logger.info(
|
||||
"ledger_audit",
|
||||
action=action,
|
||||
user_id=getattr(user, 'id', None),
|
||||
user_username=getattr(user, 'username', None),
|
||||
case_id=case_id,
|
||||
keys=keys,
|
||||
pre_balances=pre,
|
||||
post_balances=post,
|
||||
delta_balances=delta,
|
||||
)
|
||||
|
||||
|
||||
@app.post("/case/{case_id}/ledger")
|
||||
async def ledger_create(
|
||||
request: Request,
|
||||
@@ -939,6 +1033,9 @@ async def ledger_create(
|
||||
|
||||
form = await request.form()
|
||||
|
||||
# Pre-mutation totals for audit
|
||||
pre_totals = compute_case_totals_for_case_id(db, case_id)
|
||||
|
||||
# Validate
|
||||
errors, parsed = validate_ledger_fields(
|
||||
transaction_date=form.get("transaction_date"),
|
||||
@@ -987,6 +1084,16 @@ async def ledger_create(
|
||||
)
|
||||
db.add(tx)
|
||||
db.commit()
|
||||
# Post-mutation totals and audit log
|
||||
post_totals = compute_case_totals_for_case_id(db, case_id)
|
||||
_log_ledger_audit(
|
||||
action="create",
|
||||
user=user,
|
||||
case_id=case_id,
|
||||
keys=_ledger_keys_from_tx(tx),
|
||||
pre=pre_totals,
|
||||
post=post_totals,
|
||||
)
|
||||
logger.info("ledger_create", case_id=case_id, transaction_id=tx.id)
|
||||
return RedirectResponse(url=f"/case/{case_id}?saved=1", status_code=302)
|
||||
except Exception as e:
|
||||
@@ -1014,6 +1121,9 @@ async def ledger_update(
|
||||
request.session["case_update_errors"] = ["Ledger entry not found"]
|
||||
return RedirectResponse(url=f"/case/{case_id}", status_code=302)
|
||||
|
||||
# Pre-mutation totals for audit
|
||||
pre_totals = compute_case_totals_for_case_id(db, case_id)
|
||||
|
||||
errors, parsed = validate_ledger_fields(
|
||||
transaction_date=form.get("transaction_date"),
|
||||
t_code=form.get("t_code"),
|
||||
@@ -1045,6 +1155,16 @@ async def ledger_update(
|
||||
tx.description = (form.get("description") or "").strip() or None
|
||||
|
||||
db.commit()
|
||||
# Post-mutation totals and audit log
|
||||
post_totals = compute_case_totals_for_case_id(db, case_id)
|
||||
_log_ledger_audit(
|
||||
action="update",
|
||||
user=user,
|
||||
case_id=case_id,
|
||||
keys=_ledger_keys_from_tx(tx),
|
||||
pre=pre_totals,
|
||||
post=post_totals,
|
||||
)
|
||||
logger.info("ledger_update", case_id=case_id, transaction_id=tx.id)
|
||||
return RedirectResponse(url=f"/case/{case_id}?saved=1", status_code=302)
|
||||
except Exception as e:
|
||||
@@ -1071,8 +1191,21 @@ async def ledger_delete(
|
||||
return RedirectResponse(url=f"/case/{case_id}", status_code=302)
|
||||
|
||||
try:
|
||||
# Capture pre-mutation totals and keys for audit before deletion
|
||||
pre_totals = compute_case_totals_for_case_id(db, case_id)
|
||||
tx_keys = _ledger_keys_from_tx(tx)
|
||||
db.delete(tx)
|
||||
db.commit()
|
||||
# Post-mutation totals and audit log
|
||||
post_totals = compute_case_totals_for_case_id(db, case_id)
|
||||
_log_ledger_audit(
|
||||
action="delete",
|
||||
user=user,
|
||||
case_id=case_id,
|
||||
keys=tx_keys,
|
||||
pre=pre_totals,
|
||||
post=post_totals,
|
||||
)
|
||||
logger.info("ledger_delete", case_id=case_id, transaction_id=tx_id)
|
||||
return RedirectResponse(url=f"/case/{case_id}?saved=1", status_code=302)
|
||||
except Exception as e:
|
||||
@@ -2185,3 +2318,364 @@ async def phone_book_report(
|
||||
"report_phone_book.html",
|
||||
{"request": request, "user": user, "clients": clients, "q": q, "client_ids": client_ids or []},
|
||||
)
|
||||
|
||||
|
||||
# ------------------------------
|
||||
# JSON API: list/filter endpoints
|
||||
# ------------------------------
|
||||
|
||||
def _apply_sorting(query, sort_by: str | None, sort_dir: str, allowed_map: dict[str, Any], default_order: list[Any]):
|
||||
"""Apply validated sorting to a SQLAlchemy query.
|
||||
|
||||
Args:
|
||||
query: Base SQLAlchemy query object
|
||||
sort_by: Optional requested sort field
|
||||
sort_dir: 'asc' or 'desc'
|
||||
allowed_map: Map of allowed sort_by -> SQLAlchemy column or list of columns
|
||||
default_order: Fallback order_by list when sort_by is not provided
|
||||
|
||||
Returns:
|
||||
(query, applied_sort_by, applied_sort_dir)
|
||||
"""
|
||||
if not sort_by:
|
||||
for col in default_order:
|
||||
query = query.order_by(col)
|
||||
return query, None, sort_dir
|
||||
|
||||
column_expr = allowed_map.get(sort_by)
|
||||
if column_expr is None:
|
||||
raise HTTPException(status_code=400, detail=f"Invalid sort_by: '{sort_by}'. Allowed: {sorted(list(allowed_map.keys()))}")
|
||||
|
||||
def _order(expr):
|
||||
return expr.asc().nulls_last() if sort_dir == "asc" else expr.desc().nulls_last()
|
||||
|
||||
if isinstance(column_expr, (list, tuple)):
|
||||
for expr in column_expr:
|
||||
query = query.order_by(_order(expr))
|
||||
else:
|
||||
query = query.order_by(_order(column_expr))
|
||||
|
||||
return query, sort_by, sort_dir
|
||||
|
||||
@app.get("/api/rolodex", response_model=RolodexListResponse)
|
||||
async def api_list_rolodex(
|
||||
request: Request,
|
||||
q: str | None = Query(None, description="Search by first/last/company contains"),
|
||||
phone: str | None = Query(None, description="Phone number contains"),
|
||||
rolodex_id: str | None = Query(None, description="Legacy Rolodex ID 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"),
|
||||
sort_by: str | None = Query(None, description="Sort field: id, rolodex_id, last_name, first_name, company, created_at"),
|
||||
sort_dir: str = Query("asc", description="Sort direction: asc or desc"),
|
||||
db: Session = Depends(get_db),
|
||||
) -> RolodexListResponse:
|
||||
"""Return paginated clients with simple filters as JSON."""
|
||||
user = get_current_user_from_session(request.session)
|
||||
if not user:
|
||||
# Middleware ensures JSON 401 for /api/*, keep explicit for clarity
|
||||
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||
|
||||
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:
|
||||
query = query.filter(Client.phones.any(Phone.phone_number.ilike(f"%{phone}%")))
|
||||
if rolodex_id:
|
||||
query = query.filter(Client.rolodex_id.ilike(f"%{rolodex_id}%"))
|
||||
|
||||
# Sorting
|
||||
sort_dir_norm = (sort_dir or "").lower()
|
||||
if sort_dir_norm not in ("asc", "desc"):
|
||||
raise HTTPException(status_code=400, detail="Invalid sort_dir. Allowed: 'asc' or 'desc'")
|
||||
|
||||
allowed_sort = {
|
||||
"id": Client.id,
|
||||
"rolodex_id": Client.rolodex_id,
|
||||
"last_name": Client.last_name,
|
||||
"first_name": Client.first_name,
|
||||
"company": Client.company,
|
||||
"created_at": Client.created_at,
|
||||
}
|
||||
default_order = [Client.last_name.asc().nulls_last(), Client.first_name.asc().nulls_last(), Client.id.asc()]
|
||||
query, applied_sort_by, applied_sort_dir = _apply_sorting(query, sort_by, sort_dir_norm, allowed_sort, default_order)
|
||||
|
||||
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()
|
||||
|
||||
logger.info(
|
||||
"api_rolodex_list",
|
||||
query=q,
|
||||
phone=phone,
|
||||
rolodex_id=rolodex_id,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
total=total,
|
||||
sort_by=applied_sort_by,
|
||||
sort_dir=applied_sort_dir,
|
||||
)
|
||||
|
||||
items = [ClientOut.model_validate(c) for c in clients]
|
||||
return RolodexListResponse(
|
||||
items=items,
|
||||
pagination=Pagination(page=page, page_size=page_size, total=total, total_pages=total_pages),
|
||||
)
|
||||
|
||||
|
||||
@app.get("/api/files", response_model=FilesListResponse)
|
||||
async def api_list_files(
|
||||
request: Request,
|
||||
q: str | None = Query(None, description="Search file no/description/client name/company"),
|
||||
status: str | None = Query(None, description="Case status: active or closed"),
|
||||
case_type: str | None = Query(None, description="Case type contains"),
|
||||
file_no: str | None = Query(None, description="File number contains"),
|
||||
client_rolodex_id: str | None = Query(None, description="Legacy client Id contains"),
|
||||
from_open_date: str | None = Query(None, description="Opened on/after YYYY-MM-DD"),
|
||||
to_open_date: str | None = Query(None, description="Opened on/before YYYY-MM-DD"),
|
||||
page: int = Query(1, ge=1, description="Page number (1-indexed)"),
|
||||
page_size: int = Query(20, ge=1, le=100, description="Results per page"),
|
||||
sort_by: str | None = Query(None, description="Sort field: file_no, status, case_type, description, open_date, close_date, created_at, client_last_name, client_first_name, client_company"),
|
||||
sort_dir: str = Query("desc", description="Sort direction: asc or desc"),
|
||||
db: Session = Depends(get_db),
|
||||
) -> FilesListResponse:
|
||||
"""Return paginated cases with simple filters as JSON."""
|
||||
user = get_current_user_from_session(request.session)
|
||||
if not user:
|
||||
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||
|
||||
query = (
|
||||
db.query(Case)
|
||||
.join(Client, Case.client_id == Client.id)
|
||||
.options(joinedload(Case.client))
|
||||
)
|
||||
|
||||
filters = []
|
||||
if q:
|
||||
like = f"%{q}%"
|
||||
filters.append(
|
||||
or_(
|
||||
Case.file_no.ilike(like),
|
||||
Case.description.ilike(like),
|
||||
Client.first_name.ilike(like),
|
||||
Client.last_name.ilike(like),
|
||||
Client.company.ilike(like),
|
||||
)
|
||||
)
|
||||
if status:
|
||||
filters.append(Case.status.ilike(f"%{status}%"))
|
||||
if case_type:
|
||||
filters.append(Case.case_type.ilike(f"%{case_type}%"))
|
||||
if file_no:
|
||||
filters.append(Case.file_no.ilike(f"%{file_no}%"))
|
||||
if client_rolodex_id:
|
||||
filters.append(Client.rolodex_id.ilike(f"%{client_rolodex_id}%"))
|
||||
if from_open_date:
|
||||
try:
|
||||
dt = datetime.strptime(from_open_date, "%Y-%m-%d")
|
||||
filters.append(Case.open_date >= dt)
|
||||
except ValueError:
|
||||
pass
|
||||
if to_open_date:
|
||||
try:
|
||||
dt = datetime.strptime(to_open_date, "%Y-%m-%d")
|
||||
filters.append(Case.open_date <= dt)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
if filters:
|
||||
query = query.filter(and_(*filters))
|
||||
|
||||
# Sorting
|
||||
sort_dir_norm = (sort_dir or "").lower()
|
||||
if sort_dir_norm not in ("asc", "desc"):
|
||||
raise HTTPException(status_code=400, detail="Invalid sort_dir. Allowed: 'asc' or 'desc'")
|
||||
|
||||
allowed_sort = {
|
||||
"file_no": Case.file_no,
|
||||
"status": Case.status,
|
||||
"case_type": Case.case_type,
|
||||
"description": Case.description,
|
||||
"open_date": Case.open_date,
|
||||
"close_date": Case.close_date,
|
||||
"created_at": Case.created_at,
|
||||
"client_last_name": Client.last_name,
|
||||
"client_first_name": Client.first_name,
|
||||
"client_company": Client.company,
|
||||
"id": Case.id,
|
||||
}
|
||||
default_order = [Case.open_date.desc().nulls_last(), Case.created_at.desc()]
|
||||
query, applied_sort_by, applied_sort_dir = _apply_sorting(query, sort_by, sort_dir_norm, allowed_sort, default_order)
|
||||
|
||||
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
|
||||
|
||||
cases = query.offset(offset).limit(page_size).all()
|
||||
|
||||
logger.info(
|
||||
"api_files_list",
|
||||
query=q,
|
||||
status=status,
|
||||
case_type=case_type,
|
||||
file_no=file_no,
|
||||
client_rolodex_id=client_rolodex_id,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
total=total,
|
||||
sort_by=applied_sort_by,
|
||||
sort_dir=applied_sort_dir,
|
||||
)
|
||||
|
||||
items = [CaseOut.model_validate(c) for c in cases]
|
||||
return FilesListResponse(
|
||||
items=items,
|
||||
pagination=Pagination(page=page, page_size=page_size, total=total, total_pages=total_pages),
|
||||
)
|
||||
|
||||
|
||||
@app.get("/api/ledger", response_model=LedgerListResponse)
|
||||
async def api_list_ledger(
|
||||
request: Request,
|
||||
case_id: int | None = Query(None, description="Filter by case ID"),
|
||||
file_no: str | None = Query(None, description="Filter by case file number contains"),
|
||||
from_date: str | None = Query(None, description="On/after YYYY-MM-DD"),
|
||||
to_date: str | None = Query(None, description="On/before YYYY-MM-DD"),
|
||||
billed: str | None = Query(None, description="'Y' or 'N'"),
|
||||
t_code: str | None = Query(None, description="Transaction code contains"),
|
||||
t_type_l: str | None = Query(None, description="Legacy type flag (e.g., C/D)"),
|
||||
employee_number: str | None = Query(None, description="Employee number contains"),
|
||||
q: str | None = Query(None, description="Description contains"),
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(50, ge=1, le=200),
|
||||
sort_by: str | None = Query(None, description="Sort field: transaction_date, item_no, id, amount, billed, t_code, t_type_l, employee_number, case_file_no, case_id"),
|
||||
sort_dir: str = Query("desc", description="Sort direction: asc or desc"),
|
||||
db: Session = Depends(get_db),
|
||||
) -> LedgerListResponse:
|
||||
"""Return paginated ledger (transactions) with simple filters as JSON."""
|
||||
user = get_current_user_from_session(request.session)
|
||||
if not user:
|
||||
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||
|
||||
query = (
|
||||
db.query(Transaction)
|
||||
.join(Case, Transaction.case_id == Case.id)
|
||||
.options(joinedload(Transaction.case))
|
||||
)
|
||||
|
||||
filters = []
|
||||
if case_id is not None:
|
||||
filters.append(Transaction.case_id == case_id)
|
||||
if file_no:
|
||||
filters.append(Case.file_no.ilike(f"%{file_no}%"))
|
||||
if from_date:
|
||||
try:
|
||||
dt = datetime.strptime(from_date, "%Y-%m-%d")
|
||||
filters.append(Transaction.transaction_date >= dt)
|
||||
except ValueError:
|
||||
pass
|
||||
if to_date:
|
||||
try:
|
||||
dt = datetime.strptime(to_date, "%Y-%m-%d")
|
||||
filters.append(Transaction.transaction_date <= dt)
|
||||
except ValueError:
|
||||
pass
|
||||
if billed in ("Y", "N"):
|
||||
filters.append(Transaction.billed == billed)
|
||||
if t_code:
|
||||
filters.append(Transaction.t_code.ilike(f"%{t_code}%"))
|
||||
if t_type_l:
|
||||
filters.append(Transaction.t_type_l.ilike(f"%{t_type_l}%"))
|
||||
if employee_number:
|
||||
filters.append(Transaction.employee_number.ilike(f"%{employee_number}%"))
|
||||
if q:
|
||||
filters.append(Transaction.description.ilike(f"%{q}%"))
|
||||
|
||||
if filters:
|
||||
query = query.filter(and_(*filters))
|
||||
|
||||
# Sorting
|
||||
sort_dir_norm = (sort_dir or "").lower()
|
||||
if sort_dir_norm not in ("asc", "desc"):
|
||||
raise HTTPException(status_code=400, detail="Invalid sort_dir. Allowed: 'asc' or 'desc'")
|
||||
allowed_sort = {
|
||||
"transaction_date": Transaction.transaction_date,
|
||||
"item_no": Transaction.item_no,
|
||||
"id": Transaction.id,
|
||||
"amount": Transaction.amount,
|
||||
"billed": Transaction.billed,
|
||||
"t_code": Transaction.t_code,
|
||||
"t_type_l": Transaction.t_type_l,
|
||||
"employee_number": Transaction.employee_number,
|
||||
"case_file_no": Case.file_no,
|
||||
"case_id": Transaction.case_id,
|
||||
}
|
||||
default_order = [
|
||||
Transaction.transaction_date.desc().nulls_last(),
|
||||
Transaction.item_no.asc().nulls_last(),
|
||||
Transaction.id.desc(),
|
||||
]
|
||||
query, applied_sort_by, applied_sort_dir = _apply_sorting(query, sort_by, sort_dir_norm, allowed_sort, default_order)
|
||||
|
||||
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
|
||||
|
||||
txns = query.offset(offset).limit(page_size).all()
|
||||
|
||||
logger.info(
|
||||
"api_ledger_list",
|
||||
case_id=case_id,
|
||||
file_no=file_no,
|
||||
from_date=from_date,
|
||||
to_date=to_date,
|
||||
billed=billed,
|
||||
t_code=t_code,
|
||||
t_type_l=t_type_l,
|
||||
employee_number=employee_number,
|
||||
q=q,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
total=total,
|
||||
sort_by=applied_sort_by,
|
||||
sort_dir=applied_sort_dir,
|
||||
)
|
||||
|
||||
items = [
|
||||
TransactionOut(
|
||||
id=t.id,
|
||||
case_id=t.case_id,
|
||||
case_file_no=t.case.file_no if t.case else None,
|
||||
transaction_date=t.transaction_date,
|
||||
item_no=t.item_no,
|
||||
amount=t.amount,
|
||||
billed=t.billed,
|
||||
t_code=t.t_code,
|
||||
t_type_l=t.t_type_l,
|
||||
quantity=t.quantity,
|
||||
rate=t.rate,
|
||||
description=t.description,
|
||||
employee_number=t.employee_number,
|
||||
)
|
||||
for t in txns
|
||||
]
|
||||
|
||||
return LedgerListResponse(
|
||||
items=items,
|
||||
pagination=Pagination(page=page, page_size=page_size, total=total, total_pages=total_pages),
|
||||
)
|
||||
|
||||
101
app/schemas.py
Normal file
101
app/schemas.py
Normal file
@@ -0,0 +1,101 @@
|
||||
"""
|
||||
Pydantic schemas for API responses.
|
||||
|
||||
Defines output models for Clients, Phones, Cases, and Transactions, along with
|
||||
shared pagination envelopes for list endpoints.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
|
||||
class PhoneOut(BaseModel):
|
||||
id: int
|
||||
phone_type: Optional[str] = None
|
||||
phone_number: Optional[str] = None
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class ClientOut(BaseModel):
|
||||
id: int
|
||||
rolodex_id: Optional[str] = None
|
||||
first_name: Optional[str] = None
|
||||
last_name: Optional[str] = None
|
||||
company: Optional[str] = None
|
||||
address: Optional[str] = None
|
||||
city: Optional[str] = None
|
||||
state: Optional[str] = None
|
||||
zip_code: Optional[str] = None
|
||||
phones: Optional[List[PhoneOut]] = None
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class CaseClientOut(BaseModel):
|
||||
id: int
|
||||
rolodex_id: Optional[str] = None
|
||||
first_name: Optional[str] = None
|
||||
last_name: Optional[str] = None
|
||||
company: Optional[str] = None
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class CaseOut(BaseModel):
|
||||
id: int
|
||||
file_no: str
|
||||
status: Optional[str] = None
|
||||
case_type: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
open_date: Optional[datetime] = None
|
||||
close_date: Optional[datetime] = None
|
||||
client: Optional[CaseClientOut] = None
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class TransactionOut(BaseModel):
|
||||
id: int
|
||||
case_id: int
|
||||
case_file_no: Optional[str] = None
|
||||
transaction_date: Optional[datetime] = None
|
||||
item_no: Optional[int] = None
|
||||
amount: Optional[float] = None
|
||||
billed: Optional[str] = None
|
||||
t_code: Optional[str] = None
|
||||
t_type_l: Optional[str] = None
|
||||
quantity: Optional[float] = None
|
||||
rate: Optional[float] = None
|
||||
description: Optional[str] = None
|
||||
employee_number: Optional[str] = None
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class Pagination(BaseModel):
|
||||
page: int
|
||||
page_size: int
|
||||
total: int
|
||||
total_pages: int
|
||||
|
||||
|
||||
class RolodexListResponse(BaseModel):
|
||||
items: List[ClientOut]
|
||||
pagination: Pagination
|
||||
|
||||
|
||||
class FilesListResponse(BaseModel):
|
||||
items: List[CaseOut]
|
||||
pagination: Pagination
|
||||
|
||||
|
||||
class LedgerListResponse(BaseModel):
|
||||
items: List[TransactionOut]
|
||||
pagination: Pagination
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
96
app/templates/partials/answer_table_macros.html
Normal file
96
app/templates/partials/answer_table_macros.html
Normal 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">«</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">»</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
{% endif %}
|
||||
{%- endmacro %}
|
||||
|
||||
|
||||
@@ -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,44 +35,27 @@
|
||||
</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">
|
||||
<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>
|
||||
{% 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 %}
|
||||
<td>
|
||||
<input class="form-check-input" type="checkbox" name="client_ids" value="{{ c.id }}">
|
||||
</td>
|
||||
<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><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>
|
||||
@@ -97,57 +82,25 @@
|
||||
<td colspan="8" class="text-center text-muted py-4">No clients found.</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endcall %}
|
||||
|
||||
{% if enable_bulk %}
|
||||
<div class="d-flex gap-2">
|
||||
{% 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>
|
||||
</div>
|
||||
{% 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">«</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 %}
|
||||
{{ 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 %}
|
||||
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
fastapi==0.104.1
|
||||
pydantic>=2.7,<3
|
||||
sqlalchemy==1.4.54
|
||||
alembic==1.12.1
|
||||
python-multipart==0.0.6
|
||||
|
||||
27
scripts/smoke.sh
Normal file
27
scripts/smoke.sh
Normal file
@@ -0,0 +1,27 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
BASE_URL="${BASE_URL:-http://localhost:8000}"
|
||||
|
||||
echo "[1] Health check"
|
||||
curl -sf "$BASE_URL/health" >/dev/null || { echo "Health check failed"; exit 1; }
|
||||
|
||||
echo "[2] API unauthenticated should return 401 JSON"
|
||||
code=$(curl -s -o /dev/null -w "%{http_code}" "$BASE_URL/api/rolodex")
|
||||
if [ "$code" != "401" ]; then
|
||||
echo "Expected 401, got $code"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cat << 'EX'
|
||||
Login via UI at /login to create a session cookie in your browser.
|
||||
For scripted tests, copy the cookie to cookies.txt and run examples:
|
||||
|
||||
curl -b cookies.txt "$BASE_URL/api/rolodex?page=1&page_size=2&sort_by=last_name&sort_dir=asc"
|
||||
curl -b cookies.txt "$BASE_URL/api/files?page=1&page_size=2&sort_by=open_date&sort_dir=desc"
|
||||
curl -b cookies.txt "$BASE_URL/api/ledger?page=1&page_size=2&sort_by=transaction_date&sort_dir=desc"
|
||||
EX
|
||||
|
||||
echo "Smoke tests completed."
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user