Update src/streamlit_app.py
Browse files- src/streamlit_app.py +84 -48
src/streamlit_app.py
CHANGED
|
@@ -12,7 +12,7 @@ os.environ["HF_HOME"] = os.path.join(HOME, ".cache", "huggingface")
|
|
| 12 |
os.environ["HUGGINGFACE_HUB_CACHE"] = os.path.join(HOME, ".cache", "huggingface", "hub")
|
| 13 |
os.environ["TRANSFORMERS_CACHE"] = os.path.join(HOME, ".cache", "huggingface", "transformers")
|
| 14 |
os.environ["TORCH_HOME"] = os.path.join(HOME, ".cache", "torch")
|
| 15 |
-
#
|
| 16 |
for d in [
|
| 17 |
HOME,
|
| 18 |
os.environ["XDG_CACHE_HOME"],
|
|
@@ -32,8 +32,6 @@ import base64
|
|
| 32 |
import hashlib
|
| 33 |
from io import BytesIO
|
| 34 |
from pathlib import Path
|
| 35 |
-
from typing import Dict, Any
|
| 36 |
-
|
| 37 |
import pandas as pd
|
| 38 |
from PIL import Image
|
| 39 |
import streamlit as st
|
|
@@ -100,11 +98,11 @@ def _get_hf_token():
|
|
| 100 |
pass
|
| 101 |
return None, None
|
| 102 |
|
| 103 |
-
def hf_login(token
|
| 104 |
try:
|
| 105 |
try:
|
| 106 |
login(token, add_to_git_credential=False)
|
| 107 |
-
except TypeError: # older hub
|
| 108 |
login(token)
|
| 109 |
return True, None
|
| 110 |
except Exception as e:
|
|
@@ -128,7 +126,6 @@ if hf_token is None:
|
|
| 128 |
st.session_state.logged_in = True
|
| 129 |
st.toast("Logged in successfully.", icon="✅")
|
| 130 |
else:
|
| 131 |
-
# Using env/secrets token; login caches to our writable HOME
|
| 132 |
ok, err = hf_login(hf_token)
|
| 133 |
if not ok:
|
| 134 |
st.error(f"Failed to log in with {hf_src or 'unknown'} token: {err}")
|
|
@@ -168,11 +165,12 @@ def load_model_and_processor(hf_model_id: str, task_prompt: str):
|
|
| 168 |
f"Original error: {e}"
|
| 169 |
)
|
| 170 |
|
|
|
|
| 171 |
model.eval()
|
| 172 |
-
device =
|
| 173 |
model.to(device)
|
| 174 |
|
| 175 |
-
with
|
| 176 |
decoder_input_ids = processor.tokenizer(
|
| 177 |
task_prompt,
|
| 178 |
add_special_tokens=False,
|
|
@@ -181,7 +179,7 @@ def load_model_and_processor(hf_model_id: str, task_prompt: str):
|
|
| 181 |
|
| 182 |
return processor, model, device, decoder_input_ids
|
| 183 |
|
| 184 |
-
def run_inference_on_image(image
|
| 185 |
import torch
|
| 186 |
pixel_values = processor(images=image, return_tensors="pt").pixel_values.to(device)
|
| 187 |
gen_kwargs = dict(
|
|
@@ -211,7 +209,7 @@ def run_inference_on_image(image: Image.Image, processor, model, device, decoder
|
|
| 211 |
return pred_dict
|
| 212 |
|
| 213 |
def map_prediction_to_ui(pred):
|
| 214 |
-
import
|
| 215 |
from collections import defaultdict
|
| 216 |
|
| 217 |
def safe_json_load(s):
|
|
@@ -229,7 +227,8 @@ def map_prediction_to_ui(pred):
|
|
| 229 |
subs, stack, start = [], [], None
|
| 230 |
for i, ch in enumerate(s):
|
| 231 |
if ch == "{":
|
| 232 |
-
if not stack:
|
|
|
|
| 233 |
stack.append("{")
|
| 234 |
elif ch == "}":
|
| 235 |
if stack:
|
|
@@ -244,21 +243,28 @@ def map_prediction_to_ui(pred):
|
|
| 244 |
return None
|
| 245 |
|
| 246 |
def clean_number(x):
|
| 247 |
-
if x is None:
|
| 248 |
-
|
|
|
|
|
|
|
| 249 |
s = re.sub(r"[,\s]", "", str(x).strip())
|
| 250 |
s = re.sub(r"[^\d\.\-]", "", s)
|
| 251 |
-
if s in ("", ".", "-", "-."):
|
| 252 |
-
|
| 253 |
-
|
|
|
|
|
|
|
|
|
|
| 254 |
|
| 255 |
def collect_keys(obj, out):
|
| 256 |
if isinstance(obj, dict):
|
| 257 |
for k, v in obj.items():
|
| 258 |
lk = str(k).strip().lower()
|
| 259 |
-
out[lk].append(v)
|
|
|
|
| 260 |
elif isinstance(obj, list):
|
| 261 |
-
for it in obj:
|
|
|
|
| 262 |
|
| 263 |
def collect_lists_of_dicts(obj, out_lists):
|
| 264 |
if isinstance(obj, dict):
|
|
@@ -275,7 +281,8 @@ def map_prediction_to_ui(pred):
|
|
| 275 |
collect_lists_of_dicts(it, out_lists)
|
| 276 |
|
| 277 |
def map_item_dict(it):
|
| 278 |
-
if not isinstance(it, dict):
|
|
|
|
| 279 |
lower = {str(k).strip().lower(): v for k, v in it.items()}
|
| 280 |
desc = (lower.get("descriptions") or lower.get("description") or lower.get("desc") or lower.get("item") or "")
|
| 281 |
qty = lower.get("quantity") or lower.get("qty") or lower.get("count") or ""
|
|
@@ -293,8 +300,10 @@ def map_prediction_to_ui(pred):
|
|
| 293 |
}
|
| 294 |
|
| 295 |
parsed = safe_json_load(pred) if isinstance(pred, str) else pred
|
| 296 |
-
if parsed is None and isinstance(pred, str):
|
| 297 |
-
|
|
|
|
|
|
|
| 298 |
|
| 299 |
ui = {
|
| 300 |
"Invoice Number": "", "Invoice Date": "", "Due Date": "", "Currency": "",
|
|
@@ -315,10 +324,13 @@ def map_prediction_to_ui(pred):
|
|
| 315 |
lk = k.strip().lower()
|
| 316 |
if lk in key_map:
|
| 317 |
for v in key_map[lk]:
|
| 318 |
-
if v is None:
|
| 319 |
-
|
|
|
|
|
|
|
| 320 |
s = str(v).strip()
|
| 321 |
-
if s != "":
|
|
|
|
| 322 |
return None
|
| 323 |
|
| 324 |
ui["Invoice Number"] = pick_first("invoice_no", "invoice_number", "invoiceid", "invoice id") or ""
|
|
@@ -333,23 +345,26 @@ def map_prediction_to_ui(pred):
|
|
| 333 |
for bk in ("bank_name","bank_account_number","bank_acc_name","bank_iban","bank_swift","bank_routing","bank_branch","iban"):
|
| 334 |
val = pick_first(bk, bk.replace("bank_", ""))
|
| 335 |
if val:
|
| 336 |
-
bank["bank_iban" if bk=="iban" else bk] = str(val)
|
| 337 |
ui["Bank Details"] = bank
|
| 338 |
|
| 339 |
-
def _num(*keys):
|
| 340 |
v = pick_first(*keys) or 0.0
|
| 341 |
-
try:
|
| 342 |
-
|
|
|
|
|
|
|
| 343 |
|
| 344 |
-
ui["Subtotal"] = _num("subtotal","sub_total","sub total")
|
| 345 |
-
ui["Tax Percentage"] = _num("tax_rate","tax_percentage","tax pct","tax percentage")
|
| 346 |
-
ui["Total Tax"] = _num("tax_amount","tax","total_tax")
|
| 347 |
-
ui["Total Amount"] = _num("total_amount","grand_total","total","amount")
|
| 348 |
ui["Currency"] = (pick_first("currency") or "").strip()
|
| 349 |
|
| 350 |
items_rows = []
|
| 351 |
def list_looks_like_items(lst):
|
| 352 |
-
if not isinstance(lst, list) or not lst or not isinstance(lst[0], dict):
|
|
|
|
| 353 |
expected = {"descriptions","description","desc","item","quantity","qty","amount","unit_price","line_total","line total"}
|
| 354 |
return bool(expected.intersection({str(k).strip().lower() for k in lst[0].keys()}))
|
| 355 |
|
|
@@ -357,14 +372,17 @@ def map_prediction_to_ui(pred):
|
|
| 357 |
if list_looks_like_items(cand):
|
| 358 |
for it in cand:
|
| 359 |
row = map_item_dict(it)
|
| 360 |
-
if row is not None:
|
| 361 |
-
|
|
|
|
|
|
|
| 362 |
|
| 363 |
if not items_rows and isinstance(parsed, dict):
|
| 364 |
single_keys = {k.strip().lower() for k in parsed.keys()}
|
| 365 |
if single_keys.intersection({"descriptions","description","desc","item","quantity","qty","unit_price","unit price","price","amount","line_total","line total","sku","tax","tax_amount"}):
|
| 366 |
row = map_item_dict(parsed)
|
| 367 |
-
if row is not None:
|
|
|
|
| 368 |
|
| 369 |
if not items_rows:
|
| 370 |
for _, vals in key_map.items():
|
|
@@ -373,7 +391,8 @@ def map_prediction_to_ui(pred):
|
|
| 373 |
lower_keys = {str(x).strip().lower() for x in v.keys()}
|
| 374 |
if lower_keys.intersection({"descriptions","description","desc","amount","line_total","quantity","qty","unit_price"}):
|
| 375 |
row = map_item_dict(v)
|
| 376 |
-
if row is not None:
|
|
|
|
| 377 |
|
| 378 |
if not items_rows:
|
| 379 |
desc, amt, qty, unit_price = pick_first("descriptions","description"), pick_first("amount","line_total"), pick_first("quantity","qty"), pick_first("unit_price","price")
|
|
@@ -430,16 +449,19 @@ def flatten_invoice_to_rows(invoice_data) -> list:
|
|
| 430 |
|
| 431 |
if not line_items:
|
| 432 |
row = base_invoice_info()
|
| 433 |
-
for k in EXPECTED_BANK_FIELDS:
|
|
|
|
| 434 |
row.update({
|
| 435 |
"Item Description": "", "Item Quantity": 0, "Item Unit Price": 0.0,
|
| 436 |
"Item Amount": 0.0, "Item Tax": 0.0, "Item Line Total": 0.0,
|
| 437 |
})
|
| 438 |
-
rows.append(row)
|
|
|
|
| 439 |
|
| 440 |
for item in line_items:
|
| 441 |
row = base_invoice_info()
|
| 442 |
-
for k in EXPECTED_BANK_FIELDS:
|
|
|
|
| 443 |
row.update({
|
| 444 |
"Item Description": item.get("Description", "") if isinstance(item, dict) else "",
|
| 445 |
"Item Quantity": item.get("Quantity", 0) if isinstance(item, dict) else 0,
|
|
@@ -641,11 +663,19 @@ elif len(st.session_state.batch_results) > 0:
|
|
| 641 |
except Exception as e:
|
| 642 |
st.error(f"Re-run failed: {e}")
|
| 643 |
|
| 644 |
-
# All editors inside one form (prevents reruns while typing)
|
| 645 |
with st.form(f"edit_form_{selected_hash}", clear_on_submit=False):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 646 |
tabs = st.tabs(["Invoice Details", "Sender/Recipient info", "Bank Details", "Line Items"])
|
| 647 |
|
| 648 |
-
# Invoice Details
|
| 649 |
with tabs[0]:
|
| 650 |
form_data['Invoice Number'] = st.text_input("Invoice Number", value=form_data.get('Invoice Number', ''), key=f"invoice_number_{selected_hash}")
|
| 651 |
form_data['Invoice Date'] = st.text_input("Invoice Date", value=str(form_data.get('Invoice Date', '')).strip(), key=f"invoice_date_text_{selected_hash}")
|
|
@@ -662,13 +692,14 @@ elif len(st.session_state.batch_results) > 0:
|
|
| 662 |
form_data['Total Tax'] = safe_number_input("Total Tax", form_data.get('Total Tax', 0.0), f"total_tax_{selected_hash}")
|
| 663 |
form_data['Total Amount'] = safe_number_input("Total Amount", form_data.get('Total Amount', 0.0), f"total_amount_{selected_hash}")
|
| 664 |
|
| 665 |
-
# Sender / Recipient
|
| 666 |
with tabs[1]:
|
| 667 |
sender_name = st.text_input("Sender Name*", value=form_data.get('Sender Name', ''), key=f"sender_name_{selected_hash}")
|
| 668 |
sender_address = st.text_area("Sender Address*", value=form_data.get('Sender Address', ''), key=f"sender_address_{selected_hash}")
|
| 669 |
recipient_name = st.text_input("Recipient Name*", value=form_data.get('Recipient Name', ''), key=f"recipient_name_{selected_hash}")
|
| 670 |
recipient_address = st.text_area("Recipient Address*", value=form_data.get('Recipient Address', ''), key=f"recipient_address_{selected_hash}")
|
| 671 |
|
|
|
|
| 672 |
if st.button("⇄ Swap", help="Swap sender and recipient information", key=f"swap_{selected_hash}"):
|
| 673 |
sender_name, recipient_name = recipient_name, sender_name
|
| 674 |
sender_address, recipient_address = recipient_address, sender_address
|
|
@@ -678,7 +709,7 @@ elif len(st.session_state.batch_results) > 0:
|
|
| 678 |
form_data['Recipient Name'] = recipient_name
|
| 679 |
form_data['Recipient Address'] = recipient_address
|
| 680 |
|
| 681 |
-
# Bank Details
|
| 682 |
with tabs[2]:
|
| 683 |
bank_details = form_data.get("Bank Details", {}) if isinstance(form_data.get("Bank Details", {}), dict) else {}
|
| 684 |
bank_name = st.text_input("Bank Name", value=bank_details.get('bank_name', ''), key=f"bank_name_{selected_hash}")
|
|
@@ -698,7 +729,7 @@ elif len(st.session_state.batch_results) > 0:
|
|
| 698 |
form_data["Bank Details"]["bank_routing"] = bank_routing
|
| 699 |
form_data["Bank Details"]["bank_branch"] = bank_branch
|
| 700 |
|
| 701 |
-
# Line Items (fixed height)
|
| 702 |
with tabs[3]:
|
| 703 |
editor_key = f"item_editor_{selected_hash}"
|
| 704 |
item_rows = form_data.get('Itemized Data', []) or []
|
|
@@ -740,12 +771,17 @@ elif len(st.session_state.batch_results) > 0:
|
|
| 740 |
height=420, # fixes growth/shrink reflow
|
| 741 |
)
|
| 742 |
|
| 743 |
-
#
|
| 744 |
-
|
| 745 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 746 |
form_data['Itemized Data'] = edited_df.to_dict('records')
|
| 747 |
st.session_state.batch_results[selected_hash]["edited_data"] = form_data
|
| 748 |
st.toast(f"Saved edits for {current['file_name']}", icon="💾")
|
|
|
|
| 749 |
|
| 750 |
# Per-file CSV download
|
| 751 |
st.markdown("---")
|
|
|
|
| 12 |
os.environ["HUGGINGFACE_HUB_CACHE"] = os.path.join(HOME, ".cache", "huggingface", "hub")
|
| 13 |
os.environ["TRANSFORMERS_CACHE"] = os.path.join(HOME, ".cache", "huggingface", "transformers")
|
| 14 |
os.environ["TORCH_HOME"] = os.path.join(HOME, ".cache", "torch")
|
| 15 |
+
# Create directories so first write doesn't fail
|
| 16 |
for d in [
|
| 17 |
HOME,
|
| 18 |
os.environ["XDG_CACHE_HOME"],
|
|
|
|
| 32 |
import hashlib
|
| 33 |
from io import BytesIO
|
| 34 |
from pathlib import Path
|
|
|
|
|
|
|
| 35 |
import pandas as pd
|
| 36 |
from PIL import Image
|
| 37 |
import streamlit as st
|
|
|
|
| 98 |
pass
|
| 99 |
return None, None
|
| 100 |
|
| 101 |
+
def hf_login(token):
|
| 102 |
try:
|
| 103 |
try:
|
| 104 |
login(token, add_to_git_credential=False)
|
| 105 |
+
except TypeError: # older hub versions
|
| 106 |
login(token)
|
| 107 |
return True, None
|
| 108 |
except Exception as e:
|
|
|
|
| 126 |
st.session_state.logged_in = True
|
| 127 |
st.toast("Logged in successfully.", icon="✅")
|
| 128 |
else:
|
|
|
|
| 129 |
ok, err = hf_login(hf_token)
|
| 130 |
if not ok:
|
| 131 |
st.error(f"Failed to log in with {hf_src or 'unknown'} token: {err}")
|
|
|
|
| 165 |
f"Original error: {e}"
|
| 166 |
)
|
| 167 |
|
| 168 |
+
import torch
|
| 169 |
model.eval()
|
| 170 |
+
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
| 171 |
model.to(device)
|
| 172 |
|
| 173 |
+
with torch.no_grad():
|
| 174 |
decoder_input_ids = processor.tokenizer(
|
| 175 |
task_prompt,
|
| 176 |
add_special_tokens=False,
|
|
|
|
| 179 |
|
| 180 |
return processor, model, device, decoder_input_ids
|
| 181 |
|
| 182 |
+
def run_inference_on_image(image, processor, model, device, decoder_input_ids):
|
| 183 |
import torch
|
| 184 |
pixel_values = processor(images=image, return_tensors="pt").pixel_values.to(device)
|
| 185 |
gen_kwargs = dict(
|
|
|
|
| 209 |
return pred_dict
|
| 210 |
|
| 211 |
def map_prediction_to_ui(pred):
|
| 212 |
+
import re, json
|
| 213 |
from collections import defaultdict
|
| 214 |
|
| 215 |
def safe_json_load(s):
|
|
|
|
| 227 |
subs, stack, start = [], [], None
|
| 228 |
for i, ch in enumerate(s):
|
| 229 |
if ch == "{":
|
| 230 |
+
if not stack:
|
| 231 |
+
start = i
|
| 232 |
stack.append("{")
|
| 233 |
elif ch == "}":
|
| 234 |
if stack:
|
|
|
|
| 243 |
return None
|
| 244 |
|
| 245 |
def clean_number(x):
|
| 246 |
+
if x is None:
|
| 247 |
+
return 0.0
|
| 248 |
+
if isinstance(x, (int, float)):
|
| 249 |
+
return float(x)
|
| 250 |
s = re.sub(r"[,\s]", "", str(x).strip())
|
| 251 |
s = re.sub(r"[^\d\.\-]", "", s)
|
| 252 |
+
if s in ("", ".", "-", "-."):
|
| 253 |
+
return 0.0
|
| 254 |
+
try:
|
| 255 |
+
return float(s)
|
| 256 |
+
except Exception:
|
| 257 |
+
return 0.0
|
| 258 |
|
| 259 |
def collect_keys(obj, out):
|
| 260 |
if isinstance(obj, dict):
|
| 261 |
for k, v in obj.items():
|
| 262 |
lk = str(k).strip().lower()
|
| 263 |
+
out[lk].append(v)
|
| 264 |
+
collect_keys(v, out)
|
| 265 |
elif isinstance(obj, list):
|
| 266 |
+
for it in obj:
|
| 267 |
+
collect_keys(it, out)
|
| 268 |
|
| 269 |
def collect_lists_of_dicts(obj, out_lists):
|
| 270 |
if isinstance(obj, dict):
|
|
|
|
| 281 |
collect_lists_of_dicts(it, out_lists)
|
| 282 |
|
| 283 |
def map_item_dict(it):
|
| 284 |
+
if not isinstance(it, dict):
|
| 285 |
+
return None
|
| 286 |
lower = {str(k).strip().lower(): v for k, v in it.items()}
|
| 287 |
desc = (lower.get("descriptions") or lower.get("description") or lower.get("desc") or lower.get("item") or "")
|
| 288 |
qty = lower.get("quantity") or lower.get("qty") or lower.get("count") or ""
|
|
|
|
| 300 |
}
|
| 301 |
|
| 302 |
parsed = safe_json_load(pred) if isinstance(pred, str) else pred
|
| 303 |
+
if parsed is None and isinstance(pred, str):
|
| 304 |
+
parsed = None
|
| 305 |
+
if parsed is None and not isinstance(pred, dict):
|
| 306 |
+
parsed = pred
|
| 307 |
|
| 308 |
ui = {
|
| 309 |
"Invoice Number": "", "Invoice Date": "", "Due Date": "", "Currency": "",
|
|
|
|
| 324 |
lk = k.strip().lower()
|
| 325 |
if lk in key_map:
|
| 326 |
for v in key_map[lk]:
|
| 327 |
+
if v is None:
|
| 328 |
+
continue
|
| 329 |
+
if isinstance(v, (dict, list)):
|
| 330 |
+
return v
|
| 331 |
s = str(v).strip()
|
| 332 |
+
if s != "":
|
| 333 |
+
return s
|
| 334 |
return None
|
| 335 |
|
| 336 |
ui["Invoice Number"] = pick_first("invoice_no", "invoice_number", "invoiceid", "invoice id") or ""
|
|
|
|
| 345 |
for bk in ("bank_name","bank_account_number","bank_acc_name","bank_iban","bank_swift","bank_routing","bank_branch","iban"):
|
| 346 |
val = pick_first(bk, bk.replace("bank_", ""))
|
| 347 |
if val:
|
| 348 |
+
bank["bank_iban" if bk == "iban" else bk] = str(val)
|
| 349 |
ui["Bank Details"] = bank
|
| 350 |
|
| 351 |
+
def _num(*keys):
|
| 352 |
v = pick_first(*keys) or 0.0
|
| 353 |
+
try:
|
| 354 |
+
return float(str(v).replace(",", ""))
|
| 355 |
+
except Exception:
|
| 356 |
+
return 0.0
|
| 357 |
|
| 358 |
+
ui["Subtotal"] = _num("subtotal", "sub_total", "sub total")
|
| 359 |
+
ui["Tax Percentage"] = _num("tax_rate", "tax_percentage", "tax pct", "tax percentage")
|
| 360 |
+
ui["Total Tax"] = _num("tax_amount", "tax", "total_tax")
|
| 361 |
+
ui["Total Amount"] = _num("total_amount", "grand_total", "total", "amount")
|
| 362 |
ui["Currency"] = (pick_first("currency") or "").strip()
|
| 363 |
|
| 364 |
items_rows = []
|
| 365 |
def list_looks_like_items(lst):
|
| 366 |
+
if not isinstance(lst, list) or not lst or not isinstance(lst[0], dict):
|
| 367 |
+
return False
|
| 368 |
expected = {"descriptions","description","desc","item","quantity","qty","amount","unit_price","line_total","line total"}
|
| 369 |
return bool(expected.intersection({str(k).strip().lower() for k in lst[0].keys()}))
|
| 370 |
|
|
|
|
| 372 |
if list_looks_like_items(cand):
|
| 373 |
for it in cand:
|
| 374 |
row = map_item_dict(it)
|
| 375 |
+
if row is not None:
|
| 376 |
+
items_rows.append(row)
|
| 377 |
+
if items_rows:
|
| 378 |
+
break
|
| 379 |
|
| 380 |
if not items_rows and isinstance(parsed, dict):
|
| 381 |
single_keys = {k.strip().lower() for k in parsed.keys()}
|
| 382 |
if single_keys.intersection({"descriptions","description","desc","item","quantity","qty","unit_price","unit price","price","amount","line_total","line total","sku","tax","tax_amount"}):
|
| 383 |
row = map_item_dict(parsed)
|
| 384 |
+
if row is not None:
|
| 385 |
+
items_rows.append(row)
|
| 386 |
|
| 387 |
if not items_rows:
|
| 388 |
for _, vals in key_map.items():
|
|
|
|
| 391 |
lower_keys = {str(x).strip().lower() for x in v.keys()}
|
| 392 |
if lower_keys.intersection({"descriptions","description","desc","amount","line_total","quantity","qty","unit_price"}):
|
| 393 |
row = map_item_dict(v)
|
| 394 |
+
if row is not None:
|
| 395 |
+
items_rows.append(row)
|
| 396 |
|
| 397 |
if not items_rows:
|
| 398 |
desc, amt, qty, unit_price = pick_first("descriptions","description"), pick_first("amount","line_total"), pick_first("quantity","qty"), pick_first("unit_price","price")
|
|
|
|
| 449 |
|
| 450 |
if not line_items:
|
| 451 |
row = base_invoice_info()
|
| 452 |
+
for k in EXPECTED_BANK_FIELDS:
|
| 453 |
+
row[k] = bank_details.get(k, "")
|
| 454 |
row.update({
|
| 455 |
"Item Description": "", "Item Quantity": 0, "Item Unit Price": 0.0,
|
| 456 |
"Item Amount": 0.0, "Item Tax": 0.0, "Item Line Total": 0.0,
|
| 457 |
})
|
| 458 |
+
rows.append(row)
|
| 459 |
+
return rows
|
| 460 |
|
| 461 |
for item in line_items:
|
| 462 |
row = base_invoice_info()
|
| 463 |
+
for k in EXPECTED_BANK_FIELDS:
|
| 464 |
+
row[k] = bank_details.get(k, "")
|
| 465 |
row.update({
|
| 466 |
"Item Description": item.get("Description", "") if isinstance(item, dict) else "",
|
| 467 |
"Item Quantity": item.get("Quantity", 0) if isinstance(item, dict) else 0,
|
|
|
|
| 663 |
except Exception as e:
|
| 664 |
st.error(f"Re-run failed: {e}")
|
| 665 |
|
| 666 |
+
# --------- All editors inside one form (prevents reruns while typing)
|
| 667 |
with st.form(f"edit_form_{selected_hash}", clear_on_submit=False):
|
| 668 |
+
# Top bar submit (ensures Streamlit sees a submit in the form)
|
| 669 |
+
top_bar = st.columns([1, 1, 6])
|
| 670 |
+
with top_bar[0]:
|
| 671 |
+
save_top = st.form_submit_button("💾 Save", use_container_width=True)
|
| 672 |
+
with top_bar[1]:
|
| 673 |
+
# Optional no-op to satisfy UX; not used
|
| 674 |
+
st.form_submit_button("↩️ Discard (no-op)", use_container_width=True)
|
| 675 |
+
|
| 676 |
tabs = st.tabs(["Invoice Details", "Sender/Recipient info", "Bank Details", "Line Items"])
|
| 677 |
|
| 678 |
+
# --- Invoice Details ---
|
| 679 |
with tabs[0]:
|
| 680 |
form_data['Invoice Number'] = st.text_input("Invoice Number", value=form_data.get('Invoice Number', ''), key=f"invoice_number_{selected_hash}")
|
| 681 |
form_data['Invoice Date'] = st.text_input("Invoice Date", value=str(form_data.get('Invoice Date', '')).strip(), key=f"invoice_date_text_{selected_hash}")
|
|
|
|
| 692 |
form_data['Total Tax'] = safe_number_input("Total Tax", form_data.get('Total Tax', 0.0), f"total_tax_{selected_hash}")
|
| 693 |
form_data['Total Amount'] = safe_number_input("Total Amount", form_data.get('Total Amount', 0.0), f"total_amount_{selected_hash}")
|
| 694 |
|
| 695 |
+
# --- Sender / Recipient ---
|
| 696 |
with tabs[1]:
|
| 697 |
sender_name = st.text_input("Sender Name*", value=form_data.get('Sender Name', ''), key=f"sender_name_{selected_hash}")
|
| 698 |
sender_address = st.text_area("Sender Address*", value=form_data.get('Sender Address', ''), key=f"sender_address_{selected_hash}")
|
| 699 |
recipient_name = st.text_input("Recipient Name*", value=form_data.get('Recipient Name', ''), key=f"recipient_name_{selected_hash}")
|
| 700 |
recipient_address = st.text_area("Recipient Address*", value=form_data.get('Recipient Address', ''), key=f"recipient_address_{selected_hash}")
|
| 701 |
|
| 702 |
+
# This button won’t submit; it flags the swap action for this run
|
| 703 |
if st.button("⇄ Swap", help="Swap sender and recipient information", key=f"swap_{selected_hash}"):
|
| 704 |
sender_name, recipient_name = recipient_name, sender_name
|
| 705 |
sender_address, recipient_address = recipient_address, sender_address
|
|
|
|
| 709 |
form_data['Recipient Name'] = recipient_name
|
| 710 |
form_data['Recipient Address'] = recipient_address
|
| 711 |
|
| 712 |
+
# --- Bank Details ---
|
| 713 |
with tabs[2]:
|
| 714 |
bank_details = form_data.get("Bank Details", {}) if isinstance(form_data.get("Bank Details", {}), dict) else {}
|
| 715 |
bank_name = st.text_input("Bank Name", value=bank_details.get('bank_name', ''), key=f"bank_name_{selected_hash}")
|
|
|
|
| 729 |
form_data["Bank Details"]["bank_routing"] = bank_routing
|
| 730 |
form_data["Bank Details"]["bank_branch"] = bank_branch
|
| 731 |
|
| 732 |
+
# --- Line Items (fixed height) ---
|
| 733 |
with tabs[3]:
|
| 734 |
editor_key = f"item_editor_{selected_hash}"
|
| 735 |
item_rows = form_data.get('Itemized Data', []) or []
|
|
|
|
| 771 |
height=420, # fixes growth/shrink reflow
|
| 772 |
)
|
| 773 |
|
| 774 |
+
# Bottom submit (near the editor)
|
| 775 |
+
save_bottom = st.form_submit_button("💾 Save Edits for This File", use_container_width=True)
|
| 776 |
+
|
| 777 |
+
# Commit once if either top or bottom button was clicked
|
| 778 |
+
if save_top or save_bottom:
|
| 779 |
+
# Make sure the table’s last edit is committed:
|
| 780 |
+
# (Press Enter or click outside the last cell before hitting Save)
|
| 781 |
form_data['Itemized Data'] = edited_df.to_dict('records')
|
| 782 |
st.session_state.batch_results[selected_hash]["edited_data"] = form_data
|
| 783 |
st.toast(f"Saved edits for {current['file_name']}", icon="💾")
|
| 784 |
+
st.rerun() # one-time rerun to refresh everything
|
| 785 |
|
| 786 |
# Per-file CSV download
|
| 787 |
st.markdown("---")
|