Spaces:
Sleeping
Sleeping
| """After-Shift Admin Assistant — Gradio app. | |
| Flow: record/upload -> transcribe -> extract -> EDIT & CONFIRM -> save + outputs | |
| The confirm step shows every extracted field in an editable table before anything is | |
| generated or saved. Money is on the line, so she sees what the AI heard and can fix | |
| it in two seconds — which also means a live demo can't be derailed by one misheard word. | |
| """ | |
| from __future__ import annotations | |
| import html as html_lib | |
| import inspect | |
| import math | |
| import os | |
| from datetime import datetime | |
| # Off the Grid: disable Gradio's telemetry before importing it, so nothing phones home. | |
| os.environ.setdefault("GRADIO_ANALYTICS_ENABLED", "False") | |
| import gradio as gr | |
| import database as db | |
| from config import CURRENCY | |
| from extractor import extract | |
| from generators import generate_all, money | |
| from transcriber import transcribe | |
| # Gradio can't create components dynamically, so we pre-build this many editable | |
| # order cards and show only as many as extraction (or "Add order manually") needs. | |
| MAX_ORDERS = 5 | |
| CARD_FIELDS = 8 # customer, items, total, paid, method, pickup, ingredients, notes | |
| APP_CSS = """ | |
| :root { | |
| /* Warm Bakery Theme — flour-dusted parchment, toasted caramel */ | |
| --asa-bg: #f1e8d8; | |
| --asa-surface: rgba(255, 253, 250, 0.96); | |
| --asa-surface-solid: #fffdfa; | |
| --asa-surface-soft: rgba(252, 247, 239, 0.95); | |
| --asa-text: #33271d; | |
| --asa-muted: #6e5f50; /* >= 4.5:1 on the cream surfaces (WCAG AA) */ | |
| --asa-deep: #8a4f12; | |
| --asa-serif: "Iowan Old Style", "Palatino", Georgia, "Times New Roman", serif; | |
| --asa-line: rgba(120, 85, 45, 0.16); | |
| --asa-line-strong: rgba(120, 85, 45, 0.26); | |
| --asa-primary: #c8791f; | |
| --asa-primary-hover: #b56c16; | |
| --asa-fill: rgba(120, 85, 45, 0.07); | |
| --asa-fill-hover: rgba(120, 85, 45, 0.13); | |
| --asa-radius: 20px; | |
| --asa-radius-sm: 12px; | |
| --asa-shadow: 0 1px 2px rgba(60,40,20,0.05), 0 14px 40px -14px rgba(90,60,25,0.22); | |
| --asa-ease: cubic-bezier(0.32, 0.72, 0, 1); | |
| --asa-font: -apple-system, BlinkMacSystemFont, "SF Pro Display", "SF Pro Text", "Helvetica Neue", sans-serif; | |
| } | |
| body, .gradio-container, .gradio-container * { | |
| font-family: var(--asa-font) !important; | |
| -webkit-font-smoothing: antialiased; | |
| -moz-osx-font-smoothing: grayscale; | |
| } | |
| html, body, .gradio-container { | |
| min-height: 100vh !important; | |
| margin: 0; | |
| } | |
| body, .gradio-container { | |
| color: var(--asa-text) !important; | |
| background: | |
| radial-gradient(1200px 620px at 8% -10%, #f8efdd 0%, rgba(248,239,221,0) 55%), | |
| radial-gradient(1050px 560px at 100% 4%, #f0e6d2 0%, rgba(240,230,210,0) 50%), | |
| linear-gradient(180deg, #f5eee0 0%, #ece1cd 100%) !important; | |
| background-attachment: fixed !important; | |
| background-size: cover !important; | |
| } | |
| /* The <gradio-app> host paints solid white full-width over the body gradient. | |
| Make it (and the html root) transparent so the warm wash fills the whole screen. */ | |
| html { background: transparent !important; } | |
| gradio-app { | |
| background: transparent !important; | |
| display: block; | |
| min-height: 100vh; | |
| } | |
| /* Hide Gradio default footer and padding */ | |
| footer { display: none !important; } | |
| .gradio-container { | |
| max-width: 1080px !important; | |
| margin: 0 auto !important; | |
| padding: 40px 24px 80px !important; | |
| /* Neutralize Gradio's white component wrappers so warm panels show through */ | |
| --block-background-fill: transparent; | |
| --block-border-color: transparent; | |
| --block-shadow: none; | |
| --panel-background-fill: transparent; | |
| --background-fill-primary: transparent; | |
| --background-fill-secondary: transparent; | |
| --border-color-primary: var(--asa-line); | |
| --input-background-fill: var(--asa-fill); | |
| --input-border-color: transparent; | |
| --body-text-color: var(--asa-text); | |
| --block-label-text-color: var(--asa-muted); | |
| --block-info-text-color: var(--asa-muted); | |
| } | |
| /* Belt-and-braces: blank out any remaining Gradio block/form wrappers */ | |
| .gradio-container .block, | |
| .gradio-container .form, | |
| .gradio-container [data-testid="block"] { | |
| background: transparent !important; | |
| border: none !important; | |
| box-shadow: none !important; | |
| } | |
| /* ---------- Typography & Spacing ---------- */ | |
| h1, h2, h3, h4, h5, h6 { | |
| letter-spacing: -0.02em !important; | |
| } | |
| p, span, div { | |
| letter-spacing: -0.01em; | |
| } | |
| /* ---------- Hero ---------- */ | |
| .app-header { | |
| display: grid; | |
| grid-template-columns: 1fr; | |
| background: linear-gradient(155deg, rgba(255,250,242,0.94) 0%, rgba(247,233,213,0.88) 100%); | |
| backdrop-filter: blur(40px) saturate(118%); | |
| -webkit-backdrop-filter: blur(40px) saturate(118%); | |
| border: 1px solid var(--asa-line); | |
| box-shadow: var(--asa-shadow); | |
| border-radius: 32px; | |
| padding: 48px; | |
| margin-bottom: 24px; | |
| transition: all 0.5s var(--asa-ease); | |
| } | |
| .app-kicker { | |
| font-size: 14px; | |
| font-weight: 600; | |
| letter-spacing: 0.04em !important; | |
| color: var(--asa-primary) !important; | |
| text-transform: uppercase; | |
| margin: 0 0 12px; | |
| } | |
| .app-header h1 { | |
| color: var(--asa-text) !important; | |
| font-family: var(--asa-serif) !important; | |
| font-size: 54px; | |
| font-weight: 600; | |
| line-height: 1.04; | |
| letter-spacing: -0.015em !important; | |
| margin: 0 0 16px; | |
| } | |
| .app-header p { | |
| color: var(--asa-muted) !important; | |
| font-size: 19px; | |
| font-weight: 400; | |
| line-height: 1.5; | |
| margin: 0; | |
| max-width: 540px; | |
| } | |
| .hero-tagline { | |
| margin-top: 18px !important; | |
| font-size: 14px !important; | |
| font-weight: 600; | |
| letter-spacing: .02em; | |
| color: var(--asa-deep) !important; | |
| } | |
| /* ---------- Stat cards ---------- */ | |
| .summary-grid { | |
| display: grid; | |
| grid-template-columns: repeat(4, minmax(0, 1fr)); | |
| gap: 16px; | |
| margin: 24px 0 32px; | |
| } | |
| .stat-card { | |
| background: var(--asa-surface); | |
| backdrop-filter: blur(30px) saturate(118%); | |
| -webkit-backdrop-filter: blur(30px) saturate(118%); | |
| border: 1px solid var(--asa-line); | |
| border-radius: var(--asa-radius); | |
| padding: 24px; | |
| min-height: 110px; | |
| box-shadow: var(--asa-shadow); | |
| transition: transform 0.4s var(--asa-ease), box-shadow 0.4s var(--asa-ease), background 0.4s var(--asa-ease); | |
| } | |
| .stat-card:hover { | |
| transform: scale(1.02) translateY(-2px); | |
| box-shadow: 0 12px 32px rgba(0,0,0,0.08), 0 2px 4px rgba(0,0,0,0.04); | |
| } | |
| .stat-label { | |
| color: var(--asa-muted); | |
| font-size: 14px; | |
| font-weight: 500; | |
| margin-bottom: 12px; | |
| } | |
| .stat-value { | |
| color: var(--asa-text); | |
| font-family: var(--asa-serif) !important; | |
| font-size: 38px; | |
| line-height: 1.08; | |
| font-weight: 600; | |
| letter-spacing: -0.01em; | |
| } | |
| .stat-card:first-child .stat-value { color: var(--asa-deep); } | |
| .stat-note { | |
| color: var(--asa-muted); | |
| font-size: 13px; | |
| margin-top: 8px; | |
| } | |
| /* ---------- Panels ---------- */ | |
| .panel { | |
| background: var(--asa-surface); | |
| backdrop-filter: blur(40px) saturate(118%); | |
| -webkit-backdrop-filter: blur(40px) saturate(118%); | |
| border: 1px solid var(--asa-line); | |
| border-radius: 28px; | |
| padding: 32px; | |
| box-shadow: var(--asa-shadow); | |
| } | |
| .panel-soft { background: var(--asa-surface-soft); } | |
| .step-head { | |
| display: grid; | |
| grid-template-columns: 36px 1fr; | |
| gap: 16px; | |
| align-items: start; | |
| margin-bottom: 24px; | |
| } | |
| .step-num { | |
| display: inline-grid; | |
| place-items: center; | |
| width: 36px; | |
| height: 36px; | |
| border-radius: 50%; | |
| background: var(--asa-primary); | |
| color: #fff; | |
| font-weight: 600; | |
| box-shadow: 0 4px 16px rgba(200, 121, 31, 0.4); | |
| } | |
| .step-head h2 { | |
| font-family: var(--asa-serif) !important; | |
| font-size: 26px; | |
| font-weight: 600; | |
| line-height: 1.15; | |
| letter-spacing: -0.01em; | |
| margin: 0 0 6px; | |
| } | |
| .step-head p { | |
| color: var(--asa-muted); | |
| font-size: 15px; | |
| margin: 0; | |
| line-height: 1.5; | |
| } | |
| .sample-note { | |
| background: var(--asa-fill); | |
| border-radius: var(--asa-radius-sm); | |
| padding: 16px; | |
| color: var(--asa-text); | |
| font-size: 14px; | |
| line-height: 1.5; | |
| margin-top: 16px; | |
| } | |
| .sample-note strong { color: var(--asa-text); font-weight: 600; } | |
| .status-line { | |
| background: var(--asa-surface-solid); | |
| border-radius: var(--asa-radius-sm); | |
| padding: 13px 16px; | |
| font-size: 15px; | |
| font-weight: 500; | |
| color: var(--asa-text) !important; | |
| margin-bottom: 16px; | |
| border: 1px solid var(--asa-line) !important; | |
| border-left: 4px solid var(--asa-primary) !important; | |
| } | |
| .status-line * { color: var(--asa-text) !important; } | |
| /* Readable placeholders + intentional selection color */ | |
| .gradio-container ::placeholder { color: #7d6d5c !important; opacity: 1 !important; } | |
| ::selection { background: rgba(200, 121, 31, 0.28); } | |
| /* ---------- Buttons: native bounce ---------- */ | |
| .gradio-container button { | |
| border-radius: 980px !important; | |
| font-weight: 500 !important; | |
| letter-spacing: -0.01em; | |
| cursor: pointer; | |
| transition: transform 0.3s var(--asa-ease), | |
| background-color 0.2s ease, | |
| box-shadow 0.3s var(--asa-ease), | |
| opacity 0.2s ease !important; | |
| will-change: transform; | |
| } | |
| /* Secondary = ghost/outlined, clearly distinct from the solid primary CTAs */ | |
| .gradio-container button.secondary { | |
| background: transparent !important; | |
| color: var(--asa-text) !important; | |
| border: 1.5px solid var(--asa-line-strong) !important; | |
| } | |
| .gradio-container button.secondary:hover { | |
| background: var(--asa-fill-hover) !important; | |
| border-color: var(--asa-primary) !important; | |
| } | |
| .gradio-container button.secondary:active { | |
| transform: scale(0.96); | |
| } | |
| /* Disabled is unmistakably disabled — enabled ghosts keep full-contrast text */ | |
| .gradio-container button:disabled { | |
| opacity: 0.4 !important; | |
| cursor: not-allowed !important; | |
| transform: none !important; | |
| box-shadow: none !important; | |
| } | |
| .gradio-container button.primary { | |
| background: var(--asa-primary) !important; | |
| color: #fff !important; | |
| border: none !important; | |
| box-shadow: 0 4px 14px rgba(200, 121, 31, 0.4) !important; | |
| } | |
| .gradio-container button.primary:hover { | |
| background: var(--asa-primary-hover) !important; | |
| box-shadow: 0 6px 20px rgba(200, 121, 31, 0.5) !important; | |
| } | |
| .gradio-container button.primary:active { | |
| transform: scale(0.96); | |
| box-shadow: 0 2px 8px rgba(200, 121, 31, 0.3) !important; | |
| } | |
| /* ---------- Tabs as Segmented Controls ---------- */ | |
| .gradio-container .tab-nav { | |
| border: none !important; | |
| gap: 4px; | |
| background: var(--asa-fill); | |
| padding: 4px; | |
| border-radius: 12px; | |
| width: fit-content; | |
| margin-bottom: 24px; | |
| } | |
| .gradio-container .tab-nav button { | |
| border: none !important; | |
| background: transparent !important; | |
| border-radius: 9px !important; | |
| color: var(--asa-muted) !important; | |
| padding: 9px 18px !important; | |
| font-size: 12.5px !important; | |
| font-weight: 600 !important; | |
| text-transform: uppercase !important; | |
| letter-spacing: .05em !important; | |
| transition: all 0.2s ease !important; | |
| } | |
| .gradio-container .tab-nav button:hover { | |
| color: var(--asa-text) !important; | |
| background: var(--asa-fill-hover) !important; | |
| } | |
| .gradio-container .tab-nav button.selected { | |
| background: var(--asa-surface-solid) !important; | |
| color: var(--asa-deep) !important; | |
| box-shadow: 0 1px 3px rgba(90,60,25,0.12), 0 0 0 1px var(--asa-line) !important; | |
| } | |
| /* ---------- Inputs / Table ---------- */ | |
| .gradio-container textarea, | |
| .gradio-container input, | |
| .gradio-container .gr-box { | |
| background-color: var(--asa-fill) !important; | |
| border-radius: var(--asa-radius-sm) !important; | |
| border: 1px solid transparent !important; | |
| color: var(--asa-text) !important; | |
| transition: all 0.2s var(--asa-ease) !important; | |
| } | |
| .gradio-container textarea:focus, | |
| .gradio-container input:focus { | |
| background-color: var(--asa-surface-solid) !important; | |
| border-color: var(--asa-primary) !important; | |
| box-shadow: 0 0 0 4px rgba(200, 121, 31, 0.2) !important; | |
| } | |
| /* Make tables blend perfectly */ | |
| .gradio-container table { | |
| font-size: 14px; | |
| border-radius: var(--asa-radius-sm); | |
| overflow: hidden; | |
| border: none !important; | |
| } | |
| .gradio-container table thead th { | |
| background: var(--asa-fill) !important; | |
| font-weight: 600 !important; | |
| color: var(--asa-muted) !important; | |
| border: none !important; | |
| } | |
| /* Hide useless ellipsis/sort buttons in table headers */ | |
| .gradio-container table thead th button, | |
| .gradio-container table thead th svg { | |
| display: none !important; | |
| } | |
| /* Completely hide maximize/fullscreen buttons as they break our native table styling */ | |
| .gradio-container button[aria-label="Fullscreen"], | |
| .gradio-container button[aria-label="Expand"], | |
| .gradio-container button[title="fullscreen"], | |
| .gradio-container button[aria-label*="ullscreen"], | |
| .gradio-container button svg.feather-maximize, | |
| .gradio-container [data-testid="dataframe"] .icon-button { | |
| display: none !important; | |
| } | |
| .gradio-container table tbody tr { | |
| transition: background-color 0.2s ease; | |
| border-bottom: 1px solid var(--asa-line) !important; | |
| } | |
| .gradio-container table tbody tr:hover { | |
| background: var(--asa-fill) !important; | |
| } | |
| .gradio-container table tbody td { | |
| border: none !important; | |
| color: var(--asa-text) !important; | |
| } | |
| /* Clean up markdown links / specific Gradio overrides */ | |
| .gradio-container a { | |
| color: var(--asa-primary) !important; | |
| text-decoration: none !important; | |
| } | |
| .gradio-container a:hover { | |
| text-decoration: underline !important; | |
| } | |
| /* Markdown tables */ | |
| .prose table { | |
| width: 100%; | |
| } | |
| .align-end { align-items: flex-end !important; } | |
| /* ---------- Step 1: the recorder is the hero ---------- */ | |
| .recorder-zone .audio-container, | |
| .recorder-zone .component-wrapper { min-height: 160px; } | |
| .recorder-zone button { font-size: 15px !important; } | |
| /* Clean white inset card around the audio widget so it feels deliberate */ | |
| .recorder-zone .audio-container { | |
| background: var(--asa-surface-solid) !important; | |
| border: 1px solid var(--asa-line) !important; | |
| border-radius: 16px !important; | |
| padding: 18px !important; | |
| } | |
| /* Big, unmistakable record control (Gradio class names vary across versions) */ | |
| .recorder-zone .record-button, | |
| .recorder-zone button.record, | |
| .recorder-zone button[aria-label*="ecord"] { | |
| transform: scale(1.18); | |
| transform-origin: left center; | |
| font-weight: 600 !important; | |
| } | |
| /* Pulse while recording (stop button is shown during capture) */ | |
| @keyframes asa-pulse { | |
| 0% { box-shadow: 0 0 0 0 rgba(196, 64, 38, 0.45); } | |
| 70% { box-shadow: 0 0 0 14px rgba(196, 64, 38, 0); } | |
| 100% { box-shadow: 0 0 0 0 rgba(196, 64, 38, 0); } | |
| } | |
| .recorder-zone .stop-button, | |
| .recorder-zone button.stop, | |
| .recorder-zone button[aria-label*="top recording"] { | |
| animation: asa-pulse 1.6s infinite; | |
| } | |
| /* Never truncate the mic status/permission message (it's a narrow <select>) */ | |
| .recorder-zone select.mic-select { | |
| width: auto !important; | |
| min-width: 280px !important; | |
| max-width: 100% !important; | |
| color: var(--asa-text) !important; | |
| } | |
| /* Surface the icon toggles' purpose as hover/focus tooltips */ | |
| .recorder-zone button[aria-label] { position: relative; } | |
| .recorder-zone button[aria-label]:hover::after, | |
| .recorder-zone button[aria-label]:focus-visible::after { | |
| content: attr(aria-label); | |
| position: absolute; | |
| bottom: calc(100% + 6px); | |
| left: 50%; | |
| transform: translateX(-50%); | |
| background: var(--asa-text); | |
| color: #fff; | |
| font-size: 11.5px; | |
| font-weight: 500; | |
| padding: 4px 9px; | |
| border-radius: 6px; | |
| white-space: nowrap; | |
| z-index: 50; | |
| pointer-events: none; | |
| } | |
| .mic-hint { | |
| margin-top: 12px; | |
| padding: 12px 14px; | |
| background: var(--asa-fill); | |
| border-radius: var(--asa-radius-sm); | |
| font-size: 13px; | |
| line-height: 1.55; | |
| color: var(--asa-text); | |
| } | |
| /* ---------- Step 2: editable order cards ---------- */ | |
| .order-card { | |
| background: var(--asa-surface-solid); | |
| border: 1px solid var(--asa-line); | |
| border-left: 4px solid var(--asa-primary); | |
| border-radius: 14px; | |
| padding: 16px 16px 6px; | |
| margin-top: 12px; | |
| box-shadow: 0 4px 14px -8px rgba(90, 60, 25, 0.18); | |
| } | |
| .order-card label span { font-size: 12px !important; color: var(--asa-muted) !important; } | |
| .order-customer input { font-size: 16.5px !important; font-weight: 600 !important; } | |
| .order-secondary input { font-size: 13.5px !important; } | |
| /* ---------- Step 3: collapsed until outputs exist ---------- */ | |
| .outputs-placeholder { | |
| padding: 22px; | |
| text-align: center; | |
| color: var(--asa-muted); | |
| background: var(--asa-surface-solid); | |
| border: 1.5px dashed var(--asa-line-strong); | |
| border-radius: var(--asa-radius-sm); | |
| font-size: 14.5px; | |
| } | |
| @keyframes asa-fadeup { | |
| from { opacity: 0; transform: translateY(10px); } | |
| to { opacity: 1; transform: translateY(0); } | |
| } | |
| .outputs-reveal { animation: asa-fadeup 0.45s var(--asa-ease); } | |
| /* ---------- Money-owed ledger (hero feature) ---------- */ | |
| .owe-head { | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: baseline; | |
| gap: 16px; | |
| margin-bottom: 16px; | |
| } | |
| .owe-title { | |
| font-family: var(--asa-serif) !important; | |
| font-size: 26px; | |
| font-weight: 600; | |
| color: var(--asa-text); | |
| } | |
| .owe-total { | |
| font-family: var(--asa-serif) !important; | |
| font-size: 34px; | |
| font-weight: 700; | |
| color: var(--asa-deep); | |
| } | |
| .owe-total small { | |
| display: block; | |
| font-family: var(--asa-font) !important; | |
| font-size: 12px; | |
| font-weight: 500; | |
| color: var(--asa-muted); | |
| text-align: right; | |
| } | |
| .ledger { display: grid; gap: 10px; } | |
| .ledger-row { | |
| display: grid; | |
| grid-template-columns: 46px minmax(0, 1fr) auto; | |
| gap: 16px; | |
| align-items: center; | |
| background: var(--asa-surface-solid); | |
| border: 1px solid var(--asa-line); | |
| border-radius: 14px; | |
| padding: 14px 18px; | |
| transition: transform .25s var(--asa-ease), box-shadow .25s var(--asa-ease); | |
| } | |
| .ledger-row:hover { | |
| transform: translateY(-1px); | |
| box-shadow: 0 8px 22px -10px rgba(90,60,25,0.3); | |
| } | |
| .ledger-id { | |
| display: inline-grid; | |
| place-items: center; | |
| width: 38px; | |
| height: 38px; | |
| border-radius: 10px; | |
| background: var(--asa-fill); | |
| color: var(--asa-muted); | |
| font-size: 12.5px; | |
| font-weight: 600; | |
| } | |
| .ledger-name { display: block; font-size: 16.5px; font-weight: 600; color: var(--asa-text); } | |
| .ledger-meta { display: block; font-size: 13px; color: var(--asa-muted); margin-top: 2px; } | |
| .ledger-owes { | |
| display: block; | |
| font-family: var(--asa-serif) !important; | |
| font-size: 23px; | |
| font-weight: 700; | |
| color: var(--asa-deep); | |
| text-align: right; | |
| } | |
| .ledger-date { display: block; font-size: 12px; color: var(--asa-muted); text-align: right; margin-top: 2px; } | |
| .ledger-empty { | |
| padding: 30px; | |
| text-align: center; | |
| color: var(--asa-muted); | |
| background: var(--asa-surface-solid); | |
| border: 1.5px dashed var(--asa-line-strong); | |
| border-radius: 14px; | |
| font-size: 15px; | |
| } | |
| /* ---------- Earnings cards ---------- */ | |
| .earn-grid { | |
| display: grid; | |
| grid-template-columns: repeat(3, minmax(0, 1fr)); | |
| gap: 14px; | |
| } | |
| .earn-card { | |
| background: var(--asa-surface-solid); | |
| border: 1px solid var(--asa-line); | |
| border-radius: 16px; | |
| padding: 22px; | |
| } | |
| .earn-period { | |
| font-size: 12px; | |
| font-weight: 600; | |
| text-transform: uppercase; | |
| letter-spacing: .06em; | |
| color: var(--asa-muted); | |
| margin-bottom: 10px; | |
| } | |
| .earn-big { | |
| font-family: var(--asa-serif) !important; | |
| font-size: 34px; | |
| font-weight: 700; | |
| color: var(--asa-text); | |
| line-height: 1.05; | |
| } | |
| .earn-cap { font-size: 13px; color: var(--asa-muted); margin: 6px 0 12px; } | |
| .earn-bar { | |
| height: 6px; | |
| border-radius: 3px; | |
| background: var(--asa-fill); | |
| overflow: hidden; | |
| margin-bottom: 10px; | |
| } | |
| .earn-bar i { | |
| display: block; | |
| height: 100%; | |
| border-radius: 3px; | |
| background: linear-gradient(90deg, var(--asa-primary), var(--asa-deep)); | |
| } | |
| .earn-due { font-size: 13.5px; font-weight: 600; color: var(--asa-deep); } | |
| .earn-due.zero { color: #5d7a52; } | |
| /* ---------- Shopping checklist (functional gr.CheckboxGroup) ---------- */ | |
| .shop-title { | |
| font-size: 13px; | |
| font-weight: 600; | |
| text-transform: uppercase; | |
| letter-spacing: .05em; | |
| color: var(--asa-muted); | |
| margin-bottom: 14px; | |
| } | |
| .shop-checks .wrap, | |
| .shop-checks [data-testid="checkbox-group"] { | |
| display: grid !important; | |
| gap: 8px !important; | |
| max-width: 600px; | |
| } | |
| /* Each option becomes a tappable card */ | |
| .shop-checks label { | |
| display: flex !important; | |
| align-items: center; | |
| gap: 14px; | |
| background: var(--asa-surface-solid) !important; | |
| border: 1px solid var(--asa-line) !important; | |
| border-radius: 12px !important; | |
| padding: 14px 16px !important; | |
| font-size: 15.5px !important; | |
| color: var(--asa-text) !important; | |
| cursor: pointer; | |
| transition: transform .2s var(--asa-ease), box-shadow .2s var(--asa-ease), | |
| background .2s var(--asa-ease), opacity .2s var(--asa-ease); | |
| } | |
| .shop-checks label:hover { | |
| transform: translateY(-1px); | |
| box-shadow: 0 8px 22px -12px rgba(90,60,25,0.35); | |
| } | |
| .shop-checks label input[type="checkbox"] { | |
| width: 20px !important; | |
| height: 20px !important; | |
| accent-color: var(--asa-primary); | |
| cursor: pointer; | |
| } | |
| /* A checked item is on its way off the list — show it crossed off */ | |
| .shop-checks label:has(input:checked) { | |
| opacity: 0.55; | |
| text-decoration: line-through; | |
| background: var(--asa-fill) !important; | |
| } | |
| /* ---------- Receipt output reads like a real receipt ---------- */ | |
| .receipt-mono textarea { | |
| font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace !important; | |
| font-size: 12.5px !important; | |
| line-height: 1.55 !important; | |
| } | |
| @media (max-width: 900px) { | |
| .app-header, | |
| .summary-grid, | |
| .earn-grid { grid-template-columns: 1fr; } | |
| .app-header h1 { font-size: 38px; } | |
| .gradio-container { padding: 24px 16px 60px !important; } | |
| } | |
| /* Phones: rows and the tab strip must wrap (fixed min-widths otherwise force | |
| horizontal scroll — the 4 uppercase tabs alone are ~425px in one line) */ | |
| @media (max-width: 760px) { | |
| body { overflow-x: hidden; } | |
| /* Kill the structural min-width cascade that makes content exceed the viewport */ | |
| .gradio-container, | |
| .gradio-container .main, | |
| .gradio-container .fillable, | |
| .gradio-container .contain, | |
| .gradio-container .wrap, | |
| .gradio-container .column, | |
| .gradio-container .block { max-width: 100% !important; min-width: 0 !important; } | |
| .gradio-container .row { flex-wrap: wrap !important; } | |
| .order-card .row > * { min-width: calc(50% - 8px) !important; } | |
| .gradio-container .tab-nav { | |
| flex-wrap: wrap !important; | |
| width: 100% !important; | |
| max-width: 100% !important; | |
| } | |
| .gradio-container .tab-nav button { padding: 8px 12px !important; } | |
| .app-header { padding: 28px 22px; } | |
| .panel { padding: 20px 16px; } | |
| .owe-head { flex-direction: column; gap: 4px; } | |
| .owe-total { font-size: 28px; } | |
| } | |
| """ | |
| # --- order-card <-> job conversion ------------------------------------------- | |
| def _split(s) -> list[str]: | |
| return [p.strip() for p in str(s or "").split(",") if p.strip()] | |
| def _num(x) -> float: | |
| try: | |
| value = float(x) | |
| except (TypeError, ValueError): | |
| return 0.0 | |
| return value if math.isfinite(value) else 0.0 | |
| def _card_updates(jobs: list[dict]) -> list: | |
| """Per-card updates: [group visibility, 8 field values] x MAX_ORDERS.""" | |
| ups = [] | |
| for i in range(MAX_ORDERS): | |
| if i < len(jobs): | |
| j = jobs[i] | |
| ups += [ | |
| gr.update(visible=True), | |
| gr.update(value=j.get("customer", "")), | |
| gr.update(value=", ".join(j.get("services") or [])), | |
| gr.update(value=j.get("amount_charged") or 0), | |
| gr.update(value=j.get("amount_paid") or 0), | |
| gr.update(value=j.get("payment_method") or ""), | |
| gr.update(value=j.get("next_appointment", "")), | |
| gr.update(value=", ".join(j.get("supplies") or [])), | |
| gr.update(value=j.get("notes", "")), | |
| ] | |
| else: | |
| ups += [gr.update(visible=False), | |
| gr.update(value=""), gr.update(value=""), gr.update(value=0), | |
| gr.update(value=0), gr.update(value=""), gr.update(value=""), | |
| gr.update(value=""), gr.update(value="")] | |
| return ups | |
| def _cards_unchanged() -> list: | |
| return [gr.update() for _ in range(MAX_ORDERS * (CARD_FIELDS + 1))] | |
| def _cards_to_jobs(transcript: str, n: int, *fields) -> list[dict]: | |
| """Rebuild job dicts from the flat (8 x MAX_ORDERS) card field values.""" | |
| jobs = [] | |
| now = datetime.now().isoformat(timespec="seconds") | |
| n = max(0, min(int(n or 0), MAX_ORDERS)) | |
| for i in range(n): | |
| c, items, total, paid, method, pickup, ingredients, notes = ( | |
| fields[i * CARD_FIELDS: (i + 1) * CARD_FIELDS] | |
| ) | |
| customer = str(c or "").strip() | |
| if not customer: | |
| continue # skip blank cards | |
| method = str(method or "").strip().lower() | |
| jobs.append({ | |
| "created_at": now, | |
| "customer": customer, | |
| "services": _split(items), | |
| "amount_charged": _num(total), | |
| "amount_paid": _num(paid), | |
| "payment_method": method if method in {"cash", "card", "transfer"} else "", | |
| "next_appointment": str(pickup or "").strip(), | |
| "supplies": _split(ingredients), | |
| "notes": str(notes or "").strip(), | |
| "raw_transcript": transcript, | |
| }) | |
| return jobs | |
| # --- step handlers ----------------------------------------------------------- | |
| def on_process(audio_path): | |
| if not audio_path: | |
| return ["🎙️ Record or upload a voice note first.", "", 0] + _cards_unchanged() | |
| try: | |
| text = transcribe(audio_path) | |
| except Exception as e: # model missing / decode failure | |
| return [f"⚠️ Transcription failed: {e}", "", 0] + _cards_unchanged() | |
| if not text.strip(): | |
| return ["🤫 Couldn't hear anything — try again.", "", 0] + _cards_unchanged() | |
| return _extract_to_cards(text) | |
| def on_reextract(transcript): | |
| if not (transcript or "").strip(): | |
| return (["✍️ Nothing to extract — type or record a note first.", transcript, 0] | |
| + _cards_unchanged()) | |
| return _extract_to_cards(transcript) | |
| def _extract_to_cards(text: str): | |
| try: | |
| jobs = extract(text) | |
| except FileNotFoundError as e: | |
| return [f"⚠️ {e}", text, 0] + _cards_unchanged() | |
| except Exception as e: | |
| return [f"⚠️ Extraction failed: {e}", text, 0] + _cards_unchanged() | |
| if not jobs: | |
| return (["🤔 Couldn't find an order in that. Edit the transcript below and tap " | |
| "“Use edited transcript”, or add the order manually.", text, 0] | |
| + _card_updates([])) | |
| jobs = jobs[:MAX_ORDERS] | |
| plural = "s" if len(jobs) > 1 else "" | |
| status = f"✅ Heard {len(jobs)} order{plural}. Check the details below, then save." | |
| return [status, text, len(jobs)] + _card_updates(jobs) | |
| def on_add_card(n): | |
| """Reveal the next blank order card for manual entry.""" | |
| n = max(0, min(int(n or 0), MAX_ORDERS)) | |
| if n >= MAX_ORDERS: | |
| return [f"⚠️ Up to {MAX_ORDERS} orders at a time — save these first.", n] + _cards_unchanged() | |
| ups = [] | |
| for i in range(MAX_ORDERS): | |
| if i == n: # the newly revealed card, blanked | |
| ups += [gr.update(visible=True), | |
| gr.update(value=""), gr.update(value=""), gr.update(value=0), | |
| gr.update(value=0), gr.update(value=""), gr.update(value=""), | |
| gr.update(value=""), gr.update(value="")] | |
| else: # do not touch any other card (re-sending visible=True to an | |
| ups += [gr.update()] + [gr.update()] * CARD_FIELDS # already-shown card drops the new one | |
| return ["✏️ Added a blank order card — fill in the customer and details.", n + 1] + ups | |
| def on_confirm(transcript, n, *fields): | |
| jobs = _cards_to_jobs(transcript, n, *fields) | |
| if not jobs: | |
| return ("⚠️ No orders to save — every order needs a customer name.", | |
| gr.update(visible=True), gr.update(visible=False), | |
| "", "", "", "", _summary_cards_html(), _owe_html(), | |
| _earnings_html(), _shop_update()) | |
| invoices, followups, reminders, supplies = [], [], [], [] | |
| for job in jobs: | |
| db.add_job(job) | |
| out = generate_all(job) | |
| invoices.append(out["invoice"]) | |
| followups.append(f"To {job['customer']}:\n{out['followup']}") | |
| if job.get("next_appointment"): | |
| reminders.append(out["reminder"]) | |
| if job.get("supplies"): | |
| supplies.append(out["supplies"]) | |
| plural = "s" if len(jobs) > 1 else "" | |
| status = f"💾 Saved {len(jobs)} order{plural}. Outputs ready below." | |
| sep = "\n\n" + "─" * 32 + "\n\n" | |
| return ( | |
| status, | |
| gr.update(visible=False), # collapse the placeholder | |
| gr.update(visible=True), # reveal the four outputs | |
| sep.join(invoices), | |
| sep.join(followups), | |
| "\n".join(reminders) or "No upcoming appointments.", | |
| sep.join(supplies) or "No supplies needed.", | |
| _summary_cards_html(), _owe_html(), | |
| _earnings_html(), _shop_update(), | |
| ) | |
| # --- dashboard renderers ----------------------------------------------------- | |
| def _nice_date(iso: str) -> str: | |
| try: | |
| return datetime.strptime((iso or "")[:10], "%Y-%m-%d").strftime("%b %d").replace(" 0", " ") | |
| except ValueError: | |
| return (iso or "")[:10] | |
| def _owe_html(): | |
| """The hero view: a ledger of everyone who still owes money.""" | |
| rows = db.get_outstanding() | |
| total = sum(j["balance"] for j in rows) | |
| out = [ | |
| '<div class="owe-head">' | |
| '<span class="owe-title">Who still owes me?</span>' | |
| f'<span class="owe-total">{money(total)}<small>total outstanding</small></span>' | |
| '</div>' | |
| ] | |
| if not rows: | |
| out.append('<div class="ledger-empty">Everyone has paid up 🎉</div>') | |
| return "".join(out) | |
| out.append('<div class="ledger">') | |
| for j in rows: | |
| items = html_lib.escape(", ".join(j["services"]) or "—") | |
| out.append( | |
| '<div class="ledger-row">' | |
| f'<span class="ledger-id">#{j["id"]}</span>' | |
| '<span>' | |
| f'<span class="ledger-name">{html_lib.escape(j["customer"])}</span>' | |
| f'<span class="ledger-meta">{items}</span>' | |
| '</span>' | |
| '<span>' | |
| f'<span class="ledger-owes">{money(j["balance"])}</span>' | |
| f'<span class="ledger-date">{_nice_date(j["created_at"])}</span>' | |
| '</span>' | |
| '</div>' | |
| ) | |
| out.append('</div>') | |
| return "".join(out) | |
| def _earnings_html(): | |
| e = db.get_earnings() | |
| cards = [] | |
| for label, key in (("Today", "today"), ("This week", "week"), ("This month", "month")): | |
| d = e[key] | |
| pct = 0 if d["charged"] <= 0 else min(100, round(d["collected"] / d["charged"] * 100)) | |
| n = d["jobs"] | |
| due_cls = "earn-due zero" if d["outstanding"] <= 0 else "earn-due" | |
| due_txt = "all collected" if d["outstanding"] <= 0 else f"{money(d['outstanding'])} still due" | |
| cards.append( | |
| '<div class="earn-card">' | |
| f'<div class="earn-period">{label} · {n} order{"s" if n != 1 else ""}</div>' | |
| f'<div class="earn-big">{money(d["collected"])}</div>' | |
| f'<div class="earn-cap">collected of {money(d["charged"])} ordered</div>' | |
| f'<div class="earn-bar"><i style="width:{pct}%"></i></div>' | |
| f'<div class="{due_cls}">{due_txt}</div>' | |
| '</div>' | |
| ) | |
| return '<div class="earn-grid">' + "".join(cards) + '</div>' | |
| def _shop_update(): | |
| """Refresh the shopping checklist to the not-yet-bought items.""" | |
| items = db.get_open_supplies() | |
| return gr.update(choices=items, value=[]) | |
| def on_shop_check(selected): | |
| """Tick an item -> mark it bought (persisted) -> it drops off the list.""" | |
| if selected: | |
| db.mark_supplies_bought(selected) | |
| return _shop_update(), _summary_cards_html() | |
| def on_shop_reset(): | |
| db.reset_bought_supplies() | |
| return _shop_update(), _summary_cards_html() | |
| def _style_html() -> str: | |
| return f"<style>{APP_CSS}</style>" | |
| def _app_header_html() -> str: | |
| return """ | |
| <section class="app-header"> | |
| <div> | |
| <p class="app-kicker">Home Kitchen Admin</p> | |
| <h1>Speak today’s orders.<br>Paperwork done.</h1> | |
| <p>Say the orders you took today. Get receipts, deposit reminders, a pickup | |
| schedule, and your ingredient shopping list — without piecing it together | |
| at midnight.</p> | |
| <p class="hero-tagline">Speak it → check it → send it. That’s the whole job.</p> | |
| </div> | |
| </section> | |
| """ | |
| def _summary_cards_html() -> str: | |
| earnings = db.get_earnings() | |
| today = earnings["today"] | |
| outstanding = sum(j["balance"] for j in db.get_outstanding()) | |
| supplies_count = len(db.get_open_supplies()) | |
| n = today["jobs"] | |
| collected_note = ("Nothing collected yet today" if today["collected"] <= 0 | |
| else f"{n} order{'s' if n != 1 else ''} logged") | |
| ordered_note = ("No orders yet today" if today["charged"] <= 0 | |
| else (money(today["outstanding"]) + " still due" | |
| if today["outstanding"] > 0 else "All collected")) | |
| cards = [ | |
| ("Customers owe you", money(outstanding), "Unpaid balances and deposits"), | |
| ("Collected today", money(today["collected"]), collected_note), | |
| ("Ordered today", money(today["charged"]), ordered_note), | |
| ("Shopping list", str(supplies_count), "Ingredients across recent orders"), | |
| ] | |
| html = ['<section class="summary-grid">'] | |
| for label, value, note in cards: | |
| html.append( | |
| '<article class="stat-card">' | |
| f'<div class="stat-label">{label}</div>' | |
| f'<div class="stat-value">{value}</div>' | |
| f'<div class="stat-note">{note}</div>' | |
| '</article>' | |
| ) | |
| html.append("</section>") | |
| return "".join(html) | |
| def _step_html(number: int, title: str, text: str) -> str: | |
| return ( | |
| '<div class="step-head">' | |
| f'<span class="step-num">{number}</span>' | |
| '<div>' | |
| f'<h2>{title}</h2>' | |
| f'<p>{text}</p>' | |
| '</div>' | |
| '</div>' | |
| ) | |
| def on_mark_paid(job_id): | |
| try: | |
| jid = int(job_id) | |
| except (TypeError, ValueError): | |
| return ( | |
| "Enter a valid order #.", _summary_cards_html(), _owe_html(), _earnings_html(), | |
| ) | |
| db.mark_paid(jid) | |
| return ( | |
| f"✅ Marked order #{jid} as paid.", _summary_cards_html(), _owe_html(), _earnings_html(), | |
| ) | |
| def _textbox_with_copy(**kwargs): | |
| params = inspect.signature(gr.Textbox.__init__).parameters | |
| if "buttons" in params: | |
| kwargs["buttons"] = ["copy"] | |
| else: | |
| kwargs["show_copy_button"] = True | |
| return gr.Textbox(**kwargs) | |
| LIGHT_HEAD = """ | |
| <script> | |
| // Warm bakery theme is light-only — force Gradio into light mode. | |
| (function () { | |
| var u = new URL(window.location); | |
| if (u.searchParams.get('__theme') !== 'light') { | |
| u.searchParams.set('__theme', 'light'); | |
| window.location.replace(u.toString()); | |
| } | |
| })(); | |
| </script> | |
| """ | |
| # --- UI ---------------------------------------------------------------------- | |
| def build_ui() -> gr.Blocks: | |
| db.init_db() | |
| with gr.Blocks(title="After-Shift Admin Assistant") as demo: | |
| gr.HTML(_style_html(), container=False) | |
| gr.HTML(_app_header_html(), container=False) | |
| summary_cards = gr.HTML(_summary_cards_html(), container=False) | |
| with gr.Tabs(): | |
| # ---- New job ---- | |
| with gr.Tab("New orders"): | |
| # Step 1 — the recorder IS the product; give it the full width. | |
| with gr.Group(elem_classes=["panel", "recorder-zone"]): | |
| gr.HTML( | |
| _step_html( | |
| 1, | |
| "Speak today's orders", | |
| "Talk like you'd leave yourself a voice memo — customer, " | |
| "items, price, deposit, pickup time, ingredients to buy.", | |
| ), | |
| container=False, | |
| ) | |
| with gr.Row(): | |
| with gr.Column(scale=3, min_width=320): | |
| audio = gr.Audio( | |
| sources=["microphone", "upload"], | |
| type="filepath", | |
| label="Tap record, speak, tap stop", | |
| ) | |
| process_btn = gr.Button( | |
| "Process voice note", | |
| variant="primary", | |
| size="lg", | |
| ) | |
| with gr.Column(scale=2, min_width=260): | |
| gr.HTML( | |
| '<div class="sample-note"><strong>Example:</strong><br>' | |
| '“Mrs Tan, two dozen pineapple tarts and a kaya cake, ' | |
| 'eighty dollars, paid twenty deposit by transfer, picking up ' | |
| 'Saturday, need more butter and pandan.”</div>' | |
| '<div class="mic-hint">🎙️ <b>Record</b> captures your voice; ' | |
| 'the <b>upload arrow</b> takes a voice memo you already have.' | |
| '<br>If recording won’t start, allow microphone access ' | |
| 'in your browser (the lock icon by the address bar) and ' | |
| 'reload the page.</div>', | |
| container=False, | |
| ) | |
| # Step 2 — review & fix on editable order cards. | |
| with gr.Group(elem_classes=["panel"]): | |
| gr.HTML( | |
| _step_html( | |
| 2, | |
| "Check the details", | |
| "Fix anything the AI misheard before money is saved.", | |
| ), | |
| container=False, | |
| ) | |
| status = gr.Markdown( | |
| "Ready. Record or upload a note to start.", | |
| elem_classes=["status-line"], | |
| ) | |
| transcript = gr.Textbox( | |
| label="Transcript", | |
| lines=2, | |
| placeholder="The transcribed note appears here.", | |
| ) | |
| reextract_btn = gr.Button("Use edited transcript", size="sm") | |
| n_state = gr.State(0) | |
| card_groups, card_fields = [], [] | |
| for i in range(MAX_ORDERS): | |
| with gr.Column(visible=False, elem_classes=["order-card"]) as grp: | |
| cust = gr.Textbox( | |
| label="Customer", | |
| placeholder="Who is this order for?", | |
| elem_classes=["order-customer"], | |
| ) | |
| items = gr.Textbox( | |
| label="Order", | |
| placeholder="e.g. 2 dozen pineapple tarts, kaya cake", | |
| ) | |
| with gr.Row(): | |
| total = gr.Number(label=f"Total ({CURRENCY})", value=0, | |
| min_width=110) | |
| paid = gr.Number(label=f"Paid ({CURRENCY})", value=0, | |
| min_width=110) | |
| method = gr.Dropdown( | |
| label="Payment method", | |
| choices=["", "cash", "card", "transfer"], | |
| value="", min_width=140, | |
| ) | |
| pickup = gr.Textbox(label="Pickup / delivery", | |
| placeholder="e.g. Saturday", | |
| min_width=150) | |
| with gr.Row(elem_classes=["order-secondary"]): | |
| ingredients = gr.Textbox( | |
| label="Ingredients to buy", | |
| placeholder="comma-separated, e.g. butter, pandan", | |
| ) | |
| notes = gr.Textbox(label="Notes", | |
| placeholder="anything to remember") | |
| card_groups.append(grp) | |
| card_fields += [cust, items, total, paid, method, pickup, | |
| ingredients, notes] | |
| with gr.Row(): | |
| add_btn = gr.Button("+ Add order manually", size="sm", scale=1) | |
| confirm_btn = gr.Button( | |
| "Save orders and build outputs", | |
| variant="primary", | |
| size="lg", | |
| scale=2, | |
| ) | |
| # Step 3 — collapsed to one line until there is something to copy. | |
| with gr.Group(elem_classes=["panel"]): | |
| gr.HTML( | |
| _step_html( | |
| 3, | |
| "Copy outputs", | |
| "Receipt, customer message, pickup reminders, and shopping " | |
| "list appear after saving.", | |
| ), | |
| container=False, | |
| ) | |
| outputs_placeholder = gr.HTML( | |
| '<div class="outputs-placeholder">Outputs will appear here ' | |
| 'after you save orders.</div>', | |
| container=False, | |
| ) | |
| with gr.Column(visible=False, elem_classes=["outputs-reveal"]) as outputs_group: | |
| with gr.Row(): | |
| inv_out = _textbox_with_copy(label="Order receipt", lines=11, | |
| elem_classes=["receipt-mono"]) | |
| fu_out = _textbox_with_copy(label="Customer message", lines=11) | |
| with gr.Row(): | |
| rem_out = _textbox_with_copy(label="Pickup reminders", lines=5) | |
| sup_out = _textbox_with_copy(label="Ingredients to buy", lines=5) | |
| # ---- Who owes me ---- | |
| with gr.Tab("Money owed"): | |
| with gr.Group(elem_classes=["panel"]): | |
| owe_panel = gr.HTML(_owe_html(), container=False) | |
| with gr.Row(elem_classes=["align-end"]): | |
| paid_id = gr.Number(label="Order #", precision=0, scale=1, min_width=120) | |
| mark_btn = gr.Button("Mark order paid", variant="primary", scale=2) | |
| owe_status = gr.Markdown("") | |
| refresh_owe = gr.Button("Refresh money owed", size="sm") | |
| # ---- Earnings ---- | |
| with gr.Tab("Earnings"): | |
| with gr.Group(elem_classes=["panel"]): | |
| earnings_panel = gr.HTML(_earnings_html(), container=False) | |
| refresh_earn = gr.Button("Refresh earnings", size="sm") | |
| # ---- Supplies ---- | |
| with gr.Tab("Shopping list"): | |
| with gr.Group(elem_classes=["panel"]): | |
| gr.HTML('<div class="shop-title">Tick items as you buy them</div>', | |
| container=False) | |
| shop_box = gr.CheckboxGroup( | |
| choices=db.get_open_supplies(), | |
| value=[], | |
| show_label=False, | |
| elem_classes=["shop-checks"], | |
| ) | |
| with gr.Row(): | |
| reset_sup = gr.Button("Reset list", size="sm") | |
| refresh_sup = gr.Button("Refresh", size="sm") | |
| # ---- wiring ---- | |
| # Per-card output order must match _card_updates(): group, then 8 fields. | |
| card_outs = [] | |
| for i in range(MAX_ORDERS): | |
| card_outs.append(card_groups[i]) | |
| card_outs += card_fields[i * CARD_FIELDS: (i + 1) * CARD_FIELDS] | |
| extract_outs = [status, transcript, n_state] + card_outs | |
| process_btn.click(on_process, [audio], extract_outs) | |
| reextract_btn.click(on_reextract, [transcript], extract_outs) | |
| add_btn.click(on_add_card, [n_state], [status, n_state] + card_outs) | |
| confirm_btn.click( | |
| on_confirm, [transcript, n_state] + card_fields, | |
| [ | |
| status, outputs_placeholder, outputs_group, | |
| inv_out, fu_out, rem_out, sup_out, | |
| summary_cards, owe_panel, earnings_panel, shop_box, | |
| ], | |
| ) | |
| mark_btn.click( | |
| on_mark_paid, [paid_id], | |
| [owe_status, summary_cards, owe_panel, earnings_panel], | |
| ) | |
| refresh_owe.click( | |
| lambda: (_summary_cards_html(), _owe_html()), | |
| None, | |
| [summary_cards, owe_panel], | |
| ) | |
| refresh_earn.click( | |
| lambda: (_summary_cards_html(), _earnings_html()), | |
| None, | |
| [summary_cards, earnings_panel], | |
| ) | |
| shop_box.change(on_shop_check, [shop_box], [shop_box, summary_cards]) | |
| reset_sup.click(on_shop_reset, None, [shop_box, summary_cards]) | |
| refresh_sup.click( | |
| lambda: (_summary_cards_html(), _shop_update()), | |
| None, | |
| [summary_cards, shop_box], | |
| ) | |
| # Gradio bakes each panel's initial value at launch; without this, a page | |
| # reload (or any new visitor on a Space) sees server-start data. Re-render the | |
| # live dashboards on every page load so they always reflect the current DB. | |
| demo.load( | |
| lambda: (_summary_cards_html(), _owe_html(), _earnings_html(), _shop_update()), | |
| None, | |
| [summary_cards, owe_panel, earnings_panel, shop_box], | |
| ) | |
| return demo | |
| if __name__ == "__main__": | |
| # On Hugging Face Spaces (SPACE_ID is set) bind 0.0.0.0 — it's served over HTTPS, | |
| # so the microphone works. Locally, default to 127.0.0.1 so the browser treats it | |
| # as a secure context for mic capture (plain-HTTP 0.0.0.0/LAN IPs block recording). | |
| on_spaces = bool(os.getenv("SPACE_ID")) | |
| host = os.getenv("ASA_HOST", "0.0.0.0" if on_spaces else "127.0.0.1") | |
| port = int(os.getenv("ASA_PORT", os.getenv("GRADIO_SERVER_PORT", "7860"))) | |
| launch_kwargs = {"server_name": host, "server_port": port} | |
| if os.getenv("ASA_SHARE") == "1": | |
| launch_kwargs["share"] = True | |
| launch_params = inspect.signature(gr.Blocks.launch).parameters | |
| if "head" in launch_params: # Gradio 6 moved `head` to launch() | |
| launch_kwargs["head"] = LIGHT_HEAD | |
| print(f"\n Open http://localhost:{port} (microphone needs localhost or https)\n") | |
| build_ui().launch(**launch_kwargs) | |