# 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"""
Tax Invoice
Customer {customer}
{springy_contact if inv_type=='springy' else ''}
| Item | Description | Qty/Hours | Unit Price | Price |
| {modules} |
{audit_type or "NHVR Audit"} |
1 |
{unit:.2f} |
{unit:.2f} |
{tp_rows if inv_type=='third_party' else ''}
{'' if inv_type!='third_party' or admin==0 else f' | JC Auditing administration fee | 1 | | {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 Rate | 10% |
| 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('''
''')
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
)