diff --git a/README.md b/README.md
index 80808e8a..47b934eb 100644
--- a/README.md
+++ b/README.md
@@ -1,20 +1,21 @@
# Midas
-Midas is a production-oriented MVP for automated cold outreach, follow-up orchestration, and reply handling. It combines:
+Midas is a production-oriented platform for autonomous cold outreach operations. This iteration ships a robust **lead ingestion and storage foundation** with a modern UX and API-first backend.
-- **Lead ingestion** (CSV/TXT/JSON)
-- **De-duplication** against prior contacts and opt-outs
-- **Template generation/optimization agents**
-- **Outreach sending and follow-up sequencing**
-- **Reply sentiment analysis + draft response generation**
-- **Dashboard** with campaign metrics, alerts, and controls
-- **Provider/model failover** (API key/model rotation)
+## What this base includes
+
+- Multi-format lead upload: **CSV / TXT / JSON / XLSX**
+- Field normalization for common header variations (`full_name`, `email_address`, `job_title`, etc.)
+- Database-backed deduplication and opt-out safeguards
+- Lead inventory browsing with search + status filters
+- API-first backend designed for separate frontend/backend deployments
+- Standalone frontend bundle (`/frontend`) that can run on a different host from the API
## Stack
-- FastAPI + Jinja dashboard
-- SQLAlchemy + SQLite (swap-ready for PostgreSQL)
-- Agent layer with pluggable providers (Google ADK/Gemini-ready interfaces)
+- Backend: FastAPI + SQLAlchemy
+- Database: SQLite by default (PostgreSQL-ready)
+- Frontend: static app (HTML/CSS/JS) with API integration
## Quickstart
@@ -25,19 +26,26 @@ pip install -e .[dev]
uvicorn app.main:app --reload
```
-Open `http://127.0.0.1:8000`.
+Open `http://127.0.0.1:8000` for the integrated dashboard.
-## Environment
+To host frontend separately:
+
+1. Serve `frontend/` on any static host.
+2. Set `window.MIDAS_API_BASE` in `frontend/index.html` to your backend URL.
+3. Configure backend CORS via `MIDAS_CORS_ALLOWED_ORIGINS`.
+
+## API endpoints (lead foundation)
-Set optional env vars:
+- `GET /api/v1/health`
+- `GET /api/v1/dashboard`
+- `GET /api/v1/leads?status=&search=&limit=&offset=`
+- `POST /api/v1/leads/import` (multipart `file`)
+
+## Environment
- `MIDAS_DB_URL` (default: `sqlite:///./midas.db`)
+- `MIDAS_CORS_ALLOWED_ORIGINS` (default: `*`, comma-separated)
- `MIDAS_SENDER_EMAIL` (default: `hello@midas.local`)
- `MIDAS_DAILY_SEND_LIMIT_PER_MAILBOX` (default: `80`)
- `MIDAS_REPLY_AUTO_SEND_DELAY_MINUTES` (default: `60`)
-- `MIDAS_MODEL_CONFIG` JSON list for model/key rotation (see `app/core/config.py`)
-
-## Notes
-
-- Email sending and inbound sync use adapter interfaces with a safe local logger implementation by default.
-- Replace adapters in `app/services/email_gateway.py` and `app/services/inbox_sync.py` for SMTP/IMAP, Gmail API, SES, etc.
+- `MIDAS_MODEL_CONFIG` JSON list for model/key rotation
diff --git a/app/api/routes.py b/app/api/routes.py
index 75b96d4d..94b9925e 100644
--- a/app/api/routes.py
+++ b/app/api/routes.py
@@ -1,47 +1,130 @@
from __future__ import annotations
-from fastapi import APIRouter, Depends, File, Form, Request, UploadFile
+from datetime import datetime
+
+from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, Request, UploadFile
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
-from sqlalchemy import select
+from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.db.session import get_db
-from app.models.entities import Alert, Lead, ReplyMessage
-from app.models.schemas import IncomingReply
+from app.models.entities import Alert, Lead, LeadStatus, ReplyMessage
+from app.models.schemas import IncomingReply, LeadImportResult, LeadListResponse, LeadOut
from app.services.campaign_service import CampaignService
from app.services.lead_importer import LeadImporter
router = APIRouter()
+api_router = APIRouter(prefix="/api/v1", tags=["api"])
templates = Jinja2Templates(directory="app/templates")
@router.get("/", response_class=HTMLResponse)
-def dashboard(request: Request, db: Session = Depends(get_db)):
+def dashboard(request: Request):
+ return templates.TemplateResponse(request, "dashboard.html", {})
+
+
+@router.post("/leads/import")
+async def import_leads(file: UploadFile = File(...), db: Session = Depends(get_db)):
+ payload = await file.read()
+ importer = LeadImporter(db)
+ rows = importer.parse(file.filename or "upload", payload)
+ _ = importer.import_rows(rows)
+ return RedirectResponse(url="/", status_code=303)
+
+
+@api_router.get("/health")
+def health_check() -> dict[str, str]:
+ return {"status": "ok"}
+
+
+@api_router.get("/dashboard")
+def dashboard_data(db: Session = Depends(get_db)):
service = CampaignService(db)
metrics = service.metrics()
- leads = db.scalars(select(Lead).order_by(Lead.created_at.desc()).limit(20)).all()
alerts = db.scalars(select(Alert).order_by(Alert.created_at.desc()).limit(10)).all()
replies = db.scalars(select(ReplyMessage).order_by(ReplyMessage.received_at.desc()).limit(10)).all()
- return templates.TemplateResponse(
- request,
- "dashboard.html",
- {
- "metrics": metrics,
- "leads": leads,
- "alerts": alerts,
- "replies": replies,
- },
+ return {
+ "metrics": metrics.model_dump(),
+ "alerts": [
+ {
+ "id": item.id,
+ "severity": item.severity,
+ "message": item.message,
+ "created_at": item.created_at.isoformat(),
+ }
+ for item in alerts
+ ],
+ "replies": [
+ {
+ "id": reply.id,
+ "lead_id": reply.lead_id,
+ "sentiment": reply.sentiment.value,
+ "received_at": reply.received_at.isoformat(),
+ }
+ for reply in replies
+ ],
+ }
+
+
+@api_router.get("/leads", response_model=LeadListResponse)
+def list_leads(
+ db: Session = Depends(get_db),
+ status: LeadStatus | None = Query(default=None),
+ search: str = Query(default="", min_length=0, max_length=255),
+ limit: int = Query(default=50, ge=1, le=200),
+ offset: int = Query(default=0, ge=0),
+):
+ query = select(Lead)
+ count_query = select(func.count()).select_from(Lead)
+
+ if status:
+ query = query.where(Lead.status == status)
+ count_query = count_query.where(Lead.status == status)
+
+ search_value = search.strip()
+ if search_value:
+ pattern = f"%{search_value}%"
+ query = query.where((Lead.email.ilike(pattern)) | (Lead.name.ilike(pattern)) | (Lead.company.ilike(pattern)))
+ count_query = count_query.where((Lead.email.ilike(pattern)) | (Lead.name.ilike(pattern)) | (Lead.company.ilike(pattern)))
+
+ total = db.scalar(count_query) or 0
+ leads = db.scalars(query.order_by(Lead.created_at.desc()).offset(offset).limit(limit)).all()
+
+ return LeadListResponse(
+ total=total,
+ leads=[
+ LeadOut(
+ id=lead.id,
+ name=lead.name,
+ email=lead.email,
+ company=lead.company,
+ position=lead.position,
+ niche=lead.niche,
+ status=lead.status.value,
+ created_at=lead.created_at.isoformat(),
+ )
+ for lead in leads
+ ],
)
-@router.post("/leads/import")
-async def import_leads(file: UploadFile = File(...), db: Session = Depends(get_db)):
+@api_router.post("/leads/import", response_model=LeadImportResult)
+async def import_leads_api(file: UploadFile = File(...), db: Session = Depends(get_db)):
+ if not file.filename:
+ raise HTTPException(status_code=400, detail="Uploaded file must include a filename.")
+
payload = await file.read()
+ if not payload:
+ raise HTTPException(status_code=400, detail="Uploaded file is empty.")
+
importer = LeadImporter(db)
- rows = importer.parse(file.filename, payload)
- _ = importer.import_rows(rows)
- return RedirectResponse(url="/", status_code=303)
+ try:
+ rows = importer.parse(file.filename, payload)
+ except ValueError as exc:
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
+
+ return importer.import_rows(rows)
@router.post("/templates/generate")
@@ -69,7 +152,7 @@ def send_followups(db: Session = Depends(get_db)):
def ingest_reply(payload: IncomingReply, db: Session = Depends(get_db)):
service = CampaignService(db)
service.process_incoming_reply(payload.lead_email, payload.raw_body)
- return {"status": "ok"}
+ return {"status": "ok", "received_at": datetime.utcnow().isoformat()}
@router.post("/reply/{lead_id}/approve")
diff --git a/app/core/config.py b/app/core/config.py
index a6246578..76ca3f2e 100644
--- a/app/core/config.py
+++ b/app/core/config.py
@@ -25,6 +25,13 @@ class Settings:
reply_auto_send_delay_minutes: int = int(
os.getenv("MIDAS_REPLY_AUTO_SEND_DELAY_MINUTES", "60")
)
+ cors_allowed_origins: list[str] = field(
+ default_factory=lambda: [
+ origin.strip()
+ for origin in os.getenv("MIDAS_CORS_ALLOWED_ORIGINS", "*").split(",")
+ if origin.strip()
+ ]
+ )
model_config_raw: str = os.getenv(
"MIDAS_MODEL_CONFIG",
json.dumps(
diff --git a/app/main.py b/app/main.py
index 760a8e35..6fe4fb1c 100644
--- a/app/main.py
+++ b/app/main.py
@@ -1,14 +1,24 @@
from __future__ import annotations
from fastapi import FastAPI
+from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
-from app.api.routes import router
+from app.api.routes import api_router, router
+from app.core.config import settings
from app.db.session import init_db
app = FastAPI(title="Midas")
+app.add_middleware(
+ CORSMiddleware,
+ allow_origins=settings.cors_allowed_origins,
+ allow_credentials=True,
+ allow_methods=["*"],
+ allow_headers=["*"],
+)
app.mount("/static", StaticFiles(directory="app/static"), name="static")
app.include_router(router)
+app.include_router(api_router)
@app.on_event("startup")
diff --git a/app/models/schemas.py b/app/models/schemas.py
index f87b85e0..3443305e 100644
--- a/app/models/schemas.py
+++ b/app/models/schemas.py
@@ -17,6 +17,23 @@ class LeadImportResult(BaseModel):
inserted: int
skipped_existing: int
skipped_opted_out: int
+ invalid_rows: int
+
+
+class LeadOut(BaseModel):
+ id: int
+ name: str
+ email: EmailStr
+ company: str | None
+ position: str | None
+ niche: str | None
+ status: str
+ created_at: str
+
+
+class LeadListResponse(BaseModel):
+ total: int
+ leads: list[LeadOut]
class DraftEmail(BaseModel):
diff --git a/app/services/lead_importer.py b/app/services/lead_importer.py
index 25f2430a..4f234e12 100644
--- a/app/services/lead_importer.py
+++ b/app/services/lead_importer.py
@@ -5,41 +5,49 @@
import json
from collections.abc import Iterable
+from openpyxl import load_workbook
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.models.entities import Lead
from app.models.schemas import LeadImportResult
+_FIELD_ALIASES = {
+ "name": {"name", "full_name", "lead_name", "contact_name"},
+ "email": {"email", "email_address", "mail"},
+ "company": {"company", "company_name", "organization", "org"},
+ "position": {"position", "title", "job_title", "role"},
+ "niche": {"niche", "segment", "industry"},
+}
+
class LeadImporter:
def __init__(self, db: Session) -> None:
self.db = db
- def parse(self, filename: str, payload: bytes) -> Iterable[dict[str, str]]:
+ def parse(self, filename: str, payload: bytes) -> list[dict[str, str]]:
lower = filename.lower()
- text = payload.decode("utf-8")
if lower.endswith(".csv"):
- reader = csv.DictReader(io.StringIO(text))
- return [dict(row) for row in reader]
+ return self._parse_csv(payload)
if lower.endswith(".json"):
- return json.loads(text)
+ return self._parse_json(payload)
if lower.endswith(".txt"):
- rows = []
- for line in text.splitlines():
- parts = [p.strip() for p in line.split(",")]
- if len(parts) >= 2:
- rows.append({"name": parts[0], "email": parts[1]})
- return rows
- raise ValueError("Unsupported file type. Use CSV, JSON, or TXT.")
+ return self._parse_txt(payload)
+ if lower.endswith(".xlsx"):
+ return self._parse_xlsx(payload)
+ raise ValueError("Unsupported file type. Use CSV, TXT, JSON, or XLSX.")
def import_rows(self, rows: Iterable[dict[str, str]]) -> LeadImportResult:
- inserted = skipped_existing = skipped_opted_out = 0
- for row in rows:
+ inserted = skipped_existing = skipped_opted_out = invalid_rows = 0
+ for raw in rows:
+ row = self._normalize_row(raw)
email = (row.get("email") or "").strip().lower()
name = (row.get("name") or "").strip()
+
if not email or not name:
+ invalid_rows += 1
continue
+
existing = self.db.scalar(select(Lead).where(Lead.email == email))
if existing:
if existing.opt_out:
@@ -47,6 +55,7 @@ def import_rows(self, rows: Iterable[dict[str, str]]) -> LeadImportResult:
else:
skipped_existing += 1
continue
+
lead = Lead(
name=name,
email=email,
@@ -56,9 +65,79 @@ def import_rows(self, rows: Iterable[dict[str, str]]) -> LeadImportResult:
)
self.db.add(lead)
inserted += 1
+
self.db.commit()
return LeadImportResult(
inserted=inserted,
skipped_existing=skipped_existing,
skipped_opted_out=skipped_opted_out,
+ invalid_rows=invalid_rows,
)
+
+ def _parse_csv(self, payload: bytes) -> list[dict[str, str]]:
+ text = payload.decode("utf-8-sig")
+ reader = csv.DictReader(io.StringIO(text))
+ return [dict(row) for row in reader]
+
+ def _parse_json(self, payload: bytes) -> list[dict[str, str]]:
+ text = payload.decode("utf-8")
+ data = json.loads(text)
+ if isinstance(data, dict):
+ data = [data]
+ if not isinstance(data, list):
+ raise ValueError("JSON import expects an object or an array of objects.")
+ return [dict(row) for row in data if isinstance(row, dict)]
+
+ def _parse_txt(self, payload: bytes) -> list[dict[str, str]]:
+ text = payload.decode("utf-8")
+ rows: list[dict[str, str]] = []
+ for line in text.splitlines():
+ stripped = line.strip()
+ if not stripped:
+ continue
+ parts = [p.strip() for p in stripped.split(",")]
+ if len(parts) >= 2:
+ rows.append(
+ {
+ "name": parts[0],
+ "email": parts[1],
+ "company": parts[2] if len(parts) > 2 else "",
+ "position": parts[3] if len(parts) > 3 else "",
+ }
+ )
+ else:
+ rows.append({"name": "", "email": ""})
+ return rows
+
+ def _parse_xlsx(self, payload: bytes) -> list[dict[str, str]]:
+ workbook = load_workbook(filename=io.BytesIO(payload), read_only=True)
+ sheet = workbook.active
+ rows_iter = sheet.iter_rows(values_only=True)
+ header_row = next(rows_iter, None)
+ if not header_row:
+ return []
+
+ headers = [str(h).strip() if h is not None else "" for h in header_row]
+ records: list[dict[str, str]] = []
+ for values in rows_iter:
+ row: dict[str, str] = {}
+ for idx, header in enumerate(headers):
+ if not header:
+ continue
+ value = values[idx] if idx < len(values) else None
+ row[header] = "" if value is None else str(value).strip()
+ if any(v for v in row.values()):
+ records.append(row)
+ return records
+
+ def _normalize_row(self, row: dict[str, str]) -> dict[str, str]:
+ lowered = {(k or "").strip().lower(): (v if isinstance(v, str) else str(v or "")) for k, v in row.items()}
+ normalized: dict[str, str] = {}
+ for target, aliases in _FIELD_ALIASES.items():
+ value = ""
+ for alias in aliases:
+ if alias in lowered and lowered[alias].strip():
+ value = lowered[alias].strip()
+ break
+ normalized[target] = value
+ return normalized
diff --git a/app/static/css/style.css b/app/static/css/style.css
index 8e4c4b43..3a95fb36 100644
--- a/app/static/css/style.css
+++ b/app/static/css/style.css
@@ -1,76 +1,117 @@
:root {
- --bg: #0f172a;
- --card: #111827;
- --text: #e5e7eb;
- --muted: #9ca3af;
- --accent: #d4a72c;
- --accent-soft: rgba(212, 167, 44, 0.15);
+ --bg: #060a17;
+ --surface: #0f172a;
+ --surface-2: #111b33;
+ --text: #edf2ff;
+ --muted: #95a3c3;
+ --accent: #5b8cff;
+ --success: #00c06b;
+ --danger: #ff4d6d;
+ --border: #24314f;
}
* { box-sizing: border-box; }
+
body {
margin: 0;
- font-family: Inter, system-ui, sans-serif;
- background: linear-gradient(180deg, #0b1224, #0f172a);
+ font-family: Inter, ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
color: var(--text);
+ background: radial-gradient(circle at top right, #172447, var(--bg) 60%);
}
-header { padding: 1.5rem 2rem 0.5rem; }
-header h1 { margin: 0; }
-header p { color: var(--muted); margin-top: 0.3rem; }
-.grid {
- padding: 1rem 2rem 2rem;
- display: grid;
- grid-template-columns: repeat(2, minmax(0, 1fr));
- gap: 1rem;
+.shell { display: grid; grid-template-columns: 260px 1fr; min-height: 100vh; }
+
+.sidebar {
+ border-right: 1px solid var(--border);
+ background: rgba(5, 10, 20, 0.65);
+ backdrop-filter: blur(8px);
+ padding: 2rem 1.25rem;
+}
+.sidebar h1 { margin: 0; letter-spacing: 0.05em; }
+.sidebar p { color: var(--muted); margin-top: 0.4rem; }
+.sidebar nav { margin-top: 2rem; display: grid; }
+.sidebar a {
+ text-decoration: none;
+ color: var(--text);
+ background: var(--surface);
+ border: 1px solid var(--border);
+ border-radius: 12px;
+ padding: 0.8rem;
}
+
+.content { padding: 2rem; display: grid; gap: 1rem; }
+.topbar h2 { margin: 0; }
+.topbar p { margin: 0.35rem 0 0; color: var(--muted); }
+
+.cards { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 0.8rem; }
.card {
- background: var(--card);
- border: 1px solid #1f2937;
+ background: linear-gradient(180deg, var(--surface-2), #0f1730);
+ border: 1px solid var(--border);
border-radius: 14px;
padding: 1rem;
}
-.metrics { grid-column: span 2; }
-.metric-grid {
- display: grid;
- grid-template-columns: repeat(6, minmax(0, 1fr));
- gap: 0.5rem;
+.card .label { color: var(--muted); font-size: 0.8rem; }
+.card .value { font-size: 1.5rem; margin-top: 0.3rem; font-weight: 700; }
+
+.panel {
+ background: rgba(11, 21, 41, 0.92);
+ border: 1px solid var(--border);
+ border-radius: 14px;
+ padding: 1rem;
}
-.metric-grid div {
- background: #0b1220;
- border: 1px solid #1f2937;
- border-left: 3px solid var(--accent);
- border-radius: 10px;
- padding: 0.8rem;
+.panel-upload {
+ display: grid;
+ grid-template-columns: 1fr auto;
+ gap: 1rem;
+ align-items: center;
}
-.metric-grid span { color: var(--muted); font-size: 0.75rem; display:block; }
-.metric-grid strong { font-size: 1.2rem; }
-input, textarea, button {
- width: 100%;
- border-radius: 8px;
- border: 1px solid #374151;
- background: #0b1220;
+#upload-form { display: flex; gap: 0.6rem; align-items: center; flex-wrap: wrap; }
+
+input, select, button {
+ border: 1px solid var(--border);
+ border-radius: 10px;
+ background: var(--surface);
color: var(--text);
- padding: 0.55rem 0.65rem;
+ padding: 0.6rem 0.75rem;
}
button {
- border-color: var(--accent);
- background: var(--accent-soft);
+ background: linear-gradient(180deg, #6a97ff, var(--accent));
+ border-color: #4f7de4;
+ font-weight: 600;
cursor: pointer;
}
-.stack { display: grid; gap: 0.5rem; }
-.actions { margin-top: 0.7rem; display: flex; gap: 0.5rem; }
-.actions form { flex: 1; }
+button:disabled { opacity: 0.6; cursor: not-allowed; }
+
+.message { margin: 0; color: var(--muted); }
+.message.success { color: var(--success); }
+.message.error { color: var(--danger); }
+
+.panel-head { display: flex; justify-content: space-between; gap: 1rem; align-items: center; }
+.filters { display: flex; gap: 0.5rem; }
+
+.table-wrap { overflow: auto; }
+table { width: 100%; border-collapse: collapse; margin-top: 0.8rem; }
+th, td { padding: 0.7rem 0.6rem; border-bottom: 1px solid var(--border); text-align: left; }
+th { color: var(--muted); font-weight: 600; font-size: 0.85rem; }
+.status-pill {
+ border: 1px solid var(--border);
+ border-radius: 999px;
+ padding: 0.15rem 0.55rem;
+ text-transform: capitalize;
+ font-size: 0.8rem;
+ display: inline-block;
+}
-ul { padding-left: 1rem; }
-li { margin-bottom: 0.4rem; }
-table { width: 100%; border-collapse: collapse; }
-th, td { border-bottom: 1px solid #1f2937; padding: 0.4rem; text-align:left; font-size: 0.9rem; }
-.standalone { max-width: 520px; margin: 3rem auto; }
+@media (max-width: 1100px) {
+ .shell { grid-template-columns: 1fr; }
+ .sidebar { border-right: none; border-bottom: 1px solid var(--border); }
+ .cards { grid-template-columns: repeat(2, minmax(0, 1fr)); }
+}
-@media (max-width: 900px) {
- .grid { grid-template-columns: 1fr; }
- .metrics { grid-column: span 1; }
- .metric-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
+@media (max-width: 720px) {
+ .content { padding: 1rem; }
+ .cards { grid-template-columns: 1fr; }
+ .panel-upload { grid-template-columns: 1fr; }
+ .panel-head, .filters { flex-direction: column; align-items: stretch; }
}
diff --git a/app/static/js/app.js b/app/static/js/app.js
index 37e077d8..e64d4089 100644
--- a/app/static/js/app.js
+++ b/app/static/js/app.js
@@ -1 +1,162 @@
-// Reserved for interactive dashboard enhancements.
+const API_BASE = window.MIDAS_API_BASE || "";
+
+const metricsEl = document.getElementById("metrics");
+const tableEl = document.getElementById("lead-table");
+const uploadForm = document.getElementById("upload-form");
+const fileInput = document.getElementById("lead-file");
+const uploadMessage = document.getElementById("upload-message");
+const searchInput = document.getElementById("search-input");
+const statusFilter = document.getElementById("status-filter");
+
+const defaultMetrics = [
+ ["Total Leads", 0],
+ ["New", 0],
+ ["Outreached", 0],
+ ["Follow Up Due", 0],
+ ["Replied", 0],
+ ["Opted Out", 0],
+];
+
+function renderMetricCards(metrics) {
+ const cards = [
+ ["Total Leads", metrics.total_leads ?? 0],
+ ["New", (metrics.total_leads ?? 0) - (metrics.outreached ?? 0) - (metrics.replied ?? 0) - (metrics.follow_up_due ?? 0)],
+ ["Outreached", metrics.outreached ?? 0],
+ ["Follow Up Due", metrics.follow_up_due ?? 0],
+ ["Replied", metrics.replied ?? 0],
+ ["Conversion Rate", `${metrics.conversion_rate ?? 0}%`],
+ ];
+
+ metricsEl.innerHTML = cards
+ .map(
+ ([label, value]) =>
+ `${label}
${value}
`,
+ )
+ .join("");
+}
+
+function renderFallbackMetrics() {
+ metricsEl.innerHTML = defaultMetrics
+ .map(
+ ([label, value]) =>
+ `${label}
${value}
`,
+ )
+ .join("");
+}
+
+function escapeHtml(value) {
+ return String(value ?? "")
+ .replaceAll("&", "&")
+ .replaceAll("<", "<")
+ .replaceAll(">", ">")
+ .replaceAll('"', """)
+ .replaceAll("'", "'");
+}
+
+function renderLeads(leads) {
+ if (!leads.length) {
+ tableEl.innerHTML = '
| No leads found. |
';
+ return;
+ }
+
+ tableEl.innerHTML = leads
+ .map((lead) => {
+ const createdDate = new Date(lead.created_at);
+ const formatted = Number.isNaN(createdDate.valueOf())
+ ? "-"
+ : createdDate.toLocaleDateString();
+ return `
+ | ${escapeHtml(lead.name)} |
+ ${escapeHtml(lead.email)} |
+ ${escapeHtml(lead.company || "-")} |
+ ${escapeHtml(lead.position || "-")} |
+ ${escapeHtml(lead.status.replaceAll("_", " "))} |
+ ${formatted} |
+
`;
+ })
+ .join("");
+}
+
+async function loadDashboard() {
+ try {
+ const response = await fetch(`${API_BASE}/api/v1/dashboard`);
+ if (!response.ok) throw new Error(`dashboard status ${response.status}`);
+ const payload = await response.json();
+ renderMetricCards(payload.metrics || {});
+ } catch {
+ renderFallbackMetrics();
+ }
+}
+
+async function loadLeads() {
+ const search = searchInput.value.trim();
+ const status = statusFilter.value;
+ const query = new URLSearchParams({ limit: "100" });
+ if (search) query.set("search", search);
+ if (status) query.set("status", status);
+
+ const response = await fetch(`${API_BASE}/api/v1/leads?${query.toString()}`);
+ if (!response.ok) {
+ throw new Error("Failed to load leads");
+ }
+ const payload = await response.json();
+ renderLeads(payload.leads || []);
+}
+
+async function uploadLeads(event) {
+ event.preventDefault();
+ uploadMessage.className = "message";
+ uploadMessage.textContent = "";
+
+ const file = fileInput.files?.[0];
+ if (!file) {
+ uploadMessage.classList.add("error");
+ uploadMessage.textContent = "Select a file before uploading.";
+ return;
+ }
+
+ const formData = new FormData();
+ formData.append("file", file);
+
+ const submitBtn = uploadForm.querySelector("button[type='submit']");
+ submitBtn.disabled = true;
+
+ try {
+ const response = await fetch(`${API_BASE}/api/v1/leads/import`, {
+ method: "POST",
+ body: formData,
+ });
+ const payload = await response.json();
+ if (!response.ok) {
+ throw new Error(payload.detail || "Lead upload failed.");
+ }
+
+ uploadMessage.classList.add("success");
+ uploadMessage.textContent = `Imported ${payload.inserted}. Existing ${payload.skipped_existing}. Opted-out skipped ${payload.skipped_opted_out}. Invalid ${payload.invalid_rows}.`;
+ fileInput.value = "";
+
+ await Promise.all([loadDashboard(), loadLeads()]);
+ } catch (error) {
+ uploadMessage.classList.add("error");
+ uploadMessage.textContent = error instanceof Error ? error.message : "Upload failed.";
+ } finally {
+ submitBtn.disabled = false;
+ }
+}
+
+uploadForm.addEventListener("submit", uploadLeads);
+searchInput.addEventListener("input", () => {
+ loadLeads().catch(() => {
+ tableEl.innerHTML = '| Could not load leads. |
';
+ });
+});
+statusFilter.addEventListener("change", () => {
+ loadLeads().catch(() => {
+ tableEl.innerHTML = '| Could not load leads. |
';
+ });
+});
+
+loadDashboard();
+loadLeads().catch(() => {
+ tableEl.innerHTML = '| Could not load leads. |
';
+});
diff --git a/app/templates/dashboard.html b/app/templates/dashboard.html
index 869fc052..dfa24a08 100644
--- a/app/templates/dashboard.html
+++ b/app/templates/dashboard.html
@@ -3,81 +3,75 @@
- Midas Dashboard
+ Midas | Lead Foundation
-
- Midas
- Outreach, replies, follow-ups, and conversion intelligence.
-
+
+
-
-
- Core Metrics
-
-
Total Leads{{ metrics.total_leads }}
-
Outreached{{ metrics.outreached }}
-
Replied{{ metrics.replied }}
-
Follow-up Stage{{ metrics.follow_up_due }}
-
Conversion{{ metrics.conversion_rate }}%
-
Templates{{ metrics.templates_total }}
-
-
+
+
+
+
Lead Upload & Storage
+
Upload validated lead files and track segmented lead inventory.
+
+
-
- Lead Import
-
+
- Generate Outreach Templates
-
+
+
+
Import Leads
+
Accepted formats: CSV, TXT, JSON, XLSX
+
+
+
+
-
-
-
-
-
-
-
- Recent Leads
-
- | Name | Email | Company | Status |
-
- {% for lead in leads %}
-
- | {{ lead.name }} | {{ lead.email }} | {{ lead.company or '-' }} | {{ lead.status.value }} |
-
- {% endfor %}
-
-
-
-
-
- Alerts & Reply Drafts
-
- {% for alert in alerts %}
- - [{{ alert.severity|upper }}] {{ alert.message }}
- {% endfor %}
-
- Latest Replies
-
- {% for reply in replies %}
- -
- lead_id={{ reply.lead_id }} sentiment={{ reply.sentiment.value }}
-
-
- {% endfor %}
-
-
-
+
+
+
Lead Records
+
+
+
+
+
+
+
+
+
+ | Name |
+ Email |
+ Company |
+ Position |
+ Status |
+ Added |
+
+
+
+
+
+
+
+
+