Reads our Candy PDF (deterministically, no OCR or model needed) and populates the consultant's own workbook using their formulas, column layout and PAYMENT column. Nothing is ever silently overwritten: every disagreement is reported, can be settled inside the app, and regenerated. - extract Candy text PDF -> tree (x-position column detection) - template layout detect, sequence align, write, totals, overrides - validate qty x rate and children-vs-subtotal reconciliation - profiles per-consultant layout profiles, stored on a volume Shipped with a Dockerfile + docker-compose for Coolify.
190 lines
7.5 KiB
Python
190 lines
7.5 KiB
Python
"""
|
|
Design system for the Organic / Natural style — moss, clay, and unbleached paper.
|
|
|
|
The tokens live in `static/theme.css` as CSS custom properties, NOT here.
|
|
That is deliberate: injecting a <style> block through `st.markdown` is fragile
|
|
in Streamlit (it duplicates on every rerun, and a single malformed selector
|
|
silently kills every rule after it - which happened here once already). A
|
|
served stylesheet is idempotent, cacheable, and a designer can open and retune
|
|
it without touching Python.
|
|
|
|
This module only injects the <link> once per session and provides the small
|
|
card helper used for findings.
|
|
|
|
Palette (design system, unchanged):
|
|
background #FDFCF8 rice paper foreground #2C2C24 deep loam
|
|
primary #5D7052 moss green secondary #C18C5D terracotta
|
|
accent #E6DCCD sand muted #F0EBE5 stone
|
|
border #DED8CF raw timber destructive #A85448 burnt sienna
|
|
|
|
Contrast on the paper background: #2C2C24 is 14.5:1 (AAA), moss 6.2:1 (AA),
|
|
muted text 4.8:1 (AA).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import html as _html
|
|
from pathlib import Path
|
|
|
|
import streamlit as st
|
|
|
|
CSS_PATH = Path(__file__).resolve().parent.parent / "static" / "theme.css"
|
|
STYLESHEET_URL = "app/static/theme.css" # diagnostic only; see inject()
|
|
|
|
_FONT_LINK = (
|
|
'<link rel="preconnect" href="https://fonts.googleapis.com">'
|
|
'<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>'
|
|
'<link rel="stylesheet" href="https://fonts.googleapis.com/css2?'
|
|
'family=Fraunces:opsz,wght@9..144,600;9..144,700;9..144,800'
|
|
'&family=Nunito:wght@400;500;600;700&display=swap">'
|
|
)
|
|
|
|
|
|
def inject() -> None:
|
|
"""Attach the webfonts and the stylesheet. Safe to call on every rerun.
|
|
|
|
Why `st.html` and not `st.markdown`: markdown injection duplicated the
|
|
style block on each rerun, and one malformed selector silently killed every
|
|
rule after it - the page rendered unstyled with no error anywhere. Serving
|
|
the CSS from `static/` was tried too, but Streamlit only serves its own
|
|
hashed assets from that route; an arbitrary path falls through to the SPA
|
|
shell and returns index.html as text/html. `st.html` inserts the sheet
|
|
into the page, is not iframed, and replaces cleanly on rerun.
|
|
"""
|
|
css = CSS_PATH.read_text(encoding="utf-8")
|
|
st.html(_FONT_LINK, unsafe_allow_javascript=False)
|
|
st.html(f"<style>{css}</style>", unsafe_allow_javascript=False)
|
|
|
|
|
|
SEVERITY_CSS = {
|
|
"decision": "boq-card-decision", # a person has to make a call
|
|
"source_gap": "boq-card-gap", # our pdf has no figure; theirs is better
|
|
"source_defect": "boq-card-defect", # our pdf printed something corrupt
|
|
"resolved": "boq-card-resolved",
|
|
"note": "boq-card-note",
|
|
}
|
|
|
|
SEVERITY_SWATCH = {
|
|
"decision": ("#8C363B", "needs a decision"),
|
|
"source_gap": ("#DF8441", "our PDF has no figure - theirs kept"),
|
|
"source_defect": ("#FEA944", "our PDF printed something corrupt"),
|
|
"resolved": ("#00CD8A", "settled by the consultant"),
|
|
"note": ("#00CD8A", "no action needed"),
|
|
}
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# icons
|
|
# --------------------------------------------------------------------------
|
|
# Real inline SVG, not emoji. Emoji render differently on every platform, read
|
|
# as emoticons, and cannot be sized or coloured to match the brand. These are
|
|
# all 24x24 / 1.75 stroke / round caps so the set looks drawn by one hand.
|
|
# Inline rather than a CDN icon font, because this app is meant to work with no
|
|
# network - that is the point of keeping it local.
|
|
_PATHS = {
|
|
"building": (
|
|
'<path d="M5 21V4a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v17"/>'
|
|
'<path d="M5 12H3.5A1.5 1.5 0 0 0 2 13.5V19a2 2 0 0 0 2 2h1"/>'
|
|
'<path d="M17 9h1.5A1.5 1.5 0 0 1 20 10.5V19a2 2 0 0 1-2 2h-1"/>'
|
|
'<path d="M9 6h4M9 10h4M9 14h4M9 18h4"/>'
|
|
),
|
|
"file": (
|
|
'<path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 '
|
|
'2-2V8Z"/><path d="M14 3v4a1 1 0 0 0 1 1h4"/>'
|
|
'<path d="M9 12h6M9 16h4"/>'
|
|
),
|
|
"table": (
|
|
'<rect x="3" y="4" width="18" height="16" rx="2.5"/>'
|
|
'<path d="M3 9.5h18M3 14.5h18M9.5 4v16M15 4v16"/>'
|
|
),
|
|
"alert": (
|
|
'<path d="M10.3 3.9 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3'
|
|
'L13.7 3.9a2 2 0 0 0-3.4 0Z"/><path d="M12 9v4.5"/>'
|
|
'<path d="M12 17.2h.01"/>'
|
|
),
|
|
"calculator": (
|
|
'<rect x="4" y="2.5" width="16" height="19" rx="2.5"/>'
|
|
'<path d="M8 6.5h8"/>'
|
|
'<path d="M8.5 11h.01M12 11h.01M15.5 11h.01M8.5 14.5h.01M12 14.5h.01'
|
|
'M15.5 14.5h.01M8.5 18h.01M12 18h.01M15.5 18h.01"/>'
|
|
),
|
|
"clipboard": (
|
|
'<rect x="8" y="2.5" width="8" height="3.5" rx="1.2"/>'
|
|
'<path d="M16 4.5h2a2 2 0 0 1 2 2V19a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2'
|
|
'V6.5a2 2 0 0 1 2-2h2"/><path d="m9 13.5 2 2 4-4"/>'
|
|
),
|
|
"download": (
|
|
'<path d="M21 15v3.5a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V15"/>'
|
|
'<path d="m7.5 10.5 4.5 4.5 4.5-4.5"/><path d="M12 15V3"/>'
|
|
),
|
|
"refresh": (
|
|
'<path d="M20.5 12a8.5 8.5 0 0 1-14.6 5.9L3 15"/>'
|
|
'<path d="M3 20v-5h5"/>'
|
|
'<path d="M3.5 12A8.5 8.5 0 0 1 18.1 6.1L21 9"/>'
|
|
'<path d="M21 4v5h-5"/>'
|
|
),
|
|
"check": (
|
|
'<circle cx="12" cy="12" r="9.25"/>'
|
|
'<path d="m8 12.2 2.8 2.8L16.2 9.5"/>'
|
|
),
|
|
"shield": (
|
|
'<path d="M12 21.5s7.5-3.5 7.5-9.5V5.6L12 2.5 4.5 5.6V12c0 6 7.5 9.5 '
|
|
'7.5 9.5Z"/><path d="m9 11.8 2.2 2.2 4-4"/>'
|
|
),
|
|
"info": (
|
|
'<circle cx="12" cy="12" r="9.25"/><path d="M12 16.5V11"/>'
|
|
'<path d="M12 7.8h.01"/>'
|
|
),
|
|
"user": (
|
|
'<circle cx="12" cy="8" r="3.75"/>'
|
|
'<path d="M4.5 20.5a7.5 7.5 0 0 1 15 0"/>'
|
|
),
|
|
"layers": (
|
|
'<path d="m12 2.8 9 4.7-9 4.7-9-4.7Z"/><path d="m3 12.5 9 4.7 9-4.7"/>'
|
|
'<path d="m3 17.2 9 4.7 9-4.7"/>'
|
|
),
|
|
}
|
|
|
|
|
|
def icon(name: str, size: int = 18, cls: str = "") -> str:
|
|
"""An inline SVG icon. Uses currentColor, so it picks up the theme."""
|
|
body = _PATHS.get(name, "")
|
|
if not body:
|
|
return ""
|
|
return (
|
|
f'<svg class="boq-icon {cls}" width="{size}" height="{size}" '
|
|
f'viewBox="0 0 24 24" fill="none" stroke="currentColor" '
|
|
f'stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" '
|
|
f'aria-hidden="true" focusable="false">{body}</svg>'
|
|
)
|
|
|
|
|
|
def pill(text: str, icon_name: str = "", tone: str = "muted") -> str:
|
|
"""A quiet inline note, used in place of a full alert block.
|
|
|
|
st.warning paints a filled bar and shouts. This is background information
|
|
the reader needs once, so it belongs at the volume the legend sits at.
|
|
"""
|
|
return (
|
|
f'<div class="boq-pill boq-pill-{tone}">'
|
|
f"{icon(icon_name, 15)}<span>{text}</span></div>"
|
|
)
|
|
|
|
|
|
def card(title: str, body: str, meta: str = "", severity: str = "note") -> str:
|
|
"""One finding as a card. Returns HTML so long sentences wrap naturally.
|
|
|
|
Streamlit's dataframe has no text-wrapping option - `column_config` in
|
|
1.64 offers width but not wrap - and findings are prose, not figures. A
|
|
truncated table cell is exactly the failure this avoids.
|
|
"""
|
|
cls = SEVERITY_CSS.get(severity, "boq-card-note")
|
|
meta_html = (f'<div class="boq-card-meta">{_html.escape(meta)}</div>'
|
|
if meta else "")
|
|
return (
|
|
f'<div class="boq-card {cls}">'
|
|
f"{meta_html}"
|
|
f'<div class="boq-card-title">{_html.escape(title)}</div>'
|
|
f'<div class="boq-card-body">{_html.escape(body)}</div>'
|
|
f"</div>"
|
|
)
|