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.
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
20
app/auth.py
20
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,6 +141,23 @@ 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:
|
||||
# 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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
314
app/main.py
314
app/main.py
@@ -22,7 +22,7 @@ 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_, and_
|
||||
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,6 +817,271 @@ 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():
|
||||
"""
|
||||
@@ -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,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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
|
||||
|
||||
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