Compare commits

...

4 Commits

Author SHA1 Message Date
HotSwapp
c68ba45ceb Add legacy SQLAlchemy models mapped from docs/legacy-schema.md: ROLODEX, PHONE, FILES (+R/V), LEDGER, FILESTAT, FOOTERS, EMPLOYEE, STATES, GRUPLKUP, PRINTERS, SETUP, DEPOSITS, PAYMENTS (legacy), TRNSTYPE, TRNSLKUP, PLANINFO, QDROS, PENSIONS (+RESULTS/MARRIAGE/DEATH/SCHEDULE/SEPARATE). Add appropriate FKs and indexes; keep modern models intact. 2025-10-07 10:12:00 -05:00
HotSwapp
432f303a33 docs: add inferred legacy schema from CSV headers and .SC usage to guide migration 2025-10-07 09:51:30 -05:00
HotSwapp
950d261eb4 File Cabinet MVP: case detail with inline Ledger CRUD
- Extend Transaction with ledger fields (item_no, employee_number, t_code, t_type_l, quantity, rate, billed)
- Startup SQLite migration to add missing columns on transactions
- Ledger create/update/delete endpoints with validations and auto-compute Amount = Quantity × Rate
- Uniqueness: ensure (transaction_date, item_no) per case by auto-incrementing
- Compute case totals (billed/unbilled/overall) and display in case view
- Update case.html for master-detail ledger UI; add client-side auto-compute JS
- Enhance import_ledger_data to populate extended fields
- Close/Reopen actions retained; case detail sorting by date/item
- Auth: switch to pbkdf2_sha256 default (bcrypt fallback) and seed admin robustness

Tested in Docker: health OK, login OK, import ROLODEX/FILES OK, ledger create persisted and totals displayed.
2025-10-07 09:26:58 -05:00
HotSwapp
f9c3b3cc9c MVP legacy features: payments search page, phone book report (HTML+CSV), Rolodex bulk selection + actions; audit logging for Rolodex/Phone CRUD; nav updates 2025-10-06 23:31:02 -05:00
21 changed files with 2197 additions and 25 deletions

Binary file not shown.

Binary file not shown.

View File

@@ -12,7 +12,8 @@ from .models import User
from .database import SessionLocal from .database import SessionLocal
# Configure password hashing context # Configure password hashing context
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") # Prefer pbkdf2_sha256 for portability; include bcrypt for legacy compatibility
pwd_context = CryptContext(schemes=["pbkdf2_sha256", "bcrypt"], deprecated="auto")
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -140,7 +141,24 @@ def seed_admin_user() -> None:
# Check if admin user already exists # Check if admin user already exists
existing_admin = db.query(User).filter(User.username == admin_username).first() existing_admin = db.query(User).filter(User.username == admin_username).first()
if existing_admin: if existing_admin:
logger.info(f"Admin user '{admin_username}' already exists") # Ensure default credentials work in development
needs_reset = False
try:
needs_reset = not verify_password(admin_password, existing_admin.password_hash)
except Exception as e:
logger.warning(f"Password verify failed for admin (will reset): {e}")
needs_reset = True
if needs_reset:
try:
existing_admin.password_hash = hash_password(admin_password)
db.add(existing_admin)
db.commit()
logger.info(f"Admin user '{admin_username}' password reset to default")
except Exception as e:
logger.error(f"Error updating admin password: {e}")
else:
logger.info(f"Admin user '{admin_username}' already exists")
return return
# Create admin user # Create admin user

View File

@@ -12,6 +12,7 @@ from sqlalchemy import create_engine
from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker, Session from sqlalchemy.orm import sessionmaker, Session
from dotenv import load_dotenv from dotenv import load_dotenv
from sqlalchemy import inspect, text
# Load environment variables from .env file # Load environment variables from .env file
load_dotenv() load_dotenv()
@@ -68,6 +69,42 @@ def create_tables() -> None:
""" """
Base.metadata.create_all(bind=engine) Base.metadata.create_all(bind=engine)
# Lightweight migration: ensure ledger-specific columns exist on transactions
try:
inspector = inspect(engine)
columns = {col['name'] for col in inspector.get_columns('transactions')}
migration_alters = []
# Map of column name to SQL for SQLite ALTER TABLE ADD COLUMN
required_columns_sql = {
'item_no': 'ALTER TABLE transactions ADD COLUMN item_no INTEGER',
'employee_number': 'ALTER TABLE transactions ADD COLUMN employee_number VARCHAR(20)',
't_code': 'ALTER TABLE transactions ADD COLUMN t_code VARCHAR(10)',
't_type_l': 'ALTER TABLE transactions ADD COLUMN t_type_l VARCHAR(1)',
'quantity': 'ALTER TABLE transactions ADD COLUMN quantity FLOAT',
'rate': 'ALTER TABLE transactions ADD COLUMN rate FLOAT',
'billed': 'ALTER TABLE transactions ADD COLUMN billed VARCHAR(1)'
}
for col_name, ddl in required_columns_sql.items():
if col_name not in columns:
migration_alters.append(ddl)
if migration_alters:
with engine.begin() as conn:
for ddl in migration_alters:
conn.execute(text(ddl))
except Exception as e:
# Log but do not fail startup; migrations are best-effort for SQLite
try:
from .logging_config import setup_logging
import structlog
setup_logging()
_logger = structlog.get_logger(__name__)
_logger.warning("sqlite_migration_failed", error=str(e))
except Exception:
pass
# Seed default admin user after creating tables # Seed default admin user after creating tables
try: try:
from .auth import seed_admin_user from .auth import seed_admin_user

View File

@@ -16,13 +16,13 @@ from typing import Optional, List, Dict, Any
from io import StringIO from io import StringIO
from fastapi import FastAPI, Depends, Request, Query, HTTPException, UploadFile, File, Form from fastapi import FastAPI, Depends, Request, Query, HTTPException, UploadFile, File, Form
from fastapi.responses import RedirectResponse from fastapi.responses import RedirectResponse, Response
from starlette.middleware.sessions import SessionMiddleware from starlette.middleware.sessions import SessionMiddleware
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from sqlalchemy.orm import Session, joinedload from sqlalchemy.orm import Session, joinedload
from sqlalchemy import or_ from sqlalchemy import or_, and_, func as sa_func
from dotenv import load_dotenv from dotenv import load_dotenv
from starlette.middleware.base import BaseHTTPMiddleware from starlette.middleware.base import BaseHTTPMiddleware
import structlog import structlog
@@ -611,13 +611,39 @@ def import_ledger_data(db: Session, file_path: str) -> Dict[str, Any]:
result['errors'].append(f"Row {row_num}: Invalid amount") result['errors'].append(f"Row {row_num}: Invalid amount")
continue continue
tx_date = parse_date(row.get('Date', ''))
item_no = parse_int(row.get('Item_No', '') or '')
# ensure unique item_no per date by increment
# temp session-less check via while loop
desired_item_no = item_no if item_no is not None else 1
while True:
exists = (
db.query(Transaction)
.filter(
Transaction.case_id == case.id,
Transaction.transaction_date == tx_date,
Transaction.item_no == desired_item_no,
)
.first()
)
if not exists:
break
desired_item_no += 1
transaction = Transaction( transaction = Transaction(
case_id=case.id, case_id=case.id,
transaction_date=parse_date(row.get('Date', '')), transaction_date=tx_date,
transaction_type=row.get('T_Type', '').strip() or None, transaction_type=(row.get('T_Type', '').strip() or None),
t_type_l=(row.get('T_Type_L', '').strip().upper() or None),
amount=amount, amount=amount,
description=row.get('Note', '').strip() or None, description=(row.get('Note', '').strip() or None),
reference=row.get('Item_No', '').strip() or None reference=(row.get('Item_No', '').strip() or None),
item_no=desired_item_no,
employee_number=(row.get('Empl_Num', '').strip() or None),
t_code=(row.get('T_Code', '').strip().upper() or None),
quantity=parse_float(row.get('Quantity', '')),
rate=parse_float(row.get('Rate', '')),
billed=((row.get('Billed', '') or '').strip().upper() or None),
) )
db.add(transaction) db.add(transaction)
@@ -791,12 +817,277 @@ def process_csv_import(db: Session, import_type: str, file_path: str) -> Dict[st
return import_func(db, file_path) return import_func(db, file_path)
# ------------------------------
# Ledger CRUD and helpers
# ------------------------------
def validate_ledger_fields(
*,
transaction_date: Optional[str],
t_code: Optional[str],
employee_number: Optional[str],
quantity: Optional[str],
rate: Optional[str],
amount: Optional[str],
billed: Optional[str],
) -> tuple[list[str], dict[str, Any]]:
"""Validate incoming ledger form fields and return (errors, parsed_values)."""
errors: list[str] = []
parsed: dict[str, Any] = {}
# Date
tx_dt = parse_date(transaction_date or "") if transaction_date is not None else None
if tx_dt is None:
errors.append("Date is required and must be valid")
else:
parsed["transaction_date"] = tx_dt
# T_Code
if t_code is None or not t_code.strip():
errors.append("T_Code is required")
else:
parsed["t_code"] = t_code.strip().upper()
# Employee number
if employee_number is None or not employee_number.strip():
errors.append("Empl_Num is required")
else:
parsed["employee_number"] = employee_number.strip()
# Quantity, Rate, Amount
qty = parse_float(quantity or "") if quantity is not None else None
rt = parse_float(rate or "") if rate is not None else None
amt = parse_float(amount or "") if amount is not None else None
if qty is not None:
parsed["quantity"] = qty
if rt is not None:
parsed["rate"] = rt
# Auto-compute amount if missing but quantity and rate present
if amt is None and qty is not None and rt is not None:
amt = round(qty * rt, 2)
if amt is None:
errors.append("Amount is required or derivable from Quantity × Rate")
else:
parsed["amount"] = amt
# Billed flag
billed_flag = (billed or "").strip().upper() if billed is not None else ""
if billed_flag not in ("Y", "N"):
errors.append("Billed must be 'Y' or 'N'")
else:
parsed["billed"] = billed_flag
return errors, parsed
def next_unique_item_no(db: Session, case_id: int, tx_date: datetime, desired_item_no: Optional[int]) -> int:
"""Ensure (transaction_date, item_no) uniqueness per case by incrementing if needed."""
# Start at provided item_no or at 1 if missing
item_no = int(desired_item_no) if desired_item_no is not None else 1
while True:
exists = (
db.query(Transaction)
.filter(
Transaction.case_id == case_id,
Transaction.transaction_date == tx_date,
Transaction.item_no == item_no,
)
.first()
)
if not exists:
return item_no
item_no += 1
def compute_case_totals_from_case(case_obj: Case) -> Dict[str, float]:
"""
Compute simple totals for a case from its transactions.
Returns billed, unbilled, and total sums. Amounts are treated as positive;
future enhancement could apply sign based on t_type_l.
"""
billed_total = 0.0
unbilled_total = 0.0
overall_total = 0.0
for t in (case_obj.transactions or []):
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),
}
@app.post("/case/{case_id}/ledger")
async def ledger_create(
request: Request,
case_id: int,
db: Session = Depends(get_db),
):
user = get_current_user_from_session(request.session)
if not user:
return RedirectResponse(url="/login", status_code=302)
form = await request.form()
# Validate
errors, parsed = validate_ledger_fields(
transaction_date=form.get("transaction_date"),
t_code=form.get("t_code"),
employee_number=form.get("employee_number"),
quantity=form.get("quantity"),
rate=form.get("rate"),
amount=form.get("amount"),
billed=form.get("billed"),
)
if errors:
request.session["case_update_errors"] = errors
return RedirectResponse(url=f"/case/{case_id}", status_code=302)
# Ensure case exists
case_obj = db.query(Case).filter(Case.id == case_id).first()
if not case_obj:
request.session["case_update_errors"] = ["Case not found"]
return RedirectResponse(url=f"/case/{case_id}", status_code=302)
# Assign optional fields
t_type = (form.get("transaction_type") or "").strip() or None
t_type_l = (form.get("t_type_l") or "").strip().upper() or None
reference = (form.get("reference") or "").strip() or None
desc = (form.get("description") or "").strip() or None
desired_item_no = parse_int(form.get("item_no") or "")
item_no = next_unique_item_no(db, case_id, parsed["transaction_date"], desired_item_no)
try:
tx = Transaction(
case_id=case_id,
transaction_date=parsed["transaction_date"],
transaction_type=t_type,
t_type_l=t_type_l,
amount=parsed["amount"],
description=desc,
reference=reference,
item_no=item_no,
employee_number=parsed["employee_number"],
t_code=parsed["t_code"],
quantity=parsed.get("quantity"),
rate=parsed.get("rate"),
billed=parsed["billed"],
)
db.add(tx)
db.commit()
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:
db.rollback()
logger.error("ledger_create_failed", case_id=case_id, error=str(e))
request.session["case_update_errors"] = ["Failed to create ledger entry"]
return RedirectResponse(url=f"/case/{case_id}", status_code=302)
@app.post("/case/{case_id}/ledger/{tx_id}")
async def ledger_update(
request: Request,
case_id: int,
tx_id: int,
db: Session = Depends(get_db),
):
user = get_current_user_from_session(request.session)
if not user:
return RedirectResponse(url="/login", status_code=302)
form = await request.form()
tx = db.query(Transaction).filter(Transaction.id == tx_id, Transaction.case_id == case_id).first()
if not tx:
request.session["case_update_errors"] = ["Ledger entry not found"]
return RedirectResponse(url=f"/case/{case_id}", status_code=302)
errors, parsed = validate_ledger_fields(
transaction_date=form.get("transaction_date"),
t_code=form.get("t_code"),
employee_number=form.get("employee_number"),
quantity=form.get("quantity"),
rate=form.get("rate"),
amount=form.get("amount"),
billed=form.get("billed"),
)
if errors:
request.session["case_update_errors"] = errors
return RedirectResponse(url=f"/case/{case_id}", status_code=302)
try:
tx.transaction_date = parsed["transaction_date"]
# Ensure uniqueness of (date, item_no)
desired_item_no = parse_int(form.get("item_no") or "") or tx.item_no
tx.item_no = next_unique_item_no(db, case_id, parsed["transaction_date"], desired_item_no)
tx.t_code = parsed["t_code"]
tx.employee_number = parsed["employee_number"]
tx.quantity = parsed.get("quantity")
tx.rate = parsed.get("rate")
tx.amount = parsed["amount"]
tx.billed = parsed["billed"]
tx.transaction_type = (form.get("transaction_type") or "").strip() or None
tx.t_type_l = (form.get("t_type_l") or "").strip().upper() or None
tx.reference = (form.get("reference") or "").strip() or None
tx.description = (form.get("description") or "").strip() or None
db.commit()
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:
db.rollback()
logger.error("ledger_update_failed", case_id=case_id, tx_id=tx_id, error=str(e))
request.session["case_update_errors"] = ["Failed to update ledger entry"]
return RedirectResponse(url=f"/case/{case_id}", status_code=302)
@app.post("/case/{case_id}/ledger/{tx_id}/delete")
async def ledger_delete(
request: Request,
case_id: int,
tx_id: int,
db: Session = Depends(get_db),
):
user = get_current_user_from_session(request.session)
if not user:
return RedirectResponse(url="/login", status_code=302)
tx = db.query(Transaction).filter(Transaction.id == tx_id, Transaction.case_id == case_id).first()
if not tx:
request.session["case_update_errors"] = ["Ledger entry not found"]
return RedirectResponse(url=f"/case/{case_id}", status_code=302)
try:
db.delete(tx)
db.commit()
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:
db.rollback()
logger.error("ledger_delete_failed", case_id=case_id, tx_id=tx_id, error=str(e))
request.session["case_update_errors"] = ["Failed to delete ledger entry"]
return RedirectResponse(url=f"/case/{case_id}", status_code=302)
@app.get("/") @app.get("/")
async def root(): async def root():
""" """
Root endpoint - health check. Root endpoint - serves login form for web interface.
""" """
return {"message": "Delphi Database API is running"} return RedirectResponse(url="/login", status_code=302)
@app.get("/health") @app.get("/health")
@@ -1277,6 +1568,18 @@ async def case_detail(
# Get any errors from session and clear them # Get any errors from session and clear them
errors = request.session.pop("case_update_errors", None) errors = request.session.pop("case_update_errors", None)
# Sort transactions by date then item_no for stable display
sorted_transactions = sorted(
case_obj.transactions or [],
key=lambda t: (
t.transaction_date or datetime.min,
t.item_no or 0,
)
)
case_obj.transactions = sorted_transactions
totals = compute_case_totals_from_case(case_obj)
return templates.TemplateResponse( return templates.TemplateResponse(
"case.html", "case.html",
{ {
@@ -1285,6 +1588,7 @@ async def case_detail(
"case": case_obj, "case": case_obj,
"saved": saved, "saved": saved,
"errors": errors or [], "errors": errors or [],
"totals": totals,
}, },
) )
@@ -1478,3 +1782,406 @@ async def case_reopen(
request.session["case_update_errors"] = ["Failed to reopen case. Please try again."] request.session["case_update_errors"] = ["Failed to reopen case. Please try again."]
return RedirectResponse(url=f"/case/{case_id}", status_code=302) return RedirectResponse(url=f"/case/{case_id}", status_code=302)
@app.get("/rolodex")
async def rolodex_list(
request: Request,
q: str | None = Query(None, description="Search by name or company"),
phone: str | None = Query(None, description="Search by phone contains"),
page: int = Query(1, ge=1, description="Page number (1-indexed)"),
page_size: int = Query(20, ge=1, le=100, description="Results per page"),
db: Session = Depends(get_db),
):
"""
Rolodex list with simple search and pagination.
Filters clients by name/company and optional phone substring.
"""
user = get_current_user_from_session(request.session)
if not user:
return RedirectResponse(url="/login", status_code=302)
# Eager-load phones to avoid N+1 in template
query = db.query(Client).options(joinedload(Client.phones))
if q:
like = f"%{q}%"
query = query.filter(
or_(
Client.first_name.ilike(like),
Client.last_name.ilike(like),
Client.company.ilike(like),
)
)
if phone:
like_phone = f"%{phone}%"
# Use EXISTS over join to avoid duplicate rows
query = query.filter(Client.phones.any(Phone.phone_number.ilike(like_phone)))
# Order by last then first for stable display
query = query.order_by(Client.last_name.asc().nulls_last(), Client.first_name.asc().nulls_last())
total: int = query.count()
total_pages: int = (total + page_size - 1) // page_size if total > 0 else 1
if page > total_pages:
page = total_pages
offset = (page - 1) * page_size
clients = query.offset(offset).limit(page_size).all()
start_page = max(1, page - 2)
end_page = min(total_pages, page + 2)
page_numbers = list(range(start_page, end_page + 1))
logger.info(
"rolodex_render",
query=q,
phone=phone,
page=page,
page_size=page_size,
total=total,
)
return templates.TemplateResponse(
"rolodex.html",
{
"request": request,
"user": user,
"clients": clients,
"q": q,
"phone": phone,
"page": page,
"page_size": page_size,
"total": total,
"total_pages": total_pages,
"page_numbers": page_numbers,
"start_index": (offset + 1) if total > 0 else 0,
"end_index": min(offset + len(clients), total),
"enable_bulk": True,
},
)
@app.get("/rolodex/new")
async def rolodex_new(request: Request):
user = get_current_user_from_session(request.session)
if not user:
return RedirectResponse(url="/login", status_code=302)
return templates.TemplateResponse("rolodex_edit.html", {"request": request, "user": user, "client": None})
@app.get("/rolodex/{client_id}")
async def rolodex_view(client_id: int, request: Request, db: Session = Depends(get_db)):
user = get_current_user_from_session(request.session)
if not user:
return RedirectResponse(url="/login", status_code=302)
client = (
db.query(Client)
.options(joinedload(Client.phones), joinedload(Client.cases))
.filter(Client.id == client_id)
.first()
)
if not client:
raise HTTPException(status_code=404, detail="Client not found")
return templates.TemplateResponse("rolodex_view.html", {"request": request, "user": user, "client": client})
@app.post("/rolodex/create")
async def rolodex_create(
request: Request,
first_name: str = Form(None),
last_name: str = Form(None),
company: str = Form(None),
address: str = Form(None),
city: str = Form(None),
state: str = Form(None),
zip_code: str = Form(None),
rolodex_id: str = Form(None),
db: Session = Depends(get_db),
):
user = get_current_user_from_session(request.session)
if not user:
return RedirectResponse(url="/login", status_code=302)
client = Client(
first_name=(first_name or "").strip() or None,
last_name=(last_name or "").strip() or None,
company=(company or "").strip() or None,
address=(address or "").strip() or None,
city=(city or "").strip() or None,
state=(state or "").strip() or None,
zip_code=(zip_code or "").strip() or None,
rolodex_id=(rolodex_id or "").strip() or None,
)
db.add(client)
db.commit()
db.refresh(client)
logger.info("rolodex_create", client_id=client.id, rolodex_id=client.rolodex_id)
return RedirectResponse(url=f"/rolodex/{client.id}", status_code=302)
@app.post("/rolodex/{client_id}/update")
async def rolodex_update(
client_id: int,
request: Request,
first_name: str = Form(None),
last_name: str = Form(None),
company: str = Form(None),
address: str = Form(None),
city: str = Form(None),
state: str = Form(None),
zip_code: str = Form(None),
rolodex_id: str = Form(None),
db: Session = Depends(get_db),
):
user = get_current_user_from_session(request.session)
if not user:
return RedirectResponse(url="/login", status_code=302)
client = db.query(Client).filter(Client.id == client_id).first()
if not client:
raise HTTPException(status_code=404, detail="Client not found")
client.first_name = (first_name or "").strip() or None
client.last_name = (last_name or "").strip() or None
client.company = (company or "").strip() or None
client.address = (address or "").strip() or None
client.city = (city or "").strip() or None
client.state = (state or "").strip() or None
client.zip_code = (zip_code or "").strip() or None
client.rolodex_id = (rolodex_id or "").strip() or None
db.commit()
logger.info(
"rolodex_update",
client_id=client.id,
fields={
"first_name": client.first_name,
"last_name": client.last_name,
"company": client.company,
"rolodex_id": client.rolodex_id,
},
)
return RedirectResponse(url=f"/rolodex/{client.id}", status_code=302)
@app.post("/rolodex/{client_id}/delete")
async def rolodex_delete(client_id: int, request: Request, db: Session = Depends(get_db)):
user = get_current_user_from_session(request.session)
if not user:
return RedirectResponse(url="/login", status_code=302)
client = db.query(Client).filter(Client.id == client_id).first()
if not client:
raise HTTPException(status_code=404, detail="Client not found")
db.delete(client)
db.commit()
logger.info("rolodex_delete", client_id=client_id)
return RedirectResponse(url="/rolodex", status_code=302)
@app.post("/rolodex/{client_id}/phone/add")
async def rolodex_add_phone(
client_id: int,
request: Request,
phone_number: str = Form(...),
phone_type: str = Form(None),
db: Session = Depends(get_db),
):
user = get_current_user_from_session(request.session)
if not user:
return RedirectResponse(url="/login", status_code=302)
client = db.query(Client).filter(Client.id == client_id).first()
if not client:
raise HTTPException(status_code=404, detail="Client not found")
phone = Phone(
client_id=client.id,
phone_number=(phone_number or "").strip(),
phone_type=(phone_type or "").strip() or None,
)
db.add(phone)
db.commit()
logger.info("rolodex_phone_add", client_id=client.id, phone_id=phone.id, number=phone.phone_number)
return RedirectResponse(url=f"/rolodex/{client.id}", status_code=302)
@app.post("/rolodex/{client_id}/phone/{phone_id}/delete")
async def rolodex_delete_phone(client_id: int, phone_id: int, request: Request, db: Session = Depends(get_db)):
user = get_current_user_from_session(request.session)
if not user:
return RedirectResponse(url="/login", status_code=302)
phone = db.query(Phone).filter(Phone.id == phone_id, Phone.client_id == client_id).first()
if not phone:
raise HTTPException(status_code=404, detail="Phone not found")
db.delete(phone)
db.commit()
logger.info("rolodex_phone_delete", client_id=client_id, phone_id=phone_id)
return RedirectResponse(url=f"/rolodex/{client_id}", status_code=302)
@app.get("/payments")
async def payments_search(
request: Request,
from_date: str | None = Query(None, description="YYYY-MM-DD"),
to_date: str | None = Query(None, description="YYYY-MM-DD"),
file_no: str | None = Query(None, description="Case file number"),
rolodex_id: str | None = Query(None, description="Legacy client Id"),
q: str | None = Query(None, description="Description contains"),
page: int = Query(1, ge=1),
page_size: int = Query(50, ge=1, le=200),
db: Session = Depends(get_db),
):
user = get_current_user_from_session(request.session)
if not user:
return RedirectResponse(url="/login", status_code=302)
query = (
db.query(Payment)
.join(Case, Payment.case_id == Case.id)
.join(Client, Case.client_id == Client.id)
.order_by(Payment.payment_date.desc().nulls_last(), Payment.id.desc())
)
filters = []
if from_date:
try:
dt = datetime.strptime(from_date, "%Y-%m-%d")
filters.append(Payment.payment_date >= dt)
except ValueError:
pass
if to_date:
try:
dt = datetime.strptime(to_date, "%Y-%m-%d")
filters.append(Payment.payment_date <= dt)
except ValueError:
pass
if file_no:
filters.append(Case.file_no.ilike(f"%{file_no}%"))
if rolodex_id:
filters.append(Client.rolodex_id.ilike(f"%{rolodex_id}%"))
if q:
filters.append(Payment.description.ilike(f"%{q}%"))
if filters:
query = query.filter(and_(*filters))
total = query.count()
total_pages = (total + page_size - 1) // page_size if total > 0 else 1
if page > total_pages:
page = total_pages
offset = (page - 1) * page_size
payments = query.offset(offset).limit(page_size).all()
# Totals for current result page
page_total_amount = sum(p.amount or 0 for p in payments)
logger.info(
"payments_render",
from_date=from_date,
to_date=to_date,
file_no=file_no,
rolodex_id=rolodex_id,
q=q,
total=total,
)
return templates.TemplateResponse(
"payments.html",
{
"request": request,
"user": user,
"payments": payments,
"from_date": from_date,
"to_date": to_date,
"file_no": file_no,
"rolodex_id": rolodex_id,
"q": q,
"page": page,
"page_size": page_size,
"total": total,
"total_pages": total_pages,
"start_index": (offset + 1) if total > 0 else 0,
"end_index": min(offset + len(payments), total),
"page_total_amount": page_total_amount,
},
)
@app.post("/reports/phone-book")
async def phone_book_report_post(request: Request):
"""Accepts selected client IDs from forms and redirects to GET for rendering."""
user = get_current_user_from_session(request.session)
if not user:
return RedirectResponse(url="/login", status_code=302)
form = await request.form()
client_ids = form.getlist("client_ids")
if not client_ids:
return RedirectResponse(url="/rolodex", status_code=302)
ids_param = "&".join([f"client_ids={cid}" for cid in client_ids])
return RedirectResponse(url=f"/reports/phone-book?{ids_param}", status_code=302)
@app.get("/reports/phone-book")
async def phone_book_report(
request: Request,
client_ids: List[int] | None = Query(None),
q: str | None = Query(None, description="Filter by name/company"),
format: str | None = Query(None, description="csv for CSV output"),
db: Session = Depends(get_db),
):
user = get_current_user_from_session(request.session)
if not user:
return RedirectResponse(url="/login", status_code=302)
query = db.query(Client).options(joinedload(Client.phones))
if client_ids:
query = query.filter(Client.id.in_(client_ids))
elif q:
like = f"%{q}%"
query = query.filter(
or_(Client.first_name.ilike(like), Client.last_name.ilike(like), Client.company.ilike(like))
)
clients = query.order_by(Client.last_name.asc().nulls_last(), Client.first_name.asc().nulls_last()).all()
if format == "csv":
# Build CSV output
output = StringIO()
writer = csv.writer(output)
writer.writerow(["Last", "First", "Company", "Phone Type", "Phone Number"])
for c in clients:
if c.phones:
for p in c.phones:
writer.writerow([
c.last_name or "",
c.first_name or "",
c.company or "",
p.phone_type or "",
p.phone_number or "",
])
else:
writer.writerow([c.last_name or "", c.first_name or "", c.company or "", "", ""])
csv_bytes = output.getvalue().encode("utf-8")
return Response(
content=csv_bytes,
media_type="text/csv",
headers={"Content-Disposition": "attachment; filename=phone_book.csv"},
)
logger.info("phone_book_render", count=len(clients))
return templates.TemplateResponse(
"report_phone_book.html",
{"request": request, "user": user, "clients": clients, "q": q, "client_ids": client_ids or []},
)

View File

@@ -4,7 +4,7 @@ SQLAlchemy models for the Delphi database.
All models inherit from Base which is configured in the database module. All models inherit from Base which is configured in the database module.
""" """
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Float, Text, Boolean from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Float, Text, Boolean, Date, Numeric, Index, UniqueConstraint, ForeignKeyConstraint
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.sql import func from sqlalchemy.sql import func
@@ -123,10 +123,20 @@ class Transaction(Base):
id = Column(Integer, primary_key=True, index=True) id = Column(Integer, primary_key=True, index=True)
case_id = Column(Integer, ForeignKey("cases.id"), nullable=False) case_id = Column(Integer, ForeignKey("cases.id"), nullable=False)
transaction_date = Column(DateTime(timezone=True)) transaction_date = Column(DateTime(timezone=True))
transaction_type = Column(String(20)) # Legacy/basic fields
transaction_type = Column(String(20)) # Maps to legacy T_Type
amount = Column(Float) amount = Column(Float)
description = Column(Text) description = Column(Text) # Maps to legacy Note
reference = Column(String(50)) reference = Column(String(50)) # Previously used for Item_No
# Ledger-specific fields (added for File Cabinet MVP)
item_no = Column(Integer)
employee_number = Column(String(20)) # Empl_Num
t_code = Column(String(10)) # T_Code
t_type_l = Column(String(1)) # T_Type_L (Credit/Debit marker)
quantity = Column(Float)
rate = Column(Float)
billed = Column(String(1)) # 'Y' or 'N'
created_at = Column(DateTime(timezone=True), server_default=func.now()) created_at = Column(DateTime(timezone=True), server_default=func.now())
# Relationships # Relationships
@@ -208,3 +218,468 @@ class ImportLog(Base):
def __repr__(self): def __repr__(self):
return f"<ImportLog(id={self.id}, type='{self.import_type}', status='{self.status}')>" return f"<ImportLog(id={self.id}, type='{self.import_type}', status='{self.status}')>"
# -----------------------------
# Legacy schema models (read-only/migration support)
# Derived from docs/legacy-schema.md
# -----------------------------
class Rolodex(Base):
"""
Legacy ROLODEX master table.
Primary key is the human-readable `Id`.
"""
__tablename__ = "rolodex"
id = Column(String, primary_key=True, index=True) # Id (TEXT)
prefix = Column(String)
first = Column(String)
middle = Column(String)
last = Column(String, index=True)
suffix = Column(String)
title = Column(String)
a1 = Column(String)
a2 = Column(String)
a3 = Column(String)
city = Column(String)
abrev = Column(String(2)) # state abbreviation
st = Column(String) # state name
zip = Column(String(10))
email = Column(String)
dob = Column(Date)
ss = Column(String) # SS#
legal_status = Column(String)
group = Column(String)
memo = Column(Text)
__table_args__ = (
Index("ix_rolodex_last_first", "last", "first"),
Index("ix_rolodex_group", "group"),
Index("ix_rolodex_email", "email"),
)
class LegacyPhone(Base):
"""
Legacy PHONE table (phones by Rolodex Id).
Composite PK on (id, phone) to prevent duplicates per person.
"""
__tablename__ = "phone"
id = Column(String, ForeignKey("rolodex.id", ondelete="CASCADE"), primary_key=True, index=True)
phone = Column(String, primary_key=True)
location = Column(String)
__table_args__ = (
Index("ix_phone_id", "id"),
)
class TrnsType(Base):
"""TRNSTYPE (transaction groups)."""
__tablename__ = "trnstype"
t_type = Column(String, primary_key=True)
t_type_l = Column(String)
header = Column(String)
footer = Column(String)
class TrnsLkup(Base):
"""TRNSLKUP (transaction codes)."""
__tablename__ = "trnslkup"
t_code = Column(String, primary_key=True)
t_type = Column(String, ForeignKey("trnstype.t_type"), index=True)
t_type_l = Column(String)
amount = Column(Numeric(12, 2))
description = Column(Text)
class Footers(Base):
"""FOOTERS table for footer codes and labels."""
__tablename__ = "footers"
f_code = Column(String, primary_key=True)
f_footer = Column(Text)
class FileStat(Base):
"""FILESTAT table for file statuses."""
__tablename__ = "filestat"
status = Column(String, primary_key=True)
definition = Column(Text)
send = Column(String)
footer_code = Column(String, ForeignKey("footers.f_code"), index=True)
class Employee(Base):
"""EMPLOYEE table linking employees to `Rolodex` and rate."""
__tablename__ = "employee"
empl_num = Column(String, primary_key=True)
empl_id = Column(String, ForeignKey("rolodex.id"), index=True)
rate_per_hour = Column(Numeric(12, 2))
class States(Base):
"""STATES reference table."""
__tablename__ = "states"
abrev = Column(String(2), primary_key=True)
st = Column(String)
class GroupLkup(Base):
"""GRUPLKUP reference table."""
__tablename__ = "gruplkup"
code = Column(String, primary_key=True)
description = Column(Text)
title = Column(String)
class Printers(Base):
"""PRINTERS configuration table."""
__tablename__ = "printers"
number = Column(Integer, primary_key=True)
name = Column(String)
port = Column(String)
page_break = Column(String)
setup_st = Column(String)
phone_book = Column(String)
rolodex_info = Column(String)
envelope = Column(String)
file_cabinet = Column(String)
accounts = Column(String)
statements = Column(String)
calendar = Column(String)
reset_st = Column(String)
b_underline = Column(String)
e_underline = Column(String)
b_bold = Column(String)
e_bold = Column(String)
class Setup(Base):
"""
SETUP application configuration. Not strictly keyed in legacy; introduce surrogate PK.
"""
__tablename__ = "setup"
id = Column(Integer, primary_key=True, autoincrement=True)
appl_title = Column(String)
l_head1 = Column(String)
l_head2 = Column(String)
l_head3 = Column(String)
l_head4 = Column(String)
l_head5 = Column(String)
l_head6 = Column(String)
l_head7 = Column(String)
l_head8 = Column(String)
l_head9 = Column(String)
l_head10 = Column(String)
default_printer = Column(Integer, ForeignKey("printers.number"), index=True)
class LegacyFile(Base):
"""
FILES (file cabinet) primary table.
"""
__tablename__ = "files"
file_no = Column(String, primary_key=True, index=True)
id = Column(String, ForeignKey("rolodex.id"), index=True)
file_type = Column(String, index=True)
regarding = Column(Text)
opened = Column(Date)
closed = Column(Date)
empl_num = Column(String, ForeignKey("employee.empl_num"), index=True)
rate_per_hour = Column(Numeric(12, 2))
status = Column(String, ForeignKey("filestat.status"), index=True)
footer_code = Column(String, ForeignKey("footers.f_code"), index=True)
opposing = Column(String, ForeignKey("rolodex.id"), index=True)
hours = Column(Numeric(12, 2))
hours_p = Column(Numeric(12, 2))
trust_bal = Column(Numeric(12, 2))
trust_bal_p = Column(Numeric(12, 2))
hourly_fees = Column(Numeric(12, 2))
hourly_fees_p = Column(Numeric(12, 2))
flat_fees = Column(Numeric(12, 2))
flat_fees_p = Column(Numeric(12, 2))
disbursements = Column(Numeric(12, 2))
disbursements_p = Column(Numeric(12, 2))
credit_bal = Column(Numeric(12, 2))
credit_bal_p = Column(Numeric(12, 2))
total_charges = Column(Numeric(12, 2))
total_charges_p = Column(Numeric(12, 2))
amount_owing = Column(Numeric(12, 2))
amount_owing_p = Column(Numeric(12, 2))
transferable = Column(Numeric(12, 2))
memo = Column(Text)
__table_args__ = (
Index("ix_files_id", "id"),
Index("ix_files_opposing", "opposing"),
Index("ix_files_status", "status"),
Index("ix_files_type", "file_type"),
)
class FilesR(Base):
"""FILES_R relationships per file."""
__tablename__ = "files_r"
file_no = Column(String, ForeignKey("files.file_no", ondelete="CASCADE"), primary_key=True)
relationship = Column(String, primary_key=True)
rolodex_id = Column(String, ForeignKey("rolodex.id", ondelete="CASCADE"), primary_key=True)
__table_args__ = (
Index("ix_files_r_rolodex_id", "rolodex_id"),
)
class FilesV(Base):
"""FILES_V variables per file."""
__tablename__ = "files_v"
file_no = Column(String, ForeignKey("files.file_no", ondelete="CASCADE"), primary_key=True)
identifier = Column(String, primary_key=True)
response = Column(Text)
class Ledger(Base):
"""LEDGER entries for time/charges per file."""
__tablename__ = "ledger"
file_no = Column(String, ForeignKey("files.file_no", ondelete="CASCADE"), primary_key=True)
date = Column(Date, index=True)
item_no = Column(Integer, primary_key=True)
empl_num = Column(String, ForeignKey("employee.empl_num"), index=True)
t_code = Column(String, ForeignKey("trnslkup.t_code"), index=True)
t_type = Column(String, ForeignKey("trnstype.t_type"), index=True)
t_type_l = Column(String)
quantity = Column(Numeric(12, 2))
rate = Column(Numeric(12, 2))
amount = Column(Numeric(12, 2))
billed = Column(String(1)) # 'Y' or 'N'
note = Column(Text)
__table_args__ = (
Index("ix_ledger_file_date", "file_no", "date"),
)
class Deposits(Base):
"""DEPOSITS daily totals."""
__tablename__ = "deposits"
deposit_date = Column(Date, primary_key=True)
total = Column(Numeric(12, 2))
class LegacyPayment(Base):
"""PAYMENTS legacy payments (separate from modern `payments`)."""
__tablename__ = "payments_legacy"
id = Column(Integer, primary_key=True, autoincrement=True)
deposit_date = Column(Date, ForeignKey("deposits.deposit_date"), index=True)
file_no = Column(String, ForeignKey("files.file_no"), index=True)
rolodex_id = Column(String, ForeignKey("rolodex.id"), index=True)
regarding = Column(Text)
amount = Column(Numeric(12, 2))
note = Column(Text)
class PlanInfo(Base):
"""PLANINFO reference table."""
__tablename__ = "planinfo"
plan_id = Column(String, primary_key=True)
plan_name = Column(String)
plan_type = Column(String)
empl_id_no = Column(String)
plan_no = Column(String)
nra = Column(String)
era = Column(String)
errf = Column(String)
colas = Column(String)
divided_by = Column(String)
drafted = Column(String)
benefit_c = Column(String)
qdro_c = Column(String)
rev = Column(String) # ^REV
pa = Column(String) # ^PA
form_name = Column(String)
drafted_on = Column(Date)
memo = Column(Text)
class Qdros(Base):
"""QDROS table for QDRO case data."""
__tablename__ = "qdros"
file_no = Column(String, ForeignKey("files.file_no"), primary_key=True)
version = Column(String, primary_key=True)
plan_id = Column(String, ForeignKey("planinfo.plan_id"), index=True)
_1 = Column(String) # ^1
_2 = Column(String) # ^2
part = Column(String) # ^Part
altp = Column(String) # ^AltP
pet = Column(String) # ^Pet
res = Column(String) # ^Res
case_type = Column(String)
case_code = Column(String)
section = Column(String)
case_number = Column(String)
judgment_date = Column(Date)
valuation_date = Column(Date)
married_on = Column(Date)
percent_awarded = Column(Numeric(12, 2))
ven_city = Column(String)
ven_cnty = Column(String)
ven_st = Column(String(2))
draft_out = Column(Date)
draft_apr = Column(Date)
final_out = Column(Date)
judge = Column(String)
form_name = Column(String)
class Pensions(Base):
"""PENSIONS primary table; composite key (file_no, version)."""
__tablename__ = "pensions"
file_no = Column(String, ForeignKey("files.file_no"), primary_key=True)
version = Column(String, primary_key=True)
plan_id = Column(String, ForeignKey("planinfo.plan_id"), index=True)
plan_name = Column(String)
title = Column(String)
first = Column(String)
last = Column(String)
birth = Column(Date)
race = Column(String)
sex = Column(String)
info = Column(Date)
valu = Column(Date)
accrued = Column(Numeric(12, 2))
vested_per = Column(Numeric(12, 2))
start_age = Column(Numeric(12, 2))
cola = Column(Numeric(12, 2))
max_cola = Column(Numeric(12, 2))
withdrawal = Column(Numeric(12, 2))
pre_dr = Column(Numeric(12, 2))
post_dr = Column(Numeric(12, 2))
tax_rate = Column(Numeric(12, 2))
class PensionResults(Base):
"""RESULTS derived values per pension (by file_no, version)."""
__tablename__ = "pension_results"
file_no = Column(String, primary_key=True)
version = Column(String, primary_key=True)
accrued = Column(Numeric(12, 2))
start_age = Column(Numeric(12, 2))
cola = Column(Numeric(12, 2))
withdrawal = Column(Numeric(12, 2))
pre_dr = Column(Numeric(12, 2))
post_dr = Column(Numeric(12, 2))
tax_rate = Column(Numeric(12, 2))
age = Column(Numeric(12, 2))
years_from = Column(Numeric(12, 2))
life_exp = Column(Numeric(12, 2))
ev_monthly = Column(Numeric(12, 2))
payments = Column(Numeric(12, 2))
pay_out = Column(Numeric(12, 2))
fund_value = Column(Numeric(12, 2))
pv = Column(Numeric(12, 2))
mortality = Column(Numeric(12, 2))
pv_am = Column(Numeric(12, 2))
pv_amt = Column(Numeric(12, 2))
pv_pre_db = Column(Numeric(12, 2))
pv_annuity = Column(Numeric(12, 2))
wv_at = Column(Numeric(12, 2))
pv_plan = Column(Numeric(12, 2))
years_married = Column(Numeric(12, 2))
years_service = Column(Numeric(12, 2))
marr_per = Column(Numeric(12, 2))
marr_amt = Column(Numeric(12, 2))
__table_args__ = (
ForeignKeyConstraint(["file_no", "version"], ["pensions.file_no", "pensions.version"], ondelete="CASCADE"),
Index("ix_pension_results_file_version", "file_no", "version"),
)
class PensionMarriage(Base):
"""MARRIAGE periods related to pensions."""
__tablename__ = "pension_marriage"
id = Column(Integer, primary_key=True, autoincrement=True)
file_no = Column(String, nullable=False)
version = Column(String, nullable=False)
married_from = Column(Date)
married_to = Column(Date)
married_years = Column(Numeric(12, 2))
service_from = Column(Date)
service_to = Column(Date)
service_years = Column(Numeric(12, 2))
marital_pct = Column(Numeric(12, 2))
__table_args__ = (
ForeignKeyConstraint(["file_no", "version"], ["pensions.file_no", "pensions.version"], ondelete="CASCADE"),
Index("ix_pension_marriage_file_version", "file_no", "version"),
)
class PensionDeath(Base):
"""DEATH related amounts for pensions."""
__tablename__ = "pension_death"
file_no = Column(String, primary_key=True)
version = Column(String, primary_key=True)
lump1 = Column(Numeric(12, 2))
lump2 = Column(Numeric(12, 2))
growth1 = Column(Numeric(12, 2))
growth2 = Column(Numeric(12, 2))
disc1 = Column(Numeric(12, 2))
disc2 = Column(Numeric(12, 2))
__table_args__ = (
ForeignKeyConstraint(["file_no", "version"], ["pensions.file_no", "pensions.version"], ondelete="CASCADE"),
)
class PensionSchedule(Base):
"""SCHEDULE vesting schedule for pensions."""
__tablename__ = "pension_schedule"
file_no = Column(String, primary_key=True)
version = Column(String, primary_key=True)
vests_on = Column(Date)
vests_at = Column(Numeric(12, 2))
__table_args__ = (
ForeignKeyConstraint(["file_no", "version"], ["pensions.file_no", "pensions.version"], ondelete="CASCADE"),
)
class PensionSeparate(Base):
"""SEPARATE calculations for pensions."""
__tablename__ = "pension_separate"
file_no = Column(String, primary_key=True)
version = Column(String, primary_key=True)
separation_rate = Column(Numeric(12, 2))
__table_args__ = (
ForeignKeyConstraint(["file_no", "version"], ["pensions.file_no", "pensions.version"], ondelete="CASCADE"),
)

View File

@@ -37,6 +37,12 @@
<li class="nav-item"> <li class="nav-item">
<a class="nav-link {% if 'dashboard' in request.url.path %}active{% endif %}" href="/dashboard">Dashboard</a> <a class="nav-link {% if 'dashboard' in request.url.path %}active{% endif %}" href="/dashboard">Dashboard</a>
</li> </li>
<li class="nav-item">
<a class="nav-link {% if 'rolodex' in request.url.path %}active{% endif %}" href="/rolodex">Rolodex</a>
</li>
<li class="nav-item">
<a class="nav-link {% if 'payments' in request.url.path %}active{% endif %}" href="/payments">Payments</a>
</li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link {% if 'admin' in request.url.path %}active{% endif %}" href="/admin">Admin</a> <a class="nav-link {% if 'admin' in request.url.path %}active{% endif %}" href="/admin">Admin</a>
</li> </li>

View File

@@ -180,17 +180,77 @@ Case {{ case.file_no if case else '' }} · Delphi Database
</div> </div>
</div> </div>
<div class="col-xl-4"> <div class="col-xl-8">
<div class="card h-100"> <div class="card h-100">
<div class="card-header">Transactions</div> <div class="card-header d-flex justify-content-between align-items-center">
<div class="card-body p-0"> <span>Ledger</span>
<div class="table-responsive"> <div class="small text-muted">
<table class="table table-sm mb-0 align-middle"> <span class="me-3">Billed: {{ '%.2f'|format(totals.billed_total) }}</span>
<span class="me-3">Unbilled: {{ '%.2f'|format(totals.unbilled_total) }}</span>
<span>Total: {{ '%.2f'|format(totals.overall_total) }}</span>
</div>
</div>
<div class="card-body">
<form class="row g-2 align-items-end" method="post" action="/case/{{ case.id }}/ledger">
<div class="col-md-2">
<label class="form-label">Date</label>
<input type="date" class="form-control" name="transaction_date" required>
</div>
<div class="col-md-1">
<label class="form-label">Item #</label>
<input type="number" class="form-control" name="item_no" min="1">
</div>
<div class="col-md-2">
<label class="form-label">T_Code</label>
<input type="text" class="form-control" name="t_code" maxlength="10" required>
</div>
<div class="col-md-2">
<label class="form-label">Empl_Num</label>
<input type="text" class="form-control" name="employee_number" maxlength="20" required>
</div>
<div class="col-md-1">
<label class="form-label">Qty</label>
<input type="number" class="form-control js-qty" name="quantity" step="0.01">
</div>
<div class="col-md-1">
<label class="form-label">Rate</label>
<input type="number" class="form-control js-rate" name="rate" step="0.01">
</div>
<div class="col-md-2">
<label class="form-label">Amount</label>
<input type="number" class="form-control js-amount" name="amount" step="0.01" required>
</div>
<div class="col-md-1">
<label class="form-label">Billed</label>
<select class="form-select" name="billed" required>
<option value="N">N</option>
<option value="Y">Y</option>
</select>
</div>
<div class="col-12">
<label class="form-label">Description</label>
<input type="text" class="form-control" name="description" maxlength="255">
</div>
<div class="col-12 d-flex gap-2">
<button type="submit" class="btn btn-primary"><i class="bi bi-plus-lg me-1"></i>Add</button>
<button type="reset" class="btn btn-outline-secondary">Clear</button>
</div>
</form>
<div class="table-responsive mt-3">
<table class="table table-sm align-middle">
<thead class="table-light"> <thead class="table-light">
<tr> <tr>
<th style="width: 110px;">Date</th> <th style="width: 110px;">Date</th>
<th>Type</th> <th style="width: 70px;">Item</th>
<th style="width: 90px;">T_Code</th>
<th style="width: 110px;">Empl</th>
<th class="text-end" style="width: 100px;">Qty</th>
<th class="text-end" style="width: 100px;">Rate</th>
<th class="text-end" style="width: 120px;">Amount</th> <th class="text-end" style="width: 120px;">Amount</th>
<th style="width: 70px;">Billed</th>
<th>Description</th>
<th class="text-end" style="width: 120px;">Actions</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -198,12 +258,35 @@ Case {{ case.file_no if case else '' }} · Delphi Database
{% for t in case.transactions %} {% for t in case.transactions %}
<tr> <tr>
<td>{{ t.transaction_date.strftime('%Y-%m-%d') if t.transaction_date else '' }}</td> <td>{{ t.transaction_date.strftime('%Y-%m-%d') if t.transaction_date else '' }}</td>
<td>{{ t.transaction_type or '' }}</td> <td>{{ t.item_no or '' }}</td>
<td>{{ t.t_code or '' }}</td>
<td>{{ t.employee_number or '' }}</td>
<td class="text-end">{{ '%.2f'|format(t.quantity) if t.quantity is not none else '' }}</td>
<td class="text-end">{{ '%.2f'|format(t.rate) if t.rate is not none else '' }}</td>
<td class="text-end">{{ '%.2f'|format(t.amount) if t.amount is not none else '' }}</td> <td class="text-end">{{ '%.2f'|format(t.amount) if t.amount is not none else '' }}</td>
<td>{{ t.billed or '' }}</td>
<td>{{ t.description or '' }}</td>
<td class="text-end">
<form method="post" action="/case/{{ case.id }}/ledger/{{ t.id }}" class="d-inline">
<input type="hidden" name="transaction_date" value="{{ t.transaction_date.strftime('%Y-%m-%d') if t.transaction_date else '' }}">
<input type="hidden" name="item_no" value="{{ t.item_no or '' }}">
<input type="hidden" name="t_code" value="{{ t.t_code or '' }}">
<input type="hidden" name="employee_number" value="{{ t.employee_number or '' }}">
<input type="hidden" name="quantity" value="{{ t.quantity or '' }}">
<input type="hidden" name="rate" value="{{ t.rate or '' }}">
<input type="hidden" name="amount" value="{{ t.amount or '' }}">
<input type="hidden" name="billed" value="{{ t.billed or '' }}">
<input type="hidden" name="description" value="{{ t.description or '' }}">
<button type="submit" class="btn btn-sm btn-outline-secondary" data-bs-toggle="tooltip" title="Quick save last values"><i class="bi bi-arrow-repeat"></i></button>
</form>
<form method="post" action="/case/{{ case.id }}/ledger/{{ t.id }}/delete" class="d-inline">
<button type="submit" class="btn btn-sm btn-outline-danger" data-confirm-delete="Delete this entry?"><i class="bi bi-trash"></i></button>
</form>
</td>
</tr> </tr>
{% endfor %} {% endfor %}
{% else %} {% else %}
<tr><td colspan="3" class="text-center text-muted py-3">No transactions.</td></tr> <tr><td colspan="10" class="text-center text-muted py-3">No ledger entries.</td></tr>
{% endif %} {% endif %}
</tbody> </tbody>
</table> </table>
@@ -275,6 +358,13 @@ Case {{ case.file_no if case else '' }} · Delphi Database
</div> </div>
</div> </div>
</div> </div>
{% else %}
<div class="col-12">
<div class="alert alert-warning" role="alert">
<i class="bi bi-info-circle me-2"></i>
You must be logged in to view case details and ledger.
</div>
</div>
{% endif %} {% endif %}
</div> </div>
{% endblock %} {% endblock %}

106
app/templates/payments.html Normal file
View File

@@ -0,0 +1,106 @@
{% extends "base.html" %}
{% block title %}Payments · Delphi Database{% endblock %}
{% block content %}
<div class="row g-3 align-items-center mb-3">
<div class="col-auto">
<h2 class="mb-0">Payments</h2>
</div>
<div class="col ms-auto">
<form class="row g-2" method="get">
<div class="col-md-2">
<input type="date" class="form-control" name="from_date" value="{{ from_date or '' }}" placeholder="From">
</div>
<div class="col-md-2">
<input type="date" class="form-control" name="to_date" value="{{ to_date or '' }}" placeholder="To">
</div>
<div class="col-md-2">
<input type="text" class="form-control" name="file_no" value="{{ file_no or '' }}" placeholder="File #">
</div>
<div class="col-md-2">
<input type="text" class="form-control" name="rolodex_id" value="{{ rolodex_id or '' }}" placeholder="Rolodex Id">
</div>
<div class="col-md-3">
<input type="text" class="form-control" name="q" value="{{ q or '' }}" placeholder="Description contains">
</div>
<div class="col-auto">
<input type="hidden" name="page_size" value="{{ page_size }}">
<button class="btn btn-outline-primary" type="submit"><i class="bi bi-search me-1"></i>Search</button>
</div>
<div class="col-auto">
<a class="btn btn-outline-secondary" href="/payments"><i class="bi bi-x-circle me-1"></i>Clear</a>
</div>
</form>
</div>
<div class="col-12 text-muted small">
{% if total and total > 0 %}
Showing {{ start_index }}{{ end_index }} of {{ total }} | Page total: ${{ '%.2f'|format(page_total_amount) }}
{% else %}
No results
{% endif %}
</div>
<div class="col-12">
<div class="table-responsive">
<table class="table table-hover align-middle">
<thead class="table-light">
<tr>
<th style="width: 120px;">Date</th>
<th style="width: 140px;">File #</th>
<th>Client</th>
<th>Type</th>
<th>Description</th>
<th class="text-end" style="width: 140px;">Amount</th>
</tr>
</thead>
<tbody>
{% if payments and payments|length > 0 %}
{% for p in payments %}
<tr>
<td>{{ p.payment_date.strftime('%Y-%m-%d') if p.payment_date else '' }}</td>
<td>{{ p.case.file_no if p.case else '' }}</td>
<td>
{% set client = p.case.client if p.case else None %}
{% if client %}{{ client.last_name }}, {{ client.first_name }}{% else %}<span class="text-muted"></span>{% endif %}
</td>
<td>{{ p.payment_type or '' }}</td>
<td>{{ p.description or '' }}</td>
<td class="text-end">{{ '%.2f'|format(p.amount) if p.amount is not none else '' }}</td>
</tr>
{% endfor %}
{% else %}
<tr><td colspan="6" class="text-center text-muted py-4">No payments found.</td></tr>
{% endif %}
</tbody>
</table>
</div>
</div>
<div class="col-12">
{% if total_pages and total_pages > 1 %}
<nav aria-label="Payments pagination">
<ul class="pagination mb-0">
<li class="page-item {% if page <= 1 %}disabled{% endif %}">
<a class="page-link" href="/payments?page={{ page - 1 if page > 1 else 1 }}&page_size={{ page_size }}{% if from_date %}&from_date={{ from_date }}{% endif %}{% if to_date %}&to_date={{ to_date }}{% endif %}{% if file_no %}&file_no={{ file_no | urlencode }}{% endif %}{% if rolodex_id %}&rolodex_id={{ rolodex_id | urlencode }}{% endif %}{% if q %}&q={{ q | urlencode }}{% endif %}">
&laquo;
</a>
</li>
{% for p in range(1, total_pages + 1) %}
<li class="page-item {% if p == page %}active{% endif %}">
<a class="page-link" href="/payments?page={{ p }}&page_size={{ page_size }}{% if from_date %}&from_date={{ from_date }}{% endif %}{% if to_date %}&to_date={{ to_date }}{% endif %}{% if file_no %}&file_no={{ file_no | urlencode }}{% endif %}{% if rolodex_id %}&rolodex_id={{ rolodex_id | urlencode }}{% endif %}{% if q %}&q={{ q | urlencode }}{% endif %}">{{ p }}</a>
</li>
{% endfor %}
<li class="page-item {% if page >= total_pages %}disabled{% endif %}">
<a class="page-link" href="/payments?page={{ page + 1 if page < total_pages else total_pages }}&page_size={{ page_size }}{% if from_date %}&from_date={{ from_date }}{% endif %}{% if to_date %}&to_date={{ to_date }}{% endif %}{% if file_no %}&file_no={{ file_no | urlencode }}{% endif %}{% if rolodex_id %}&rolodex_id={{ rolodex_id | urlencode }}{% endif %}{% if q %}&q={{ q | urlencode }}{% endif %}">
&raquo;
</a>
</li>
</ul>
</nav>
{% endif %}
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,61 @@
{% extends "base.html" %}
{% block title %}Phone Book · Delphi Database{% endblock %}
{% block content %}
<div class="row g-3">
<div class="col-12 d-flex align-items-center">
<a class="btn btn-sm btn-outline-secondary me-2" href="/rolodex">
<i class="bi bi-arrow-left"></i> Back
</a>
<h2 class="mb-0">Phone Book</h2>
<div class="ms-auto d-flex gap-2">
<a class="btn btn-outline-secondary btn-sm" href="/reports/phone-book?format=csv{% for id in client_ids %}&client_ids={{ id }}{% endfor %}{% if q %}&q={{ q | urlencode }}{% endif %}">
<i class="bi bi-filetype-csv me-1"></i>Download CSV
</a>
</div>
</div>
<div class="col-12">
<div class="table-responsive">
<table class="table table-sm align-middle">
<thead class="table-light">
<tr>
<th style="width: 220px;">Name</th>
<th>Company</th>
<th style="width: 160px;">Phone Type</th>
<th style="width: 220px;">Phone Number</th>
</tr>
</thead>
<tbody>
{% if clients and clients|length > 0 %}
{% for c in clients %}
{% if c.phones and c.phones|length > 0 %}
{% for p in c.phones %}
<tr>
<td><span class="fw-semibold">{{ c.last_name or '' }}, {{ c.first_name or '' }}</span></td>
<td>{{ c.company or '' }}</td>
<td>{{ p.phone_type or '' }}</td>
<td>{{ p.phone_number or '' }}</td>
</tr>
{% endfor %}
{% else %}
<tr>
<td><span class="fw-semibold">{{ c.last_name or '' }}, {{ c.first_name or '' }}</span></td>
<td>{{ c.company or '' }}</td>
<td class="text-muted"></td>
<td class="text-muted"></td>
</tr>
{% endif %}
{% endfor %}
{% else %}
<tr><td colspan="4" class="text-center text-muted py-4">No data.</td></tr>
{% endif %}
</tbody>
</table>
</div>
</div>
</div>
{% endblock %}

153
app/templates/rolodex.html Normal file
View File

@@ -0,0 +1,153 @@
{% extends "base.html" %}
{% block title %}Rolodex · Delphi Database{% endblock %}
{% block content %}
<div class="row g-3 align-items-center mb-3">
<div class="col-auto">
<h2 class="mb-0">Rolodex</h2>
</div>
<div class="col ms-auto">
<form class="row g-2" method="get" action="/rolodex">
<div class="col-md">
<input class="form-control" type="search" name="q" placeholder="Search name or company" aria-label="Search" value="{{ q or '' }}">
</div>
<div class="col-md">
<input class="form-control" type="search" name="phone" placeholder="Phone contains" aria-label="Phone" value="{{ phone or '' }}">
</div>
<div class="col-auto">
<input type="hidden" name="page_size" value="{{ page_size }}">
<button class="btn btn-outline-primary" type="submit">
<i class="bi bi-search me-1"></i>Search
</button>
</div>
<div class="col-auto">
<a class="btn btn-outline-secondary" href="/rolodex">
<i class="bi bi-x-circle me-1"></i>Clear
</a>
</div>
<div class="col-auto">
<a class="btn btn-primary" href="/rolodex/new">
<i class="bi bi-plus-lg me-1"></i>New Client
</a>
</div>
</form>
</div>
<div class="col-12 text-muted small">
{% if total and total > 0 %}
Showing {{ start_index }}{{ end_index }} of {{ total }}
{% else %}
No results
{% endif %}
</div>
<div class="col-12">
<div class="table-responsive">
<form method="post" action="/reports/phone-book" id="bulkForm">
<table class="table table-hover align-middle">
<thead class="table-light">
<tr>
{% if enable_bulk %}
<th style="width: 40px;"><input class="form-check-input" type="checkbox" id="selectAll"></th>
{% endif %}
<th style="width: 220px;">Name</th>
<th>Company</th>
<th>Address</th>
<th>City</th>
<th style="width: 80px;">State</th>
<th style="width: 110px;">ZIP</th>
<th style="width: 200px;">Phones</th>
<th class="text-end" style="width: 140px;">Actions</th>
</tr>
</thead>
<tbody>
{% if clients and clients|length > 0 %}
{% for c in clients %}
<tr>
{% if enable_bulk %}
<td>
<input class="form-check-input" type="checkbox" name="client_ids" value="{{ c.id }}">
</td>
{% endif %}
<td>
<span class="fw-semibold">{{ c.last_name or '' }}, {{ c.first_name or '' }}</span>
</td>
<td>{{ c.company or '' }}</td>
<td>{{ c.address or '' }}</td>
<td>{{ c.city or '' }}</td>
<td>{{ c.state or '' }}</td>
<td>{{ c.zip_code or '' }}</td>
<td>
{% if c.phones and c.phones|length > 0 %}
{% for p in c.phones[:3] %}
<span class="badge bg-light text-dark me-1">{{ p.phone_number }}</span>
{% endfor %}
{% else %}
<span class="text-muted"></span>
{% endif %}
</td>
<td class="text-end">
<a class="btn btn-sm btn-outline-primary" href="/rolodex/{{ c.id }}">
<i class="bi bi-person-lines-fill me-1"></i>View
</a>
</td>
</tr>
{% endfor %}
{% else %}
<tr>
<td colspan="8" class="text-center text-muted py-4">No clients found.</td>
</tr>
{% endif %}
</tbody>
</table>
{% if enable_bulk %}
<div class="d-flex gap-2">
<button type="submit" class="btn btn-outline-secondary">
<i class="bi bi-journal-text me-1"></i>Phone Book (Selected)
</button>
<a class="btn btn-outline-secondary" href="/reports/phone-book?format=csv{% if q %}&q={{ q | urlencode }}{% endif %}">
<i class="bi bi-filetype-csv me-1"></i>Phone Book CSV (Current Filter)
</a>
</div>
{% endif %}
</form>
</div>
</div>
<div class="col-12">
{% if total_pages and total_pages > 1 %}
<nav aria-label="Rolodex pagination">
<ul class="pagination mb-0">
<li class="page-item {% if page <= 1 %}disabled{% endif %}">
<a class="page-link" href="/rolodex?page={{ page - 1 if page > 1 else 1 }}&page_size={{ page_size }}{% if q %}&q={{ q | urlencode }}{% endif %}{% if phone %}&phone={{ phone | urlencode }}{% endif %}" aria-label="Previous">
<span aria-hidden="true">&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>
{% block extra_scripts %}
<script>
document.addEventListener('DOMContentLoaded', function() {
const selectAll = document.getElementById('selectAll');
if (selectAll) {
selectAll.addEventListener('change', function() {
document.querySelectorAll('input[name="client_ids"]').forEach(cb => cb.checked = selectAll.checked);
});
}
});
</script>
{% endblock %}
{% endblock %}

View File

@@ -0,0 +1,71 @@
{% extends "base.html" %}
{% block title %}{{ 'New Client' if not client else 'Edit Client' }} · Delphi Database{% endblock %}
{% block content %}
<div class="row g-3">
<div class="col-12 d-flex align-items-center">
<a class="btn btn-sm btn-outline-secondary me-2" href="/rolodex">
<i class="bi bi-arrow-left"></i>
Back
</a>
<h2 class="mb-0">{{ 'New Client' if not client else 'Edit Client' }}</h2>
</div>
<div class="col-12">
<div class="card">
<div class="card-body">
<form method="post" action="{{ '/rolodex/create' if not client else '/rolodex/' ~ client.id ~ '/update' }}">
<div class="row g-3">
<div class="col-md-4">
<label for="last_name" class="form-label">Last Name</label>
<input type="text" class="form-control" id="last_name" name="last_name" value="{{ client.last_name if client else '' }}">
</div>
<div class="col-md-4">
<label for="first_name" class="form-label">First Name</label>
<input type="text" class="form-control" id="first_name" name="first_name" value="{{ client.first_name if client else '' }}">
</div>
<div class="col-md-4">
<label for="company" class="form-label">Company</label>
<input type="text" class="form-control" id="company" name="company" value="{{ client.company if client else '' }}">
</div>
<div class="col-md-6">
<label for="address" class="form-label">Address</label>
<input type="text" class="form-control" id="address" name="address" value="{{ client.address if client else '' }}">
</div>
<div class="col-md-3">
<label for="city" class="form-label">City</label>
<input type="text" class="form-control" id="city" name="city" value="{{ client.city if client else '' }}">
</div>
<div class="col-md-1">
<label for="state" class="form-label">State</label>
<input type="text" class="form-control" id="state" name="state" value="{{ client.state if client else '' }}">
</div>
<div class="col-md-2">
<label for="zip_code" class="form-label">ZIP</label>
<input type="text" class="form-control" id="zip_code" name="zip_code" value="{{ client.zip_code if client else '' }}">
</div>
<div class="col-md-4">
<label for="rolodex_id" class="form-label">Legacy Rolodex Id</label>
<input type="text" class="form-control" id="rolodex_id" name="rolodex_id" value="{{ client.rolodex_id if client else '' }}">
</div>
<div class="col-12">
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">
<i class="bi bi-check-lg me-1"></i>Save
</button>
<a href="{{ '/rolodex/' ~ client.id if client else '/rolodex' }}" class="btn btn-outline-secondary">Cancel</a>
</div>
</div>
</div>
</form>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,170 @@
{% extends "base.html" %}
{% block title %}Client · {{ client.last_name }}, {{ client.first_name }} · Delphi Database{% endblock %}
{% block content %}
<div class="row g-3">
<div class="col-12 d-flex align-items-center">
<a class="btn btn-sm btn-outline-secondary me-2" href="/rolodex">
<i class="bi bi-arrow-left"></i>
Back
</a>
<h2 class="mb-0">Client</h2>
<div class="ms-auto">
<a class="btn btn-sm btn-outline-primary" href="/rolodex/{{ client.id }}/edit" onclick="event.preventDefault(); document.getElementById('editFormLink').submit();">
<i class="bi bi-pencil-square me-1"></i>Edit
</a>
<form id="editFormLink" method="get" action="/rolodex/new" class="d-none">
<input type="hidden" name="_" value="1">
</form>
</div>
</div>
<div class="col-12">
<div class="card">
<div class="card-body">
<div class="row mb-3">
<div class="col-md-4">
<div class="text-muted small">Name</div>
<div class="fw-semibold">{{ client.last_name or '' }}, {{ client.first_name or '' }}</div>
</div>
<div class="col-md-4">
<div class="text-muted small">Company</div>
<div>{{ client.company or '' }}</div>
</div>
<div class="col-md-4">
<div class="text-muted small">Legacy Rolodex Id</div>
<div>{{ client.rolodex_id or '' }}</div>
</div>
</div>
<div class="row mb-3">
<div class="col-md-6">
<div class="text-muted small">Address</div>
<div>{{ client.address or '' }}</div>
</div>
<div class="col-md-3">
<div class="text-muted small">City</div>
<div>{{ client.city or '' }}</div>
</div>
<div class="col-md-1">
<div class="text-muted small">State</div>
<div>{{ client.state or '' }}</div>
</div>
<div class="col-md-2">
<div class="text-muted small">ZIP</div>
<div>{{ client.zip_code or '' }}</div>
</div>
</div>
<div class="d-flex gap-2">
<a class="btn btn-primary" href="/rolodex/new" onclick="event.preventDefault(); document.getElementById('editClientForm').submit();">
<i class="bi bi-pencil-square me-1"></i>Edit Client
</a>
<form id="editClientForm" method="get" action="/rolodex/new" class="d-none">
<input type="hidden" name="_prefill" value="{{ client.id }}">
</form>
<form method="post" action="/rolodex/{{ client.id }}/delete" onsubmit="return confirm('Delete this client? This cannot be undone.');">
<button type="submit" class="btn btn-outline-danger">
<i class="bi bi-trash me-1"></i>Delete
</button>
</form>
</div>
</div>
</div>
</div>
<div class="col-lg-6">
<div class="card h-100">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0">Phones</h5>
</div>
<div class="card-body">
<form class="row g-2 mb-3" method="post" action="/rolodex/{{ client.id }}/phone/add">
<div class="col-md-6">
<input type="text" class="form-control" name="phone_number" placeholder="Phone number" required>
</div>
<div class="col-md-4">
<input type="text" class="form-control" name="phone_type" placeholder="Type (home, work)">
</div>
<div class="col-md-2">
<button type="submit" class="btn btn-primary w-100"><i class="bi bi-plus"></i></button>
</div>
</form>
<div class="table-responsive">
<table class="table table-sm align-middle">
<thead class="table-light">
<tr>
<th>Number</th>
<th>Type</th>
<th class="text-end" style="width: 100px;">Actions</th>
</tr>
</thead>
<tbody>
{% if client.phones and client.phones|length > 0 %}
{% for p in client.phones %}
<tr>
<td>{{ p.phone_number }}</td>
<td>{{ p.phone_type or '' }}</td>
<td class="text-end">
<form method="post" action="/rolodex/{{ client.id }}/phone/{{ p.id }}/delete" onsubmit="return confirm('Delete this phone?');">
<button type="submit" class="btn btn-sm btn-outline-danger">
<i class="bi bi-trash"></i>
</button>
</form>
</td>
</tr>
{% endfor %}
{% else %}
<tr><td colspan="3" class="text-center text-muted py-3">No phones.</td></tr>
{% endif %}
</tbody>
</table>
</div>
</div>
</div>
</div>
<div class="col-lg-6">
<div class="card h-100">
<div class="card-header">Related Cases</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-sm mb-0 align-middle">
<thead class="table-light">
<tr>
<th style="width: 140px;">File #</th>
<th>Description</th>
<th style="width: 90px;">Status</th>
<th style="width: 110px;">Opened</th>
<th class="text-end" style="width: 110px;">Actions</th>
</tr>
</thead>
<tbody>
{% if client.cases and client.cases|length > 0 %}
{% for c in client.cases %}
<tr>
<td>{{ c.file_no }}</td>
<td>{{ c.description or '' }}</td>
<td>{{ c.status or '' }}</td>
<td>{{ c.open_date.strftime('%Y-%m-%d') if c.open_date else '' }}</td>
<td class="text-end">
<a class="btn btn-sm btn-outline-primary" href="/case/{{ c.id }}">
<i class="bi bi-folder2-open"></i>
</a>
</td>
</tr>
{% endfor %}
{% else %}
<tr><td colspan="5" class="text-center text-muted py-3">No related cases.</td></tr>
{% endif %}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -2,4 +2,4 @@
# https://curl.se/docs/http-cookies.html # https://curl.se/docs/http-cookies.html
# This file was generated by libcurl! Edit at your own risk. # This file was generated by libcurl! Edit at your own risk.
#HttpOnly_localhost FALSE / FALSE 1761016563 session eyJ1c2VyX2lkIjogMSwgInVzZXIiOiB7ImlkIjogMSwgInVzZXJuYW1lIjogImFkbWluIn19.aOSF8w.gmvSLjQ8LTg_OFCZNUZppoDIjrY #HttpOnly_localhost FALSE / FALSE 1761056616 session eyJ1c2VyX2lkIjogMSwgInVzZXIiOiB7ImlkIjogMSwgInVzZXJuYW1lIjogImFkbWluIn19.aOUiaA.b0ACR1u9vUHgu86iSQ9Mnzw1j6U

BIN
delphi.db

Binary file not shown.

View File

@@ -1,5 +1,3 @@
version: '3.8'
services: services:
delphi-db: delphi-db:
build: . build: .

262
docs/legacy-schema.md Normal file
View File

@@ -0,0 +1,262 @@
## Legacy schema (inferred)
Source: headers in `old-database/Office/*.csv` (and subfolders) cross-checked against legacy `.SC` scripts.
Notes
- Types are inferred from names/usage; adjust during migration as needed.
- Date fields are typically legacy date types (store as DATE). Money/amounts as DECIMAL(12,2).
### ROLODEX
- Id (TEXT, pk)
- Prefix (TEXT)
- First (TEXT)
- Middle (TEXT)
- Last (TEXT)
- Suffix (TEXT)
- Title (TEXT)
- A1 (TEXT)
- A2 (TEXT)
- A3 (TEXT)
- City (TEXT)
- Abrev (TEXT)
- St (TEXT)
- Zip (TEXT)
- Email (TEXT)
- DOB (DATE)
- SS# (TEXT)
- Legal_Status (TEXT)
- Group (TEXT)
- Memo (TEXT)
### PHONE (by Id)
- Id (TEXT, fk → Rolodex.Id)
- Phone (TEXT)
- Location (TEXT)
### FILES (file cabinet)
- File_No (TEXT, pk)
- Id (TEXT, fk → Rolodex.Id)
- File_Type (TEXT, fk → FileType.File_Type)
- Regarding (TEXT)
- Opened (DATE)
- Closed (DATE, nullable)
- Empl_Num (TEXT, fk → Employee.Empl_Num)
- Rate_Per_Hour (DECIMAL)
- Status (TEXT, fk → FileStat.Status)
- Footer_Code (TEXT, fk → Footers.F_Code)
- Opposing (TEXT, fk → Rolodex.Id)
- Hours (DECIMAL)
- Hours_P (DECIMAL)
- Trust_Bal (DECIMAL)
- Trust_Bal_P (DECIMAL)
- Hourly_Fees (DECIMAL)
- Hourly_Fees_P (DECIMAL)
- Flat_Fees (DECIMAL)
- Flat_Fees_P (DECIMAL)
- Disbursements (DECIMAL)
- Disbursements_P (DECIMAL)
- Credit_Bal (DECIMAL)
- Credit_Bal_P (DECIMAL)
- Total_Charges (DECIMAL)
- Total_Charges_P (DECIMAL)
- Amount_Owing (DECIMAL)
- Amount_Owing_P (DECIMAL)
- Transferable (DECIMAL)
- Memo (TEXT)
### FILES_R (relationships)
- File_No (TEXT, fk → Files.File_No)
- Relationship (TEXT)
- Rolodex_Id (TEXT, fk → Rolodex.Id)
### FILES_V (variables)
- File_No (TEXT, fk → Files.File_No)
- Identifier (TEXT)
- Response (TEXT)
### LEDGER
- File_No (TEXT, fk → Files.File_No)
- Date (DATE)
- Item_No (INTEGER, part of pk per scripts use)
- Empl_Num (TEXT, fk → Employee.Empl_Num)
- T_Code (TEXT, fk → TrnsLkup.T_Code)
- T_Type (TEXT, fk → TrnsType.T_Type)
- T_Type_L (TEXT)
- Quantity (DECIMAL)
- Rate (DECIMAL)
- Amount (DECIMAL)
- Billed (TEXT, one of 'Y'/'N')
- Note (TEXT)
### TRNSACTN (joined outputs)
- File_No (TEXT)
- Id (TEXT)
- Footer_Code (TEXT)
- Date (DATE)
- Item_No (INTEGER)
- Empl_Num (TEXT)
- T_Code (TEXT)
- T_Type (TEXT)
- T_Type_L (TEXT)
- Quantity (DECIMAL)
- Rate (DECIMAL)
- Amount (DECIMAL)
- Billed (TEXT)
- Note (TEXT)
### TRNSLKUP (transaction codes)
- T_Code (TEXT, pk)
- T_Type (TEXT, fk → TrnsType.T_Type)
- T_Type_L (TEXT)
- Amount (DECIMAL)
- Description (TEXT)
### TRNSTYPE (transaction groups)
- T_Type (TEXT, pk)
- T_Type_L (TEXT)
- Header (TEXT)
- Footer (TEXT)
### FILESTAT
- Status (TEXT, pk)
- Definition (TEXT)
- Send (TEXT)
- Footer_Code (TEXT, fk → Footers.F_Code)
### FOOTERS
- F_Code (TEXT, pk)
- F_Footer (TEXT)
### EMPLOYEE
- Empl_Num (TEXT, pk)
- Empl_Id (TEXT, fk → Rolodex.Id)
- Rate_Per_Hour (DECIMAL)
### STATES
- Abrev (TEXT, pk)
- St (TEXT)
### GRUPLKUP
- Code (TEXT, pk)
- Description (TEXT)
- Title (TEXT)
### SETUP
- Appl_Title (TEXT)
- L_Head1..L_Head10 (TEXT)
- Default_Printer (INTEGER, fk → Printers.Number)
### PRINTERS
- Number (INTEGER, pk)
- Name (TEXT)
- Port (TEXT)
- Page_Break (TEXT)
- Setup_St (TEXT)
- Phone_Book (TEXT)
- Rolodex_Info (TEXT)
- Envelope (TEXT)
- File_Cabinet (TEXT)
- Accounts (TEXT)
- Statements (TEXT)
- Calendar (TEXT)
- Reset_St (TEXT)
- B_Underline (TEXT)
- E_Underline (TEXT)
- B_Bold (TEXT)
- E_Bold (TEXT)
### DEPOSITS / PAYMENTS
- Deposits
- Deposit_Date (DATE, pk?)
- Total (DECIMAL)
- Payments
- Deposit_Date (DATE, fk → Deposits.Deposit_Date)
- File_No (TEXT, fk → Files.File_No)
- Id (TEXT, fk → Rolodex.Id)
- Regarding (TEXT)
- Amount (DECIMAL)
- Note (TEXT)
### QDROS
- File_No (TEXT)
- Version (TEXT)
- Plan_Id (TEXT)
- ^1, ^2, ^Part, ^AltP, ^Pet, ^Res (TEXT)
- Case_Type (TEXT)
- Case_Code (TEXT)
- Section (TEXT)
- Case_Number (TEXT)
- Judgment_Date (DATE)
- Valuation_Date (DATE)
- Married_On (DATE)
- Percent_Awarded (DECIMAL)
- Ven_City (TEXT)
- Ven_Cnty (TEXT)
- Ven_St (TEXT)
- Draft_Out (DATE)
- Draft_Apr (DATE)
- Final_Out (DATE)
- Judge (TEXT)
- Form_Name (TEXT)
### PLANINFO
- Plan_Id (TEXT, pk)
- Plan_Name (TEXT)
- Plan_Type (TEXT)
- Empl_Id_No (TEXT)
- Plan_No (TEXT)
- NRA (TEXT)
- ERA (TEXT)
- ERRF (TEXT)
- COLAS (TEXT)
- Divided_By (TEXT)
- Drafted (TEXT)
- Benefit_C (TEXT)
- QDRO_C (TEXT)
- ^REV (TEXT)
- ^PA (TEXT)
- Form_Name (TEXT)
- Drafted_On (DATE)
- Memo (TEXT)
### PENSIONS (and related)
- PENSIONS
- File_No (TEXT)
- Version (TEXT)
- Plan_Id (TEXT)
- Plan_Name (TEXT)
- Title (TEXT)
- First (TEXT)
- Last (TEXT)
- Birth (DATE)
- Race (TEXT)
- Sex (TEXT)
- Info (DATE)
- Valu (DATE)
- Accrued (DECIMAL)
- Vested_Per (DECIMAL)
- Start_Age (DECIMAL)
- COLA (DECIMAL)
- Max_COLA (DECIMAL)
- Withdrawal (DECIMAL)
- Pre_DR (DECIMAL)
- Post_DR (DECIMAL)
- Tax_Rate (DECIMAL)
- RESULTS
- Accrued, Start_Age, COLA, Withdrawal, Pre_DR, Post_DR, Tax_Rate,
- Age, Years_From, Life_Exp, EV_Monthly, Payments, Pay_Out,
- Fund_Value, PV, Mortality, PV_AM, PV_AMT, PV_Pre_DB, PV_Annuity,
- WV_AT, PV_Plan, Years_Married, Years_Service, Marr_Per, Marr_Amt
- MARRIAGE
- File_No, Version, Married_From, Married_To, Married_Years,
- Service_From, Service_To, Service_Years, Marital_%
- DEATH
- File_No, Version, Lump1, Lump2, Growth1, Growth2, Disc1, Disc2
- SCHEDULE
- File_No, Version, Vests_On (DATE), Vests_At (DECIMAL)
- SEPARATE
- File_No, Version, Separation_Rate (DECIMAL)
- Life Tables (reference data)
- FORMS/LIFETABL, FORMS/NUMBERAL, PENSIONS/LIFETABL, PENSIONS/NUMBERAL

Binary file not shown.

View File

@@ -50,6 +50,24 @@ document.addEventListener('DOMContentLoaded', function() {
} }
}); });
}); });
// Auto-compute Amount = Quantity × Rate in ledger add form
var qtyInput = document.querySelector('form[action*="/ledger"] .js-qty');
var rateInput = document.querySelector('form[action*="/ledger"] .js-rate');
var amountInput = document.querySelector('form[action*="/ledger"] .js-amount');
function recomputeAmount() {
if (!qtyInput || !rateInput || !amountInput) return;
var q = parseFloat(qtyInput.value);
var r = parseFloat(rateInput.value);
if (!isNaN(q) && !isNaN(r)) {
var amt = (q * r);
amountInput.value = amt.toFixed(2);
}
}
if (qtyInput) qtyInput.addEventListener('input', recomputeAmount);
if (rateInput) rateInput.addEventListener('input', recomputeAmount);
}); });
// Utility functions // Utility functions