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:
HotSwapp
2025-10-07 09:26:58 -05:00
parent f9c3b3cc9c
commit 950d261eb4
13 changed files with 496 additions and 19 deletions

Binary file not shown.

Binary file not shown.

View File

@@ -12,7 +12,8 @@ from .models import User
from .database import SessionLocal from .database import SessionLocal
# Configure password hashing context # Configure password hashing context
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") # Prefer pbkdf2_sha256 for portability; include bcrypt for legacy compatibility
pwd_context = CryptContext(schemes=["pbkdf2_sha256", "bcrypt"], deprecated="auto")
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -140,6 +141,23 @@ def seed_admin_user() -> None:
# Check if admin user already exists # Check if admin user already exists
existing_admin = db.query(User).filter(User.username == admin_username).first() existing_admin = db.query(User).filter(User.username == admin_username).first()
if existing_admin: if existing_admin:
# 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") logger.info(f"Admin user '{admin_username}' already exists")
return return

View File

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

View File

@@ -22,7 +22,7 @@ from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from sqlalchemy.orm import Session, joinedload from sqlalchemy.orm import Session, joinedload
from sqlalchemy import or_, and_ from sqlalchemy import or_, and_, func as sa_func
from dotenv import load_dotenv from dotenv import load_dotenv
from starlette.middleware.base import BaseHTTPMiddleware from starlette.middleware.base import BaseHTTPMiddleware
import structlog import structlog
@@ -611,13 +611,39 @@ def import_ledger_data(db: Session, file_path: str) -> Dict[str, Any]:
result['errors'].append(f"Row {row_num}: Invalid amount") result['errors'].append(f"Row {row_num}: Invalid amount")
continue continue
tx_date = parse_date(row.get('Date', ''))
item_no = parse_int(row.get('Item_No', '') or '')
# ensure unique item_no per date by increment
# temp session-less check via while loop
desired_item_no = item_no if item_no is not None else 1
while True:
exists = (
db.query(Transaction)
.filter(
Transaction.case_id == case.id,
Transaction.transaction_date == tx_date,
Transaction.item_no == desired_item_no,
)
.first()
)
if not exists:
break
desired_item_no += 1
transaction = Transaction( transaction = Transaction(
case_id=case.id, case_id=case.id,
transaction_date=parse_date(row.get('Date', '')), transaction_date=tx_date,
transaction_type=row.get('T_Type', '').strip() or None, transaction_type=(row.get('T_Type', '').strip() or None),
t_type_l=(row.get('T_Type_L', '').strip().upper() or None),
amount=amount, amount=amount,
description=row.get('Note', '').strip() or None, description=(row.get('Note', '').strip() or None),
reference=row.get('Item_No', '').strip() or None reference=(row.get('Item_No', '').strip() or None),
item_no=desired_item_no,
employee_number=(row.get('Empl_Num', '').strip() or None),
t_code=(row.get('T_Code', '').strip().upper() or None),
quantity=parse_float(row.get('Quantity', '')),
rate=parse_float(row.get('Rate', '')),
billed=((row.get('Billed', '') or '').strip().upper() or None),
) )
db.add(transaction) db.add(transaction)
@@ -791,6 +817,271 @@ def process_csv_import(db: Session, import_type: str, file_path: str) -> Dict[st
return import_func(db, file_path) return import_func(db, file_path)
# ------------------------------
# Ledger CRUD and helpers
# ------------------------------
def validate_ledger_fields(
*,
transaction_date: Optional[str],
t_code: Optional[str],
employee_number: Optional[str],
quantity: Optional[str],
rate: Optional[str],
amount: Optional[str],
billed: Optional[str],
) -> tuple[list[str], dict[str, Any]]:
"""Validate incoming ledger form fields and return (errors, parsed_values)."""
errors: list[str] = []
parsed: dict[str, Any] = {}
# Date
tx_dt = parse_date(transaction_date or "") if transaction_date is not None else None
if tx_dt is None:
errors.append("Date is required and must be valid")
else:
parsed["transaction_date"] = tx_dt
# T_Code
if t_code is None or not t_code.strip():
errors.append("T_Code is required")
else:
parsed["t_code"] = t_code.strip().upper()
# Employee number
if employee_number is None or not employee_number.strip():
errors.append("Empl_Num is required")
else:
parsed["employee_number"] = employee_number.strip()
# Quantity, Rate, Amount
qty = parse_float(quantity or "") if quantity is not None else None
rt = parse_float(rate or "") if rate is not None else None
amt = parse_float(amount or "") if amount is not None else None
if qty is not None:
parsed["quantity"] = qty
if rt is not None:
parsed["rate"] = rt
# Auto-compute amount if missing but quantity and rate present
if amt is None and qty is not None and rt is not None:
amt = round(qty * rt, 2)
if amt is None:
errors.append("Amount is required or derivable from Quantity × Rate")
else:
parsed["amount"] = amt
# Billed flag
billed_flag = (billed or "").strip().upper() if billed is not None else ""
if billed_flag not in ("Y", "N"):
errors.append("Billed must be 'Y' or 'N'")
else:
parsed["billed"] = billed_flag
return errors, parsed
def next_unique_item_no(db: Session, case_id: int, tx_date: datetime, desired_item_no: Optional[int]) -> int:
"""Ensure (transaction_date, item_no) uniqueness per case by incrementing if needed."""
# Start at provided item_no or at 1 if missing
item_no = int(desired_item_no) if desired_item_no is not None else 1
while True:
exists = (
db.query(Transaction)
.filter(
Transaction.case_id == case_id,
Transaction.transaction_date == tx_date,
Transaction.item_no == item_no,
)
.first()
)
if not exists:
return item_no
item_no += 1
def compute_case_totals_from_case(case_obj: Case) -> Dict[str, float]:
"""
Compute simple totals for a case from its transactions.
Returns billed, unbilled, and total sums. Amounts are treated as positive;
future enhancement could apply sign based on t_type_l.
"""
billed_total = 0.0
unbilled_total = 0.0
overall_total = 0.0
for t in (case_obj.transactions or []):
amt = float(t.amount) if t.amount is not None else 0.0
overall_total += amt
if (t.billed or '').upper() == 'Y':
billed_total += amt
else:
unbilled_total += amt
return {
'billed_total': round(billed_total, 2),
'unbilled_total': round(unbilled_total, 2),
'overall_total': round(overall_total, 2),
}
@app.post("/case/{case_id}/ledger")
async def ledger_create(
request: Request,
case_id: int,
db: Session = Depends(get_db),
):
user = get_current_user_from_session(request.session)
if not user:
return RedirectResponse(url="/login", status_code=302)
form = await request.form()
# Validate
errors, parsed = validate_ledger_fields(
transaction_date=form.get("transaction_date"),
t_code=form.get("t_code"),
employee_number=form.get("employee_number"),
quantity=form.get("quantity"),
rate=form.get("rate"),
amount=form.get("amount"),
billed=form.get("billed"),
)
if errors:
request.session["case_update_errors"] = errors
return RedirectResponse(url=f"/case/{case_id}", status_code=302)
# Ensure case exists
case_obj = db.query(Case).filter(Case.id == case_id).first()
if not case_obj:
request.session["case_update_errors"] = ["Case not found"]
return RedirectResponse(url=f"/case/{case_id}", status_code=302)
# Assign optional fields
t_type = (form.get("transaction_type") or "").strip() or None
t_type_l = (form.get("t_type_l") or "").strip().upper() or None
reference = (form.get("reference") or "").strip() or None
desc = (form.get("description") or "").strip() or None
desired_item_no = parse_int(form.get("item_no") or "")
item_no = next_unique_item_no(db, case_id, parsed["transaction_date"], desired_item_no)
try:
tx = Transaction(
case_id=case_id,
transaction_date=parsed["transaction_date"],
transaction_type=t_type,
t_type_l=t_type_l,
amount=parsed["amount"],
description=desc,
reference=reference,
item_no=item_no,
employee_number=parsed["employee_number"],
t_code=parsed["t_code"],
quantity=parsed.get("quantity"),
rate=parsed.get("rate"),
billed=parsed["billed"],
)
db.add(tx)
db.commit()
logger.info("ledger_create", case_id=case_id, transaction_id=tx.id)
return RedirectResponse(url=f"/case/{case_id}?saved=1", status_code=302)
except Exception as e:
db.rollback()
logger.error("ledger_create_failed", case_id=case_id, error=str(e))
request.session["case_update_errors"] = ["Failed to create ledger entry"]
return RedirectResponse(url=f"/case/{case_id}", status_code=302)
@app.post("/case/{case_id}/ledger/{tx_id}")
async def ledger_update(
request: Request,
case_id: int,
tx_id: int,
db: Session = Depends(get_db),
):
user = get_current_user_from_session(request.session)
if not user:
return RedirectResponse(url="/login", status_code=302)
form = await request.form()
tx = db.query(Transaction).filter(Transaction.id == tx_id, Transaction.case_id == case_id).first()
if not tx:
request.session["case_update_errors"] = ["Ledger entry not found"]
return RedirectResponse(url=f"/case/{case_id}", status_code=302)
errors, parsed = validate_ledger_fields(
transaction_date=form.get("transaction_date"),
t_code=form.get("t_code"),
employee_number=form.get("employee_number"),
quantity=form.get("quantity"),
rate=form.get("rate"),
amount=form.get("amount"),
billed=form.get("billed"),
)
if errors:
request.session["case_update_errors"] = errors
return RedirectResponse(url=f"/case/{case_id}", status_code=302)
try:
tx.transaction_date = parsed["transaction_date"]
# Ensure uniqueness of (date, item_no)
desired_item_no = parse_int(form.get("item_no") or "") or tx.item_no
tx.item_no = next_unique_item_no(db, case_id, parsed["transaction_date"], desired_item_no)
tx.t_code = parsed["t_code"]
tx.employee_number = parsed["employee_number"]
tx.quantity = parsed.get("quantity")
tx.rate = parsed.get("rate")
tx.amount = parsed["amount"]
tx.billed = parsed["billed"]
tx.transaction_type = (form.get("transaction_type") or "").strip() or None
tx.t_type_l = (form.get("t_type_l") or "").strip().upper() or None
tx.reference = (form.get("reference") or "").strip() or None
tx.description = (form.get("description") or "").strip() or None
db.commit()
logger.info("ledger_update", case_id=case_id, transaction_id=tx.id)
return RedirectResponse(url=f"/case/{case_id}?saved=1", status_code=302)
except Exception as e:
db.rollback()
logger.error("ledger_update_failed", case_id=case_id, tx_id=tx_id, error=str(e))
request.session["case_update_errors"] = ["Failed to update ledger entry"]
return RedirectResponse(url=f"/case/{case_id}", status_code=302)
@app.post("/case/{case_id}/ledger/{tx_id}/delete")
async def ledger_delete(
request: Request,
case_id: int,
tx_id: int,
db: Session = Depends(get_db),
):
user = get_current_user_from_session(request.session)
if not user:
return RedirectResponse(url="/login", status_code=302)
tx = db.query(Transaction).filter(Transaction.id == tx_id, Transaction.case_id == case_id).first()
if not tx:
request.session["case_update_errors"] = ["Ledger entry not found"]
return RedirectResponse(url=f"/case/{case_id}", status_code=302)
try:
db.delete(tx)
db.commit()
logger.info("ledger_delete", case_id=case_id, transaction_id=tx_id)
return RedirectResponse(url=f"/case/{case_id}?saved=1", status_code=302)
except Exception as e:
db.rollback()
logger.error("ledger_delete_failed", case_id=case_id, tx_id=tx_id, error=str(e))
request.session["case_update_errors"] = ["Failed to delete ledger entry"]
return RedirectResponse(url=f"/case/{case_id}", status_code=302)
@app.get("/") @app.get("/")
async def root(): async def root():
""" """
@@ -1277,6 +1568,18 @@ async def case_detail(
# Get any errors from session and clear them # Get any errors from session and clear them
errors = request.session.pop("case_update_errors", None) errors = request.session.pop("case_update_errors", None)
# Sort transactions by date then item_no for stable display
sorted_transactions = sorted(
case_obj.transactions or [],
key=lambda t: (
t.transaction_date or datetime.min,
t.item_no or 0,
)
)
case_obj.transactions = sorted_transactions
totals = compute_case_totals_from_case(case_obj)
return templates.TemplateResponse( return templates.TemplateResponse(
"case.html", "case.html",
{ {
@@ -1285,6 +1588,7 @@ async def case_detail(
"case": case_obj, "case": case_obj,
"saved": saved, "saved": saved,
"errors": errors or [], "errors": errors or [],
"totals": totals,
}, },
) )

View File

@@ -123,10 +123,20 @@ class Transaction(Base):
id = Column(Integer, primary_key=True, index=True) id = Column(Integer, primary_key=True, index=True)
case_id = Column(Integer, ForeignKey("cases.id"), nullable=False) case_id = Column(Integer, ForeignKey("cases.id"), nullable=False)
transaction_date = Column(DateTime(timezone=True)) transaction_date = Column(DateTime(timezone=True))
transaction_type = Column(String(20)) # Legacy/basic fields
transaction_type = Column(String(20)) # Maps to legacy T_Type
amount = Column(Float) amount = Column(Float)
description = Column(Text) description = Column(Text) # Maps to legacy Note
reference = Column(String(50)) reference = Column(String(50)) # Previously used for Item_No
# Ledger-specific fields (added for File Cabinet MVP)
item_no = Column(Integer)
employee_number = Column(String(20)) # Empl_Num
t_code = Column(String(10)) # T_Code
t_type_l = Column(String(1)) # T_Type_L (Credit/Debit marker)
quantity = Column(Float)
rate = Column(Float)
billed = Column(String(1)) # 'Y' or 'N'
created_at = Column(DateTime(timezone=True), server_default=func.now()) created_at = Column(DateTime(timezone=True), server_default=func.now())
# Relationships # Relationships

View File

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

View File

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

BIN
delphi.db

Binary file not shown.

Binary file not shown.

View File

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