"""
Kirana AI — UI renderer.
Loads Jinja2 templates and builds the per-page context from database state.
"""
import datetime
from collections import defaultdict
from pathlib import Path
from jinja2 import Environment, FileSystemLoader, select_autoescape
import os
import kirana_db as db
from seasonal_calendar import (
get_upcoming_festivals,
get_seasonal_context,
build_seasonal_summary,
)
def is_model_available() -> bool:
return bool(os.getenv("MODAL_RECEIPT_ENDPOINT", "").strip())
# ──────────────────────────────────────────────────────────────────────────────
TEMPLATES_DIR = Path(__file__).parent / "templates"
env = Environment(
loader=FileSystemLoader(str(TEMPLATES_DIR)),
autoescape=select_autoescape(["html"]),
trim_blocks=True,
lstrip_blocks=True,
)
# ──────────────────────────────────────────────────────────────────────────────
# SVG icon library (1.6px stroke, rounded — SF Symbols-inspired)
# ──────────────────────────────────────────────────────────────────────────────
def _svg(body: str, size: int = 18) -> str:
return (
f''
)
ICON = {
"dashboard": _svg(''
''
''
''),
"inventory": _svg(''
''
''),
"add": _svg(''
''),
"orders": _svg(''
''
''),
"analytics": _svg(''
''
''
''),
"seasonal": _svg(''
''
''),
"settings": _svg(''
''),
"play": _svg('', 14),
"refresh": _svg('', 14),
"sparkles": _svg(''
'', 14),
"plus": _svg('', 14),
"edit": _svg(''),
"rupee": _svg(''),
"trash": _svg(''
''),
"search": _svg(''),
"filter": _svg(''),
"mic": _svg(''
''),
"camera": _svg(''
''),
"check": _svg('', 14),
"x": _svg('', 14),
"package": _svg(''
''),
"alert": _svg(''),
"clock": _svg(''),
"trend": _svg(''),
"trophy": _svg(''
''),
"store": _svg(''
''
''
''),
"info": _svg(''),
"pulse": _svg(''),
"shipped": _svg(''
''),
"medal": _svg(''
''),
"bell": _svg(''
''),
"sun": _svg(''
'', 16),
"moon": _svg('', 16),
}
LOGO_SVG = (
''
)
# ──────────────────────────────────────────────────────────────────────────────
# Navigation definition
# ──────────────────────────────────────────────────────────────────────────────
NAV_PAGES = [
{"key": "dashboard", "label": "Overview", "icon": ICON["dashboard"], "title": "Overview",
"subtitle": "Real-time snapshot of your store"},
{"key": "inventory", "label": "Inventory", "icon": ICON["inventory"], "title": "Inventory",
"subtitle": "Browse, search, and update your full stock"},
{"key": "add", "label": "Add Product", "icon": ICON["add"], "title": "Add Product",
"subtitle": "Photo, voice, or manual entry"},
{"key": "orders", "label": "Orders", "icon": ICON["orders"], "title": "Restock Orders",
"subtitle": "AI-suggested orders awaiting your approval"},
{"key": "analytics", "label": "Analytics", "icon": ICON["analytics"], "title": "Analytics",
"subtitle": "Category mix and best-selling products"},
{"key": "seasonal", "label": "Seasonal", "icon": ICON["seasonal"], "title": "Seasonal Forecast",
"subtitle": "Upcoming festivals and demand spikes"},
{"key": "settings", "label": "Settings", "icon": ICON["settings"], "title": "Settings",
"subtitle": "Shop info, thresholds, model status"},
]
STATUS_FILTERS = ["All", "OK", "Low Stock", "Expiring", "Expired"]
# ──────────────────────────────────────────────────────────────────────────────
# Helpers
# ──────────────────────────────────────────────────────────────────────────────
def _status_of(p: dict) -> tuple[str, str, str]:
"""Return (label, kind, raw_key) for a product."""
today = datetime.date.today().isoformat()
week = (datetime.date.today() + datetime.timedelta(days=7)).isoformat()
exp = p.get("expiry_date") or ""
if exp and exp < today:
return "Expired", "danger", "Expired"
if exp and exp <= week:
return "Expiring", "warn", "Expiring"
if (p.get("quantity") or 0) <= (p.get("min_stock") or 0):
return "Low", "warn", "Low Stock"
return "OK", "success", "OK"
def _nav_with_badges() -> list[dict]:
s = db.get_summary()
badges = {
"dashboard": s.get("low_stock", 0) or 0,
"orders": len(db.get_pending_orders()),
}
result = []
for item in NAV_PAGES:
copy = dict(item)
n = badges.get(item["key"], 0)
if n:
copy["badge"] = n
copy["badge_kind"] = "warn"
result.append(copy)
return result
# ──────────────────────────────────────────────────────────────────────────────
# Page builders
# ──────────────────────────────────────────────────────────────────────────────
def _build_kpis() -> list[dict]:
s = db.get_summary()
total = s.get("total", 0) or 0
low = s.get("low_stock", 0) or 0
expd = s.get("expired", 0) or 0
exp7 = s.get("expiring_soon", 0) or 0
val = s.get("total_value", 0) or 0
cost = s.get("cost_value", 0) or 0
margin = val - cost
needs = expd + exp7
if expd and exp7:
needs_foot = f"{expd} expired · {exp7} expiring 7d"
elif expd:
needs_foot = f"{expd} expired, clear today"
elif exp7:
needs_foot = f"{exp7} expiring within 7 days"
else:
needs_foot = "Nothing on the clock"
return [
{"label": "Products", "value": total, "icon": ICON["package"], "color": "primary",
"foot": "across all categories"},
{"label": "Low stock", "value": low, "icon": ICON["trend"], "color": "warn" if low else "success",
"foot": f"{low} need restocking" if low else "All stocked"},
{"label": "Needs attention", "value": needs, "icon": ICON["alert"], "color": "danger" if expd else ("warn" if exp7 else "success"),
"foot": needs_foot},
{"label": "Inventory value", "value": f"₹{val:,.0f}", "icon": ICON["rupee"], "color": "info",
"foot": f"Margin ≈ ₹{margin:,.0f}"},
]
def _build_health() -> dict:
products = db.get_all_products()
today = datetime.date.today().isoformat()
week = (datetime.date.today() + datetime.timedelta(days=7)).isoformat()
healthy = low = expiring = expired = 0
for p in products:
exp = p.get("expiry_date") or ""
if exp and exp < today: expired += 1
elif exp and exp <= week: expiring += 1
elif (p["quantity"] or 0) <= (p["min_stock"] or 0): low += 1
else: healthy += 1
total = max(len(products), 1)
def pct(n): return max(round((n / total) * 100, 1), 2 if n else 1)
return {
"total": len(products),
"segments": [
{"label": "Healthy", "value": healthy, "cls": "healthy", "flex": pct(healthy)},
{"label": "Low", "value": low, "cls": "low", "flex": pct(low)},
{"label": "Expiring", "value": expiring, "cls": "expiring", "flex": pct(expiring)},
{"label": "Expired", "value": expired, "cls": "expired", "flex": pct(expired)},
],
}
def _build_alerts() -> dict:
low_items = db.get_low_stock()
expir_items = db.get_expiring_soon(7)
expd_items = db.get_expired()
n_expd, n_exp7, n_low = len(expd_items), len(expir_items), len(low_items)
total = n_expd + n_exp7 + n_low
chips = [
{"label": "Expired", "value": n_expd, "kind": "danger"},
{"label": "Expiring 7d", "value": n_exp7, "kind": "warn"},
{"label": "Low stock", "value": n_low, "kind": "warn"},
]
# Top 3 most urgent: expired first, then soonest expiry, then lowest %-of-min
top = []
for p in expd_items[:3]:
top.append({
"kind": "danger", "icon": "🚨", "tag": "EXPIRED",
"title": p["name"],
"sub": f"{p['quantity']} {p['unit']} · Expired {p['expiry_date']}",
})
if len(top) < 3:
for p in expir_items[: 3 - len(top)]:
days = (datetime.date.fromisoformat(p["expiry_date"]) - datetime.date.today()).days
top.append({
"kind": "warn", "icon": "⏰", "tag": f"{days}d LEFT",
"title": p["name"],
"sub": f"{p['quantity']} {p['unit']} · Expires {p['expiry_date']}",
})
if len(top) < 3:
for p in low_items[: 3 - len(top)]:
pct = int((p["quantity"] / max(p["min_stock"], 0.01)) * 100)
top.append({
"kind": "warn", "icon": "📉", "tag": "LOW",
"title": p["name"],
"sub": f"{p['quantity']} {p['unit']} ({pct}% of min {p['min_stock']}) · {p['supplier'] or '—'}",
})
overflow = max(total - len(top), 0)
badge_cls = ("badge-danger" if expd_items else
"badge-warn" if (low_items or expir_items) else "badge-success")
return {
"chips": chips,
"top": top,
"overflow": overflow,
"total": total,
"badge_cls": badge_cls,
# back-compat alias for any template still reading `.entries`
"entries": top,
}
def _build_festivals() -> dict:
ctx = get_seasonal_context()
upcoming = get_upcoming_festivals(30)
cards = [{
"name": f["name"], "name_te": f["name_te"],
"mult": f["demand_multiplier"],
"demand_items": ", ".join(f["demand_items"][:6]),
"tips": f["tips"],
} for f in upcoming[:6]]
return {
"season": ctx["season"], "note": ctx["note"],
"cards": cards, "count": len(upcoming),
}
def _build_pulse(days: int = 14) -> dict:
"""Daily revenue series for the dashboard hero sparkline.
Returns a path-ready SVG polyline + area, indexed counts, peak/avg
summary, and a "today vs prior-week" delta so the viz can pulse and
label itself without any client-side math.
"""
conn = db.get_conn()
rows = conn.execute(
"SELECT date(sale_date) AS d, "
" COALESCE(SUM(qty_sold * price_per_unit), 0) AS rev, "
" COALESCE(SUM(qty_sold), 0) AS units "
"FROM sales WHERE sale_date >= date('now', ?) "
"GROUP BY date(sale_date)",
(f"-{days - 1} days",),
).fetchall()
conn.close()
by_day = {r["d"]: (float(r["rev"] or 0), float(r["units"] or 0)) for r in rows}
today = datetime.date.today()
series: list[dict] = []
for i in range(days):
d = today - datetime.timedelta(days=days - 1 - i)
rev, units = by_day.get(d.isoformat(), (0.0, 0.0))
series.append({
"date": d.isoformat(),
"label": d.strftime("%a"),
"rev": rev, "units": units,
})
revs = [pt["rev"] for pt in series]
peak = max(revs) if revs else 0
total = sum(revs)
avg = total / max(len(revs), 1)
today_rev = revs[-1] if revs else 0
prior_week = sum(revs[:7]) # days 1..7 of the 14-day window
last_week = sum(revs[7:]) # days 8..14
if prior_week > 0:
delta_pct = round(((last_week - prior_week) / prior_week) * 100, 1)
else:
delta_pct = 100.0 if last_week else 0.0
# SVG path generation — 100-wide × 36-tall normalized viewBox
W, H, PAD = 100.0, 36.0, 2.0
n = len(series)
if n < 2 or peak <= 0:
line_path = f"M{PAD},{H - PAD}L{W - PAD},{H - PAD}"
area_path = (f"M{PAD},{H - PAD}L{W - PAD},{H - PAD}"
f"L{W - PAD},{H}L{PAD},{H}Z")
points = []
else:
step = (W - PAD * 2) / (n - 1)
coords = []
for i, pt in enumerate(series):
x = PAD + step * i
y = (H - PAD) - (pt["rev"] / peak) * (H - PAD * 2)
coords.append((x, y))
# Smooth Catmull-Rom-ish path via Bezier
def _seg(p0, p1, p2, p3):
t = 0.18
c1x = p1[0] + (p2[0] - p0[0]) * t
c1y = p1[1] + (p2[1] - p0[1]) * t
c2x = p2[0] - (p3[0] - p1[0]) * t
c2y = p2[1] - (p3[1] - p1[1]) * t
return f" C{c1x:.2f},{c1y:.2f} {c2x:.2f},{c2y:.2f} {p2[0]:.2f},{p2[1]:.2f}"
parts = [f"M{coords[0][0]:.2f},{coords[0][1]:.2f}"]
for i in range(len(coords) - 1):
p0 = coords[i - 1] if i > 0 else coords[i]
p1 = coords[i]
p2 = coords[i + 1]
p3 = coords[i + 2] if i + 2 < len(coords) else coords[i + 1]
parts.append(_seg(p0, p1, p2, p3))
line_path = "".join(parts)
area_path = (line_path
+ f"L{coords[-1][0]:.2f},{H}L{coords[0][0]:.2f},{H}Z")
points = [{
"x": round(x, 2), "y": round(y, 2),
"rev": pt["rev"], "label": pt["label"], "date": pt["date"],
} for (x, y), pt in zip(coords, series)]
return {
"series": series,
"points": points,
"line_path": line_path,
"area_path": area_path,
"viewbox": f"0 0 {int(W)} {int(H)}",
"today_rev_fmt": f"₹{today_rev:,.0f}",
"avg_rev_fmt": f"₹{avg:,.0f}",
"peak_rev_fmt": f"₹{peak:,.0f}",
"total_rev_fmt": f"₹{total:,.0f}",
"delta_pct": delta_pct,
"delta_kind": "up" if delta_pct > 0 else ("down" if delta_pct < 0 else "flat"),
"delta_label": f"{'+' if delta_pct > 0 else ''}{delta_pct}%",
"has_data": peak > 0,
"days": days,
}
def _build_categories() -> dict:
products = db.get_all_products()
if not products:
return {"rows": [], "total_value": 0, "total_count": 0}
by_cat = defaultdict(lambda: {"count": 0, "value": 0.0})
for p in products:
by_cat[p["category"]]["count"] += 1
by_cat[p["category"]]["value"] += (p["quantity"] or 0) * (p["sell_price"] or 0)
rows_raw = sorted(by_cat.items(), key=lambda kv: kv[1]["value"], reverse=True)
grand = sum(v["value"] for _, v in rows_raw) or 1
max_v = max((v["value"] for _, v in rows_raw), default=1) or 1
rows = [{
"name": cat,
"count": v["count"],
"value": v["value"],
"value_fmt": f"{v['value']:,.0f}",
"share": round((v["value"] / grand) * 100, 1), # % of total
"bar_pct": round((v["value"] / max_v) * 100, 1), # bar width (vs leader)
} for cat, v in rows_raw]
return {
"rows": rows,
"total_value": grand,
"total_value_fmt": f"{grand:,.0f}",
"total_count": sum(r["count"] for r in rows),
}
def _build_sellers(days: int = 30) -> dict:
raw = db.get_top_sellers(n=10, days=days)
if not raw:
return {"rows": [], "total_revenue": 0}
revs = [(t.get("revenue") or 0) for t in raw]
total_rev = sum(revs) or 1
max_rev = max(revs) or 1
rows = [{
"name": t["name"],
"name_local": t.get("name_local") or "",
"sold": f"{t['total_sold']:.1f}",
"unit": t["unit"],
"revenue": t.get("revenue") or 0,
"revenue_fmt": f"{(t.get('revenue') or 0):,.0f}",
"share": round(((t.get("revenue") or 0) / total_rev) * 100, 1),
"bar_pct": round(((t.get("revenue") or 0) / max_rev) * 100, 1),
} for t in raw]
return {"rows": rows, "total_revenue": total_rev,
"total_revenue_fmt": f"{total_rev:,.0f}"}
# ──────────────────────────────────────────────────────────────────────────────
# Per-page context
# ──────────────────────────────────────────────────────────────────────────────
# ── AI insights cache (auto-run, 5-min TTL) ───────────────────────────────────
_INSIGHTS_TTL = 300
_INSIGHTS_CACHE: dict = {"ts": None, "data": None, "running": False}
def invalidate_insights() -> None:
"""Mark the insights cache stale so the next dashboard render re-runs analysis."""
_INSIGHTS_CACHE["ts"] = None
_INSIGHTS_CACHE["data"] = None
def _confidence_band(pct: int) -> tuple[str, str]:
if pct >= 85: return "high", "High confidence"
if pct >= 70: return "med", "Medium confidence"
return "low", "Low confidence"
def _build_structured_insights(agent_state: dict) -> dict:
"""Convert the agent's KiranaState into typed dashboard rows.
Each row carries the verb + noun + qty + reason needed for Principle 1
(AI suggestions must be concrete). No row renders without all four.
"""
today = datetime.date.today()
# ── Reorder queue (ranked) ────────────────────────────────────────────────
reorders: list[dict] = []
for o in agent_state.get("suggested_orders", []) or []:
pid = o.get("product_id")
prod = db.get_product(pid) if pid else None
name_te = (prod or {}).get("name_local") or ""
on_hand = (prod or {}).get("quantity")
unit = o.get("unit") or ((prod or {}).get("unit") or "")
qty = o.get("qty_needed") or 0
if not o.get("product_name") or not qty or not unit:
continue
conf_pct = int(round((o.get("ai_confidence") or 0) * 100))
conf_cls, conf_label = _confidence_band(conf_pct)
reorders.append({
"pid": pid,
"name": o["product_name"],
"name_te": name_te,
"qty": qty,
"qty_fmt": (f"{qty:g}"),
"unit": unit,
"on_hand": f"{on_hand:g} {unit}" if on_hand is not None else "",
"reason": o.get("reason") or "Below minimum stock.",
"conf_pct": conf_pct,
"conf_cls": conf_cls,
"conf_label": conf_label,
})
reorders.sort(key=lambda r: -r["conf_pct"])
# ── Expiry liquidation (stacked) ──────────────────────────────────────────
liquidation: list[dict] = []
for p in agent_state.get("expired_items", []) or []:
liquidation.append({
"pid": p.get("id"),
"name": p["name"],
"name_te": p.get("name_local") or "",
"qty_fmt": f"{p['quantity']:g} {p['unit']}",
"days_left": -1,
"kind": "expired",
"chip_label": "Past expiry",
"expiry_date": p.get("expiry_date") or "",
"action_verb": "Pull from godown",
"action_hint": "Past expiry. Remove from godown today.",
})
for p in agent_state.get("expiring_items", []) or []:
try:
days = (datetime.date.fromisoformat(p["expiry_date"]) - today).days
except (ValueError, TypeError):
days = 99
if days <= 2:
kind, discount, route = "danger", "15%", "route-3 kiranas"
elif days <= 4:
kind, discount, route = "warn", "12%", "your nearest route"
else:
kind, discount, route = "warn", "10%", "route-3 kiranas"
chip = "Expires today" if days == 0 else ("Expires tomorrow" if days == 1 else f"{days} days left")
liquidation.append({
"pid": p.get("id"),
"name": p["name"],
"name_te": p.get("name_local") or "",
"qty_fmt": f"{p['quantity']:g} {p['unit']}",
"days_left": days,
"kind": kind,
"chip_label": chip,
"expiry_date": p["expiry_date"],
"action_verb": f"Offer {discount} off",
"action_hint": f"Discount {discount} to {route} to clear {p['quantity']:g} {p['unit']} before expiry.",
})
liquidation.sort(key=lambda r: (r["days_left"] if r["days_left"] >= 0 else -1))
# ── Festival demand strip (timeline) ──────────────────────────────────────
inv = agent_state.get("all_products", []) or []
inv_names = {p["name"].lower() for p in inv}
fest_strip: list[dict] = []
for f in (agent_state.get("upcoming_festivals", []) or [])[:4]:
demand_items = f.get("demand_items", []) or []
missing = [i for i in demand_items if i.lower() not in inv_names]
stocked = [i for i in demand_items if i.lower() in inv_names]
prep_days = f.get("prep_days", 14)
# Approximate timing: festival is "this month" or "next month".
# Without exact dates we render "within prep window" / "stocking starts soon".
this_month = today.month in (f.get("months") or [])
urgent = this_month # stocking window has opened
eta_label = "This month" if this_month else "Next month"
fest_strip.append({
"key": f.get("key"),
"name": f["name"],
"name_te": f.get("name_te", ""),
"mult": f.get("demand_multiplier", 1.0),
"mult_fmt": f"{f.get('demand_multiplier', 1.0):g}×",
"prep_days": prep_days,
"eta_label": eta_label,
"urgent": urgent,
"stocked_n": len(stocked),
"demand_n": len(demand_items),
"missing_n": len(missing),
"missing_preview": ", ".join(missing[:3]),
"tip": f.get("tips", ""),
})
return {
"reorders": reorders,
"liquidation": liquidation,
"festival_strip": fest_strip,
"counts": {
"reorders": len(reorders),
"liquidation": len(liquidation),
"festivals": len(fest_strip),
},
}
def _get_insights(force: bool = False) -> tuple[dict, datetime.datetime | None, str | None]:
"""Return (structured_data, last_run_ts, error). Auto-runs analysis on TTL miss."""
now = datetime.datetime.now()
cache = _INSIGHTS_CACHE
fresh = (
cache["data"] is not None
and cache["ts"] is not None
and (now - cache["ts"]).total_seconds() < _INSIGHTS_TTL
)
if fresh and not force:
return cache["data"], cache["ts"], None
try:
from frontend_backend import run_analysis # local import: avoid circular at module load
agent_state = run_analysis()
data = _build_structured_insights(agent_state)
cache["ts"] = now
cache["data"] = data
return data, now, None
except Exception as e: # noqa: BLE001
# Fall back to last good data if available
return (cache["data"] or _empty_insights()), cache["ts"], str(e)
def _empty_insights() -> dict:
return {
"reorders": [], "liquidation": [], "festival_strip": [],
"counts": {"reorders": 0, "liquidation": 0, "festivals": 0},
}
def _format_ts(ts: datetime.datetime | None) -> str:
if ts is None:
return "Never analysed"
delta = datetime.datetime.now() - ts
secs = int(delta.total_seconds())
if secs < 60: return "just now"
if secs < 3600: return f"{secs // 60} min ago"
if secs < 86400: return f"{secs // 3600} hr ago"
return ts.strftime("%b %d, %H:%M")
def _ctx_dashboard(state: dict) -> dict:
insights, ts, err = _get_insights()
return {
"kpis": _build_kpis(),
"pulse": _build_pulse(14),
"health": _build_health(),
"alerts": _build_alerts(),
"festivals": _build_festivals(),
"icons": {
"health": ICON["pulse"],
"alerts": ICON["bell"],
"festivals": ICON["seasonal"],
"ai": ICON["sparkles"],
"play": ICON["play"],
"refresh": ICON["refresh"],
"package": ICON["package"],
"search": ICON["search"],
"trophy": ICON["trophy"],
"plus": ICON["plus"],
"clock": ICON["clock"],
"trend": ICON["trend"],
"shipped": ICON["shipped"],
},
"insights": insights,
"insights_meta": {
"last_run": _format_ts(ts),
"stale": ts is None,
"error": err,
},
}
def _ctx_inventory(state: dict) -> dict:
f = state.get("filters") or {}
q = f.get("q", "") or ""
category = f.get("category", "All") or "All"
status = f.get("status", "All") or "All"
products = db.search_products(q) if q.strip() else db.get_all_products()
if category != "All":
products = [p for p in products if p["category"] == category]
if status != "All":
products = [p for p in products if _status_of(p)[2] == status]
rows = []
for p in products:
label, kind, _ = _status_of(p)
rows.append({
"id": p["id"], "name": p["name"],
"name_local": p["name_local"], "category": p["category"],
"qty_str": f"{p['quantity']} {p['unit']}",
"min_str": f"{p['min_stock']} {p['unit']}",
"sell_price": p["sell_price"],
"expiry": p.get("expiry_date") or "",
"supplier": p.get("supplier") or "",
"status_label": label, "status_kind": kind,
})
return {
"products": rows,
"categories": ["All"] + db.CATEGORIES,
"statuses": STATUS_FILTERS,
"filters": {"q": q, "category": category, "status": status},
}
def _ctx_add(state: dict) -> dict:
return {
"categories": db.CATEGORIES,
"units": db.UNITS,
"active_method": state.get("active_method", "manual"),
"photo_result": state.get("photo_result"),
"voice_result": state.get("voice_result"),
}
def _ctx_orders(state: dict) -> dict:
active_filter = state.get("orders_filter", "pending")
all_orders = db.get_all_orders()
if active_filter != "all":
all_orders = [o for o in all_orders if o["status"] == active_filter]
status_kind = {"pending": "warn", "approved": "success",
"received": "success", "rejected": "danger"}
rows = [{
"id": o["id"], "product_name": o["product_name"],
"product_id": o["product_id"],
"qty_needed": o["qty_needed"],
"unit": o["unit"],
"qty_str": f"{o['qty_needed']} {o['unit']}",
"reason": (o["reason"] or "")[:80],
"confidence": f"{o['ai_confidence']*100:.0f}",
"status_raw": o["status"],
"status": o["status"].upper(),
"status_kind": status_kind.get(o["status"], "neutral"),
"created_at": (o["created_at"] or "")[:16],
} for o in all_orders]
return {
"orders": rows,
"order_filters": ["pending", "approved", "received", "rejected", "all"],
"active_filter": active_filter,
}
def _ctx_analytics(state: dict) -> dict:
try:
days = int(state.get("analytics_days", 30))
except (TypeError, ValueError):
days = 30
if days not in {7, 30, 90}:
days = 30
s = db.get_summary()
val = s.get("total_value", 0) or 0
cost = s.get("cost_value", 0) or 0
margin = val - cost
margin_pct = (margin / val * 100) if val else 0
top = db.get_top_sellers(n=5, days=days)
units_sold = sum(t["total_sold"] for t in top) if top else 0
revenue = sum((t.get("revenue") or 0) for t in top) if top else 0
return {
"categories": _build_categories(),
"sellers": _build_sellers(days),
"analytics_days": days,
"analytics_ranges": [7, 30, 90],
"icon_chart": ICON["analytics"],
"icon_trophy": ICON["trophy"],
"metrics": [
{"label": f"{days}-Day Revenue", "value": f"₹{revenue:,.0f}",
"icon": ICON["rupee"], "color": "success", "foot": "from top 5 sellers"},
{"label": "Units Sold", "value": f"{units_sold:.0f}",
"icon": ICON["shipped"], "color": "primary", "foot": f"last {days} days"},
{"label": "Gross Margin", "value": f"{margin_pct:.1f}%",
"icon": ICON["trend"], "color": "info",
"foot": f"₹{margin:,.0f} held on shelf"},
],
}
def _ctx_seasonal(_state: dict) -> dict:
return {
"festivals": _build_festivals(),
"brief": build_seasonal_summary().replace("\n", "
"),
}
def _ctx_settings(_state: dict) -> dict:
return {
"s": {
"shop_name": db.get_setting("shop_name", ""),
"owner_name": db.get_setting("owner_name", ""),
"region": db.get_setting("region", ""),
"low_stock_days_ahead": db.get_setting("low_stock_days_ahead", "7"),
"expiry_warn_days": db.get_setting("expiry_warn_days", "7"),
},
"vision_online": is_model_available(),
"vision_status": (
"Vision model available (LLaVA)" if is_model_available()
else "Vision model not loaded — run setup.sh to download"
),
}
_CTX = {
"dashboard": _ctx_dashboard,
"inventory": _ctx_inventory,
"add": _ctx_add,
"orders": _ctx_orders,
"analytics": _ctx_analytics,
"seasonal": _ctx_seasonal,
"settings": _ctx_settings,
}
# ──────────────────────────────────────────────────────────────────────────────
# Top-bar action buttons
# ──────────────────────────────────────────────────────────────────────────────
def _theme_toggle() -> str:
return (
''
)
def _topbar_actions(page: str) -> str:
today = datetime.date.today().strftime("%a, %d %b %Y")
badge = f'{today}'
toggle = _theme_toggle()
if page == "dashboard":
return (
badge +
''
''
+ toggle
)
if page == "inventory":
return (
badge +
''
+ toggle
)
if page == "orders":
return (
badge +
''
+ toggle
)
return badge + toggle
# ──────────────────────────────────────────────────────────────────────────────
# Public render entry
# ──────────────────────────────────────────────────────────────────────────────
def render(page: str, state: dict | None = None, toast: str = "") -> str:
state = state or {}
if page not in _CTX:
page = "dashboard"
nav_info = NAV_PAGES_BY_KEY[page]
body_template = env.get_template(f"{page}.html")
ctx = _CTX[page](state)
ctx.setdefault("icons", {})
for _k in ("seasonal", "sparkles", "package", "bell", "info", "clock",
"edit", "trash", "store", "settings", "mic", "camera",
"plus", "refresh", "search", "trend", "pulse", "rupee",
"shipped", "trophy", "alert", "check", "play", "x"):
if _k in ICON:
ctx["icons"].setdefault(_k, ICON[_k])
# Aliases used by dashboard
_aliases = {"health": "pulse", "alerts": "bell",
"festivals": "seasonal", "ai": "sparkles"}
for _a, _src in _aliases.items():
ctx["icons"].setdefault(_a, ICON[_src])
body = body_template.render(**ctx)
return env.get_template("base.html").render(
page=page,
nav=_nav_with_badges(),
title=nav_info["title"],
subtitle=nav_info["subtitle"],
topbar_actions=_topbar_actions(page),
body=body,
logo_svg=LOGO_SVG,
shop_name=db.get_setting("shop_name", "My Kirana Store"),
region=db.get_setting("region", "India"),
vision_online=is_model_available(),
toast=toast,
)
NAV_PAGES_BY_KEY = {p["key"]: p for p in NAV_PAGES}