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:
Sean McElwain 2026-04-17 20:26:53 -05:00
parent bb8cde4c47
commit fcce99a091
4 changed files with 305 additions and 27 deletions

View File

@ -12,6 +12,8 @@ from app.models.document import Document
from app.models.extracted_field import ExtractedField from app.models.extracted_field import ExtractedField
from app.models.receipt_line_item import ReceiptLineItem from app.models.receipt_line_item import ReceiptLineItem
from app.models.text_version import TextVersion 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)") 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() 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: def _clean_item_description(text: str) -> str:
cleaned = re.sub(r"\s+", " ", text.strip()) cleaned = re.sub(r"\s+", " ", text.strip())
cleaned = cleaned.strip("-: ") cleaned = cleaned.strip("-: ")
@ -741,6 +752,44 @@ def _extract_receipt_line_items(lines: list[DocumentLine]) -> list[dict]:
return items 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: def _replace_receipt_line_items(db: Session, document: Document, items: list[dict]) -> None:
existing_items = list(getattr(document, "receipt_line_items", []) or []) existing_items = list(getattr(document, "receipt_line_items", []) or [])
for item in existing_items: for item in existing_items:
@ -884,8 +933,10 @@ def save_extracted_fields(
line_items = parsed_extra.get("line_items", []) line_items = parsed_extra.get("line_items", [])
if isinstance(line_items, list): if isinstance(line_items, list):
_replace_receipt_line_items(db, document, line_items) _replace_receipt_line_items(db, document, line_items)
_replace_document_line_items(db, document, line_items)
else: else:
_replace_receipt_line_items(db, document, []) _replace_receipt_line_items(db, document, [])
_replace_document_line_items(db, document, [])
db.commit() db.commit()
db.refresh(current) db.refresh(current)

View File

@ -31,6 +31,10 @@ from app.logic.extraction import (
) )
from app.logic.ingest import compute_quality_score, rerun_ocr_for_document from app.logic.ingest import compute_quality_score, rerun_ocr_for_document
from app.models.document import 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 import DocumentAdditionalField
from app.models.document_additional_field_version import DocumentAdditionalFieldVersion from app.models.document_additional_field_version import DocumentAdditionalFieldVersion
from app.models.extracted_field_version import ExtractedFieldVersion 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 document.document_type = document_type.strip() or None
db.commit() 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) @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") @router.get("/{document_id}/preview-file")
def document_preview_file(document_id: str, db: Session = Depends(get_db)): def document_preview_file(document_id: str, db: Session = Depends(get_db)):
document = db.query(Document).filter(Document.document_id == document_id).first() 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_additional = _get_current_additional_fields(document)
current_extracted_version_number = _get_current_extracted_version_number(document) current_extracted_version_number = _get_current_extracted_version_number(document)
current_additional_version_number = _get_current_additional_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) queue_nav = _get_queue_navigation(db, document)
naming_row = document.naming_fields[0] if getattr(document, "naming_fields", None) else None 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) file_exists = _version_file_available(version, document.document_id)
version_rows.append((version, file_exists)) 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 = [ ocr_version_options = [
(v.version_number, v.version_type, v.created_at) (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) 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, "file_url": file_url,
"storage_available": storage_available, "storage_available": storage_available,
"version_rows": version_rows, "version_rows": version_rows,
"current_line_item_version": current_line_item_version,
"ocr_version_options": ocr_version_options, "ocr_version_options": ocr_version_options,
"extracted_version_options": extracted_version_options, "extracted_version_options": extracted_version_options,
"additional_version_options": additional_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, "additional_form": additional_form,
"current_additional": current_additional, "current_additional": current_additional,
"current_additional_version_number": current_additional_version_number, "current_additional_version_number": current_additional_version_number,
"line_items": line_items,
"presets": all_presets, "presets": all_presets,
"selected_preset_id": preset_id, "selected_preset_id": preset_id,
"existing_document_types": existing_document_types, "existing_document_types": existing_document_types,

View File

@ -10,7 +10,8 @@ from sqlalchemy.orm import Session, selectinload
from app.db.deps import get_db from app.db.deps import get_db
from app.logic.extraction import get_current_extracted_fields from app.logic.extraction import get_current_extracted_fields
from app.models.document import Document 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"]) router = APIRouter(prefix="/line-items", tags=["line-items"])
@ -36,27 +37,27 @@ def _to_decimal(value: str | None) -> Decimal | None:
return None return None
def _line_item_extra(item: ReceiptLineItem) -> dict: def _line_item_extra(item: DocumentLineItem) -> dict:
return dict(item.extra_json or {}) 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") value = _line_item_extra(item).get("quality_rating")
return "" if value is None else str(value) 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") value = _line_item_extra(item).get("quality_note")
return "" if value is None else str(value) 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") value = _line_item_extra(item).get("quality_status")
return "" if value is None else str(value) return "" if value is None else str(value)
def _is_quality_queue_candidate(item: ReceiptLineItem) -> bool: def _is_quality_queue_candidate(item: DocumentLineItem) -> bool:
if (item.item_category or "").lower() != "cocktail": if (item.category or "").lower() != "cocktail":
return False return False
extra = _line_item_extra(item) extra = _line_item_extra(item)
@ -71,8 +72,9 @@ def _is_quality_queue_candidate(item: ReceiptLineItem) -> bool:
return True return True
def _build_row(item: ReceiptLineItem) -> dict | None: def _build_row(item: DocumentLineItem) -> dict | None:
document = item.document 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: if document is None:
return None return None
@ -89,6 +91,8 @@ def _build_row(item: ReceiptLineItem) -> dict | None:
if extracted.transaction_date: if extracted.transaction_date:
transaction_date = extracted.transaction_date.isoformat() 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: if not transaction_date and document.created_at:
transaction_date = document.created_at.date().isoformat() transaction_date = document.created_at.date().isoformat()
@ -97,31 +101,33 @@ def _build_row(item: ReceiptLineItem) -> dict | None:
"document_id": document.document_id, "document_id": document.document_id,
"transaction_date": transaction_date, "transaction_date": transaction_date,
"merchant": merchant_value, "merchant": merchant_value,
"description": item.normalized_description or item.raw_description or "", "description": item.description or "",
"raw_description": item.raw_description or "", "raw_description": item.description or "",
"quantity": _decimal_to_str(item.quantity), "quantity": _decimal_to_str(item.quantity),
"line_total": _decimal_to_str(item.line_total), "line_total": _decimal_to_str(item.line_total),
"category": item.item_category or "", "category": item.category or "",
"confidence": _decimal_to_str(item.confidence), "confidence": "",
"quality_rating": _line_item_quality_rating(item), "quality_rating": _line_item_quality_rating(item),
"quality_note": _line_item_quality_note(item), "quality_note": _line_item_quality_note(item),
"quality_status": _line_item_quality_status(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 ( return (
db.query(ReceiptLineItem) db.query(DocumentLineItem)
.options( .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() .all()
) )
def _build_filtered_rows( def _build_filtered_rows(
items: list[ReceiptLineItem], items: list[DocumentLineItem],
q: str, q: str,
merchant: str, merchant: str,
category: str, category: str,
@ -175,7 +181,7 @@ def _build_filtered_rows(
return 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() q_norm = q.strip().lower()
grouped: dict[str, dict] = {} grouped: dict[str, dict] = {}
@ -257,7 +263,7 @@ def save_line_item_review(
quality_status: str = Form(""), quality_status: str = Form(""),
db: Session = Depends(get_db), 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: if item is None:
return RedirectResponse(url="/line-items/", status_code=303) return RedirectResponse(url="/line-items/", status_code=303)
@ -385,11 +391,13 @@ def quality_queue(
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
items = ( items = (
db.query(ReceiptLineItem) db.query(DocumentLineItem)
.options( .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() .all()
) )

View File

@ -19,6 +19,8 @@
<div class="error-box"> <div class="error-box">
Could not save OCR-corrected PDF. Check that reviewed OCR line count matches raw OCR line count. Could not save OCR-corrected PDF. Check that reviewed OCR line count matches raw OCR line count.
</div> </div>
{% elif success == "rerun_ocr" %}
<div class="success-message">OCR rerun successfully.</div>
{% elif error == "rerun_ocr_failed" %} {% elif error == "rerun_ocr_failed" %}
<div class="error-box">OCR rerun failed.</div> <div class="error-box">OCR rerun failed.</div>
{% elif error == "save_field_enriched_failed" %} {% 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 == '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 == '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 == '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 == '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 == '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> <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"> <div class="tab-panel{% if active_tab == 'ocr-review' %} active{% endif %}" data-panel="ocr-review">
<h2 class="card-title">Reviewed OCR</h2> <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 %} {% 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> <p>Current OCR version: v{{ current_text_version.version_number }} — {{ current_text_version.version_type }} — {{ current_text_version.created_at }}</p>
{% else %} {% else %}
@ -343,6 +352,93 @@
</form> </form>
</div> </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"> <div class="tab-panel{% if active_tab == 'versions' %} active{% endif %}" data-panel="versions">
<h2 class="card-title">Document versions</h2> <h2 class="card-title">Document versions</h2>
{% if version_rows %} {% if version_rows %}