# 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 = "
" + "
".join(lines) + "
" # 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"{d}" # header branding header_logo = f'' if PREVIEW_LOGO else "" brand_html = "" if PREVIEW_LOGO else """
SPRINGY CONSULTING SERVICES
HEAVY VEHICLE AUDITING & COMPLIANCE
""" return f"""
{header_logo}{brand_html}
Invoice Date{inv_date}
Invoice #{inv_number}
ABN646 382 464 92
Tax Invoice
Customer   {customer}
{springy_contact if inv_type=='springy' else ''}
{tp_rows if inv_type=='third_party' else ''} {'' if inv_type!='third_party' or admin==0 else f''}
ItemDescriptionQty/HoursUnit PricePrice
{modules} {audit_type or "NHVR Audit"} 1 {unit:.2f} {unit:.2f}
JC Auditing administration fee1{admin:.2f}
All payments can be made by direct deposit to the following

NAB
BSB 085 005
Account 898 164 211

Invoice due in 14 days

contact@springyconsultingservices.com
Invoice Subtotal${subtotal:.2f}
Tax Rate10%
GST${gst:.2f}
Total${total:.2f}
Thankyou for your Business
""" # ---------- 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('''
NHVR Audit Invoice Generator
Professional • Fast • Accurate
''') with gr.Row(elem_classes=["main-container"]): # Left Panel with gr.Column(elem_classes=["controls-panel"], scale=1): gr.HTML('
Invoice Settings
') with gr.Group(elem_classes=["upload-section"]): gr.HTML('
Upload Report
') 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('
Auto-fill invoice details
') 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('
Live Preview
') # 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 )