From 8f206aa4f155665362dc0dc086019d822dfe83ca Mon Sep 17 00:00:00 2001 From: root Date: Thu, 18 Jun 2026 20:41:05 +0000 Subject: [PATCH] Initial commit: Jetour BI Dashboard FastAPI backend with PostgreSQL queries for sales/CRM pipeline. Chart.js dark-themed dashboard with: - Pipeline funnel, velocity, conversion rates - Monthly trend, lead sources, model distribution - Vehicle order overview with per-color breakdown + subtotals - Salesperson leaderboard, new vs used, lost reason analysis - Email report settings (recipients + SMTP config) - n8n integration for daily/weekly/monthly reports --- .gitignore | 7 + app.py | 1111 ++++++++++++++++++++++++++++++++++++++++++ static/index.html | 474 ++++++++++++++++++ static/settings.html | 124 +++++ 4 files changed, 1716 insertions(+) create mode 100644 .gitignore create mode 100644 app.py create mode 100644 static/index.html create mode 100644 static/settings.html diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8dafef6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +# Jetour BI Dashboard +__pycache__/ +*.pyc +.venv/ +.env +*.log +.DS_Store diff --git a/app.py b/app.py new file mode 100644 index 0000000..f60df56 --- /dev/null +++ b/app.py @@ -0,0 +1,1111 @@ +"""Jetour BI Dashboard — FastAPI backend serving CMS BI data from PostgreSQL.""" +from datetime import datetime, timedelta, timezone +from fastapi import FastAPI, Query +from fastapi.staticfiles import StaticFiles +import psycopg +from psycopg.rows import dict_row + +app = FastAPI(title="Jetour BI Dashboard") + +# ── Auth ────────────────────────────────────────────────────────────────── +from datetime import timedelta as td +from jose import JWTError, jwt +from passlib.context import CryptContext +from fastapi import Depends, HTTPException, Security, status +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials +from pydantic import BaseModel +from typing import Optional +import secrets + +# Config +SECRET_KEY = secrets.token_hex(32) # regenerates on restart — set env var for stability +ALGORITHM = "HS256" +ACCESS_TOKEN_EXPIRE_MINUTES = 480 # 8 hours +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") +security = HTTPBearer() + +# Models +class UserCreate(BaseModel): + username: str + password: str + email: Optional[str] = None + role: str = "standard" + +class UserLogin(BaseModel): + username: str + password: str + +class ForgotPassword(BaseModel): + email: str + +class ResetPassword(BaseModel): + token: str + password: str + +class UserOut(BaseModel): + id: int + username: str + email: Optional[str] = None + role: str + created_at: str + +def ensure_users_table(c): + cur = c.cursor() + # Add email column if missing (migration-safe) + cur.execute("SELECT column_name FROM information_schema.columns WHERE table_name='dashboard_users' AND column_name='email'") + if not cur.fetchone(): + cur.execute("ALTER TABLE dashboard_users ADD COLUMN email VARCHAR(200)") + c.commit() + # Create tables if missing + cur.execute(""" + CREATE TABLE IF NOT EXISTS dashboard_users ( + id BIGSERIAL PRIMARY KEY, + username VARCHAR(100) UNIQUE NOT NULL, + password_hash VARCHAR(200) NOT NULL, + email VARCHAR(200), + role VARCHAR(20) NOT NULL DEFAULT 'standard', + created_at TIMESTAMPTZ DEFAULT NOW() + ) + """) + cur.execute(""" + CREATE TABLE IF NOT EXISTS password_reset_tokens ( + id BIGSERIAL PRIMARY KEY, + email VARCHAR(200) NOT NULL, + token VARCHAR(100) UNIQUE NOT NULL, + expires_at TIMESTAMPTZ NOT NULL, + used BOOLEAN DEFAULT false, + created_at TIMESTAMPTZ DEFAULT NOW() + ) + """) + c.commit() + +def create_default_admin(c): + """Create default admin if no users exist.""" + row = c.execute("SELECT count(*) as n FROM dashboard_users").fetchone() + if row["n"] == 0: + c.execute( + "INSERT INTO dashboard_users (username, password_hash, role) VALUES (%s, %s, %s)", + ("admin", pwd_context.hash("admin123"), "admin"), + ) + c.commit() + return True + return False + +def get_user(c, username: str): + row = c.execute("SELECT * FROM dashboard_users WHERE username = %s", (username,)).fetchone() + return row + +def verify_password(plain, hashed): + return pwd_context.verify(plain, hashed) + +def create_access_token(data: dict, expires_delta: Optional[td] = None): + to_encode = data.copy() + expire = datetime.now(timezone.utc) + (expires_delta or td(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)) + to_encode.update({"exp": expire}) + return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) + +async def get_current_user(credentials: HTTPAuthorizationCredentials = Security(security)): + """Dependency: validate JWT and return user dict.""" + token = credentials.credentials + try: + payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) + username: str = payload.get("sub") + if username is None: + raise HTTPException(status_code=401, detail="Invalid token") + return {"username": username, "role": payload.get("role", "standard")} + except JWTError: + raise HTTPException(status_code=401, detail="Invalid token") + +def require_admin(user=Depends(get_current_user)): + """Dependency: require admin role.""" + if user["role"] != "admin": + raise HTTPException(status_code=403, detail="Admin access required") + return user + +# ── Auth Endpoints ──────────────────────────────────────────────────────── +@app.post("/api/auth/login") +def login(body: UserLogin): + c = conn() + try: + ensure_users_table(c) + create_default_admin(c) + user = get_user(c, body.username) + if not user or not verify_password(body.password, user["password_hash"]): + raise HTTPException(status_code=401, detail="Invalid credentials") + token = create_access_token({"sub": user["username"], "role": user["role"]}) + return {"token": token, "user": {"username": user["username"], "role": user["role"]}} + finally: + c.close() + +@app.get("/api/auth/me") +def me(user=Depends(get_current_user)): + return user + +@app.post("/api/auth/change-password") +def change_password(body: dict, user=Depends(get_current_user)): + current_pw = body.get("current") + new_pw = body.get("password") + if not current_pw or not new_pw: + raise HTTPException(status_code=400, detail="Current and new password required") + c = conn() + try: + db_user = get_user(c, user["username"]) + if not db_user or not verify_password(current_pw, db_user["password_hash"]): + raise HTTPException(status_code=401, detail="Current password incorrect") + cur = c.cursor() + cur.execute("UPDATE dashboard_users SET password_hash = %s WHERE username = %s", + (pwd_context.hash(new_pw), user["username"])) + c.commit() + return {"status": "ok"} + finally: + c.close() + +@app.post("/api/auth/forgot-password") +def forgot_password(body: ForgotPassword): + """Send password reset email if account exists.""" + if not body.email or "@" not in body.email: + raise HTTPException(status_code=400, detail="Valid email required") + c = conn() + try: + ensure_users_table(c) + cur = c.cursor() + cur.execute("SELECT username, email FROM dashboard_users WHERE email = %s", (body.email,)) + user_row = cur.fetchone() + if not user_row: + # Don't reveal if email exists — always return ok + return {"status": "ok", "message": "If the email exists, a reset link has been sent."} + # Generate token + token = secrets.token_urlsafe(32) + expires = datetime.now(timezone.utc) + td(hours=1) + cur.execute( + "INSERT INTO password_reset_tokens (email, token, expires_at) VALUES (%s, %s, %s)", + (body.email, token, expires), + ) + c.commit() + # Try to send email via SMTP + reset_url = f"http://100.91.25.139:8765/reset-password?token={token}" + try: + _send_email( + to=body.email, + subject="Jetour BI — Password Reset", + body=f"Click the link below to reset your password:\n\n{reset_url}\n\nThis link expires in 1 hour.", + ) + except Exception: + pass # SMTP may not be configured — token is still in DB + return {"status": "ok", "message": "If the email exists, a reset link has been sent."} + finally: + c.close() + +@app.post("/api/auth/reset-password") +def reset_password(body: ResetPassword): + """Reset password using token from email.""" + if len(body.password) < 3: + raise HTTPException(status_code=400, detail="Password must be at least 3 characters") + c = conn() + try: + ensure_users_table(c) + cur = c.cursor() + cur.execute( + "SELECT email, expires_at, used FROM password_reset_tokens WHERE token = %s", + (body.token,), + ) + row = cur.fetchone() + if not row: + raise HTTPException(status_code=400, detail="Invalid or expired reset token") + if row["used"]: + raise HTTPException(status_code=400, detail="This reset link has already been used") + if row["expires_at"] < datetime.now(timezone.utc): + raise HTTPException(status_code=400, detail="This reset link has expired") + # Update password + cur.execute( + "UPDATE dashboard_users SET password_hash = %s WHERE email = %s", + (pwd_context.hash(body.password), row["email"]), + ) + cur.execute("UPDATE password_reset_tokens SET used = true WHERE token = %s", (body.token,)) + c.commit() + return {"status": "ok", "message": "Password has been reset. You can now log in."} + finally: + c.close() + +# ── Email helper ─────────────────────────────────────────────────────── +def _get_smtp_settings(c): + """Read SMTP settings from app_settings table.""" + try: + cur = c.cursor() + cur.execute("SELECT key, value FROM app_settings WHERE key LIKE 'smtp_%'") + rows = cur.fetchall() + return {r["key"]: r["value"] for r in rows} + except Exception: + return {} + +def _send_email(to, subject, body): + """Send email using configured SMTP. Raises on failure.""" + import smtplib + from email.mime.text import MIMEText + c = conn() + try: + settings = _get_smtp_settings(c) + if not settings.get("smtp_host"): + raise Exception("SMTP not configured") + msg = MIMEText(body) + msg["Subject"] = subject + msg["From"] = settings.get("smtp_from", "reports@jetour.mu") + msg["To"] = to + with smtplib.SMTP(settings["smtp_host"], int(settings.get("smtp_port", 587))) as server: + if settings.get("smtp_use_tls", "true") == "true": + server.starttls() + if settings.get("smtp_user"): + server.login(settings["smtp_user"], settings.get("smtp_pass", "")) + server.send_message(msg) + finally: + c.close() + +@app.get("/api/users") +def list_users(user=Depends(require_admin)): + c = conn() + try: + ensure_users_table(c) + rows = c.execute("SELECT id, username, email, role, created_at FROM dashboard_users ORDER BY id").fetchall() + return [{"id": r["id"], "username": r["username"], "email": r["email"], "role": r["role"], "created_at": str(r["created_at"])} for r in rows] + finally: + c.close() + +@app.post("/api/users") +def create_user(body: UserCreate, user=Depends(require_admin)): + if body.role not in ("admin", "standard"): + raise HTTPException(status_code=400, detail="Role must be 'admin' or 'standard'") + c = conn() + try: + ensure_users_table(c) + existing = get_user(c, body.username) + if existing: + raise HTTPException(status_code=409, detail="Username already exists") + c.execute( + "INSERT INTO dashboard_users (username, password_hash, email, role) VALUES (%s, %s, %s, %s)", + (body.username, pwd_context.hash(body.password), body.email, body.role), + ) + c.commit() + return {"status": "ok", "username": body.username, "role": body.role} + finally: + c.close() + +@app.delete("/api/users/{username}") +def delete_user(username: str, user=Depends(require_admin)): + if username == "admin": + raise HTTPException(status_code=400, detail="Cannot delete default admin") + c = conn() + try: + cur = c.cursor() + cur.execute("DELETE FROM dashboard_users WHERE username = %s RETURNING id", (username,)) + deleted = cur.fetchone() + if not deleted: + raise HTTPException(status_code=404, detail="User not found") + c.commit() + return {"status": "deleted", "username": username} + finally: + c.close() + +@app.put("/api/users/{username}") +def update_user(username: str, body: dict, user=Depends(require_admin)): + """Update user email, role, and optionally password.""" + c = conn() + try: + ensure_users_table(c) + cur = c.cursor() + cur.execute("SELECT id FROM dashboard_users WHERE username = %s", (username,)) + if not cur.fetchone(): + raise HTTPException(status_code=404, detail="User not found") + updates = [] + params = [] + if "email" in body: + updates.append("email = %s") + params.append(body["email"]) + if "role" in body and body["role"] in ("admin", "standard"): + if username == "admin" and body["role"] != "admin": + raise HTTPException(status_code=400, detail="Cannot demote default admin") + updates.append("role = %s") + params.append(body["role"]) + if "password" in body and body["password"]: + updates.append("password_hash = %s") + params.append(pwd_context.hash(body["password"])) + if not updates: + raise HTTPException(status_code=400, detail="No fields to update") + params.append(username) + cur.execute(f"UPDATE dashboard_users SET {', '.join(updates)} WHERE username = %s", params) + c.commit() + return {"status": "updated", "username": username} + finally: + c.close() + +# ── Protected API (example: settings) ───────────────────────────────────── +@app.get("/api/settings") +def get_settings(user=Depends(require_admin)): + return {"app_name": "Jetour BI Dashboard", "version": "2.0", "features": ["period_selector", "vehicle_orders", "access_control"]} + +# ── Existing API endpoints ──────────────────────────────────────────────── + +# Database connection +HOST, PORT, DB, USER = "100.91.65.42", 5432, "jetour-bi", "postgres" +PASS = "#AlteraM@01" +from urllib.parse import quote_plus +DB_URL = f"postgresql://{USER}:{quote_plus(PASS)}@{HOST}:{PORT}/{DB}?sslmode=disable" + +def conn(): + return psycopg.connect(DB_URL, row_factory=dict_row) + +def date_filter(period): + now = datetime.now(timezone.utc) + if period == "mtd": + return "WHERE dt_prospect_created >= %(s)s", {"s": now.replace(day=1).strftime("%Y-%m-%d")} + elif period == "last30": + return "WHERE dt_prospect_created >= %(s)s", {"s": (now - timedelta(days=30)).strftime("%Y-%m-%d")} + elif period and period != "all" and period.endswith("d"): + days = int(period[:-1]) + return "WHERE dt_prospect_created >= %(s)s", {"s": (now - timedelta(days=days)).strftime("%Y-%m-%d")} + elif period and period != "all" and len(period) == 7: # YYYY-MM + return "WHERE dt_prospect_created >= %(s)s AND dt_prospect_created < %(e)s", { + "s": period + "-01", + "e": (datetime.strptime(period + "-01", "%Y-%m-%d").replace(day=28) + timedelta(days=4)).replace(day=1).strftime("%Y-%m-%d") + } + return "", {} + +@app.get("/api/months") +def available_months(): + """Return distinct year-months with data, plus year list for dropdown.""" + c = conn() + try: + cur = c.cursor() + cur.execute(""" + SELECT to_char(dt_prospect_created,'YYYY-MM') as m, + to_char(dt_prospect_created,'YYYY') as y, + count(*) as n + FROM prospects WHERE dt_prospect_created IS NOT NULL + GROUP BY m, y ORDER BY m + """) + months = [{"month": r["m"], "year": r["y"], "count": r["n"]} for r in cur.fetchall()] + years = sorted(set(r["year"] for r in months), reverse=True) + return {"months": months, "years": years} + finally: + c.close() + +@app.get("/api/summary") +def summary(period: str = Query("all")): + c = conn() + try: + df, params = date_filter(period) + cur = c.cursor() + cur.execute(f"SELECT count(*) as n FROM prospects {df}", params) + tp = cur.fetchone()["n"] + cur.execute(f"SELECT prospect_status, count(*) as n FROM prospects {df} GROUP BY prospect_status ORDER BY n DESC", params) + st = {r["prospect_status"]: r["n"] for r in cur.fetchall()} + cur.execute(f"SELECT count(*) as n FROM prospect_otps o JOIN prospects p ON o.prospect_id=p.id {df.replace('dt_prospect_created','p.dt_prospect_created')}", params) + to = cur.fetchone()["n"] + cur.execute("SELECT otp_new_used, count(*) as n FROM prospect_otps GROUP BY otp_new_used") + ot = {r["otp_new_used"] or "Unknown": r["n"] for r in cur.fetchall()} + cur.execute(f"SELECT coalesce(sum(amount),0) as t FROM prospect_otps o JOIN prospects p ON o.prospect_id=p.id {df.replace('dt_prospect_created','p.dt_prospect_created')}", params) + amt = float(cur.fetchone()["t"]) + return {"total_prospects": tp, "total_otps": to, "total_amount": amt, "statuses": st, "otp_by_type": ot, "sold": st.get("Sold", 0), "pending": st.get("Pending", 0), "otp_status": st.get("OTP", 0), "finance": st.get("FinanceApplied", 0), "period": period} + finally: + c.close() + +@app.get("/api/pipeline") +def pipeline(period: str = Query("all")): + c = conn() + try: + df, params = date_filter(period) + cur = c.cursor() + cur.execute(f"SELECT prospect_status, count(*) as n FROM prospects p {df} GROUP BY prospect_status ORDER BY n DESC", params) + return [{"status": r["prospect_status"] or "Unknown", "count": r["n"]} for r in cur.fetchall()] + finally: + c.close() + +@app.get("/api/models") +def models(period: str = Query("all")): + c = conn() + try: + cur = c.cursor() + df, params = date_filter(period) + df2 = df.replace('dt_prospect_created','p.dt_prospect_created') if df else '' + where = df2 + " AND" if df2 else "WHERE" + cur.execute(f"SELECT o.model, count(*) as n, coalesce(sum(o.amount),0) as t FROM prospect_otps o JOIN prospects p ON p.id = o.prospect_id {where} o.model IS NOT NULL AND o.model != '' GROUP BY o.model ORDER BY n DESC LIMIT 10", params) + return [{"model": r["model"], "count": r["n"], "amount": float(r["t"])} for r in cur.fetchall()] + finally: + c.close() + +@app.get("/api/sources") +def sources(period: str = Query("all")): + c = conn() + try: + cur = c.cursor() + df, params = date_filter(period) + where = df + " AND" if df else "WHERE" + cur.execute(f"SELECT source_of_initial_contact, count(*) as n FROM prospects {where} source_of_initial_contact IS NOT NULL AND source_of_initial_contact != '' GROUP BY source_of_initial_contact ORDER BY n DESC", params) + return [{"source": r["source_of_initial_contact"], "count": r["n"]} for r in cur.fetchall()] + finally: + c.close() + +@app.get("/api/sales") +def sales(): + c = conn() + try: + cur = c.cursor() + cur.execute("SELECT sales_person, count(*) as n, count(*) FILTER (WHERE prospect_status='Sold') as sold, count(*) FILTER (WHERE prospect_status='OTP') as otp FROM prospects WHERE sales_person IS NOT NULL AND sales_person != '' GROUP BY sales_person ORDER BY n DESC LIMIT 10") + return [{"name": r["sales_person"], "total": r["n"], "sold": r["sold"], "otp": r["otp"]} for r in cur.fetchall()] + finally: + c.close() + +@app.get("/api/trend") +def trend(): + c = conn() + try: + cur = c.cursor() + cur.execute("SELECT to_char(dt_prospect_created,'YYYY-MM') as m, count(*) as p, count(*) FILTER (WHERE prospect_status='Sold') as s FROM prospects WHERE dt_prospect_created IS NOT NULL GROUP BY m ORDER BY m") + return [{"month": r["m"], "prospects": r["p"], "sold": r["s"]} for r in cur.fetchall()] + finally: + c.close() + +@app.get("/api/changes") +def changes(limit: int = Query(20)): + c = conn() + try: + cur = c.cursor() + cur.execute("SELECT table_name, business_key, field_name, old_value, new_value, changed_at, detected_by FROM change_log ORDER BY changed_at DESC LIMIT %(l)s", {"l": limit}) + return [{"table": r["table_name"], "key": r["business_key"], "field": r["field_name"], "old": str(r["old_value"] or ""), "new": str(r["new_value"] or ""), "when": str(r["changed_at"]), "source": r["detected_by"]} for r in cur.fetchall()] + finally: + c.close() + +@app.get("/api/vehicle-orders") +def vehicle_orders(period: str = Query("all")): + c = conn() + try: + cur = c.cursor() + df, params = date_filter(period) + dt_where = df.replace("dt_prospect_created", "p.dt_prospect_created") if df else "" + dt_params = {k: v for k, v in params.items()} if params else {} + + rows = [] + offset = 0 + while True: + cur.execute(f""" + SELECT o.model, o.colour, o.otp_new_used, count(*) as n, + coalesce(sum(o.amount),0) as total_amt, round(avg(o.amount),0) as avg_amt, + count(*) FILTER (WHERE EXISTS ( + SELECT 1 FROM prospects p2 WHERE p2.id=o.prospect_id AND p2.prospect_status='Sold' + )) as sold, + count(*) FILTER (WHERE o.dt_invoiced IS NOT NULL) as invoiced, + count(*) FILTER (WHERE o.stock_no IS NOT NULL AND o.stock_no != '') as in_stock + FROM prospect_otps o + JOIN prospects p ON o.prospect_id = p.id + {dt_where} + AND o.model IS NOT NULL AND o.model != '' + GROUP BY o.model, o.colour, o.otp_new_used + ORDER BY o.model, o.colour + LIMIT 15 OFFSET %(off)s + """, {**dt_params, "off": offset}) + chunk = cur.fetchall() + if not chunk: break + rows.extend(chunk) + offset += len(chunk) + + # Group: model → group (CASE logic), model → colors with amounts + groups = {} + for r in rows: + md = r["model"]; col = r["colour"] or "Unspecified" + typ = r["otp_new_used"] or "Unknown" + n = r["n"]; amt = float(r["total_amt"]); avg = float(r["avg_amt"]); sold = r["sold"]; inv = r["invoiced"]; istk = r["in_stock"] + + # Model group classification + if md.startswith("T2"): grp = "T2" + elif md.startswith("T1"): grp = "T1" + elif md.upper().startswith("DASHING"): grp = "Dashing" + elif md.upper().startswith("X70"): grp = "X70" + else: grp = "Other" + + if grp not in groups: + groups[grp] = {"group": grp, "total_otps": 0, "total_amount": 0, "sold": 0, "invoiced": 0, "in_stock": 0, "models": {}} + groups[grp]["total_otps"] += n + groups[grp]["total_amount"] += amt + groups[grp]["sold"] += sold + groups[grp]["invoiced"] += inv + groups[grp]["in_stock"] += istk + + if md not in groups[grp]["models"]: + groups[grp]["models"][md] = {"model": md, "total_otps": 0, "total_amount": 0, "sold": 0, "invoiced": 0, "in_stock": 0, "colors": []} + groups[grp]["models"][md]["total_otps"] += n + groups[grp]["models"][md]["total_amount"] += amt + groups[grp]["models"][md]["sold"] += sold + groups[grp]["models"][md]["invoiced"] += inv + groups[grp]["models"][md]["in_stock"] += istk + groups[grp]["models"][md]["colors"].append({ + "color": col, "type": typ, "count": n, + "amount": amt, "avg_amount": avg, "sold": sold, "invoiced": inv, "in_stock": istk + }) + + result = [] + for g in sorted(groups.values(), key=lambda x: -x["total_otps"]): + g["models"] = sorted(g["models"].values(), key=lambda m: -m["total_otps"]) + for m in g["models"]: + m["colors"].sort(key=lambda c: -c["count"]) + result.append(g) + return result + finally: + c.close() + +@app.get("/api/health") +def health(): + c = conn() + try: + cur = c.cursor(); cur.execute("SELECT count(*) as n FROM prospects"); p = cur.fetchone()["n"]; cur.execute("SELECT count(*) as n FROM prospect_otps"); o = cur.fetchone()["n"] + return {"status": "ok", "prospects": p, "otps": o} + finally: + c.close() + +@app.get("/api/setup-tables") +def setup_tables(): + """Create prospect_appointments and prospect_leads tables for n8n.""" + c = conn() + try: + cur = c.cursor() + # Use minimal statements to avoid MTU issues + cur.execute("CREATE TABLE IF NOT EXISTS appointments (id BIGSERIAL PRIMARY KEY, prospect_id BIGINT, cms_prospect_id INTEGER, dt_scheduled TIMESTAMPTZ, appointment_type VARCHAR(100), appointment_status VARCHAR(50), confirmed BOOLEAN)") + c.commit() + cur.execute("CREATE TABLE IF NOT EXISTS leads (id BIGSERIAL PRIMARY KEY, lead_id INTEGER, prospect_id BIGINT, cms_prospect_id INTEGER, name VARCHAR(100), surname VARCHAR(100), cell_number VARCHAR(50), email_addr VARCHAR(200), prospect_status VARCHAR(50), lead_source VARCHAR(50))") + c.commit() + # Report tables + cur.execute("CREATE TABLE IF NOT EXISTS report_daily_kpis (id BIGSERIAL PRIMARY KEY, report_date DATE NOT NULL UNIQUE, total_prospects INT DEFAULT 0, new_prospects INT DEFAULT 0, total_otps INT DEFAULT 0, new_otps INT DEFAULT 0, sold_count INT DEFAULT 0, pipeline_value NUMERIC(18,2) DEFAULT 0, avg_otp_value NUMERIC(18,2) DEFAULT 0, pending_count INT DEFAULT 0, finance_count INT DEFAULT 0, lost_count INT DEFAULT 0, conversion_rate NUMERIC(5,2) DEFAULT 0, generated_at TIMESTAMPTZ DEFAULT NOW())") + c.commit() + cur.execute("CREATE TABLE IF NOT EXISTS report_model_performance (id BIGSERIAL PRIMARY KEY, report_date DATE NOT NULL, model_group VARCHAR(100) NOT NULL, model VARCHAR(200) NOT NULL, color VARCHAR(100), otp_type VARCHAR(20), otp_count INT DEFAULT 0, total_amount NUMERIC(18,2) DEFAULT 0, avg_amount NUMERIC(18,2) DEFAULT 0, sold_count INT DEFAULT 0, generated_at TIMESTAMPTZ DEFAULT NOW(), UNIQUE(report_date, model_group, model, color, otp_type))") + c.commit() + cur.execute("CREATE TABLE IF NOT EXISTS report_salesperson_performance (id BIGSERIAL PRIMARY KEY, report_date DATE NOT NULL, sales_person VARCHAR(150) NOT NULL, total_prospects INT DEFAULT 0, otp_count INT DEFAULT 0, sold_count INT DEFAULT 0, finance_count INT DEFAULT 0, lost_count INT DEFAULT 0, total_pipeline_value NUMERIC(18,2) DEFAULT 0, generated_at TIMESTAMPTZ DEFAULT NOW(), UNIQUE(report_date, sales_person))") + c.commit() + return {"status": "ok", "tables": ["report_daily_kpis","report_model_performance","report_salesperson_performance"]} + finally: + c.close() + +# ── Reports ───────────────────────────────────────────────────── + +@app.get("/api/reports/kpis") +def report_kpis(days: int = Query(30, description="Days to include in report")): + c = conn() + try: + cur = c.cursor() + cur.execute(""" + WITH daily AS ( + SELECT date_trunc('day', dt_prospect_created) as day, + count(*) as new_prospects, + count(*) FILTER (WHERE prospect_status='Sold') as new_sold + FROM prospects WHERE dt_prospect_created >= NOW() - (%(d)s || ' days')::INTERVAL + GROUP BY day + ), + otp_daily AS ( + SELECT date_trunc('day', o.dt_otp_created) as day, count(*) as new_otps, + coalesce(sum(o.amount),0) as pipeline + FROM prospect_otps o WHERE o.dt_otp_created >= NOW() - (%(d)s || ' days')::INTERVAL + GROUP BY day + ) + SELECT to_char(d.day, 'YYYY-MM-DD') as date, d.new_prospects, d.new_sold, + coalesce(od.new_otps,0) as new_otps, coalesce(od.pipeline,0) as pipeline_value + FROM daily d LEFT JOIN otp_daily od ON d.day = od.day + ORDER BY d.day + """, {"d": days}) + daily = [{"date": r["date"], "prospects": r["new_prospects"], "sold": r["new_sold"], + "otps": r["new_otps"], "pipeline": float(r["pipeline_value"])} for r in cur.fetchall()] + + # Totals for period + cur.execute(""" + SELECT count(*) as total_p, count(*) FILTER (WHERE prospect_status='Sold') as sold_p, + count(*) FILTER (WHERE prospect_status='OTP') as otp_p, + count(*) FILTER (WHERE prospect_status='FinanceApplied') as fin_p, + count(*) FILTER (WHERE prospect_status='Pending') as pen_p + FROM prospects WHERE dt_prospect_created >= NOW() - (%(d)s || ' days')::INTERVAL + """, {"d": days}) + totals = cur.fetchone() + + cur.execute(""" + SELECT count(*) as total_o, coalesce(sum(amount),0) as total_amt, round(avg(amount),0) as avg_amt + FROM prospect_otps WHERE dt_otp_created >= NOW() - (%(d)s || ' days')::INTERVAL + """, {"d": days}) + otp_totals = cur.fetchone() + + return { + "period_days": days, + "daily": daily, + "totals": { + "prospects": totals["total_p"], "sold": totals["sold_p"], + "otp_status": totals["otp_p"], "finance": totals["fin_p"], "pending": totals["pen_p"], + "total_otps": otp_totals["total_o"], "pipeline_value": float(otp_totals["total_amt"]), + "avg_otp": float(otp_totals["avg_amt"]) + }, + "conversion": { + "lead_to_otp": round(totals["otp_p"] / max(totals["total_p"], 1) * 100, 1), + "otp_to_finance": round(totals["fin_p"] / max(totals["otp_p"], 1) * 100, 1), + "finance_to_sold": round(totals["sold_p"] / max(totals["fin_p"] + totals["otp_p"], 1) * 100, 1) + } + } + finally: + c.close() + +@app.get("/api/reports/performance") +def report_performance(): + c = conn() + try: + cur = c.cursor() + cur.execute(""" + WITH mg AS ( + SELECT CASE + WHEN model_range IS NOT NULL AND model_range != '' THEN initcap(model_range) + WHEN model ~ '^T[12]' THEN regexp_replace(model, '^(T[12])\\s.*', '\\1') + WHEN model ~ '^(DASHING|Dashing)' THEN 'Dashing' + WHEN model ~ '^(X70)' THEN 'X70' + ELSE 'Other' END as model_group, + o.*, p.prospect_status + FROM prospect_otps o JOIN prospects p ON o.prospect_id = p.id + WHERE o.model IS NOT NULL AND o.model != '' + ) + SELECT model_group, count(*) as total_otps, + coalesce(sum(amount),0) as total_value, + round(avg(amount),0) as avg_value, + count(*) FILTER (WHERE prospect_status='Sold') as sold, + count(*) FILTER (WHERE prospect_status='OTP') as otp_active, + count(*) FILTER (WHERE prospect_status='FinanceApplied') as finance + FROM mg GROUP BY model_group ORDER BY total_otps DESC + """) + groups = [] + for r in cur.fetchall(): + groups.append({ + "group": r["model_group"], "total_otps": r["total_otps"], + "total_value": float(r["total_value"]), "avg_value": float(r["avg_value"]), + "sold": r["sold"], "otp_active": r["otp_active"], "finance": r["finance"], + "conversion": round(r["sold"] / max(r["total_otps"], 1) * 100, 1) + }) + return groups + finally: + c.close() + +@app.get("/api/pipeline-velocity") +def pipeline_velocity(period: str = Query("all")): + """Days between pipeline stages: OTP→Invoice, OTP→Delivery, Invoice→Delivery.""" + c = conn() + try: + cur = c.cursor() + df, params = date_filter(period) + df2 = df.replace('dt_prospect_created', 'p.dt_prospect_created') if df else '' + df2 = df2.replace('WHERE ', '', 1) if df2.startswith('WHERE') else df2 + where = (df2 + ' AND ') if df2 else '' + + # OTP → Invoice + cur.execute(f""" + SELECT extract(epoch FROM (o.dt_invoiced - o.dt_otp_created))/86400 as days + FROM prospect_otps o + JOIN prospects p ON p.id = o.prospect_id + WHERE {where}o.dt_invoiced IS NOT NULL AND o.dt_otp_created IS NOT NULL + AND o.dt_invoiced > o.dt_otp_created + """, params) + otp_to_invoice = [round(r["days"], 1) for r in cur.fetchall()] + + # OTP → Delivery + cur.execute(f""" + SELECT extract(epoch FROM (a.dt_created - o.dt_otp_created))/86400 as days + FROM prospect_otps o + JOIN prospects p ON p.id = o.prospect_id + JOIN appointments a ON a.prospect_id = o.prospect_id + WHERE {where}a.appointment_type = 'Delivery' + AND a.dt_created IS NOT NULL AND o.dt_otp_created IS NOT NULL + AND a.dt_created > o.dt_otp_created + """, params) + otp_to_delivery = [round(r["days"], 1) for r in cur.fetchall()] + + # Invoice → Delivery + cur.execute(f""" + SELECT extract(epoch FROM (a.dt_created - o.dt_invoiced))/86400 as days + FROM prospect_otps o + JOIN prospects p ON p.id = o.prospect_id + JOIN appointments a ON a.prospect_id = o.prospect_id + WHERE {where}a.appointment_type = 'Delivery' + AND o.dt_invoiced IS NOT NULL AND a.dt_created IS NOT NULL + AND a.dt_created > o.dt_invoiced + """, params) + inv_to_delivery = [round(r["days"], 1) for r in cur.fetchall()] + + def stats(vals): + if not vals: return {"count": 0, "avg": 0, "median": 0, "min": 0, "max": 0, "values": []} + sv = sorted(vals) + n = len(sv) + med = sv[n//2] if n % 2 == 1 else (sv[n//2-1] + sv[n//2]) / 2 + return {"count": n, "avg": round(sum(sv)/n, 1), "median": round(med, 1), + "min": sv[0], "max": sv[-1], "values": sv} + + return { + "stages": [ + {"label": "OTP → Invoice", "metric": "days_otp_to_invoice", **stats(otp_to_invoice)}, + {"label": "OTP → Delivery", "metric": "days_otp_to_delivery", **stats(otp_to_delivery)}, + {"label": "Invoice → Delivery", "metric": "days_invoice_to_delivery", **stats(inv_to_delivery)} + ] + } + finally: + c.close() + +@app.get("/api/audit") +def db_audit(): + """Return DB data audit for dashboard gap analysis.""" + c = conn() + try: + cur = c.cursor() + result = {} + + cur.execute("SELECT prospect_status, count(*) as n FROM prospects GROUP BY prospect_status ORDER BY n DESC") + result["status_dist"] = [{"status": r["prospect_status"], "count": r["n"]} for r in cur.fetchall()] + + cur.execute("SELECT lost_reason, count(*) as n FROM prospects WHERE lost_reason IS NOT NULL AND lost_reason != '' GROUP BY lost_reason ORDER BY n DESC") + result["lost_reasons"] = [{"reason": r["lost_reason"], "count": r["n"]} for r in cur.fetchall()] + + cur.execute("SELECT otp_new_used, count(*) as n FROM prospect_otps GROUP BY otp_new_used ORDER BY n DESC") + result["new_used"] = [{"type": r["otp_new_used"], "count": r["n"]} for r in cur.fetchall()] + + cur.execute("SELECT appointment_type, count(*) as n FROM appointments GROUP BY appointment_type ORDER BY n DESC") + result["appointments"] = [{"type": r["appointment_type"], "count": r["n"]} for r in cur.fetchall()] + + cur.execute("SELECT referral_source, count(*) as n FROM prospects WHERE referral_source IS NOT NULL AND referral_source != '' GROUP BY referral_source ORDER BY n DESC LIMIT 8") + result["referrals"] = [{"source": r["referral_source"], "count": r["n"]} for r in cur.fetchall()] + + return result + finally: + c.close() + +@app.get("/api/lost-reasons") +def lost_reasons(): + c = conn() + try: + cur = c.cursor() + cur.execute("SELECT lost_reason as reason, count(*) as n FROM prospects WHERE lost_reason IS NOT NULL AND lost_reason != '' GROUP BY lost_reason ORDER BY n DESC") + return [{"reason": r["reason"], "count": r["n"]} for r in cur.fetchall()] + finally: + c.close() + +@app.get("/api/new-used") +def new_used(): + c = conn() + try: + cur = c.cursor() + cur.execute("SELECT coalesce(otp_new_used,'Unknown') as type, count(*) as n, coalesce(sum(amount),0) as value FROM prospect_otps GROUP BY otp_new_used ORDER BY n DESC") + return [{"type": r["type"], "count": r["n"], "value": float(r["value"])} for r in cur.fetchall()] + finally: + c.close() + +# ── Recipient Management ────────────────────────────────────────── + +@app.get("/api/recipients") +def list_recipients(user=Depends(require_admin)): + c = conn() + try: + cur = c.cursor() + cur.execute("CREATE TABLE IF NOT EXISTS report_recipients (id SERIAL PRIMARY KEY, report_type VARCHAR(20) NOT NULL, email VARCHAR(200) NOT NULL, name VARCHAR(100), active BOOLEAN DEFAULT true, added_at TIMESTAMPTZ DEFAULT NOW(), UNIQUE(report_type, email))") + c.commit() + cur.execute("SELECT id, report_type, email, name, active, added_at FROM report_recipients ORDER BY report_type, email") + return [{"id": r["id"], "type": r["report_type"], "email": r["email"], "name": r["name"], "active": r["active"], "added": str(r["added_at"])} for r in cur.fetchall()] + finally: + c.close() + +@app.post("/api/recipients") +async def add_recipient(data: dict, user=Depends(require_admin)): + c = conn() + try: + cur = c.cursor() + cur.execute("INSERT INTO report_recipients (report_type, email, name) VALUES (%(t)s, %(e)s, %(n)s) ON CONFLICT (report_type, email) DO UPDATE SET active=true, name=COALESCE(%(n)s, report_recipients.name) RETURNING id", + {"t": data["type"], "e": data["email"], "n": data.get("name")}) + c.commit() + return {"id": cur.fetchone()["id"], "status": "added"} + finally: + c.close() + +@app.delete("/api/recipients/{rid}") +def remove_recipient(rid: int, user=Depends(require_admin)): + c = conn() + try: + cur = c.cursor() + cur.execute("DELETE FROM report_recipients WHERE id=%(i)s", {"i": rid}) + c.commit() + return {"status": "removed"} + finally: + c.close() + +@app.put("/api/recipients/{rid}") +async def toggle_recipient(rid: int, user=Depends(require_admin)): + c = conn() + try: + cur = c.cursor() + cur.execute("UPDATE report_recipients SET active=NOT active WHERE id=%(i)s RETURNING active", {"i": rid}) + c.commit() + r = cur.fetchone() + return {"active": r["active"]} if r else {"error": "not found"} + finally: + c.close() + +# ── SMTP Settings ───────────────────────────────────────────────── + +@app.get("/api/settings/smtp") +def get_smtp_settings(user=Depends(require_admin)): + c = conn() + try: + cur = c.cursor() + cur.execute("CREATE TABLE IF NOT EXISTS app_settings (key VARCHAR(100) PRIMARY KEY, value TEXT, updated_at TIMESTAMPTZ DEFAULT NOW())") + c.commit() + cur.execute("SELECT key, value FROM app_settings WHERE key LIKE 'smtp_%'") + cfg = {r["key"]: r["value"] for r in cur.fetchall()} + return {"smtp_host": cfg.get("smtp_host",""), "smtp_port": cfg.get("smtp_port","587"), + "smtp_user": cfg.get("smtp_user",""), "smtp_pass": "", "smtp_from": cfg.get("smtp_from",""), + "smtp_use_tls": cfg.get("smtp_use_tls","true")} + finally: + c.close() + +@app.put("/api/settings/smtp") +async def save_smtp_settings(data: dict, user=Depends(require_admin)): + c = conn() + try: + cur = c.cursor() + cur.execute("CREATE TABLE IF NOT EXISTS app_settings (key VARCHAR(100) PRIMARY KEY, value TEXT, updated_at TIMESTAMPTZ DEFAULT NOW())") + c.commit() + for key in ["smtp_host","smtp_port","smtp_user","smtp_from","smtp_use_tls"]: + if key in data: + cur.execute("INSERT INTO app_settings (key, value) VALUES (%(k)s, %(v)s) ON CONFLICT (key) DO UPDATE SET value=%(v)s, updated_at=NOW()", + {"k": key, "v": data[key]}) + if data.get("smtp_pass") and data["smtp_pass"].strip(): + cur.execute("INSERT INTO app_settings (key, value) VALUES ('smtp_pass', %(v)s) ON CONFLICT (key) DO UPDATE SET value=%(v)s, updated_at=NOW()", + {"v": data["smtp_pass"]}) + c.commit() + return {"status": "saved"} + finally: + c.close() + +@app.post("/api/settings/smtp/test") +async def test_smtp(data: dict, user=Depends(require_admin)): + """Send a test email to verify SMTP config.""" + import smtplib, ssl + from email.mime.text import MIMEText + from email.mime.multipart import MIMEMultipart + + test_email = data.get("email", "").strip() + if not test_email or "@" not in test_email: + return {"error": "Valid email address required"} + + c = conn() + try: + cur = c.cursor() + cur.execute("SELECT key, value FROM app_settings WHERE key LIKE 'smtp_%'") + cfg = {r["key"]: r["value"] for r in cur.fetchall()} + if not cfg.get("smtp_host"): + return {"error": "SMTP not configured — save settings first"} + + port = int(cfg.get("smtp_port", 587)) + use_tls = cfg.get("smtp_use_tls", "true") == "true" + + html = f""" +

✅ SMTP Test Successful

+

Your Jetour BI email configuration is working correctly.

+

Host: {cfg['smtp_host']}:{port} · TLS: {use_tls}

+ """ + + msg = MIMEMultipart("alternative") + msg["Subject"] = "Jetour BI — SMTP Test" + msg["From"] = cfg["smtp_from"] + msg["To"] = test_email + msg.attach(MIMEText(html, "html")) + + if use_tls: + ctx = ssl.create_default_context() + with smtplib.SMTP(cfg["smtp_host"], port, timeout=15) as server: + server.starttls(context=ctx) + server.login(cfg["smtp_user"], cfg["smtp_pass"]) + server.sendmail(cfg["smtp_from"], [test_email], msg.as_string()) + else: + with smtplib.SMTP(cfg["smtp_host"], port, timeout=15) as server: + server.login(cfg["smtp_user"], cfg["smtp_pass"]) + server.sendmail(cfg["smtp_from"], [test_email], msg.as_string()) + + return {"status": "sent", "to": test_email} + except smtplib.SMTPAuthenticationError: + return {"error": "Authentication failed — check username/password"} + except smtplib.SMTPConnectError: + return {"error": f"Could not connect to {cfg.get('smtp_host')}:{port} — check host/port"} + except Exception as e: + return {"error": str(e)} + finally: + if c: c.close() + +# ── Report Dispatch ─────────────────────────────────────────────── + +def send_email_smtp(settings, to_list, subject, html_body): + import smtplib, ssl + from email.mime.text import MIMEText + from email.mime.multipart import MIMEMultipart + + msg = MIMEMultipart("alternative") + msg["Subject"] = subject + msg["From"] = settings["smtp_from"] + msg["To"] = to_list + msg.attach(MIMEText(html_body, "html")) + + port = int(settings.get("smtp_port", 587)) + use_tls = settings.get("smtp_use_tls", "true") == "true" + + if use_tls: + ctx = ssl.create_default_context() + with smtplib.SMTP(settings["smtp_host"], port) as server: + server.starttls(context=ctx) + server.login(settings["smtp_user"], settings["smtp_pass"]) + server.sendmail(settings["smtp_from"], to_list.split(","), msg.as_string()) + else: + with smtplib.SMTP(settings["smtp_host"], port) as server: + server.login(settings["smtp_user"], settings["smtp_pass"]) + server.sendmail(settings["smtp_from"], to_list.split(","), msg.as_string()) + return True + +def build_report_html(report_type, period_label, kpis, sales, models): + def fm(v): return f'MUR {(float(v or 0)/1e6):.1f}M' + sales_html = ''.join([f'• {s.get("sales_person","")}: {s.get("total",0)} prospects, {s.get("sold",0)} sold' for s in sales[:3]]) if sales else '' + models_html = ''.join([f'• {m.get("grp","")}: {m.get("n",0)} OTPs, {fm(m.get("amt",0))}' for m in models[:3]]) if models else '' + changes = int(kpis.get("changes", 0)) + + return f''' + + +
+ + + + + + + + +{sales_html} +{models_html} + +
+
{report_type} Report
+

Jetour Mauritius BI

+
{period_label}
+
+ + + + + +
New Prospects
{kpis.get("new_prospects",0)}
New OTPs
{kpis.get("new_otps",0)}
Sold
{kpis.get("new_sold",0)}
Pipeline
{fm(kpis.get("pipeline",0))}
+
+ All-time: {kpis.get("total_prospects",0)} prospects · {kpis.get("total_otps",0)} OTPs · {kpis.get("total_sold",0)} sold · {fm(kpis.get("total_pipeline",0))} +
⚡ {changes} field changes detected
+ + + +
Jetour BI · Auto-generated📊 Full Dashboard →
+
''' + +@app.post("/api/send-report/{report_type}") +def send_report(report_type: str): + """Trigger email report: daily, weekly, or monthly. Called by n8n.""" + import smtplib, ssl + from email.mime.text import MIMEText + from email.mime.multipart import MIMEMultipart + + c = conn() + try: + cur = c.cursor() + + # Get SMTP settings + cur.execute("SELECT key, value FROM app_settings WHERE key LIKE 'smtp_%'") + cfg = {r["key"]: r["value"] for r in cur.fetchall()} + if not cfg.get("smtp_host"): + return {"error": "SMTP not configured", "url": "/settings.html"} + + # Get active recipients + cur.execute("SELECT string_agg(email, ',') as to_list FROM report_recipients WHERE report_type=%s AND active=true", (report_type,)) + row = cur.fetchone() + if not row or not row["to_list"]: + return {"error": f"No active recipients for {report_type}"} + to_list = row["to_list"] + + # Build SQL based on report type + if report_type == "daily": + period_clause = "CURRENT_DATE - 1" + period_label_query = "SELECT to_char(CURRENT_DATE - 1, 'DD Mon YYYY') as label" + elif report_type == "weekly": + period_clause = "CURRENT_DATE - 7" + period_label_query = "SELECT to_char(CURRENT_DATE - 7, 'DD Mon') || ' – ' || to_char(CURRENT_DATE - 1, 'DD Mon YYYY') as label" + else: # monthly + period_label_query = "SELECT to_char(date_trunc('month', NOW()) - INTERVAL '1 month', 'Month YYYY') as label" + + # Run period-specific queries + if report_type == "monthly": + cur.execute(""" + SELECT (SELECT count(*) FROM prospects WHERE dt_prospect_created >= date_trunc('month', NOW()) - INTERVAL '1 month' AND dt_prospect_created < date_trunc('month', NOW())) as new_prospects, + (SELECT count(*) FROM prospects WHERE prospect_status='Sold' AND dt_prospect_created >= date_trunc('month', NOW()) - INTERVAL '1 month' AND dt_prospect_created < date_trunc('month', NOW())) as new_sold, + (SELECT count(*) FROM prospect_otps WHERE dt_otp_created >= date_trunc('month', NOW()) - INTERVAL '1 month' AND dt_otp_created < date_trunc('month', NOW())) as new_otps, + (SELECT coalesce(sum(amount),0) FROM prospect_otps WHERE dt_otp_created >= date_trunc('month', NOW()) - INTERVAL '1 month' AND dt_otp_created < date_trunc('month', NOW())) as pipeline, + (SELECT count(*) FROM prospects) as total_prospects, (SELECT count(*) FROM prospect_otps) as total_otps, + (SELECT count(*) FROM prospects WHERE prospect_status='Sold') as total_sold, (SELECT coalesce(sum(amount),0) FROM prospect_otps) as total_pipeline, + (SELECT count(*) FROM change_log WHERE changed_at >= date_trunc('month', NOW()) - INTERVAL '1 month' AND changed_at < date_trunc('month', NOW())) as changes + """) + kpis = dict(cur.fetchone()) + cur.execute("SELECT sales_person, count(*) as total, count(*) FILTER (WHERE prospect_status='Sold') as sold FROM prospects WHERE sales_person IS NOT NULL AND sales_person != '' AND dt_prospect_created >= date_trunc('month', NOW()) - INTERVAL '1 month' AND dt_prospect_created < date_trunc('month', NOW()) GROUP BY sales_person ORDER BY sold DESC LIMIT 5") + sales = [dict(r) for r in cur.fetchall()] + cur.execute("SELECT coalesce(nullif(model_range,''),model) as grp, count(*) as n, coalesce(sum(amount),0) as amt FROM prospect_otps WHERE dt_otp_created >= date_trunc('month', NOW()) - INTERVAL '1 month' AND dt_otp_created < date_trunc('month', NOW()) AND model IS NOT NULL AND model != '' GROUP BY coalesce(nullif(model_range,''),model) ORDER BY n DESC LIMIT 5") + models = [dict(r) for r in cur.fetchall()] + else: + cur.execute(f"""SELECT (SELECT count(*) FROM prospects WHERE dt_prospect_created::date = {period_clause}) as new_prospects, + (SELECT count(*) FROM prospects WHERE prospect_status='Sold' AND dt_prospect_created::date = {period_clause}) as new_sold, + (SELECT count(*) FROM prospect_otps WHERE dt_otp_created::date = {period_clause}) as new_otps, + (SELECT coalesce(sum(amount),0) FROM prospect_otps WHERE dt_otp_created::date = {period_clause}) as pipeline, + (SELECT count(*) FROM prospects) as total_prospects, (SELECT count(*) FROM prospect_otps) as total_otps, + (SELECT count(*) FROM prospects WHERE prospect_status='Sold') as total_sold, (SELECT coalesce(sum(amount),0) FROM prospect_otps) as total_pipeline, + (SELECT count(*) FROM change_log WHERE changed_at::date = {period_clause}) as changes""") + kpis = dict(cur.fetchone()) + cur.execute(f"SELECT sales_person, count(*) as total, count(*) FILTER (WHERE prospect_status='Sold') as sold FROM prospects WHERE sales_person IS NOT NULL AND sales_person != '' AND dt_prospect_created::date = {period_clause} GROUP BY sales_person ORDER BY sold DESC LIMIT 5") + sales = [dict(r) for r in cur.fetchall()] + cur.execute(f"SELECT coalesce(nullif(model_range,''),model) as grp, count(*) as n, coalesce(sum(amount),0) as amt FROM prospect_otps WHERE dt_otp_created::date = {period_clause} AND model IS NOT NULL AND model != '' GROUP BY coalesce(nullif(model_range,''),model) ORDER BY n DESC LIMIT 5") + models = [dict(r) for r in cur.fetchall()] + + # Get period label + cur.execute(period_label_query) + period_label = cur.fetchone()["label"].strip() + + # Build and send email + html = build_report_html(report_type.title(), period_label, kpis, sales, models) + + # Send via SMTP + port = int(cfg.get("smtp_port", 587)) + use_tls = cfg.get("smtp_use_tls", "true") == "true" + msg = MIMEMultipart("alternative") + msg["Subject"] = f"Jetour BI {report_type.title()} · {period_label}" + msg["From"] = cfg["smtp_from"] + msg["To"] = to_list + msg.attach(MIMEText(html, "html")) + + if use_tls: + ctx = ssl.create_default_context() + with smtplib.SMTP(cfg["smtp_host"], port, timeout=30) as server: + server.starttls(context=ctx) + server.login(cfg["smtp_user"], cfg["smtp_pass"]) + server.sendmail(cfg["smtp_from"], [e.strip() for e in to_list.split(",")], msg.as_string()) + else: + with smtplib.SMTP(cfg["smtp_host"], port, timeout=30) as server: + server.login(cfg["smtp_user"], cfg["smtp_pass"]) + server.sendmail(cfg["smtp_from"], [e.strip() for e in to_list.split(",")], msg.as_string()) + + return {"status": "sent", "to": to_list, "period": period_label} + except Exception as e: + return {"error": str(e)} + finally: + c.close() + + + +@app.get("/reset-password") +async def reset_password_page(): + from fastapi.responses import HTMLResponse + import os + path = os.path.join(os.path.dirname(__file__), "static", "reset-password.html") + with open(path) as f: + return HTMLResponse(f.read()) + +app.mount("/", StaticFiles(directory="static", html=True), name="static") diff --git a/static/index.html b/static/index.html new file mode 100644 index 0000000..6c5244c --- /dev/null +++ b/static/index.html @@ -0,0 +1,474 @@ + + + + + +Jetour BI — Sales & CRM Dashboard + + + + + + +
+ +
+ + + + + + + + + + + + + \ No newline at end of file diff --git a/static/settings.html b/static/settings.html new file mode 100644 index 0000000..ad7822e --- /dev/null +++ b/static/settings.html @@ -0,0 +1,124 @@ + + + + + +Jetour BI — Settings + + + + +
+
+
Daily Report
+
Weekly Report
+
Monthly Report
+
SMTP Config
+
Users
+
+ +
+
+ + + +
+
    +
    No recipients yet.
    +
    + +
    +
    +
    + + +
    +
    + + +
    +
    + + +
    + +
    +
    + + +
    +
    +
    + +
    +
    Change Password
    +
    + + +
    + +
    Manage Users
    +
    + + + +
    + +
    +
    + + + +
    + + + +