Nguyen Minh Nhat
After-Shift Admin Assistant
11b9749
Raw
History Blame Contribute Delete
46.8 kB
"""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&rsquo;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 &mdash; without piecing it together
at midnight.</p>
<p class="hero-tagline">Speak it &rarr; check it &rarr; send it. That&rsquo;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>'
'&ldquo;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.&rdquo;</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&rsquo;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)