add
This commit is contained in:
1
app_core/__init__.py
Normal file
1
app_core/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# App Core Package
|
||||
BIN
app_core/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
app_core/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
1
app_core/config/__init__.py
Normal file
1
app_core/config/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Config Package
|
||||
BIN
app_core/config/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
app_core/config/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app_core/config/__pycache__/settings.cpython-312.pyc
Normal file
BIN
app_core/config/__pycache__/settings.cpython-312.pyc
Normal file
Binary file not shown.
78
app_core/config/settings.py
Normal file
78
app_core/config/settings.py
Normal file
@@ -0,0 +1,78 @@
|
||||
import os
|
||||
from dotenv import load_dotenv
|
||||
from pydantic import BaseModel
|
||||
|
||||
# Load .env first (if present)
|
||||
load_dotenv(dotenv_path=".env", override=False)
|
||||
# Also load .env-example.txt as a fallback for local dev (does not override)
|
||||
load_dotenv(dotenv_path=".env-example.txt", override=False)
|
||||
|
||||
class AppSettings(BaseModel):
|
||||
# Raw pieces
|
||||
db_host: str | None = os.getenv("DB_HOST")
|
||||
db_port: str | None = os.getenv("DB_PORT")
|
||||
db_name: str | None = os.getenv("DB_NAME")
|
||||
db_user: str | None = os.getenv("DB_USER")
|
||||
db_password: str | None = os.getenv("DB_PASSWORD")
|
||||
db_echo: bool = os.getenv("DB_ECHO", "false").lower() == "true"
|
||||
|
||||
# Optional complete URL (takes precedence if set)
|
||||
database_url_env: str | None = os.getenv("DATABASE_URL")
|
||||
|
||||
app_secret: str = os.getenv("APP_SECRET", "change_me")
|
||||
background_image_url: str | None = os.getenv("BACKGROUND_IMAGE_URL")
|
||||
|
||||
# SMTP / Email settings
|
||||
smtp_host: str | None = os.getenv("SMTP_HOST")
|
||||
smtp_port: int | None = int(os.getenv("SMTP_PORT", "587"))
|
||||
smtp_user: str | None = os.getenv("SMTP_USER")
|
||||
smtp_password: str | None = os.getenv("SMTP_PASSWORD")
|
||||
smtp_use_tls: bool = os.getenv("SMTP_USE_TLS", "true").lower() == "true"
|
||||
smtp_from_email: str | None = os.getenv("SMTP_FROM_EMAIL")
|
||||
smtp_from_name: str = os.getenv("SMTP_FROM_NAME", "Workolik Team")
|
||||
|
||||
# Default recipients for automated reports (comma-separated)
|
||||
report_recipients: str | None = os.getenv("REPORT_RECIPIENTS")
|
||||
|
||||
@property
|
||||
def database_url(self) -> str:
|
||||
if self.database_url_env:
|
||||
# Normalize asyncpg to psycopg2 if needed
|
||||
if self.database_url_env.startswith("postgresql+asyncpg://"):
|
||||
return self.database_url_env.replace(
|
||||
"postgresql+asyncpg://", "postgresql+psycopg2://", 1
|
||||
)
|
||||
return self.database_url_env
|
||||
# Build from parts
|
||||
if all([self.db_host, self.db_port, self.db_name, self.db_user, self.db_password]):
|
||||
return (
|
||||
f"postgresql+psycopg2://{self.db_user}:{self.db_password}"
|
||||
f"@{self.db_host}:{self.db_port}/{self.db_name}"
|
||||
)
|
||||
# Fallback empty (will error at runtime if used)
|
||||
return ""
|
||||
|
||||
# Fixed mapping of stores to tenant IDs and division codes
|
||||
# Used by analytics and data pages to scope queries per store
|
||||
STORES = [
|
||||
{"label": "Porters Liquor Claremont - PC", "code": "PC", "tenant_id": 1},
|
||||
{"label": "Porters Iluka - IP", "code": "IP", "tenant_id": 2},
|
||||
{"label": "Cellarbrations at Morris Place - ML", "code": "ML", "tenant_id": 3},
|
||||
{"label": "Cellarbrations at Lynwood - CL", "code": "CL4", "tenant_id": 4},
|
||||
{"label": "Cellarbrations at Nicholson Road - NL", "code": "NL", "tenant_id": 5},
|
||||
{"label": "Cellarbrations at Treeby - CL ", "code": "CL6", "tenant_id": 6},
|
||||
{"label": "The Bottle-O Rossmoyne - RC", "code": "RC", "tenant_id": 7},
|
||||
{"label": "Porters Liquor Piara Waters - PL", "code": "PL", "tenant_id": 8},
|
||||
]
|
||||
|
||||
# Helper map for quick lookups by code (supports variants like CL-4 → CL4)
|
||||
STORE_CODE_TO_TENANT_ID: dict[str, int] = {
|
||||
"PC": 1,
|
||||
"IP": 2,
|
||||
"ML": 3,
|
||||
"CL4": 4, "CL-4": 4, "CL_4": 4, "CL": 4, # default CL → 4
|
||||
"NL": 5, "NL5": 5, "NL-5": 5,
|
||||
"CL6": 6, "CL-6": 6, "CL_6": 6,
|
||||
"RC": 7,
|
||||
"PL": 8,
|
||||
}
|
||||
1
app_core/db/__init__.py
Normal file
1
app_core/db/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Database Package
|
||||
BIN
app_core/db/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
app_core/db/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app_core/db/__pycache__/database.cpython-312.pyc
Normal file
BIN
app_core/db/__pycache__/database.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app_core/db/__pycache__/models.cpython-312.pyc
Normal file
BIN
app_core/db/__pycache__/models.cpython-312.pyc
Normal file
Binary file not shown.
21
app_core/db/database.py
Normal file
21
app_core/db/database.py
Normal file
@@ -0,0 +1,21 @@
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker, declarative_base
|
||||
from app_core.config.settings import AppSettings
|
||||
|
||||
settings = AppSettings()
|
||||
|
||||
if not settings.database_url:
|
||||
raise RuntimeError(
|
||||
"Database configuration missing. Set DATABASE_URL or DB_HOST/DB_PORT/DB_NAME/DB_USER/DB_PASSWORD in a .env file at the project root."
|
||||
)
|
||||
|
||||
engine = create_engine(settings.database_url, pool_pre_ping=True, future=True, echo=settings.db_echo)
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine, future=True)
|
||||
Base = declarative_base()
|
||||
|
||||
def get_db_session():
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
36
app_core/db/models.py
Normal file
36
app_core/db/models.py
Normal file
@@ -0,0 +1,36 @@
|
||||
from sqlalchemy import Column, Integer, String, DateTime, func, UniqueConstraint
|
||||
from .database import Base
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "workolik_users"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("email", name="uq_workolik_users_email"),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
email = Column(String(255), nullable=False, unique=True, index=True)
|
||||
password_hash = Column(String(255), nullable=False)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||||
|
||||
|
||||
class EmailLog(Base):
|
||||
__tablename__ = "email_logs"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
sent_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||||
recipients = Column(String(1024), nullable=False)
|
||||
subject = Column(String(255), nullable=False)
|
||||
status = Column(String(50), nullable=False) # sent / failed
|
||||
error = Column(String(1024))
|
||||
date_for = Column(String(32), nullable=False)
|
||||
|
||||
class TriumphDebtorMapping(Base):
|
||||
__tablename__ = "triumph_debtor_mappings"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
code = Column(String(50))
|
||||
name = Column(String(255))
|
||||
dbmacc = Column(String(50))
|
||||
outlet = Column(String(255))
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
BIN
app_core/services/__pycache__/auth_service.cpython-312.pyc
Normal file
BIN
app_core/services/__pycache__/auth_service.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app_core/services/__pycache__/daily_report.cpython-312.pyc
Normal file
BIN
app_core/services/__pycache__/daily_report.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app_core/services/__pycache__/mailer_service.cpython-312.pyc
Normal file
BIN
app_core/services/__pycache__/mailer_service.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app_core/services/__pycache__/mappings_service.cpython-312.pyc
Normal file
BIN
app_core/services/__pycache__/mappings_service.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app_core/services/__pycache__/scheduler_service.cpython-312.pyc
Normal file
BIN
app_core/services/__pycache__/scheduler_service.cpython-312.pyc
Normal file
Binary file not shown.
46
app_core/services/auth_service.py
Normal file
46
app_core/services/auth_service.py
Normal file
@@ -0,0 +1,46 @@
|
||||
from typing import Optional
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import select
|
||||
import bcrypt
|
||||
from app_core.db.database import engine, Base, SessionLocal
|
||||
from app_core.db.models import User
|
||||
|
||||
# Create tables on import
|
||||
Base.metadata.create_all(bind=engine)
|
||||
|
||||
class AuthService:
|
||||
def __init__(self) -> None:
|
||||
self._session_factory = SessionLocal
|
||||
|
||||
def _hash_password(self, raw_password: str) -> str:
|
||||
salt = bcrypt.gensalt()
|
||||
return bcrypt.hashpw(raw_password.encode("utf-8"), salt).decode("utf-8")
|
||||
|
||||
def _verify_password(self, raw_password: str, hashed: str) -> bool:
|
||||
try:
|
||||
return bcrypt.checkpw(raw_password.encode("utf-8"), hashed.encode("utf-8"))
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def signup(self, email: str, password: str) -> tuple[bool, str]:
|
||||
email = email.strip().lower()
|
||||
if not email or not password:
|
||||
return False, "Email and password are required."
|
||||
with self._session_factory() as db: # type: Session
|
||||
exists = db.execute(select(User).where(User.email == email)).scalar_one_or_none()
|
||||
if exists:
|
||||
return False, "Email already registered."
|
||||
user = User(email=email, password_hash=self._hash_password(password))
|
||||
db.add(user)
|
||||
db.commit()
|
||||
return True, "Account created. Please login."
|
||||
|
||||
def login(self, email: str, password: str) -> tuple[bool, Optional[dict], str]:
|
||||
email = email.strip().lower()
|
||||
if not email or not password:
|
||||
return False, None, "Email and password are required."
|
||||
with self._session_factory() as db: # type: Session
|
||||
user = db.execute(select(User).where(User.email == email)).scalar_one_or_none()
|
||||
if not user or not self._verify_password(password, user.password_hash):
|
||||
return False, None, "Invalid credentials."
|
||||
return True, {"id": user.id, "email": user.email}, "Login successful."
|
||||
53
app_core/services/daily_report.py
Normal file
53
app_core/services/daily_report.py
Normal file
@@ -0,0 +1,53 @@
|
||||
import os
|
||||
from datetime import datetime
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from app_core.config.settings import AppSettings
|
||||
from app_core.services.mailer_service import MailerService
|
||||
|
||||
|
||||
def main(for_date: str | None = None, force: bool = False) -> int:
|
||||
settings = AppSettings()
|
||||
service = MailerService(settings)
|
||||
|
||||
if for_date:
|
||||
try:
|
||||
chosen = datetime.strptime(for_date, "%Y-%m-%d").date()
|
||||
except ValueError:
|
||||
print(f"Invalid date format: {for_date}. Expected YYYY-MM-DD.")
|
||||
return 1
|
||||
else:
|
||||
today_ist = datetime.now(ZoneInfo("Asia/Kolkata")).date()
|
||||
chosen = service.select_report_date(preferred=today_ist)
|
||||
|
||||
if not chosen:
|
||||
print("No data available to send.")
|
||||
return 1
|
||||
|
||||
if not force and service.has_sent_for_date(str(chosen)):
|
||||
print(f"Already sent for {chosen}; skipping.")
|
||||
return 0
|
||||
|
||||
df = service.fetch_daily_rows(chosen)
|
||||
if df.empty:
|
||||
print("Selected date has no rows; nothing to send.")
|
||||
return 0
|
||||
|
||||
row = df.iloc[0].to_dict()
|
||||
html = service.build_email_html(row, df)
|
||||
|
||||
recipients_env = settings.report_recipients or os.getenv("REPORT_RECIPIENTS")
|
||||
if not recipients_env:
|
||||
print("REPORT_RECIPIENTS env var is empty. Set it to comma-separated emails.")
|
||||
return 2
|
||||
recipients = [r.strip() for r in recipients_env.split(',') if r.strip()]
|
||||
ok, msg = service.send_email(recipients, subject=f"Daily Digest - {chosen}", html=html)
|
||||
service.log_email(recipients, subject=f"Daily Digest - {chosen}", date_for=str(chosen), status="sent" if ok else "failed", error=None if ok else msg)
|
||||
print("Sent" if ok else f"Failed: {msg}")
|
||||
return 0 if ok else 3
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
||||
|
||||
327
app_core/services/mailer_service.py
Normal file
327
app_core/services/mailer_service.py
Normal file
@@ -0,0 +1,327 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import smtplib
|
||||
import sys
|
||||
import os
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from email.mime.text import MIMEText
|
||||
from typing import List, Tuple
|
||||
from datetime import date
|
||||
from datetime import date
|
||||
import pandas as pd
|
||||
from sqlalchemy import text
|
||||
import streamlit as st
|
||||
|
||||
# Add the project root to Python path
|
||||
project_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
if project_root not in sys.path:
|
||||
sys.path.insert(0, project_root)
|
||||
|
||||
from app_core.config.settings import AppSettings, STORES
|
||||
from app_core.db.database import engine, SessionLocal
|
||||
from app_core.db.models import EmailLog
|
||||
|
||||
|
||||
class MailerService:
|
||||
def __init__(self, settings: AppSettings | None = None) -> None:
|
||||
self.settings = settings or AppSettings()
|
||||
|
||||
def fetch_daily_rows(self, report_date) -> pd.DataFrame:
|
||||
sql = (
|
||||
'SELECT * FROM "tenantpostings" '
|
||||
'WHERE "created_at"::date = %(d)s '
|
||||
'ORDER BY "id" DESC '
|
||||
'LIMIT 10000'
|
||||
)
|
||||
with engine.connect() as conn:
|
||||
df = pd.read_sql(sql, conn, params={"d": report_date})
|
||||
return df
|
||||
|
||||
def select_report_date(self, preferred: date | None = None) -> date | None:
|
||||
"""Return preferred date if it has data; else most recent date with data; else None."""
|
||||
with engine.connect() as conn:
|
||||
dates_df = pd.read_sql(
|
||||
'SELECT "created_at"::date AS d, COUNT(*) AS c\n'
|
||||
'FROM "tenantpostings"\n'
|
||||
'GROUP BY d\n'
|
||||
'ORDER BY d DESC',
|
||||
conn,
|
||||
)
|
||||
if dates_df.empty:
|
||||
return None
|
||||
# Normalize
|
||||
if 'd' not in dates_df.columns:
|
||||
return None
|
||||
dates_df['d'] = pd.to_datetime(dates_df['d'], errors='coerce')
|
||||
available = [d.date() for d in dates_df['d'].dropna().tolist()]
|
||||
if preferred and preferred in available:
|
||||
return preferred
|
||||
return available[0] if available else None
|
||||
|
||||
def build_email_html(self, row: dict, df: pd.DataFrame | None = None) -> str:
|
||||
outlet = row.get("outlet_name") or row.get("register_name") or "Outlet"
|
||||
division = row.get("division_code") or "PC"
|
||||
status = (row.get("triumph_status") or "Posted successfully").capitalize()
|
||||
register_close_id = row.get("register_close_id", "—")
|
||||
register_id = row.get("register_id", "—")
|
||||
|
||||
def lines_for(ptype: str) -> list[str]:
|
||||
"""Return formatted lines for all rows of a processing_type.
|
||||
Example line: 3,616.19 (Event ID: 2904783)
|
||||
"""
|
||||
if df is None or df.empty or 'processing_type' not in df.columns:
|
||||
return []
|
||||
sub = df[df['processing_type'].astype(str).str.upper() == ptype.upper()] if 'processing_type' in df.columns else pd.DataFrame()
|
||||
if sub.empty:
|
||||
return []
|
||||
|
||||
# De-duplicate by triumph_event to avoid double-counting retries
|
||||
if 'triumph_event' in sub.columns:
|
||||
sub = sub.sort_values(['triumph_event', 'id'], ascending=[True, False]).drop_duplicates(subset=['triumph_event'], keep='first')
|
||||
|
||||
result: list[str] = []
|
||||
for _, r in sub.sort_values('id', ascending=False).iterrows():
|
||||
amt = r.get('total_amount')
|
||||
evt = r.get('triumph_event', '—')
|
||||
try:
|
||||
amt_str = f"{float(amt):,.2f}"
|
||||
except Exception:
|
||||
amt_str = str(amt) if amt is not None else '—'
|
||||
result.append(f"<span style=\"font-weight:600;\">{amt_str}</span> (Event ID: <span style=\"font-weight:600;\">{evt}</span>)")
|
||||
return result
|
||||
|
||||
journal_lines = lines_for('JOURNAL')
|
||||
bank_journal_lines = lines_for('BANKING_JOURNAL')
|
||||
invoice_lines = lines_for('INVOICE')
|
||||
receipt_lines = lines_for('RECEIPT')
|
||||
|
||||
# Optional: transaction summary by store (single table)
|
||||
store_summary_table_html = ""
|
||||
events_matrix_html = ""
|
||||
if isinstance(df, pd.DataFrame) and not df.empty and ('tenant_id' in df.columns):
|
||||
def summarize_for(store: dict) -> dict[str, str]:
|
||||
sid = store.get('tenant_id')
|
||||
name = store.get('label')
|
||||
sub = df[df['tenant_id'] == sid]
|
||||
|
||||
# De-duplicate each processing type context within the store
|
||||
if not sub.empty and 'triumph_event' in sub.columns and 'processing_type' in sub.columns:
|
||||
# Filter out non-event rows before dedupe if necessary, but here we just dedupe everything with an event ID
|
||||
# We keep the one with highest ID for each event
|
||||
has_event = sub['triumph_event'].fillna('').astype(str).str.strip() != ''
|
||||
sub_with_events = sub[has_event].sort_values(['processing_type', 'triumph_event', 'id'], ascending=[True, True, False]).drop_duplicates(subset=['processing_type', 'triumph_event'], keep='first')
|
||||
sub_no_events = sub[~has_event]
|
||||
sub = pd.concat([sub_with_events, sub_no_events]).sort_values('id', ascending=False)
|
||||
|
||||
def pick_total(kind: str) -> tuple[str, int]:
|
||||
if sub.empty or 'processing_type' not in sub.columns:
|
||||
return ("0.00", 0)
|
||||
s = sub[sub['processing_type'].astype(str).str.upper() == kind]
|
||||
if s.empty:
|
||||
return ("0.00", 0)
|
||||
try:
|
||||
total = float(s['total_amount'].fillna(0).sum()) if 'total_amount' in s.columns else 0.0
|
||||
except Exception:
|
||||
total = 0.0
|
||||
return (f"{total:,.2f}", len(s))
|
||||
def has_rows(kind: str) -> bool:
|
||||
if sub.empty or 'processing_type' not in sub.columns:
|
||||
return False
|
||||
s = sub[sub['processing_type'].astype(str).str.upper() == kind]
|
||||
return not s.empty
|
||||
def latest_event(kind: str) -> str:
|
||||
if sub.empty or 'processing_type' not in sub.columns:
|
||||
return "—"
|
||||
s = sub[sub['processing_type'].astype(str).str.upper() == kind]
|
||||
if s.empty:
|
||||
return "—"
|
||||
series = s.get('triumph_event') if 'triumph_event' in s.columns else None
|
||||
if series is None or series.empty:
|
||||
return "—"
|
||||
try:
|
||||
return str(series.dropna().astype(str).iloc[0])
|
||||
except Exception:
|
||||
return "—"
|
||||
def latest_status_emoji(kind: str) -> str:
|
||||
if sub.empty or 'processing_type' not in sub.columns:
|
||||
return ""
|
||||
s = sub[sub['processing_type'].astype(str).str.upper() == kind]
|
||||
if s.empty:
|
||||
return ""
|
||||
status_series = s.get('triumph_status') if 'triumph_status' in s.columns else None
|
||||
if status_series is None or status_series.empty:
|
||||
return ""
|
||||
try:
|
||||
val = str(status_series.iloc[0]).strip().lower()
|
||||
except Exception:
|
||||
val = ""
|
||||
if any(x in val for x in ["success", "ok", "completed", "done"]):
|
||||
return " ✅"
|
||||
if any(x in val for x in ["fail", "error", "invalid", "dead"]):
|
||||
return " ❌"
|
||||
if any(x in val for x in ["pending", "queue", "waiting", "processing"]):
|
||||
return " ⚠️"
|
||||
return ""
|
||||
j_total, _ = pick_total('JOURNAL')
|
||||
b_total, _ = pick_total('BANKING_JOURNAL')
|
||||
i_total, _ = pick_total('INVOICE')
|
||||
r_total, _ = pick_total('RECEIPT')
|
||||
j_eid = latest_event('JOURNAL'); j_stat = latest_status_emoji('JOURNAL')
|
||||
b_eid = latest_event('BANKING_JOURNAL'); b_stat = latest_status_emoji('BANKING_JOURNAL')
|
||||
i_eid = latest_event('INVOICE'); i_stat = latest_status_emoji('INVOICE')
|
||||
r_eid = latest_event('RECEIPT'); r_stat = latest_status_emoji('RECEIPT')
|
||||
def render_cell(exists: bool, total: str, eid: str, stat: str, ptype: str = "") -> str:
|
||||
if not exists:
|
||||
return "<span style=\"color:#9AA4B2;\">Nill</span>"
|
||||
|
||||
# For INVOICE and RECEIPT, show individual line items if multiple exist
|
||||
if ptype.upper() in ['INVOICE', 'RECEIPT'] and sub is not None and not sub.empty:
|
||||
type_sub = sub[sub['processing_type'].astype(str).str.upper() == ptype.upper()]
|
||||
if len(type_sub) > 1: # Multiple transactions
|
||||
individual_lines = []
|
||||
for _, r in type_sub.sort_values('id', ascending=False).iterrows():
|
||||
amt = r.get('total_amount')
|
||||
evt = r.get('triumph_event', '—')
|
||||
status_val = str(r.get('triumph_status', '')).strip().lower()
|
||||
status_emoji = ""
|
||||
if any(x in status_val for x in ["success", "ok", "completed", "done"]):
|
||||
status_emoji = " ✅"
|
||||
elif any(x in status_val for x in ["fail", "error", "invalid", "dead"]):
|
||||
status_emoji = " ❌"
|
||||
elif any(x in status_val for x in ["pending", "queue", "waiting", "processing"]):
|
||||
status_emoji = " ⚠️"
|
||||
try:
|
||||
amt_str = f"{float(amt):,.2f}"
|
||||
except Exception:
|
||||
amt_str = str(amt) if amt is not None else '—'
|
||||
individual_lines.append(f"<div style=\"font-size:11px;margin:1px 0;\">{amt_str} ({evt}){status_emoji}</div>")
|
||||
|
||||
return f"<strong>{total}</strong><br/><span style=\"color:#64748b;font-size:10px;\">Total ({len(type_sub)} items)</span><br/>{''.join(individual_lines)}"
|
||||
|
||||
return f"<strong>{total}</strong><br/><span style=\"color:#64748b\">({eid})</span> {stat}"
|
||||
|
||||
return {
|
||||
"name": name,
|
||||
"journal": render_cell(has_rows('JOURNAL'), j_total, j_eid, j_stat),
|
||||
"banking": render_cell(has_rows('BANKING_JOURNAL'), b_total, b_eid, b_stat),
|
||||
"invoice": render_cell(has_rows('INVOICE'), i_total, i_eid, i_stat, 'INVOICE'),
|
||||
"receipt": render_cell(has_rows('RECEIPT'), r_total, r_eid, r_stat, 'RECEIPT'),
|
||||
}
|
||||
|
||||
rows = [summarize_for(s) for s in STORES]
|
||||
# Build single HTML table
|
||||
header = (
|
||||
"<tr>"
|
||||
"<th style=\"text-align:left;padding:10px;color:#E2E8F0;\">Store Name</th>"
|
||||
"<th style=\"text-align:left;padding:10px;color:#E2E8F0;\">Journal</th>"
|
||||
"<th style=\"text-align:left;padding:10px;color:#E2E8F0;\">Banking Journal</th>"
|
||||
"<th style=\"text-align:left;padding:10px;color:#E2E8F0;\">Account Sales</th>"
|
||||
"<th style=\"text-align:left;padding:10px;color:#E2E8F0;\">Account Payments</th>"
|
||||
"</tr>"
|
||||
)
|
||||
body = []
|
||||
for r in rows:
|
||||
body.append(
|
||||
"<tr>"
|
||||
f"<td style=\"padding:10px;border-top:1px solid #1F2937;color:#F8FAFC;\">{r['name']}</td>"
|
||||
f"<td style=\"padding:10px;border-top:1px solid #1F2937;color:#F8FAFC;\">{r['journal']}</td>"
|
||||
f"<td style=\"padding:10px;border-top:1px solid #1F2937;color:#F8FAFC;\">{r['banking']}</td>"
|
||||
f"<td style=\"padding:10px;border-top:1px solid #1F2937;color:#F8FAFC;\">{r['invoice']}</td>"
|
||||
f"<td style=\"padding:10px;border-top:1px solid #1F2937;color:#F8FAFC;\">{r['receipt']}</td>"
|
||||
"</tr>"
|
||||
)
|
||||
store_summary_table_html = (
|
||||
"<div style=\"background:#111827;border-radius:12px;padding:12px;\">"
|
||||
"<div style=\"font-weight:700;color:#F8FAFC;margin-bottom:6px;\">Transaction Summary by Store</div>"
|
||||
"<table style=\"width:100%;border-collapse:collapse;font-size:12px;\">"
|
||||
+ header + "".join(body) + "</table></div>"
|
||||
)
|
||||
|
||||
html = f"""
|
||||
<div style="font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial; color:#0f172a; font-size:13px; line-height:1.5;">
|
||||
<p style="margin:0 0 8px 0">Hello <strong>Tucker Fresh</strong>,</p>
|
||||
<p style="margin:0 0 12px 0">Here’s your daily digest of posted transactions:</p>
|
||||
{store_summary_table_html}
|
||||
<p style="margin:12px 0 6px 0">Thank you for staying updated with us.</p>
|
||||
<p style="margin:0">Best regards,<br/><strong>Workolik Team</strong></p>
|
||||
</div>
|
||||
"""
|
||||
return html
|
||||
|
||||
def send_email(self, recipients: List[str], subject: str, html: str) -> Tuple[bool, str]:
|
||||
s = self.settings
|
||||
if not all([s.smtp_host, s.smtp_port, s.smtp_user, s.smtp_password, s.smtp_from_email]):
|
||||
return False, "SMTP settings are incomplete."
|
||||
|
||||
# Optional BCC via env (comma-separated), default empty
|
||||
bcc_env = os.getenv("BCC_RECIPIENTS", "").strip()
|
||||
bcc_recipients = [e.strip() for e in bcc_env.split(',') if e.strip()] if bcc_env else []
|
||||
all_recipients = recipients + bcc_recipients
|
||||
|
||||
msg = MIMEMultipart("alternative")
|
||||
msg["From"] = f"{s.smtp_from_name} <{s.smtp_from_email}>"
|
||||
msg["To"] = ", ".join(recipients)
|
||||
msg["Subject"] = subject
|
||||
msg.attach(MIMEText(html, "html"))
|
||||
|
||||
try:
|
||||
server = smtplib.SMTP(s.smtp_host, s.smtp_port, timeout=30)
|
||||
if s.smtp_use_tls:
|
||||
server.starttls()
|
||||
server.login(s.smtp_user, s.smtp_password)
|
||||
server.sendmail(s.smtp_from_email, all_recipients, msg.as_string())
|
||||
server.quit()
|
||||
return True, "sent"
|
||||
except Exception as e:
|
||||
return False, str(e)
|
||||
|
||||
def log_email(self, recipients: List[str], subject: str, date_for: str, status: str, error: str | None = None) -> None:
|
||||
with SessionLocal() as db:
|
||||
entry = EmailLog(
|
||||
recipients=", ".join(recipients),
|
||||
subject=subject,
|
||||
status=status,
|
||||
error=error,
|
||||
date_for=date_for,
|
||||
)
|
||||
db.add(entry)
|
||||
db.commit()
|
||||
|
||||
def has_sent_for_date(self, date_for: str) -> bool:
|
||||
"""Return True if a successful send log exists for the given date."""
|
||||
with SessionLocal() as db:
|
||||
row = (
|
||||
db.query(EmailLog)
|
||||
.filter(EmailLog.date_for == date_for, EmailLog.status == "sent")
|
||||
.order_by(EmailLog.sent_at.desc())
|
||||
.first()
|
||||
)
|
||||
return row is not None
|
||||
|
||||
def recent_logs(self, limit: int = 50) -> list[dict]:
|
||||
return _get_recent_logs_cached(limit)
|
||||
|
||||
|
||||
@st.cache_data(ttl=60) # Cache for 1 minute
|
||||
def _get_recent_logs_cached(limit: int = 50) -> list[dict]:
|
||||
"""Cached function to get recent email logs."""
|
||||
with SessionLocal() as db:
|
||||
rows = (
|
||||
db.query(EmailLog)
|
||||
.order_by(EmailLog.sent_at.desc())
|
||||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
return [
|
||||
{
|
||||
"id": r.id,
|
||||
"sent_at": r.sent_at,
|
||||
"recipients": r.recipients,
|
||||
"subject": r.subject,
|
||||
"status": r.status,
|
||||
"error": r.error,
|
||||
"date_for": r.date_for,
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
52
app_core/services/mappings_service.py
Normal file
52
app_core/services/mappings_service.py
Normal file
@@ -0,0 +1,52 @@
|
||||
from typing import List, Optional
|
||||
from sqlalchemy.orm import Session
|
||||
from app_core.db.database import SessionLocal
|
||||
from app_core.db.models import TriumphDebtorMapping
|
||||
from datetime import datetime
|
||||
|
||||
class MappingsService:
|
||||
def __init__(self):
|
||||
pass
|
||||
|
||||
def get_all_mappings(self) -> List[TriumphDebtorMapping]:
|
||||
with SessionLocal() as db:
|
||||
return db.query(TriumphDebtorMapping).order_by(TriumphDebtorMapping.id.asc()).all()
|
||||
|
||||
def get_mapping_by_id(self, mapping_id: int) -> Optional[TriumphDebtorMapping]:
|
||||
with SessionLocal() as db:
|
||||
return db.query(TriumphDebtorMapping).filter(TriumphDebtorMapping.id == mapping_id).first()
|
||||
|
||||
def create_mapping(self, code: str, name: str, dbmacc: str, outlet: str) -> TriumphDebtorMapping:
|
||||
with SessionLocal() as db:
|
||||
mapping = TriumphDebtorMapping(
|
||||
code=code,
|
||||
name=name,
|
||||
dbmacc=dbmacc,
|
||||
outlet=outlet
|
||||
)
|
||||
db.add(mapping)
|
||||
db.commit()
|
||||
db.refresh(mapping)
|
||||
return mapping
|
||||
|
||||
def update_mapping(self, mapping_id: int, code: str, name: str, dbmacc: str, outlet: str) -> bool:
|
||||
with SessionLocal() as db:
|
||||
mapping = db.query(TriumphDebtorMapping).filter(TriumphDebtorMapping.id == mapping_id).first()
|
||||
if mapping:
|
||||
mapping.code = code
|
||||
mapping.name = name
|
||||
mapping.dbmacc = dbmacc
|
||||
mapping.outlet = outlet
|
||||
mapping.updated_at = datetime.now()
|
||||
db.commit()
|
||||
return True
|
||||
return False
|
||||
|
||||
def delete_mapping(self, mapping_id: int) -> bool:
|
||||
with SessionLocal() as db:
|
||||
mapping = db.query(TriumphDebtorMapping).filter(TriumphDebtorMapping.id == mapping_id).first()
|
||||
if mapping:
|
||||
db.delete(mapping)
|
||||
db.commit()
|
||||
return True
|
||||
return False
|
||||
89
app_core/services/scheduler_service.py
Normal file
89
app_core/services/scheduler_service.py
Normal file
@@ -0,0 +1,89 @@
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime
|
||||
from zoneinfo import ZoneInfo
|
||||
from apscheduler.schedulers.background import BackgroundScheduler
|
||||
from apscheduler.triggers.cron import CronTrigger
|
||||
from apscheduler.executors.pool import ThreadPoolExecutor
|
||||
from apscheduler.jobstores.memory import MemoryJobStore
|
||||
|
||||
from app_core.services.daily_report import main as run_daily_report
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class SchedulerService:
|
||||
def __init__(self):
|
||||
self.scheduler = None
|
||||
self.ist = ZoneInfo("Asia/Kolkata")
|
||||
|
||||
def start_scheduler(self):
|
||||
"""Start the background scheduler for daily email reports."""
|
||||
if self.scheduler and self.scheduler.running:
|
||||
logger.info("Scheduler is already running")
|
||||
return
|
||||
|
||||
# Configure job stores and executors
|
||||
jobstores = {
|
||||
'default': MemoryJobStore()
|
||||
}
|
||||
executors = {
|
||||
'default': ThreadPoolExecutor(20)
|
||||
}
|
||||
job_defaults = {
|
||||
'coalesce': False,
|
||||
'max_instances': 1
|
||||
}
|
||||
|
||||
self.scheduler = BackgroundScheduler(
|
||||
jobstores=jobstores,
|
||||
executors=executors,
|
||||
job_defaults=job_defaults,
|
||||
timezone=self.ist
|
||||
)
|
||||
|
||||
# Schedule daily email at 8:00 PM IST (20:00)
|
||||
self.scheduler.add_job(
|
||||
func=self._send_daily_report,
|
||||
trigger=CronTrigger(hour=20, minute=0, timezone=self.ist),
|
||||
id='daily_email_report',
|
||||
name='Daily Email Report',
|
||||
replace_existing=True
|
||||
)
|
||||
|
||||
# Start the scheduler
|
||||
self.scheduler.start()
|
||||
logger.info("Daily email scheduler started - will send reports at 8:00 PM IST")
|
||||
|
||||
def stop_scheduler(self):
|
||||
"""Stop the background scheduler."""
|
||||
if self.scheduler and self.scheduler.running:
|
||||
self.scheduler.shutdown()
|
||||
logger.info("Daily email scheduler stopped")
|
||||
|
||||
def _send_daily_report(self):
|
||||
"""Internal method to send daily report."""
|
||||
try:
|
||||
logger.info(f"Starting daily report at {datetime.now(self.ist)}")
|
||||
result = run_daily_report()
|
||||
if result == 0:
|
||||
logger.info("Daily report sent successfully")
|
||||
else:
|
||||
logger.warning(f"Daily report failed with exit code: {result}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error sending daily report: {str(e)}")
|
||||
|
||||
def get_next_run_time(self):
|
||||
"""Get the next scheduled run time for the daily report."""
|
||||
if not self.scheduler or not self.scheduler.running:
|
||||
return None
|
||||
|
||||
job = self.scheduler.get_job('daily_email_report')
|
||||
if job:
|
||||
return job.next_run_time
|
||||
return None
|
||||
|
||||
def is_running(self):
|
||||
"""Check if scheduler is running."""
|
||||
return self.scheduler is not None and self.scheduler.running
|
||||
BIN
app_core/ui/__pycache__/auth_ui.cpython-312.pyc
Normal file
BIN
app_core/ui/__pycache__/auth_ui.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app_core/ui/__pycache__/layout.cpython-312.pyc
Normal file
BIN
app_core/ui/__pycache__/layout.cpython-312.pyc
Normal file
Binary file not shown.
37
app_core/ui/auth_ui.py
Normal file
37
app_core/ui/auth_ui.py
Normal file
@@ -0,0 +1,37 @@
|
||||
import re
|
||||
import streamlit as st
|
||||
from app_core.services.auth_service import AuthService
|
||||
|
||||
|
||||
def _is_valid_email(value: str) -> bool:
|
||||
if not value:
|
||||
return False
|
||||
pattern = r"^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$"
|
||||
return re.match(pattern, value.strip()) is not None
|
||||
|
||||
|
||||
def render_auth_card(auth_service: AuthService) -> None:
|
||||
left, center, right = st.columns([1, 1.2, 1])
|
||||
with center:
|
||||
|
||||
st.markdown('<div class="auth-title">Welcome !!</div>', unsafe_allow_html=True)
|
||||
st.markdown('<div class="muted" style="margin-bottom:16px;">Sign in to continue</div>', unsafe_allow_html=True)
|
||||
|
||||
with st.form("login_form", clear_on_submit=False):
|
||||
email = st.text_input("Email", placeholder="you@example.com", key="login_email")
|
||||
password = st.text_input("Password", type="password", placeholder="••••••••", key="login_password")
|
||||
submitted = st.form_submit_button("Sign in →", use_container_width=True)
|
||||
if submitted:
|
||||
if not _is_valid_email(email):
|
||||
st.error("Enter a valid email address.")
|
||||
elif not password:
|
||||
st.error("Password is required.")
|
||||
else:
|
||||
ok, user, msg = auth_service.login(email, password)
|
||||
if ok and user:
|
||||
st.session_state.auth_user = user
|
||||
st.success(msg)
|
||||
st.rerun()
|
||||
else:
|
||||
st.error(msg)
|
||||
st.markdown('</div>', unsafe_allow_html=True)
|
||||
314
app_core/ui/layout.py
Normal file
314
app_core/ui/layout.py
Normal file
@@ -0,0 +1,314 @@
|
||||
import streamlit as st
|
||||
from app_core.config.settings import STORES, STORE_CODE_TO_TENANT_ID
|
||||
|
||||
def apply_global_style(background_url: str | None = None) -> None:
|
||||
css = """
|
||||
<style>
|
||||
:root { --brand: ; --brandDark: #0F4FD6; --text: #0F172A; --muted: #64748b; --border: rgba(15, 23, 42, 0.08); --inputBorder: rgba(22,98,243,0.35); }
|
||||
.stApp { background: transparent !important; position: relative !important; min-height: 100vh; font-size: 17px; }
|
||||
[data-testid="stAppViewContainer"] { background: transparent !important; }
|
||||
[data-testid="stAppViewContainer"] > header, [data-testid="stHeader"], .stApp header { background: transparent !important; box-shadow: none !important; }
|
||||
.stAppToolbar { background: transparent !important; box-shadow: none !important; border-bottom: none !important; }
|
||||
|
||||
|
||||
/* Header sections */
|
||||
.tfw-header { background: #FFFFFF; border-bottom: 1px solid var(--border); }
|
||||
.tfw-header-white { background: #FFFFFF; padding: 16px 0; }
|
||||
.tfw-header-grey { background: #F8FAFC; padding: 12px 0; border-top: 1px solid rgba(15, 23, 42, 0.00); }
|
||||
.tfw-header-content { max-width: 1200px; margin: 0 auto; padding: 0 20px; }
|
||||
|
||||
/* Topbar (same height, narrower sides) */
|
||||
.tfw-topbar { position: fixed; top: 0; left: 0; right: 0; height: 48px; background: rgba(248,250,252,0.6); backdrop-filter: saturate(180%) border-bottom: none; z-index: 999; }
|
||||
.tfw-topbar .tfw-inner { height: 48px; display: flex; align-items: center; gap: 8px; padding: 0 8px; max-width: 1100px; margin: 0 auto; }
|
||||
.tfw-logo { width: 18px; height: 18px; border-radius: 6px; background: linear-gradient(135deg, var(--brand), var(--brandDark)); display: inline-block; }
|
||||
.tfw-title { font-weight: 800; color: var(--text); letter-spacing: -0.02em; font-size: 1.0rem; }
|
||||
|
||||
/* Sidebar enhancements */
|
||||
[data-testid="stSidebar"] .sidebar-content { display: flex; flex-direction: column; height: 100%; font-size: 1rem; }
|
||||
[data-testid="stSidebar"] .sidebar-section { padding: 8px 6px; }
|
||||
[data-testid="stSidebar"] .sidebar-spacer { flex: 1 1 auto; }
|
||||
[data-testid="stSidebar"] .sidebar-logout { padding: 10px 6px; border-top: 1px solid var(--border); }
|
||||
[data-testid="stSidebar"] .sidebar-logout button { width: 100%; border-radius: 10px; }
|
||||
|
||||
/* Sidebar logo */
|
||||
.sidebar-logo { display: flex; align-items: center; gap: 12px; padding: 16px 20px; border-bottom: 1px solid var(--border); margin-bottom: 8px; }
|
||||
.sidebar-logo-icon { width: 32px; height: 32px; border-radius: 8px; overflow: hidden; }
|
||||
.sidebar-logo-icon img { width: 100%; height: 100%; object-fit: contain; }
|
||||
.sidebar-logo-text { font-weight: 800; color: var(--text); font-size: 1.1rem; letter-spacing: -0.02em; }
|
||||
|
||||
/* Auth card (extra-slim, centered) */
|
||||
.auth-card { position: relative; padding: 24px; background: rgba(255,255,255,0.85); backdrop-filter: blur(8px); border: 1px solid rgba(22,98,240,0.22); border-radius: 16px; box-shadow: 0 18px 40px rgba(2,6,23,0.10); transition: box-shadow .2s ease, transform .2s ease; max-width: 520px; width: 100%; margin: 0 auto; }
|
||||
.auth-card.auth-slim { max-width: 420px; }
|
||||
.auth-card.auth-xs { max-width: 360px; }
|
||||
.auth-card::before { content: ""; position: absolute; top: 0; left: 0; right: 0; height: 6px; background: linear-gradient(90deg, #22C55E, #16A34A, #0ea5e9); border-top-left-radius: 16px; border-top-right-radius: 16px; }
|
||||
.auth-card:hover { box-shadow: 0 26px 56px rgba(2,6,23,0.16); transform: translateY(-2px); }
|
||||
|
||||
/* Success ribbon shown on login */
|
||||
.login-success { background: linear-gradient(90deg, #22C55E, #16A34A); color: #fff; border-radius: 12px; padding: 10px 14px; box-shadow: 0 10px 24px rgba(34,197,94,0.35); display: flex; align-items: center; gap: 8px; font-weight: 700; }
|
||||
.login-success .emoji { filter: drop-shadow(0 4px 8px rgba(0,0,0,0.2)); }
|
||||
|
||||
.auth-title { margin: 6px 0 8px 0; font-size: 1.8rem; font-weight: 800; color: var(--text); letter-spacing: -0.02em; text-align: center; }
|
||||
.muted { color: var(--muted); font-size: 1.0rem; text-align: center; }
|
||||
|
||||
/* Inputs: light blue border (global) */
|
||||
div[data-testid="stTextInput"] input,
|
||||
div[data-testid="stPassword"] input,
|
||||
textarea {
|
||||
border-radius: 10px !important;
|
||||
border: 1px solid var(--inputBorder) !important;
|
||||
box-shadow: inset 0 1px 2px rgba(2,6,23,0.04) !important;
|
||||
background: #FFFFFF !important;
|
||||
}
|
||||
/* Prevent outer wrapper hover/focus rings (avoid double boxes) */
|
||||
div[data-baseweb="input"]:hover, div[data-baseweb="input"]:focus-within,
|
||||
div[data-baseweb="textarea"]:hover, div[data-baseweb="textarea"]:focus-within,
|
||||
div[data-testid="stTextInput"] > div:hover, div[data-testid="stTextInput"] > div:focus-within,
|
||||
div[data-testid="stTextInput"] > div > div:hover, div[data-testid="stTextInput"] > div > div:focus-within,
|
||||
div[data-testid="stPassword"] > div:hover, div[data-testid="stPassword"] > div:focus-within,
|
||||
div[data-testid="stPassword"] > div > div:hover, div[data-testid="stPassword"] > div > div:focus-within {
|
||||
outline: none !important; box-shadow: none !important; border-color: transparent !important;
|
||||
}
|
||||
/* Subtle inner hover/focus on the actual field only */
|
||||
div[data-testid="stTextInput"] input:hover,
|
||||
div[data-testid="stPassword"] input:hover,
|
||||
textarea:hover { border-color: var(--inputBorder) !important; box-shadow: inset 0 0 0 1px rgba(22,98,243,0.25) !important; }
|
||||
div[data-testid="stTextInput"] input:focus,
|
||||
div[data-testid="stPassword"] input:focus,
|
||||
textarea:focus { outline: none !important; border-color: var(--inputBorder) !important; box-shadow: inset 0 0 0 1px rgba(22,98,243,0.45) !important; }
|
||||
|
||||
/* Constrain Streamlit form width regardless of dynamic class names */
|
||||
form[class*="stForm"], div[class*="stForm"] { max-width: 760px !important; margin-left: auto !important; margin-right: auto !important; padding-left: 8px !important; padding-right: 8px !important; }
|
||||
|
||||
/* Password field styling - expand to match email box and position eye icon */
|
||||
div[data-testid="stPassword"] { width: 100% !important; }
|
||||
div[data-testid="stPassword"] input { width: 100% !important; padding-right: 60px !important; }
|
||||
div[data-testid="stPassword"] button { position: absolute !important; right: 8px !important; top: 50% !important; transform: translateY(-50%) !important; background: none !important; border: none !important; padding: 4px !important; margin: 0 !important; }
|
||||
div[data-testid="stPassword"] button:hover { background: rgba(0,0,0,0.05) !important; border-radius: 4px !important; }
|
||||
|
||||
/* Buttons: global size + hover/transition */
|
||||
.stButton > button, [data-testid="stDownloadButton"] button { height: 40px; font-size: 0.95rem; border-radius: 10px; transition: transform .15s ease, box-shadow .15s ease; }
|
||||
.stButton > button:hover, [data-testid="stDownloadButton"] button:hover { transform: translateY(-1px); box-shadow: 0 10px 18px rgba(22,98,243,0.25); }
|
||||
|
||||
.auth-card .stCheckbox { font-size: 1.0rem; }
|
||||
|
||||
/* Auth buttons inherit global size but keep gradient */
|
||||
.auth-card .stButton > button { background: linear-gradient(135deg, #22C55E, #16A34A); color: #fff; font-weight: 800; letter-spacing: .2px; border: none; box-shadow: 0 10px 18px rgba(34,197,94,0.28); }
|
||||
.auth-card .stButton > button:hover { filter: brightness(0.98); }
|
||||
|
||||
/* Match info alert with content card look */
|
||||
div[role="alert"] { background: #F6FAFF !important; border: 1px solid rgba(22,98,243,0.18) !important; color: var(--text) !important; }
|
||||
|
||||
/* DataFrame font sizes */
|
||||
div[data-testid="stDataFrame"] table { font-size: 0.98rem; }
|
||||
div[data-testid="stDataFrame"] th { font-size: 1.0rem; }
|
||||
</style>
|
||||
"""
|
||||
st.markdown(css, unsafe_allow_html=True)
|
||||
|
||||
# Optional login/background image with 50% transparency
|
||||
if background_url:
|
||||
# Support @prefix and local files by embedding as base64 when needed
|
||||
try:
|
||||
import os, base64
|
||||
url = background_url.lstrip('@').strip()
|
||||
if url.startswith('http://') or url.startswith('https://'):
|
||||
data_url = url
|
||||
else:
|
||||
# Treat as local file path
|
||||
# Map shorthand names to assets/ if needed
|
||||
if url in {"bg.jpg", "workolik.png"}:
|
||||
url = os.path.join("assets", url)
|
||||
if os.path.exists(url):
|
||||
ext = os.path.splitext(url)[1].lower()
|
||||
mime = 'image/jpeg' if ext in ['.jpg', '.jpeg'] else 'image/png' if ext == '.png' else 'image/webp' if ext == '.webp' else 'image/*'
|
||||
with open(url, 'rb') as f:
|
||||
b64 = base64.b64encode(f.read()).decode()
|
||||
data_url = f'data:{mime};base64,{b64}'
|
||||
else:
|
||||
data_url = url # fallback; let browser try
|
||||
st.markdown(
|
||||
f"""
|
||||
<style>
|
||||
.stApp::before {{
|
||||
content: "";
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
background-image: url('{data_url}');
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
opacity: 0.5; /* 50% transparent */
|
||||
filter: saturate(110%);
|
||||
}}
|
||||
</style>
|
||||
""",
|
||||
unsafe_allow_html=True,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def render_header(brand_name: str = "Workolik") -> None:
|
||||
st.markdown(
|
||||
f"""
|
||||
<div class="tfw-header">
|
||||
|
||||
</div>
|
||||
""",
|
||||
unsafe_allow_html=True,
|
||||
)
|
||||
|
||||
|
||||
def render_topbar(brand_name: str = "") -> None:
|
||||
st.markdown(
|
||||
f"""
|
||||
<div class=\"tfw-topbar\">\n <div class=\"tfw-inner\">\n <span class=\"tfw-logo\"></span>\n <span class=\"tfw-title\">{brand_name}</span>\n </div>\n</div>
|
||||
""",
|
||||
unsafe_allow_html=True,
|
||||
)
|
||||
|
||||
|
||||
def render_sidebar_logo(brand_name: str = "Workolik") -> None:
|
||||
import streamlit as st
|
||||
import base64
|
||||
import os
|
||||
|
||||
try:
|
||||
# Read the image file and encode it as base64
|
||||
logo_path = os.path.join("assets", "workolik.png")
|
||||
if os.path.exists(logo_path):
|
||||
with open(logo_path, "rb") as img_file:
|
||||
img_data = base64.b64encode(img_file.read()).decode()
|
||||
|
||||
st.markdown(
|
||||
f"""
|
||||
<div class="sidebar-logo">
|
||||
<div class="sidebar-logo-icon">
|
||||
<img src="data:image/png;base64,{img_data}" alt="Workolik Logo" style="width: 100%; height: 100%; object-fit: contain;" />
|
||||
</div>
|
||||
<div class="sidebar-logo-text">{brand_name}</div>
|
||||
</div>
|
||||
""",
|
||||
unsafe_allow_html=True,
|
||||
)
|
||||
else:
|
||||
raise FileNotFoundError("Logo file not found")
|
||||
except Exception as e:
|
||||
# Fallback to text logo if image fails to load
|
||||
st.markdown(
|
||||
f"""
|
||||
<div class="sidebar-logo">
|
||||
<div class="sidebar-logo-icon" style="background: linear-gradient(135deg, var(--brand), var(--brandDark)); display: flex; align-items: center; justify-content: center; color: white; font-weight: 700; font-size: 14px;">TW</div>
|
||||
<div class="sidebar-logo-text">{brand_name}</div>
|
||||
</div>
|
||||
""",
|
||||
unsafe_allow_html=True,
|
||||
)
|
||||
|
||||
|
||||
def render_store_selector() -> tuple[int | None, str | None]:
|
||||
"""Render a compact, classy store selector box.
|
||||
|
||||
Returns (tenant_id, label). Also persists selection in session_state.
|
||||
"""
|
||||
st.markdown(
|
||||
"""
|
||||
<div style="
|
||||
margin: 8px 0 12px 0; padding: 16px 18px;
|
||||
border: 1px solid var(--border);
|
||||
background: linear-gradient(135deg,#ffffff, #f8fbff);
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 10px 24px rgba(2,6,23,0.06);
|
||||
">
|
||||
<div style="display:flex; align-items:center; justify-content:space-between; gap:12px;">
|
||||
<div style="display:flex; align-items:center; gap:10px; color: var(--text);">
|
||||
<span style="display:inline-flex;align-items:center;justify-content:center;width:24px;height:24px;border-radius:999px;background:linear-gradient(135deg,#22C55E,#16A34A);box-shadow:0 4px 10px rgba(34,197,94,0.25);">🛍️</span>
|
||||
<div style="font-weight: 700; letter-spacing:-0.01em;">Choose the store you want to view</div>
|
||||
<span style="opacity:0.9">✨</span>
|
||||
</div>
|
||||
<div style="font-size:12px;color:#64748b;">👉 Click a card to select</div>
|
||||
</div>
|
||||
</div>
|
||||
""",
|
||||
unsafe_allow_html=True,
|
||||
)
|
||||
|
||||
# Sub caption + clear selection
|
||||
current_label = st.session_state.get("store_label")
|
||||
left_cap, right_clear = st.columns([6, 1])
|
||||
with left_cap:
|
||||
st.caption("Please choose a store before surfing !")
|
||||
if current_label:
|
||||
st.caption(f"Selected: {current_label}")
|
||||
with right_clear:
|
||||
if st.button("Clear", key="clear_store_sel"):
|
||||
st.session_state["tenant_id"] = None
|
||||
st.session_state["store_label"] = None
|
||||
st.experimental_rerun() if hasattr(st, "experimental_rerun") else st.rerun()
|
||||
|
||||
# We no longer use query params; selection happens in-session only
|
||||
chosen_from_query: str | None = None
|
||||
|
||||
# Grid of store boxes (soft gradient cards with per-store colors and emojis)
|
||||
emoji_map = {"PC": "🍷", "IP": "🍻", "ML": "🥂", "CL4": "🍸", "NL": "🥃", "CL6": "🍾", "RC": "🍹", "PL": "🍺"}
|
||||
|
||||
color_rgb = {"PC": (37,99,235), "IP": (22,163,74), "ML": (245,158,11), "CL4": (220,38,38), "NL": (124,58,237), "CL6": (234,179,8), "RC": (6, 182, 212), "PL": (236, 72, 153)}
|
||||
preselect_label = st.session_state.get("store_label")
|
||||
chosen_label = None
|
||||
# No search box; show all stores
|
||||
filtered_stores = STORES
|
||||
# Always render 3 columns per row (e.g., 3 + 3 for 6 stores)
|
||||
rows = [filtered_stores[i:i+3] for i in range(0, len(filtered_stores), 3)]
|
||||
for row in rows:
|
||||
cols = st.columns(3)
|
||||
for i, store in enumerate(row):
|
||||
with cols[i]:
|
||||
icon = emoji_map.get(store["code"], "🏬")
|
||||
r, g, b = color_rgb.get(store["code"], (14,165,233))
|
||||
is_selected = (preselect_label == store["label"]) or (chosen_from_query == store["label"]) # highlight current
|
||||
border_alpha = 0.48 if is_selected else 0.28
|
||||
shadow = "0 18px 42px rgba(2,6,23,0.16)" if is_selected else "0 12px 28px rgba(2,6,23,0.10)"
|
||||
border_width = "2px" if is_selected else "1px"
|
||||
check = " ✅" if is_selected else ""
|
||||
# Render a card-like button that sets selection without changing URL
|
||||
clicked = st.button(
|
||||
f"{icon} {store['label']}{check}",
|
||||
key=f"store_card_{store['code']}",
|
||||
use_container_width=True,
|
||||
type="secondary",
|
||||
)
|
||||
# Lightweight card styling via inline CSS targeting this button
|
||||
st.markdown(
|
||||
f"""
|
||||
<style>
|
||||
div[data-testid='stButton'] button#store_card_{store['code']} {{
|
||||
background: linear-gradient(135deg, rgba({r},{g},{b},0.12), rgba({r},{g},{b},0.20));
|
||||
border: {border_width} solid rgba({r},{g},{b},{border_alpha});
|
||||
border-radius: 18px; padding: 18px; box-shadow: {shadow};
|
||||
color: #0F172A; font-weight: 800; text-align: left;
|
||||
}}
|
||||
div[data-testid='stButton'] button#store_card_{store['code']}:hover {{
|
||||
transform: translateY(-2px); box-shadow: 0 22px 48px rgba(2,6,23,0.18);
|
||||
}}
|
||||
</style>
|
||||
""",
|
||||
unsafe_allow_html=True,
|
||||
)
|
||||
if clicked:
|
||||
st.session_state["tenant_id"] = store["tenant_id"]
|
||||
st.session_state["store_label"] = store["label"]
|
||||
chosen_label = store["label"]
|
||||
st.rerun()
|
||||
|
||||
# Resolve tenant_id
|
||||
effective_label = chosen_label or preselect_label
|
||||
selected = next((s for s in STORES if s["label"] == effective_label), None)
|
||||
tenant_id = selected["tenant_id"] if selected else None
|
||||
|
||||
# Persist
|
||||
st.session_state["tenant_id"] = tenant_id
|
||||
st.session_state["store_label"] = selected["label"] if selected else None
|
||||
st.session_state["division_code"] = None
|
||||
|
||||
return tenant_id, (selected["label"] if selected else None)
|
||||
Reference in New Issue
Block a user