feat: Phase 4.3 queue + line item polish
- migrated line item queue to generic document line items - added detected-count line item rows with add-row - restored rerun OCR in OCR review tab - improved line item dates and title case
This commit is contained in:
parent
bb8cde4c47
commit
fcce99a091
|
|
@ -12,6 +12,8 @@ from app.models.document import Document
|
|||
from app.models.extracted_field import ExtractedField
|
||||
from app.models.receipt_line_item import ReceiptLineItem
|
||||
from app.models.text_version import TextVersion
|
||||
from app.models.document_line_item import DocumentLineItem
|
||||
from app.models.document_line_item_set import DocumentLineItemSet
|
||||
|
||||
|
||||
MONEY_RE = re.compile(r"(?<!\d)([0-9]+(?:\.[0-9]{2}))(?!\d)")
|
||||
|
|
@ -467,6 +469,15 @@ def _normalize_item_description(text: str) -> str:
|
|||
return cleaned.title()
|
||||
|
||||
|
||||
|
||||
def _to_title_case(text: str | None) -> str | None:
|
||||
if text is None:
|
||||
return None
|
||||
cleaned = str(text).strip()
|
||||
if not cleaned:
|
||||
return None
|
||||
return cleaned.title()
|
||||
|
||||
def _clean_item_description(text: str) -> str:
|
||||
cleaned = re.sub(r"\s+", " ", text.strip())
|
||||
cleaned = cleaned.strip("-: ")
|
||||
|
|
@ -741,6 +752,44 @@ def _extract_receipt_line_items(lines: list[DocumentLine]) -> list[dict]:
|
|||
return items
|
||||
|
||||
|
||||
|
||||
def _replace_document_line_items(db: Session, document: Document, items: list[dict]) -> None:
|
||||
line_item_set = getattr(document, "line_item_set", None)
|
||||
extracted = get_current_extracted_fields(document)
|
||||
default_entry_date = extracted.transaction_date if extracted and extracted.transaction_date else None
|
||||
if line_item_set is None:
|
||||
line_item_set = DocumentLineItemSet(
|
||||
document_id=document.id,
|
||||
schema_type=document.document_type or "generic",
|
||||
)
|
||||
db.add(line_item_set)
|
||||
db.flush()
|
||||
document.line_item_set = line_item_set
|
||||
|
||||
line_item_set.schema_type = document.document_type or "generic"
|
||||
|
||||
existing_items = list(getattr(line_item_set, "items", []) or [])
|
||||
for item in existing_items:
|
||||
db.delete(item)
|
||||
db.flush()
|
||||
|
||||
for idx, item in enumerate(items, start=1):
|
||||
db.add(
|
||||
DocumentLineItem(
|
||||
line_item_set_id=line_item_set.id,
|
||||
line_number=idx,
|
||||
entry_date=default_entry_date,
|
||||
description=_to_title_case(item.get("raw_description") or item.get("normalized_description") or None),
|
||||
quantity=_to_decimal(item.get("quantity")),
|
||||
unit_price=_to_decimal(item.get("unit_price")),
|
||||
line_total=_to_decimal(item.get("line_total")),
|
||||
tax_amount=None,
|
||||
category=item.get("item_category") or None,
|
||||
notes=None,
|
||||
raw_json=item.get("extra_json") or {},
|
||||
)
|
||||
)
|
||||
|
||||
def _replace_receipt_line_items(db: Session, document: Document, items: list[dict]) -> None:
|
||||
existing_items = list(getattr(document, "receipt_line_items", []) or [])
|
||||
for item in existing_items:
|
||||
|
|
@ -884,8 +933,10 @@ def save_extracted_fields(
|
|||
line_items = parsed_extra.get("line_items", [])
|
||||
if isinstance(line_items, list):
|
||||
_replace_receipt_line_items(db, document, line_items)
|
||||
_replace_document_line_items(db, document, line_items)
|
||||
else:
|
||||
_replace_receipt_line_items(db, document, [])
|
||||
_replace_document_line_items(db, document, [])
|
||||
|
||||
db.commit()
|
||||
db.refresh(current)
|
||||
|
|
|
|||
|
|
@ -31,6 +31,10 @@ from app.logic.extraction import (
|
|||
)
|
||||
from app.logic.ingest import compute_quality_score, rerun_ocr_for_document
|
||||
from app.models.document import Document
|
||||
from app.models.document_line_item import DocumentLineItem
|
||||
from app.models.document_line_item_set import DocumentLineItemSet
|
||||
from app.models.document_line_item_set_version import DocumentLineItemSetVersion
|
||||
from app.models.document_line_item_version_item import DocumentLineItemVersionItem
|
||||
from app.models.document_additional_field import DocumentAdditionalField
|
||||
from app.models.document_additional_field_version import DocumentAdditionalFieldVersion
|
||||
from app.models.extracted_field_version import ExtractedFieldVersion
|
||||
|
|
@ -877,7 +881,7 @@ def save_document_type_route(
|
|||
document.document_type = document_type.strip() or None
|
||||
db.commit()
|
||||
|
||||
return RedirectResponse(url=f"/documents/{document.document_id}", status_code=303)
|
||||
return RedirectResponse(url=f"/documents/{document.document_id}?tab=ocr-review&success=rerun_ocr", status_code=303)
|
||||
|
||||
|
||||
@router.post("/{document_id}/rerun-ocr", response_class=RedirectResponse)
|
||||
|
|
@ -1249,6 +1253,108 @@ def save_additional_fields_route(
|
|||
)
|
||||
|
||||
|
||||
|
||||
@router.post("/{document_id}/save-line-items", response_class=RedirectResponse)
|
||||
async def save_line_items(
|
||||
document_id: str,
|
||||
request: Request,
|
||||
row_count: int = Form(...),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
document = (
|
||||
db.query(Document)
|
||||
.options(
|
||||
selectinload(Document.line_item_set).selectinload(DocumentLineItemSet.items),
|
||||
selectinload(Document.line_item_set_versions),
|
||||
)
|
||||
.filter(Document.document_id == document_id)
|
||||
.first()
|
||||
)
|
||||
if document is None:
|
||||
return RedirectResponse(url="/documents/", status_code=303)
|
||||
|
||||
form = await request.form()
|
||||
|
||||
if document.line_item_set is None:
|
||||
document.line_item_set = DocumentLineItemSet(
|
||||
document_id=document.id,
|
||||
schema_type=document.document_type or "generic",
|
||||
)
|
||||
db.add(document.line_item_set)
|
||||
db.flush()
|
||||
|
||||
document.line_item_set.schema_type = document.document_type or "generic"
|
||||
document.line_item_set.items.clear()
|
||||
db.flush()
|
||||
|
||||
for i in range(row_count):
|
||||
entry_date = (form.get(f"entry_date_{i}") or "").strip()
|
||||
description = (form.get(f"description_{i}") or "").strip()
|
||||
quantity = (form.get(f"quantity_{i}") or "").strip()
|
||||
unit_price = (form.get(f"unit_price_{i}") or "").strip()
|
||||
line_total = (form.get(f"line_total_{i}") or "").strip()
|
||||
tax_amount = (form.get(f"tax_amount_{i}") or "").strip()
|
||||
category = (form.get(f"category_{i}") or "").strip()
|
||||
notes = (form.get(f"notes_{i}") or "").strip()
|
||||
|
||||
if not any([entry_date, description, quantity, unit_price, line_total, tax_amount, category, notes]):
|
||||
continue
|
||||
|
||||
item = DocumentLineItem(
|
||||
line_item_set_id=document.line_item_set.id,
|
||||
line_number=i + 1,
|
||||
entry_date=datetime.strptime(entry_date, "%Y-%m-%d").date() if entry_date else None,
|
||||
description=description or None,
|
||||
quantity=Decimal(quantity) if quantity else None,
|
||||
unit_price=Decimal(unit_price) if unit_price else None,
|
||||
line_total=Decimal(line_total) if line_total else None,
|
||||
tax_amount=Decimal(tax_amount) if tax_amount else None,
|
||||
category=category or None,
|
||||
notes=notes or None,
|
||||
)
|
||||
db.add(item)
|
||||
|
||||
db.flush()
|
||||
|
||||
next_version = max([v.version_number for v in document.line_item_set_versions], default=0) + 1
|
||||
version = DocumentLineItemSetVersion(
|
||||
document_id=document.id,
|
||||
version_number=next_version,
|
||||
schema_type=document.line_item_set.schema_type,
|
||||
created_by="save_line_items",
|
||||
notes="Saved line items from document detail tab.",
|
||||
)
|
||||
db.add(version)
|
||||
db.flush()
|
||||
|
||||
current_items = (
|
||||
db.query(DocumentLineItem)
|
||||
.filter(DocumentLineItem.line_item_set_id == document.line_item_set.id)
|
||||
.order_by(DocumentLineItem.line_number.asc())
|
||||
.all()
|
||||
)
|
||||
|
||||
for item in current_items:
|
||||
db.add(DocumentLineItemVersionItem(
|
||||
set_version_id=version.id,
|
||||
line_number=item.line_number,
|
||||
entry_date=item.entry_date,
|
||||
description=item.description,
|
||||
quantity=item.quantity,
|
||||
unit_price=item.unit_price,
|
||||
line_total=item.line_total,
|
||||
tax_amount=item.tax_amount,
|
||||
category=item.category,
|
||||
notes=item.notes,
|
||||
raw_json=item.raw_json,
|
||||
))
|
||||
|
||||
db.commit()
|
||||
return RedirectResponse(
|
||||
url=f"/documents/{document.document_id}?tab=line-items",
|
||||
status_code=303,
|
||||
)
|
||||
|
||||
@router.get("/{document_id}/preview-file")
|
||||
def document_preview_file(document_id: str, db: Session = Depends(get_db)):
|
||||
document = db.query(Document).filter(Document.document_id == document_id).first()
|
||||
|
|
@ -1330,6 +1436,14 @@ def document_detail(document_id: str, request: Request, queue: str | None = None
|
|||
current_additional = _get_current_additional_fields(document)
|
||||
current_extracted_version_number = _get_current_extracted_version_number(document)
|
||||
current_additional_version_number = _get_current_additional_version_number(document)
|
||||
|
||||
line_items = []
|
||||
if document.line_item_set and document.line_item_set.items:
|
||||
line_items = sorted(
|
||||
document.line_item_set.items,
|
||||
key=lambda x: x.line_number or 0,
|
||||
)
|
||||
|
||||
queue_nav = _get_queue_navigation(db, document)
|
||||
|
||||
naming_row = document.naming_fields[0] if getattr(document, "naming_fields", None) else None
|
||||
|
|
@ -1350,6 +1464,13 @@ def document_detail(document_id: str, request: Request, queue: str | None = None
|
|||
file_exists = _version_file_available(version, document.document_id)
|
||||
version_rows.append((version, file_exists))
|
||||
|
||||
current_line_item_version = None
|
||||
if document.line_item_set_versions:
|
||||
current_line_item_version = max(
|
||||
document.line_item_set_versions,
|
||||
key=lambda v: (v.version_number, v.created_at),
|
||||
)
|
||||
|
||||
ocr_version_options = [
|
||||
(v.version_number, v.version_type, v.created_at)
|
||||
for v in sorted(getattr(document, "text_versions", []), key=lambda v: v.version_number, reverse=True)
|
||||
|
|
@ -1386,6 +1507,7 @@ def document_detail(document_id: str, request: Request, queue: str | None = None
|
|||
"file_url": file_url,
|
||||
"storage_available": storage_available,
|
||||
"version_rows": version_rows,
|
||||
"current_line_item_version": current_line_item_version,
|
||||
"ocr_version_options": ocr_version_options,
|
||||
"extracted_version_options": extracted_version_options,
|
||||
"additional_version_options": additional_version_options,
|
||||
|
|
@ -1406,6 +1528,7 @@ def document_detail(document_id: str, request: Request, queue: str | None = None
|
|||
"additional_form": additional_form,
|
||||
"current_additional": current_additional,
|
||||
"current_additional_version_number": current_additional_version_number,
|
||||
"line_items": line_items,
|
||||
"presets": all_presets,
|
||||
"selected_preset_id": preset_id,
|
||||
"existing_document_types": existing_document_types,
|
||||
|
|
|
|||
|
|
@ -10,7 +10,8 @@ from sqlalchemy.orm import Session, selectinload
|
|||
from app.db.deps import get_db
|
||||
from app.logic.extraction import get_current_extracted_fields
|
||||
from app.models.document import Document
|
||||
from app.models.receipt_line_item import ReceiptLineItem
|
||||
from app.models.document_line_item import DocumentLineItem
|
||||
from app.models.document_line_item_set import DocumentLineItemSet
|
||||
|
||||
router = APIRouter(prefix="/line-items", tags=["line-items"])
|
||||
|
||||
|
|
@ -36,27 +37,27 @@ def _to_decimal(value: str | None) -> Decimal | None:
|
|||
return None
|
||||
|
||||
|
||||
def _line_item_extra(item: ReceiptLineItem) -> dict:
|
||||
return dict(item.extra_json or {})
|
||||
def _line_item_extra(item: DocumentLineItem) -> dict:
|
||||
return dict(item.raw_json or {})
|
||||
|
||||
|
||||
def _line_item_quality_rating(item: ReceiptLineItem) -> str:
|
||||
def _line_item_quality_rating(item: DocumentLineItem) -> str:
|
||||
value = _line_item_extra(item).get("quality_rating")
|
||||
return "" if value is None else str(value)
|
||||
|
||||
|
||||
def _line_item_quality_note(item: ReceiptLineItem) -> str:
|
||||
def _line_item_quality_note(item: DocumentLineItem) -> str:
|
||||
value = _line_item_extra(item).get("quality_note")
|
||||
return "" if value is None else str(value)
|
||||
|
||||
|
||||
def _line_item_quality_status(item: ReceiptLineItem) -> str:
|
||||
def _line_item_quality_status(item: DocumentLineItem) -> str:
|
||||
value = _line_item_extra(item).get("quality_status")
|
||||
return "" if value is None else str(value)
|
||||
|
||||
|
||||
def _is_quality_queue_candidate(item: ReceiptLineItem) -> bool:
|
||||
if (item.item_category or "").lower() != "cocktail":
|
||||
def _is_quality_queue_candidate(item: DocumentLineItem) -> bool:
|
||||
if (item.category or "").lower() != "cocktail":
|
||||
return False
|
||||
|
||||
extra = _line_item_extra(item)
|
||||
|
|
@ -71,8 +72,9 @@ def _is_quality_queue_candidate(item: ReceiptLineItem) -> bool:
|
|||
return True
|
||||
|
||||
|
||||
def _build_row(item: ReceiptLineItem) -> dict | None:
|
||||
document = item.document
|
||||
def _build_row(item: DocumentLineItem) -> dict | None:
|
||||
line_item_set = item.line_item_set
|
||||
document = line_item_set.document if line_item_set is not None else None
|
||||
if document is None:
|
||||
return None
|
||||
|
||||
|
|
@ -89,6 +91,8 @@ def _build_row(item: ReceiptLineItem) -> dict | None:
|
|||
if extracted.transaction_date:
|
||||
transaction_date = extracted.transaction_date.isoformat()
|
||||
|
||||
if not transaction_date and item.entry_date:
|
||||
transaction_date = item.entry_date.isoformat()
|
||||
if not transaction_date and document.created_at:
|
||||
transaction_date = document.created_at.date().isoformat()
|
||||
|
||||
|
|
@ -97,31 +101,33 @@ def _build_row(item: ReceiptLineItem) -> dict | None:
|
|||
"document_id": document.document_id,
|
||||
"transaction_date": transaction_date,
|
||||
"merchant": merchant_value,
|
||||
"description": item.normalized_description or item.raw_description or "",
|
||||
"raw_description": item.raw_description or "",
|
||||
"description": item.description or "",
|
||||
"raw_description": item.description or "",
|
||||
"quantity": _decimal_to_str(item.quantity),
|
||||
"line_total": _decimal_to_str(item.line_total),
|
||||
"category": item.item_category or "",
|
||||
"confidence": _decimal_to_str(item.confidence),
|
||||
"category": item.category or "",
|
||||
"confidence": "",
|
||||
"quality_rating": _line_item_quality_rating(item),
|
||||
"quality_note": _line_item_quality_note(item),
|
||||
"quality_status": _line_item_quality_status(item),
|
||||
}
|
||||
|
||||
|
||||
def _load_all_items(db: Session) -> list[ReceiptLineItem]:
|
||||
def _load_all_items(db: Session) -> list[DocumentLineItem]:
|
||||
return (
|
||||
db.query(ReceiptLineItem)
|
||||
db.query(DocumentLineItem)
|
||||
.options(
|
||||
selectinload(ReceiptLineItem.document).selectinload(Document.extracted_fields)
|
||||
selectinload(DocumentLineItem.line_item_set)
|
||||
.selectinload(DocumentLineItemSet.document)
|
||||
.selectinload(Document.extracted_fields)
|
||||
)
|
||||
.order_by(ReceiptLineItem.id.desc())
|
||||
.order_by(DocumentLineItem.id.desc())
|
||||
.all()
|
||||
)
|
||||
|
||||
|
||||
def _build_filtered_rows(
|
||||
items: list[ReceiptLineItem],
|
||||
items: list[DocumentLineItem],
|
||||
q: str,
|
||||
merchant: str,
|
||||
category: str,
|
||||
|
|
@ -175,7 +181,7 @@ def _build_filtered_rows(
|
|||
return rows
|
||||
|
||||
|
||||
def _build_summary_rows(items: list[ReceiptLineItem], q: str) -> list[dict]:
|
||||
def _build_summary_rows(items: list[DocumentLineItem], q: str) -> list[dict]:
|
||||
q_norm = q.strip().lower()
|
||||
grouped: dict[str, dict] = {}
|
||||
|
||||
|
|
@ -257,7 +263,7 @@ def save_line_item_review(
|
|||
quality_status: str = Form(""),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
item = db.query(ReceiptLineItem).filter(ReceiptLineItem.id == line_item_id).first()
|
||||
item = db.query(DocumentLineItem).filter(DocumentLineItem.id == line_item_id).first()
|
||||
if item is None:
|
||||
return RedirectResponse(url="/line-items/", status_code=303)
|
||||
|
||||
|
|
@ -385,11 +391,13 @@ def quality_queue(
|
|||
db: Session = Depends(get_db),
|
||||
):
|
||||
items = (
|
||||
db.query(ReceiptLineItem)
|
||||
db.query(DocumentLineItem)
|
||||
.options(
|
||||
selectinload(ReceiptLineItem.document).selectinload(Document.extracted_fields)
|
||||
selectinload(DocumentLineItem.line_item_set)
|
||||
.selectinload(DocumentLineItemSet.document)
|
||||
.selectinload(Document.extracted_fields)
|
||||
)
|
||||
.order_by(ReceiptLineItem.id.asc())
|
||||
.order_by(DocumentLineItem.id.asc())
|
||||
.all()
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -19,6 +19,8 @@
|
|||
<div class="error-box">
|
||||
Could not save OCR-corrected PDF. Check that reviewed OCR line count matches raw OCR line count.
|
||||
</div>
|
||||
{% elif success == "rerun_ocr" %}
|
||||
<div class="success-message">OCR rerun successfully.</div>
|
||||
{% elif error == "rerun_ocr_failed" %}
|
||||
<div class="error-box">OCR rerun failed.</div>
|
||||
{% elif error == "save_field_enriched_failed" %}
|
||||
|
|
@ -137,6 +139,7 @@
|
|||
<button class="tab-button{% if active_tab == 'ocr-review' %} active{% endif %}" type="button" data-tab="ocr-review">OCR Review</button>
|
||||
<button class="tab-button{% if active_tab == 'extracted-fields' %} active{% endif %}" type="button" data-tab="extracted-fields">Extracted Fields</button>
|
||||
<button class="tab-button{% if active_tab == 'additional-fields' %} active{% endif %}" type="button" data-tab="additional-fields">Additional Fields</button>
|
||||
<button class="tab-button{% if active_tab == 'line-items' %} active{% endif %}" type="button" data-tab="line-items">Line Items</button>
|
||||
<button class="tab-button{% if active_tab == 'versions' %} active{% endif %}" type="button" data-tab="versions">Versions</button>
|
||||
<button class="tab-button{% if active_tab == 'raw-ocr' %} active{% endif %}" type="button" data-tab="raw-ocr">Raw OCR</button>
|
||||
<button class="tab-button{% if active_tab == 'source-options' %} active{% endif %}" type="button" data-tab="source-options">Source Options</button>
|
||||
|
|
@ -144,6 +147,12 @@
|
|||
|
||||
<div class="tab-panel{% if active_tab == 'ocr-review' %} active{% endif %}" data-panel="ocr-review">
|
||||
<h2 class="card-title">Reviewed OCR</h2>
|
||||
|
||||
<form method="post" action="/documents/{{ document.document_id }}/rerun-ocr" style="margin: 0.75rem 0;">
|
||||
<div class="button-row">
|
||||
<button type="submit">Rerun OCR</button>
|
||||
</div>
|
||||
</form>
|
||||
{% if current_text_version %}
|
||||
<p>Current OCR version: v{{ current_text_version.version_number }} — {{ current_text_version.version_type }} — {{ current_text_version.created_at }}</p>
|
||||
{% else %}
|
||||
|
|
@ -343,6 +352,93 @@
|
|||
</form>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="tab-panel{% if active_tab == 'line-items' %} active{% endif %}" data-panel="line-items">
|
||||
<h2 class="card-title">Line Items</h2>
|
||||
|
||||
{% if current_line_item_version %}
|
||||
<p>Current line item version: v{{ current_line_item_version.version_number }} — {{ current_line_item_version.created_at }}</p>
|
||||
{% else %}
|
||||
<p class="empty-state">No line items saved yet.</p>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action="/documents/{{ document.document_id }}/save-line-items">
|
||||
{% set base_count = line_items|length %}
|
||||
{% set row_count = base_count + 3 if base_count > 0 else 12 %}
|
||||
<input type="hidden" name="row_count" value="{{ row_count }}">
|
||||
|
||||
<div style="margin-bottom: 0.5rem;">
|
||||
<button type="button" onclick="addRow()">+ Add Row</button>
|
||||
|
||||
</div>
|
||||
|
||||
<div style="overflow-x:auto;">
|
||||
<table style="width:100%; border-collapse:collapse;">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="text-align:left; padding:0.5rem;">#</th>
|
||||
<th style="text-align:left; padding:0.5rem;">Date</th>
|
||||
<th style="text-align:left; padding:0.5rem;">Description</th>
|
||||
<th style="text-align:left; padding:0.5rem;">Qty</th>
|
||||
<th style="text-align:left; padding:0.5rem;">Unit</th>
|
||||
<th style="text-align:left; padding:0.5rem;">Total</th>
|
||||
<th style="text-align:left; padding:0.5rem;">Tax</th>
|
||||
<th style="text-align:left; padding:0.5rem;">Category</th>
|
||||
<th style="text-align:left; padding:0.5rem;">Notes</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for i in range(row_count) %}
|
||||
{% set item = line_items[i] if i < line_items|length else None %}
|
||||
<tr>
|
||||
<td style="padding:0.35rem;">{{ i + 1 }}</td>
|
||||
<td style="padding:0.35rem;"><input type="date" name="entry_date_{{ i }}" value="{{ item.entry_date.isoformat() if item and item.entry_date else '' }}" style="width:100%;"></td>
|
||||
<td style="padding:0.35rem;"><input type="text" name="description_{{ i }}" value="{{ item.description if item else '' }}" style="width:100%;"></td>
|
||||
<td style="padding:0.35rem;"><input type="text" name="quantity_{{ i }}" value="{{ item.quantity if item and item.quantity is not none else '' }}" style="width:100%;"></td>
|
||||
<td style="padding:0.35rem;"><input type="text" name="unit_price_{{ i }}" value="{{ item.unit_price if item and item.unit_price is not none else '' }}" style="width:100%;"></td>
|
||||
<td style="padding:0.35rem;"><input type="text" name="line_total_{{ i }}" value="{{ item.line_total if item and item.line_total is not none else '' }}" style="width:100%;"></td>
|
||||
<td style="padding:0.35rem;"><input type="text" name="tax_amount_{{ i }}" value="{{ item.tax_amount if item and item.tax_amount is not none else '' }}" style="width:100%;"></td>
|
||||
<td style="padding:0.35rem;"><input type="text" name="category_{{ i }}" value="{{ item.category if item else '' }}" style="width:100%;"></td>
|
||||
<td style="padding:0.35rem;"><input type="text" name="notes_{{ i }}" value="{{ item.notes if item else '' }}" style="width:100%;"></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="button-row" style="margin-top: 1rem;">
|
||||
<button class="primary" type="submit">Save line items</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<script>
|
||||
function addRow() {
|
||||
const panel = document.querySelector('[data-panel="line-items"]');
|
||||
if (!panel) return;
|
||||
|
||||
const tbody = panel.querySelector("tbody");
|
||||
const rowCountInput = panel.querySelector('input[name="row_count"]');
|
||||
const i = tbody.querySelectorAll("tr").length;
|
||||
|
||||
const row = document.createElement("tr");
|
||||
row.innerHTML = `
|
||||
<td style="padding:0.35rem;">${i + 1}</td>
|
||||
<td style="padding:0.35rem;"><input type="date" name="entry_date_${i}" style="width:100%;"></td>
|
||||
<td style="padding:0.35rem;"><input type="text" name="description_${i}" style="width:100%;"></td>
|
||||
<td style="padding:0.35rem;"><input type="text" name="quantity_${i}" style="width:100%;"></td>
|
||||
<td style="padding:0.35rem;"><input type="text" name="unit_price_${i}" style="width:100%;"></td>
|
||||
<td style="padding:0.35rem;"><input type="text" name="line_total_${i}" style="width:100%;"></td>
|
||||
<td style="padding:0.35rem;"><input type="text" name="tax_amount_${i}" style="width:100%;"></td>
|
||||
<td style="padding:0.35rem;"><input type="text" name="category_${i}" style="width:100%;"></td>
|
||||
<td style="padding:0.35rem;"><input type="text" name="notes_${i}" style="width:100%;"></td>
|
||||
`;
|
||||
tbody.appendChild(row);
|
||||
|
||||
rowCountInput.value = i + 1;
|
||||
}
|
||||
</script>
|
||||
</div>
|
||||
|
||||
<div class="tab-panel{% if active_tab == 'versions' %} active{% endif %}" data-panel="versions">
|
||||
<h2 class="card-title">Document versions</h2>
|
||||
{% if version_rows %}
|
||||
|
|
|
|||
Loading…
Reference in New Issue