Compare commits

...

5 Commits

15 changed files with 867 additions and 127 deletions

View File

@@ -82,6 +82,39 @@ curl http://localhost:8000/health
4. Access the API at `http://localhost:8000` 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 ## Project Structure
``` ```

View File

@@ -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. 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 ### 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`. - 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. - 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). - 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. - 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. - 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) - [ ] 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. - Example: Home shows links to Rolodex, File Cabinet, Pensions, Plan Info, Deposits, Utilities, Printers/Customize, Tally Accounts.
### Rolodex (names/addresses/phones) ### 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`). - 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`. - 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 - [ ] 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. - Example: Select one or more contacts, choose a form, and produce a merged document with their address data.
### File Cabinet (client files and billing) ### 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. - Example: Selecting a file shows ledger entries inline; adding a ledger line updates file totals.
- [ ] Ask/Search dialog over `Files` - [ ] Ask/Search dialog over `Files`
@@ -70,10 +70,10 @@ Purpose: Track features and workflows from legacy Paradox (.SC) scripts to port
- [ ] Validations and defaults - [ ] 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. - 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. - 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. - Example: On save, if `(File_No, Date, Item_No)` conflicts, increment `Item_No` until unique, then persist.
- [ ] Recompute file totals (Tally_Ledger) on change - [ ] 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. - Example: Keyboard toggle to set `Billed` Y/N; buttons to shift `Date` ±1 day.
### Deposits / Payments ### 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. - 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 - [ ] Reports: Summary and Detailed payments
@@ -94,7 +94,7 @@ Purpose: Track features and workflows from legacy Paradox (.SC) scripts to port
### Plan Info ### Plan Info
- [ ] CRUD for plan catalog - [ ] 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) ### Pensions (annuity evaluator)
- [ ] Life Expectancy Method (uses `LifeTabl`) - [ ] Life Expectancy Method (uses `LifeTabl`)

Binary file not shown.

Binary file not shown.

View File

@@ -16,7 +16,7 @@ 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, Response from fastapi.responses import RedirectResponse, Response, JSONResponse
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
@@ -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 .models import User, Case, Client, Phone, Transaction, Document, Payment, ImportLog
from .auth import authenticate_user, get_current_user_from_session from .auth import authenticate_user, get_current_user_from_session
from .logging_config import setup_logging from .logging_config import setup_logging
from .schemas import (
ClientOut,
PhoneOut,
CaseOut,
TransactionOut,
Pagination,
RolodexListResponse,
FilesListResponse,
LedgerListResponse,
)
# Load environment variables # Load environment variables
load_dotenv() load_dotenv()
@@ -73,6 +83,9 @@ class AuthMiddleware(BaseHTTPMiddleware):
# Enforce authentication for other paths # Enforce authentication for other paths
if not request.session.get("user_id"): 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 RedirectResponse(url="/login", status_code=302)
return await call_next(request) 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") @app.post("/case/{case_id}/ledger")
async def ledger_create( async def ledger_create(
request: Request, request: Request,
@@ -939,6 +1033,9 @@ async def ledger_create(
form = await request.form() form = await request.form()
# Pre-mutation totals for audit
pre_totals = compute_case_totals_for_case_id(db, case_id)
# Validate # Validate
errors, parsed = validate_ledger_fields( errors, parsed = validate_ledger_fields(
transaction_date=form.get("transaction_date"), transaction_date=form.get("transaction_date"),
@@ -987,6 +1084,16 @@ async def ledger_create(
) )
db.add(tx) db.add(tx)
db.commit() 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) logger.info("ledger_create", case_id=case_id, transaction_id=tx.id)
return RedirectResponse(url=f"/case/{case_id}?saved=1", status_code=302) return RedirectResponse(url=f"/case/{case_id}?saved=1", status_code=302)
except Exception as e: except Exception as e:
@@ -1014,6 +1121,9 @@ async def ledger_update(
request.session["case_update_errors"] = ["Ledger entry not found"] request.session["case_update_errors"] = ["Ledger entry not found"]
return RedirectResponse(url=f"/case/{case_id}", status_code=302) 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( errors, parsed = validate_ledger_fields(
transaction_date=form.get("transaction_date"), transaction_date=form.get("transaction_date"),
t_code=form.get("t_code"), t_code=form.get("t_code"),
@@ -1045,6 +1155,16 @@ async def ledger_update(
tx.description = (form.get("description") or "").strip() or None tx.description = (form.get("description") or "").strip() or None
db.commit() 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) logger.info("ledger_update", case_id=case_id, transaction_id=tx.id)
return RedirectResponse(url=f"/case/{case_id}?saved=1", status_code=302) return RedirectResponse(url=f"/case/{case_id}?saved=1", status_code=302)
except Exception as e: except Exception as e:
@@ -1071,8 +1191,21 @@ async def ledger_delete(
return RedirectResponse(url=f"/case/{case_id}", status_code=302) return RedirectResponse(url=f"/case/{case_id}", status_code=302)
try: 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.delete(tx)
db.commit() 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) logger.info("ledger_delete", case_id=case_id, transaction_id=tx_id)
return RedirectResponse(url=f"/case/{case_id}?saved=1", status_code=302) return RedirectResponse(url=f"/case/{case_id}?saved=1", status_code=302)
except Exception as e: except Exception as e:
@@ -2185,3 +2318,364 @@ async def phone_book_report(
"report_phone_book.html", "report_phone_book.html",
{"request": request, "user": user, "clients": clients, "q": q, "client_ids": client_ids or []}, {"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
View 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

View File

@@ -133,11 +133,12 @@ Case {{ case.file_no if case else '' }} · Delphi Database
</div> </div>
</div> </div>
<div class="card-body"> <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"> <form method="post" action="/case/{{ case.id }}/update">
<div class="row g-3"> <div class="row g-3">
<div class="col-md-6"> <div class="col-md-6">
<label for="status" class="form-label">Status</label> <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="active" {% if case.status == 'active' %}selected{% endif %}>Active</option>
<option value="closed" {% if case.status == 'closed' %}selected{% endif %}>Closed</option> <option value="closed" {% if case.status == 'closed' %}selected{% endif %}>Closed</option>
</select> </select>
@@ -145,24 +146,24 @@ Case {{ case.file_no if case else '' }} · Delphi Database
<div class="col-md-6"> <div class="col-md-6">
<label for="case_type" class="form-label">Case Type</label> <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 '' }}"> value="{{ case.case_type or '' }}">
</div> </div>
<div class="col-12"> <div class="col-12">
<label for="description" class="form-label">Description</label> <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>
<div class="col-md-6"> <div class="col-md-6">
<label for="open_date" class="form-label">Open Date</label> <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 '' }}"> value="{{ case.open_date.strftime('%Y-%m-%d') if case.open_date else '' }}">
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
<label for="close_date" class="form-label">Close Date</label> <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 '' }}"> value="{{ case.close_date.strftime('%Y-%m-%d') if case.close_date else '' }}">
</div> </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 %} {% block title %}Rolodex · Delphi Database{% endblock %}
{% from "partials/answer_table_macros.html" import results_summary, pagination, answer_table, bulk_actions_bar %}
{% block content %} {% block content %}
<div class="row g-3 align-items-center mb-3"> <div class="row g-3 align-items-center mb-3">
<div class="col-auto"> <div class="col-auto">
@@ -33,44 +35,27 @@
</div> </div>
</form> </form>
</div> </div>
<div class="col-12 text-muted small"> <div class="col-12 text-muted small">{{ results_summary(start_index, end_index, total) }}</div>
{% if total and total > 0 %}
Showing {{ start_index }}{{ end_index }} of {{ total }}
{% else %}
No results
{% endif %}
</div>
<div class="col-12"> <div class="col-12">
<div class="table-responsive"> <div class="table-responsive">
<form method="post" action="/reports/phone-book" id="bulkForm"> {% set headers = [
<table class="table table-hover align-middle"> { 'title': 'Name', 'width': '220px' },
<thead class="table-light"> { 'title': 'Company' },
<tr> { 'title': 'Address' },
{% if enable_bulk %} { 'title': 'City' },
<th style="width: 40px;"><input class="form-check-input" type="checkbox" id="selectAll"></th> { 'title': 'State', 'width': '80px' },
{% endif %} { 'title': 'ZIP', 'width': '110px' },
<th style="width: 220px;">Name</th> { 'title': 'Phones', 'width': '200px' },
<th>Company</th> { 'title': 'Actions', 'width': '140px', 'align': 'end' },
<th>Address</th> ] %}
<th>City</th> {% call(answer_table(headers, form_action='/reports/phone-book', select_name='client_ids', enable_bulk=enable_bulk)) %}
<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 %} {% if clients and clients|length > 0 %}
{% for c in clients %} {% for c in clients %}
<tr> <tr>
{% if enable_bulk %} {% if enable_bulk %}
<td> <td><input class="form-check-input" type="checkbox" name="client_ids" value="{{ c.id }}"></td>
<input class="form-check-input" type="checkbox" name="client_ids" value="{{ c.id }}">
</td>
{% endif %} {% endif %}
<td> <td><span class="fw-semibold">{{ c.last_name or '' }}, {{ c.first_name or '' }}</span></td>
<span class="fw-semibold">{{ c.last_name or '' }}, {{ c.first_name or '' }}</span>
</td>
<td>{{ c.company or '' }}</td> <td>{{ c.company or '' }}</td>
<td>{{ c.address or '' }}</td> <td>{{ c.address or '' }}</td>
<td>{{ c.city 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> <td colspan="8" class="text-center text-muted py-4">No clients found.</td>
</tr> </tr>
{% endif %} {% endif %}
</tbody> {% endcall %}
</table>
{% if enable_bulk %} {% if enable_bulk %}
<div class="d-flex gap-2"> {% call(bulk_actions_bar()) %}
<button type="submit" class="btn btn-outline-secondary"> <button type="submit" class="btn btn-outline-secondary">
<i class="bi bi-journal-text me-1"></i>Phone Book (Selected) <i class="bi bi-journal-text me-1"></i>Phone Book (Selected)
</button> </button>
<a class="btn btn-outline-secondary" href="/reports/phone-book?format=csv{% if q %}&q={{ q | urlencode }}{% endif %}"> <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) <i class="bi bi-filetype-csv me-1"></i>Phone Book CSV (Current Filter)
</a> </a>
</div> {% endcall %}
{% endif %} {% endif %}
</form>
</div> </div>
</div> </div>
<div class="col-12"> <div class="col-12">
{% if total_pages and total_pages > 1 %} {{ pagination('/rolodex', page, total_pages, page_size, {'q': q, 'phone': phone}) }}
<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 %}
</div> </div>
</div> </div>
{% block extra_scripts %} {% block extra_scripts %}{% endblock %}
<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 %} {% endblock %}

View File

@@ -16,18 +16,19 @@
<div class="card"> <div class="card">
<div class="card-body"> <div class="card-body">
<form method="post" action="{{ '/rolodex/create' if not client else '/rolodex/' ~ client.id ~ '/update' }}"> <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="row g-3">
<div class="col-md-4"> <div class="col-md-4">
<label for="last_name" class="form-label">Last Name</label> <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>
<div class="col-md-4"> <div class="col-md-4">
<label for="first_name" class="form-label">First Name</label> <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>
<div class="col-md-4"> <div class="col-md-4">
<label for="company" class="form-label">Company</label> <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>
<div class="col-md-6"> <div class="col-md-6">
@@ -36,20 +37,20 @@
</div> </div>
<div class="col-md-3"> <div class="col-md-3">
<label for="city" class="form-label">City</label> <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>
<div class="col-md-1"> <div class="col-md-1">
<label for="state" class="form-label">State</label> <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>
<div class="col-md-2"> <div class="col-md-2">
<label for="zip_code" class="form-label">ZIP</label> <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>
<div class="col-md-4"> <div class="col-md-4">
<label for="rolodex_id" class="form-label">Legacy Rolodex Id</label> <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>
<div class="col-12"> <div class="col-12">

BIN
delphi.db

Binary file not shown.

View File

@@ -1,4 +1,5 @@
fastapi==0.104.1 fastapi==0.104.1
pydantic>=2.7,<3
sqlalchemy==1.4.54 sqlalchemy==1.4.54
alembic==1.12.1 alembic==1.12.1
python-multipart==0.0.6 python-multipart==0.0.6

27
scripts/smoke.sh Normal file
View 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."

View File

@@ -68,6 +68,39 @@ document.addEventListener('DOMContentLoaded', function() {
if (qtyInput) qtyInput.addEventListener('input', recomputeAmount); if (qtyInput) qtyInput.addEventListener('input', recomputeAmount);
if (rateInput) rateInput.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 // Utility functions