Shami96's picture
Update app.py
285f943 verified
# imports
import gradio as gr
from invoice import (
do_parse, do_generate, load_pricing, compute_totals,
today_str, new_invoice_code, logo_data_uri
)
import os, re, shutil, uuid
import io
from pathlib import Path
PREVIEW_LOGO = logo_data_uri() # empty string if no asset file is present
# ---------- helpers ----------
TMP_DIR = Path("/tmp")
def _stage_file(maybe_bytes_or_path, filename: str | None):
"""
Accepts: file path (str/Path) OR bytes-like (bytes/BytesIO) OR None.
Returns: string path that exists on disk, or None.
"""
if not maybe_bytes_or_path:
return None
# If it's already a path and exists, use it
if isinstance(maybe_bytes_or_path, (str, Path)):
p = Path(maybe_bytes_or_path)
return str(p) if p.exists() and p.is_file() else None
# If it's bytes / BytesIO, write it
data = None
if isinstance(maybe_bytes_or_path, bytes):
data = maybe_bytes_or_path
elif isinstance(maybe_bytes_or_path, io.BytesIO):
data = maybe_bytes_or_path.getvalue()
if data is None:
return None
# pick a safe name
safe = (filename or "file.bin").replace("/", "_")
out = TMP_DIR / safe
out.write_bytes(data)
return str(out) if out.exists() else None
def _stage_for_download(path):
"""
Copy `path` to /tmp with a safe filename so Hugging Face Spaces can serve it.
Returns the new path or None.
"""
if not path or not os.path.isfile(path):
return None
base = os.path.basename(path)
root, ext = os.path.splitext(base)
# sanitize: keep only safe chars
safe_root = re.sub(r'[^A-Za-z0-9_.-]+', '_', root).strip('_') or f"file_{uuid.uuid4().hex[:8]}"
safe_ext = ext if ext else ""
safe = f"/tmp/{safe_root}{safe_ext}"
try:
if os.path.abspath(path) != os.path.abspath(safe):
shutil.copyfile(path, safe)
return safe
except Exception:
return None
def _unit(modules, unit_override):
tiers = load_pricing()
try:
return float(unit_override) if unit_override not in (None, "", "auto") else tiers.get(int(modules or 1), 650.0)
except Exception:
return tiers.get(int(modules or 1), 650.0)
def _preview(inv_type, inv_date, inv_number, modules, audit_type,
s_name, s_addr, s_email, s_phone, s_audit_date, unit_override):
modules = int(modules or 1)
unit = _unit(modules, unit_override)
admin, subtotal, gst, total = compute_totals(modules, inv_type, unit)
inv_date = inv_date or today_str()
if not inv_number or inv_number == "Auto":
inv_number = new_invoice_code(inv_type)
customer = s_name or "Customer"
# springy contact block
springy_contact = ""
if inv_type == "springy":
lines = [x for x in [s_addr, s_email, s_phone, (s_audit_date or "")] if x]
if lines:
springy_contact = "<div style='margin-top:6px'>" + "<br/>".join(lines) + "</div>"
# third-party notes
tp_rows = ""
if inv_type == "third_party":
detail_lines = [f"{customer} NHVR audit {s_audit_date or ''}", s_addr, s_email, s_phone]
for d in [x for x in detail_lines if x]:
tp_rows += f"<tr><td colspan='5' class='left' style='font-style:italic;background:#f9f9f9;'>{d}</td></tr>"
# header branding
header_logo = f'<img src="{PREVIEW_LOGO}" alt="" style="display:block;margin:0 auto;height:70px;max-width:100%;object-fit:contain;border:0;" />' if PREVIEW_LOGO else ""
brand_html = "" if PREVIEW_LOGO else """
<div style="text-align:center">
<div class="brand1">SPRINGY CONSULTING SERVICES</div>
<div class="brand2">HEAVY VEHICLE AUDITING &amp; COMPLIANCE</div>
</div>
"""
return f"""
<style>
:root {{ --brand:#6FB643; --grid:#e2e8f0; --head:#0f172a; }}
/* kill any inherited fading/filters from Gradio */
.invoice-preview, .invoice-preview * {{
opacity: 1 !important; filter: none !important; mix-blend-mode: normal !important;
-webkit-text-fill-color: currentColor !important; color:#0f172a !important;
}}
.invoice-preview {{
width:100%; max-width:860px; margin:0 auto; background:#fff; border:1px solid #cbd5e1;
border-radius:12px; font-family: Arial, Helvetica, sans-serif; font-size:12px; line-height:1.3;
box-sizing:border-box;
}}
.header {{ padding:14px 18px 6px 18px; }}
.meta .row {{ display:flex; gap:10px; margin:2px 0; }}
.meta .lbl {{ font-weight:900; min-width:92px; color:#111827 !important; }}
.brand1 {{ color:var(--brand); font-size:26px; font-weight:900; letter-spacing:.2px; }}
.brand2 {{ font-size:12px; font-weight:900; text-transform:uppercase; }}
table.inv {{
width:calc(100% - 36px); margin:10px 18px; border-collapse:collapse; border-spacing:0;
table-layout:fixed; box-sizing:border-box;
}}
table.inv th {{
background:var(--head) !important; color:#fff !important; font-weight:700;
padding:8px 10px; border:1px solid var(--head); white-space:nowrap; box-sizing:border-box;
}}
table.inv td {{
border:1px solid var(--grid); padding:8px 10px; box-sizing:border-box;
}}
.right {{ text-align:right; font-variant-numeric:tabular-nums; }}
.left {{ text-align:left; }}
.topgrid {{ display:grid; grid-template-columns:1fr 1fr; gap:18px; padding:0 18px 8px 18px; }}
.totals {{ display:grid; grid-template-columns:2fr 1fr; gap:16px; margin:12px 18px 14px 18px; }}
.payinfo {{ line-height:1.5; }}
.box table {{ width:100%; border-collapse:collapse; }}
.box td {{ border:1px solid var(--grid); padding:8px 10px; }}
.box td:first-child {{ font-weight:900; background:#f5f5f5; }}
.footer {{ background:var(--brand); color:#fff !important; text-align:center; padding:8px; font-weight:900; border-radius:0 0 12px 12px; }}
</style>
<div class="invoice-preview">
<div class="header">
{header_logo}{brand_html}
<div class="meta" style="display:flex; justify-content:flex-end; gap:24px; margin-top:10px;">
<div>
<div class="row"><span class="lbl">Invoice Date</span><span>{inv_date}</span></div>
<div class="row"><span class="lbl">Invoice #</span><span>{inv_number}</span></div>
<div class="row"><span class="lbl">ABN</span><span>646 382 464 92</span></div>
</div>
</div>
</div>
<div style="padding:0 18px 8px 18px; font-weight:900;">Tax Invoice</div>
<div class="topgrid">
<div>
<div><b>Customer</b> &nbsp; {customer}</div>
{springy_contact if inv_type=='springy' else ''}
</div>
<div></div>
</div>
<table class="inv">
<thead>
<tr><th style="width:60px;">Item</th><th style="width:auto;">Description</th><th style="width:90px;">Qty/Hours</th><th style="width:100px;">Unit Price</th><th style="width:100px;">Price</th></tr>
</thead>
<tbody>
<tr>
<td class="right">{modules}</td>
<td class="left">{audit_type or "NHVR Audit"}</td>
<td class="right">1</td>
<td class="right">{unit:.2f}</td>
<td class="right">{unit:.2f}</td>
</tr>
{tp_rows if inv_type=='third_party' else ''}
{'' if inv_type!='third_party' or admin==0 else f'<tr><td></td><td class="left">JC Auditing administration fee</td><td class="right">1</td><td class="right"></td><td class="right">{admin:.2f}</td></tr>'}
</tbody>
</table>
<div class="totals">
<div class="payinfo">
<div><b>All payments can be made by direct deposit to the following</b></div><br/>
<div><b>NAB</b></div>
<div>BSB 085 005</div>
<div>Account 898 164 211</div><br/>
<div>Invoice due in 14 days</div><br/>
<div>contact@springyconsultingservices.com</div>
</div>
<div class="box">
<table>
<tr><td>Invoice Subtotal</td><td class="right">${subtotal:.2f}</td></tr>
<tr><td>Tax Rate</td><td class="right">10%</td></tr>
<tr><td>GST</td><td class="right">${gst:.2f}</td></tr>
<tr><td><b>Total</b></td><td class="right"><b>${total:.2f}</b></td></tr>
</table>
</div>
</div>
<div style="padding:0 18px 12px 18px; font-weight:900;">Thankyou for your Business</div>
<div class="footer">0417 664 190 | P.O. BOX 14, O’Halloran Hill, SA 5158 | www.springyconsultingservices.com</div>
</div>
"""
# ---------- audit choices ----------
AUDIT_CHOICES_SPRINGY = [
"NHVR Maintenance Audit",
"NHVR Mass Audit",
"NHVR Fatigue Audit",
"NHVR Maintenance & Mass Audit",
"NHVR Maintenance & Fatigue Audit",
"NHVR Maintenance, Mass & Fatigue Audit",
"NHVR Mass & Fatigue Audit",
"Accreditation Manual NHVR & WA Main Roads",
"Policy & Procedure Manual NHVR",
"Policy & Procedure Manual WA Main roads",
"Compliance",
"Consulting",
"WA Maintenance, Fatigue, Dimensions & Loading Audit",
"WA Maintenance, Fatigue, Dimensions & Loading, Mass Audit",
"Travel",
"Submission of NHVR audit summary report",
"Pre Trip Inspection Books",
]
AUDIT_CHOICES_THIRDPARTY = [
"NHVR Maintenance Audit",
"NHVR Mass Audit",
"NHVR Fatigue Audit",
"NHVR Maintenance & Mass Audit",
"NHVR Maintenance & Fatigue Audit",
"NHVR Maintenance, Mass & Fatigue Audit",
"NHVR Mass & Fatigue Audit",
"Manual NHVR",
"Policy & Procedure Manual NHVR",
"Policy & Procedure Manual WA Main roads",
"Compliance",
"Consulting",
"WA Maintenance, Fatigue, Dimensions & Loading",
]
def _choices_for(inv_type: str):
return AUDIT_CHOICES_SPRINGY if inv_type == "springy" else AUDIT_CHOICES_THIRDPARTY
def _normalize_choice(inv_type: str, parsed: str):
choices = _choices_for(inv_type)
return parsed if parsed in choices else choices[0]
# ---------- callbacks ----------
def on_upload(files):
f = None
if files is None:
return "springy", 1, "NHVR Audit", "", "", "", "", today_str(), "", ""
if isinstance(files, list) and files:
f = files[0]
else:
f = files
meta, inv_type, modules, audit_type, audit_date, name, address, email, phone = do_parse(f)
# normalize parsed audit type to the proper list
audit_type = _normalize_choice(inv_type, audit_type or "")
return inv_type, modules, audit_type, name, address, email, phone, audit_date, today_str(), ""
def on_change(inv_type, inv_date, inv_number, modules, audit_type,
s_name, s_addr, s_email, s_phone, s_audit_date, unit_override):
return _preview(inv_type, inv_date, inv_number, modules, audit_type,
s_name, s_addr, s_email, s_phone, s_audit_date, unit_override)
def on_generate(uploaded_file, inv_date, inv_num, inv_type, modules, audit_type, s_audit_date, s_name, s_addr, s_email, s_phone, unit_override):
# backend returns (meta, xlsx, pdf) where xlsx/pdf may be bytes or a path
meta, xlsx, pdf = do_generate(
uploaded_file, inv_type, modules, audit_type, s_audit_date, s_name, s_addr, s_email, s_phone
)
# ensure we always have real files on disk for gr.DownloadButton
# use recognizable names so the browser saves with the right extension
inv_code = inv_num if inv_num and inv_num != "Auto" else new_invoice_code(inv_type)
xlsx_path = _stage_file(xlsx, f"{inv_code}.xlsx")
pdf_path = _stage_file(pdf, f"{inv_code}.pdf")
preview_html = _preview(inv_type, inv_date, inv_code, modules, audit_type, s_name, s_addr, s_email, s_phone, s_audit_date, unit_override)
return (
preview_html,
gr.update(value=xlsx_path, visible=bool(xlsx_path)),
gr.update(value=pdf_path, visible=bool(pdf_path)),
)
def update_audit_dropdown(inv_type_value, current_audit):
choices = _choices_for(inv_type_value)
value = current_audit if current_audit in choices else choices[0]
return gr.update(choices=choices, value=value), value
# ---------- UI ----------
custom_css = """
.preview-panel, .preview-panel *, .gradio-html, .gradio-html * { opacity: 1 !important; filter: none !important; pointer-events: auto !important; }
.gradio-html { opacity: 1 !important; }
/* Header Styles */
.app-header {
text-align: center;
margin-bottom: 16px;
padding: 18px;
background: rgba(255,255,255,0.95);
border-radius: 14px;
backdrop-filter: blur(10px);
box-shadow: 0 8px 25px rgba(0,0,0,0.1);
}
.app-title {
font-size: 26px;
font-weight: 900;
background: linear-gradient(135deg, #5A8E37, #2c5530);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
margin-bottom: 4px;
letter-spacing: -0.3px;
}
.app-subtitle {
font-size: 13px;
color: #64748b;
font-weight: 500;
}
/* Main Layout - Fixed Height, No Scroll */
.main-container {
display: grid;
grid-template-columns: 380px 1fr;
gap: 16px;
height: calc(100vh - 140px);
min-height: 700px;
max-height: 820px;
}
/* Controls Panel */
.controls-panel {
background: rgba(255,255,255,0.95);
border-radius: 14px;
padding: 18px;
backdrop-filter: blur(10px);
box-shadow: 0 8px 25px rgba(0,0,0,0.1);
border: 1px solid rgba(255,255,255,0.3);
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
box-sizing: border-box;
}
.controls-title {
font-size: 15px;
font-weight: 700;
color: #1e293b;
margin-bottom: 12px;
text-align: center;
flex-shrink: 0;
}
/* Upload Section */
.upload-section {
margin-bottom: 12px;
padding: 12px;
background: linear-gradient(135deg, #f8fafc, #e2e8f0);
border-radius: 8px;
border: 2px dashed #cbd5e1;
transition: all 0.3s ease;
flex-shrink: 0;
}
.upload-section:hover {
border-color: #5A8E37;
background: linear-gradient(135deg, #f0fdf4, #dcfce7);
}
/* Form Controls — fill available height to remove the large empty block */
.form-controls {
flex: 1;
display: flex;
flex-direction: column;
justify-content: space-between; /* fills the vertical gap */
gap: 8px;
overflow: hidden;
}
/* Generate Button */
.generate-section {
margin-top: auto;
flex-shrink: 0;
padding-top: 12px;
border-top: 1px solid #e5e7eb;
}
.generate-btn {
background: linear-gradient(135deg, #5A8E37, #4ade80) !important;
border: none !important;
border-radius: 6px !important;
padding: 10px 20px !important;
font-weight: 700 !important;
font-size: 12px !important;
color: white !important;
cursor: pointer !important;
transition: all 0.3s ease !important;
box-shadow: 0 3px 12px rgba(90, 142, 55, 0.3) !important;
width: 100% !important;
height: 36px !important;
}
/* Preview Panel */
.preview-panel {
background: rgba(255,255,255,0.95);
border-radius: 14px;
padding: 16px;
backdrop-filter: blur(10px);
box-shadow: 0 8px 25px rgba(0,0,0,0.1);
border: 1px solid rgba(255,255,255,0.3);
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
box-sizing: border-box;
}
.preview-title {
font-size: 15px;
font-weight: 700;
color: #1e293b;
margin-bottom: 12px;
text-align: center;
flex-shrink: 0;
}
.preview-container {
flex: 1;
display: flex;
justify-content: center;
align-items: flex-start;
overflow: auto;
min-height: 0;
padding: 8px;
box-sizing: border-box;
}
/* Download Section */
.download-section {
display: flex;
gap: 8px;
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid #e5e7eb;
justify-content: center;
flex-shrink: 0;
}
.download-btn {
flex: 1;
background: linear-gradient(135deg, #3b82f6, #1d4ed8) !important;
border: none !important;
border-radius: 4px !important;
padding: 8px 14px !important;
color: white !important;
font-weight: 600 !important;
font-size: 11px !important;
transition: all 0.3s ease !important;
height: 32px !important;
}
/* Labels */
.gradio-textbox > label, .gradio-dropdown > label, .gradio-radio > label {
font-size: 11px !important;
font-weight: 600 !important;
color: #374151 !important;
margin-bottom: 4px !important;
}
/* Responsive */
@media (max-width: 1200px) {
.main-container { grid-template-columns: 1fr; height: auto; max-height: none; gap: 12px; }
.controls-panel { order: 1; height: auto; max-height: 350px; overflow-y: auto; }
.preview-panel { order: 2; height: 500px; }
}
.gradio-group { gap: 8px !important; }
.gradio-row { gap: 8px !important; }
.gradio-column { min-width: 0 !important; }
.gradio-textbox, .gradio-dropdown, .gradio-radio { margin-bottom: 0 !important; }
"""
with gr.Blocks(title="Professional Invoice Generator", css=custom_css) as demo:
gr.HTML('''
<div class="app-header">
<div class="app-title">NHVR Audit Invoice Generator</div>
<div class="app-subtitle">Professional • Fast • Accurate</div>
</div>
''')
with gr.Row(elem_classes=["main-container"]):
# Left Panel
with gr.Column(elem_classes=["controls-panel"], scale=1):
gr.HTML('<div class="controls-title">Invoice Settings</div>')
with gr.Group(elem_classes=["upload-section"]):
gr.HTML('<div style="text-align: center; margin-bottom: 8px; font-weight: 600; color: #374151; font-size: 11px;">Upload Report</div>')
up = gr.File(label="Upload Report (.pdf or .docx)", file_types=[".pdf", ".docx"], file_count="single")
# ADD THIS LINE HERE
file_display = gr.File(label="Selected file", interactive=False, visible=False)
gr.HTML('<div style="font-size: 10px; color: #6b7280; text-align: center; margin-top: 4px;">Auto-fill invoice details</div>')
with gr.Group(elem_classes=["form-controls"]):
inv_type = gr.Radio(
[("Springy Direct", "springy"), ("Third Party (JC)", "third_party")],
value="springy",
label="Invoice Type"
)
modules = gr.Dropdown(choices=[1, 2, 3, 4], value=1, label="Modules")
audit_type = gr.Dropdown(choices=AUDIT_CHOICES_SPRINGY, value=AUDIT_CHOICES_SPRINGY[0], label="Audit Type")
with gr.Group(elem_classes=["generate-section"]):
gen_btn = gr.Button("Generate Invoice", variant="primary", elem_classes=["generate-btn"])
# Right Panel
with gr.Column(elem_classes=["preview-panel"], scale=2):
gr.HTML('<div class="preview-title">Live Preview</div>')
# startup: professional invoice, not placeholder
with gr.Group(elem_classes=["preview-container"]):
preview = gr.HTML(_preview(
"springy", today_str(), new_invoice_code("springy"),
1, "NHVR Maintenance Audit", "", "", "", "", "", "auto"
))
with gr.Group(elem_classes=["download-section"]):
dl_xlsx = gr.DownloadButton("Download Excel", elem_classes=["download-btn"], size="sm", visible=False)
dl_pdf = gr.DownloadButton("Download PDF", elem_classes=["download-btn"], size="sm", visible=False)
# Hidden states
s_name = gr.State("")
s_addr = gr.State("")
s_email = gr.State("")
s_phone = gr.State("")
s_audit_date = gr.State("")
uploaded_file = gr.State(None)
inv_date = gr.State(today_str()) # hidden
inv_num = gr.State("Auto") # hidden
unit_override = gr.State("auto") # hidden
# Upload handler: normalize audit choice to the list for detected type
def _remember_and_parse(files):
uploaded = files[0] if isinstance(files, list) else files
out = on_upload(files)
inv_t, mods, parsed_audit = out[0], out[1], out[2]
normalized = _normalize_choice(inv_t, parsed_audit or "")
return (
gr.update(value=uploaded, visible=True), # <- show file
uploaded, # <- save to state
inv_t, mods, normalized,
out[3], out[4], out[5], out[6], out[7],
today_str(), "Auto"
)
up.upload(
_remember_and_parse,
inputs=[up],
outputs=[file_display, uploaded_file, inv_type, modules, audit_type, s_name, s_addr, s_email, s_phone, s_audit_date, inv_date, inv_num],
api_name=False
)
inv_type.change(
update_audit_dropdown,
inputs=[inv_type, audit_type],
outputs=[audit_type, audit_type],
api_name=False
).then(
on_change,
inputs=[inv_type, inv_date, inv_num, modules, audit_type, s_name, s_addr, s_email, s_phone, s_audit_date, unit_override],
outputs=[preview],
api_name=False
)
for w in [modules, audit_type]:
w.change(
on_change,
inputs=[inv_type, inv_date, inv_num, modules, audit_type, s_name, s_addr, s_email, s_phone, s_audit_date, unit_override],
outputs=[preview],
api_name=False
)
up.upload(
on_change,
inputs=[inv_type, inv_date, inv_num, modules, audit_type, s_name, s_addr, s_email, s_phone, s_audit_date, unit_override],
outputs=[preview],
api_name=False
)
gen_btn.click(
on_generate,
inputs=[uploaded_file, inv_date, inv_num, inv_type, modules, audit_type, s_audit_date, s_name, s_addr, s_email, s_phone, unit_override],
outputs=[preview, dl_xlsx, dl_pdf],
api_name=False
)
if __name__ == "__main__":
import os
on_spaces = bool(os.getenv("SPACE_ID"))
# IMPORTANT: hide OpenAPI/schema (works around the json-schema bug)
demo.launch(
server_name="0.0.0.0" if not on_spaces else None,
server_port=int(os.getenv("PORT", "7860")) if not on_spaces else None,
show_error=True,
show_api=False, # <—— prevents the schema from being built
share=False # <—— Spaces manages the URL; share=True not allowed
)