Compare commits
4 Commits
d456ae4f39
...
c68ba45ceb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c68ba45ceb | ||
|
|
432f303a33 | ||
|
|
950d261eb4 | ||
|
|
f9c3b3cc9c |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
22
app/auth.py
22
app/auth.py
@@ -12,7 +12,8 @@ from .models import User
|
||||
from .database import SessionLocal
|
||||
|
||||
# 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__)
|
||||
|
||||
@@ -140,7 +141,24 @@ def seed_admin_user() -> None:
|
||||
# Check if admin user already exists
|
||||
existing_admin = db.query(User).filter(User.username == admin_username).first()
|
||||
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
|
||||
|
||||
# Create admin user
|
||||
|
||||
@@ -12,6 +12,7 @@ from sqlalchemy import create_engine
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
from sqlalchemy.orm import sessionmaker, Session
|
||||
from dotenv import load_dotenv
|
||||
from sqlalchemy import inspect, text
|
||||
|
||||
# Load environment variables from .env file
|
||||
load_dotenv()
|
||||
@@ -68,6 +69,42 @@ def create_tables() -> None:
|
||||
"""
|
||||
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
|
||||
try:
|
||||
from .auth import seed_admin_user
|
||||
|
||||
723
app/main.py
723
app/main.py
@@ -16,13 +16,13 @@ from typing import Optional, List, Dict, Any
|
||||
from io import StringIO
|
||||
|
||||
from fastapi import FastAPI, Depends, Request, Query, HTTPException, UploadFile, File, Form
|
||||
from fastapi.responses import RedirectResponse
|
||||
from fastapi.responses import RedirectResponse, Response
|
||||
from starlette.middleware.sessions import SessionMiddleware
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.templating import Jinja2Templates
|
||||
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 starlette.middleware.base import BaseHTTPMiddleware
|
||||
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")
|
||||
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(
|
||||
case_id=case.id,
|
||||
transaction_date=parse_date(row.get('Date', '')),
|
||||
transaction_type=row.get('T_Type', '').strip() or None,
|
||||
transaction_date=tx_date,
|
||||
transaction_type=(row.get('T_Type', '').strip() or None),
|
||||
t_type_l=(row.get('T_Type_L', '').strip().upper() or None),
|
||||
amount=amount,
|
||||
description=row.get('Note', '').strip() or None,
|
||||
reference=row.get('Item_No', '').strip() or None
|
||||
description=(row.get('Note', '').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)
|
||||
@@ -791,12 +817,277 @@ def process_csv_import(db: Session, import_type: str, file_path: str) -> Dict[st
|
||||
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("/")
|
||||
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")
|
||||
@@ -1277,6 +1568,18 @@ async def case_detail(
|
||||
# Get any errors from session and clear them
|
||||
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(
|
||||
"case.html",
|
||||
{
|
||||
@@ -1285,6 +1588,7 @@ async def case_detail(
|
||||
"case": case_obj,
|
||||
"saved": saved,
|
||||
"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."]
|
||||
|
||||
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 []},
|
||||
)
|
||||
|
||||
483
app/models.py
483
app/models.py
@@ -4,7 +4,7 @@ SQLAlchemy models for the Delphi database.
|
||||
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.ext.declarative import declarative_base
|
||||
from sqlalchemy.sql import func
|
||||
@@ -123,10 +123,20 @@ class Transaction(Base):
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
case_id = Column(Integer, ForeignKey("cases.id"), nullable=False)
|
||||
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)
|
||||
description = Column(Text)
|
||||
reference = Column(String(50))
|
||||
description = Column(Text) # Maps to legacy Note
|
||||
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())
|
||||
|
||||
# Relationships
|
||||
@@ -208,3 +218,468 @@ class ImportLog(Base):
|
||||
|
||||
def __repr__(self):
|
||||
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"),
|
||||
)
|
||||
|
||||
@@ -37,6 +37,12 @@
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if 'dashboard' in request.url.path %}active{% endif %}" href="/dashboard">Dashboard</a>
|
||||
</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">
|
||||
<a class="nav-link {% if 'admin' in request.url.path %}active{% endif %}" href="/admin">Admin</a>
|
||||
</li>
|
||||
|
||||
@@ -180,17 +180,77 @@ Case {{ case.file_no if case else '' }} · Delphi Database
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-xl-4">
|
||||
<div class="col-xl-8">
|
||||
<div class="card h-100">
|
||||
<div class="card-header">Transactions</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm mb-0 align-middle">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<span>Ledger</span>
|
||||
<div class="small text-muted">
|
||||
<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">
|
||||
<tr>
|
||||
<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 style="width: 70px;">Billed</th>
|
||||
<th>Description</th>
|
||||
<th class="text-end" style="width: 120px;">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -198,12 +258,35 @@ Case {{ case.file_no if case else '' }} · Delphi Database
|
||||
{% for t in case.transactions %}
|
||||
<tr>
|
||||
<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>{{ 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>
|
||||
{% endfor %}
|
||||
{% 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 %}
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -275,6 +358,13 @@ Case {{ case.file_no if case else '' }} · Delphi Database
|
||||
</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 %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
106
app/templates/payments.html
Normal file
106
app/templates/payments.html
Normal 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 %}">
|
||||
«
|
||||
</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 %}">
|
||||
»
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
|
||||
61
app/templates/report_phone_book.html
Normal file
61
app/templates/report_phone_book.html
Normal 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
153
app/templates/rolodex.html
Normal 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">«</span>
|
||||
</a>
|
||||
</li>
|
||||
{% for p in page_numbers %}
|
||||
<li class="page-item {% if p == page %}active{% endif %}">
|
||||
<a class="page-link" href="/rolodex?page={{ p }}&page_size={{ page_size }}{% if q %}&q={{ q | urlencode }}{% endif %}{% if phone %}&phone={{ phone | urlencode }}{% endif %}">{{ p }}</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
<li class="page-item {% if page >= total_pages %}disabled{% endif %}">
|
||||
<a class="page-link" href="/rolodex?page={{ page + 1 if page < total_pages else total_pages }}&page_size={{ page_size }}{% if q %}&q={{ q | urlencode }}{% endif %}{% if phone %}&phone={{ phone | urlencode }}{% endif %}" aria-label="Next">
|
||||
<span aria-hidden="true">»</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
{% endif %}
|
||||
</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 %}
|
||||
|
||||
|
||||
71
app/templates/rolodex_edit.html
Normal file
71
app/templates/rolodex_edit.html
Normal 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 %}
|
||||
|
||||
|
||||
170
app/templates/rolodex_view.html
Normal file
170
app/templates/rolodex_view.html
Normal 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 %}
|
||||
|
||||
|
||||
@@ -2,4 +2,4 @@
|
||||
# https://curl.se/docs/http-cookies.html
|
||||
# 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
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
delphi-db:
|
||||
build: .
|
||||
|
||||
262
docs/legacy-schema.md
Normal file
262
docs/legacy-schema.md
Normal 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
|
||||
|
||||
|
||||
BIN
old-database/Table Structures (Some).pdf
Normal file
BIN
old-database/Table Structures (Some).pdf
Normal file
Binary file not shown.
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user