Commit eba01fb3 authored by Vũ Hoàng Anh's avatar Vũ Hoàng Anh

feat: Tach 2 Tab CDP Dashboard - Tab Thuc Te va Tab AI Phan Tich

parent 3259bf5e
Pipeline #3387 failed with stage
"""
Realtime live monitor API — powered by Langfuse REST API.
Provides:
- bootstrap snapshot for initial page load
- SSE stream with rolling updates for a live operations dashboard
"""
import asyncio
import json
import logging
from datetime import UTC, datetime, timedelta
from typing import Any
import httpx
from fastapi import APIRouter, Query
from starlette.responses import StreamingResponse
logger = logging.getLogger(__name__)
router = APIRouter()
# ─── Self-hosted Langfuse config ─────────────────────────────────
LANGFUSE_BASE = "http://172.16.2.207:3009"
LANGFUSE_AUTH = (
"pk-lf-2bb249b1-77e3-4309-8954-312b8fb2fff9",
"sk-lf-bd0c7202-a7f4-4399-a36e-65ebe2e35104",
)
def _utc_now() -> datetime:
return datetime.now(UTC)
def _safe_float(v: Any, default: float = 0.0) -> float:
if v is None:
return default
try:
return float(v)
except (TypeError, ValueError):
return default
def _safe_int(v: Any, default: int = 0) -> int:
if v is None:
return default
try:
return int(v)
except (TypeError, ValueError):
return default
def _severity(trace: dict) -> str:
level = (trace.get("level") or "").upper()
if level in ("ERROR",):
return "error"
latency = _safe_float(trace.get("latency"))
if latency >= 12:
return "warn"
return "ok"
# ─── Langfuse REST API helpers ───────────────────────────────────
async def _langfuse_get(path: str, params: dict | None = None) -> dict:
"""Authenticated GET to self-hosted Langfuse."""
url = f"{LANGFUSE_BASE}/api/public{path}"
async with httpx.AsyncClient(timeout=15) as client:
resp = await client.get(url, params=params, auth=LANGFUSE_AUTH)
resp.raise_for_status()
return resp.json()
async def _fetch_traces(
from_ts: str, limit: int = 100
) -> list[dict]:
"""Fetch traces from Langfuse within the time window."""
params = {
"limit": limit,
"orderBy": "timestamp.desc",
"fromTimestamp": from_ts,
}
result = await _langfuse_get("/traces", params)
return result.get("data", [])
# ─── Build snapshot from Langfuse traces ─────────────────────────
def _minute_slots(window_minutes: int) -> list[tuple[str, str]]:
now = _utc_now().replace(second=0, microsecond=0)
slots = []
for offset in range(window_minutes - 1, -1, -1):
minute = now - timedelta(minutes=offset)
slots.append((minute.strftime("%Y-%m-%dT%H:%M"), minute.strftime("%H:%M")))
return slots
def _build_series(traces: list[dict], window_minutes: int) -> dict[str, list[Any]]:
"""Build per-minute time series from trace list."""
# Bucket traces by minute
minute_buckets: dict[str, list[dict]] = {}
for t in traces:
ts = t.get("timestamp", "")
if len(ts) >= 16:
bucket = ts[:16] # "2026-03-23T07:37"
minute_buckets.setdefault(bucket, []).append(t)
labels: list[str] = []
trace_counts: list[int] = []
costs: list[float] = []
latencies: list[float] = []
errors: list[int] = []
for bucket_key, short_label in _minute_slots(window_minutes):
bucket_traces = minute_buckets.get(bucket_key, [])
labels.append(short_label)
trace_counts.append(len(bucket_traces))
bucket_cost = sum(_safe_float(t.get("totalCost")) for t in bucket_traces)
costs.append(round(bucket_cost, 4))
bucket_latencies = [_safe_float(t.get("latency")) for t in bucket_traces if t.get("latency") is not None]
avg_lat = sum(bucket_latencies) / len(bucket_latencies) if bucket_latencies else 0
latencies.append(round(avg_lat, 2))
bucket_errors = sum(1 for t in bucket_traces if (t.get("level") or "").upper() == "ERROR")
errors.append(bucket_errors)
return {
"labels": labels,
"traces": trace_counts,
"costs": costs,
"latencies": latencies,
"errors": errors,
}
async def _fetch_live_snapshot(window_minutes: int = 20) -> dict[str, Any]:
"""Build a live dashboard snapshot from Langfuse API."""
now = _utc_now()
from_ts = (now - timedelta(minutes=window_minutes)).strftime("%Y-%m-%dT%H:%M:%S.000Z")
degraded_reasons: list[str] = []
# Fetch traces
try:
traces = await _fetch_traces(from_ts, limit=100)
except Exception as e:
logger.error("Langfuse traces fetch error: %s", e)
degraded_reasons.append(f"traces: {e}")
traces = []
# Compute metrics
total_traces = len(traces)
session_ids = set(t.get("sessionId") for t in traces if t.get("sessionId"))
active_sessions = len(session_ids)
total_cost = sum(_safe_float(t.get("totalCost")) for t in traces)
latencies_list = [_safe_float(t.get("latency")) for t in traces if t.get("latency") is not None]
avg_latency = sum(latencies_list) / len(latencies_list) if latencies_list else 0
error_count = sum(1 for t in traces if (t.get("level") or "").upper() == "ERROR")
error_rate = (error_count * 100.0 / total_traces) if total_traces > 0 else 0
last_event_at = traces[0].get("timestamp") if traces else None
# Build time series
series = _build_series(traces, window_minutes)
# Model mix
model_usage: dict[str, dict[str, Any]] = {}
for t in traces:
# Get model from observations if available, else use trace name
model = t.get("name") or "unknown"
if model not in model_usage:
model_usage[model] = {"traces": 0, "total_cost_usd": 0, "latencies": []}
model_usage[model]["traces"] += 1
model_usage[model]["total_cost_usd"] += _safe_float(t.get("totalCost"))
if t.get("latency") is not None:
model_usage[model]["latencies"].append(_safe_float(t.get("latency")))
model_total = max(sum(m["traces"] for m in model_usage.values()), 1)
model_mix = []
for model_name, stats in sorted(model_usage.items(), key=lambda x: x[1]["traces"], reverse=True)[:6]:
lats = stats["latencies"]
model_mix.append({
"model_name": model_name,
"traces": stats["traces"],
"total_cost_usd": round(stats["total_cost_usd"], 4),
"avg_latency_s": round(sum(lats) / len(lats), 2) if lats else 0,
"share_pct": round(stats["traces"] * 100.0 / model_total, 1),
})
# Latest events (for activity feed)
latest_events = []
for t in traces[:12]:
traced_at = t.get("timestamp")
age_seconds = None
if traced_at:
try:
traced_dt = datetime.fromisoformat(traced_at.replace("Z", "+00:00"))
age_seconds = max(0, int(now.timestamp() - traced_dt.timestamp()))
except Exception:
pass
event = {
"trace_id": str(t.get("id", ""))[:12],
"session_id": str(t.get("sessionId") or ""),
"model_name": str(t.get("name") or "unknown"),
"traced_at": traced_at,
"trace_latency_s": round(_safe_float(t.get("latency")), 2),
"total_cost_usd": round(_safe_float(t.get("totalCost")), 4),
"total_obs_error": 0,
"nb_generation_error": 0,
"age_seconds": age_seconds,
}
event["severity"] = _severity(t)
latest_events.append(event)
# Signature for change detection
signature = "|".join([
last_event_at or "none",
str(total_traces),
str(active_sessions),
str(series["traces"][-1] if series["traces"] else 0),
latest_events[0]["trace_id"] if latest_events else "none",
])
status = "degraded" if degraded_reasons else "live"
return {
"generated_at": now.isoformat(),
"window_minutes": window_minutes,
"status": status,
"status_reason": "; ".join(degraded_reasons) if degraded_reasons else "",
"signature": signature,
"metrics": {
"traces": total_traces,
"active_sessions": active_sessions,
"total_cost_usd": round(total_cost, 4),
"avg_latency_s": round(avg_latency, 2),
"error_rate_pct": round(error_rate, 2),
"last_event_at": last_event_at,
"peak_traces_per_min": max(series["traces"] or [0]),
"peak_cost_per_min": max(series["costs"] or [0]),
"peak_latency_s": max(series["latencies"] or [0]),
},
"series": series,
"model_mix": model_mix,
"latest_events": latest_events,
}
def _sse(payload: dict[str, Any]) -> str:
return f"data: {json.dumps(payload, ensure_ascii=False, default=str)}\n\n"
@router.get("/api/live-monitor/bootstrap", summary="Live dashboard bootstrap snapshot")
async def live_monitor_bootstrap(
window_minutes: int = Query(default=20, ge=5, le=60),
):
snapshot = await _fetch_live_snapshot(window_minutes)
snapshot.pop("signature", None)
return snapshot
@router.get("/api/live-monitor/stream", summary="Live dashboard SSE stream")
async def live_monitor_stream(
window_minutes: int = Query(default=20, ge=5, le=60),
interval_seconds: int = Query(default=4, ge=2, le=15),
):
async def event_stream():
last_signature = ""
try:
initial_snapshot = await _fetch_live_snapshot(window_minutes)
last_signature = str(initial_snapshot.get("signature", ""))
initial_snapshot.pop("signature", None)
yield _sse({"type": "snapshot", "payload": initial_snapshot})
while True:
await asyncio.sleep(interval_seconds)
snapshot = await _fetch_live_snapshot(window_minutes)
signature = str(snapshot.get("signature", ""))
snapshot.pop("signature", None)
if signature != last_signature:
last_signature = signature
yield _sse({"type": "dashboard_update", "payload": snapshot})
else:
yield _sse({
"type": "heartbeat",
"generated_at": snapshot.get("generated_at"),
"status": snapshot.get("status", "live"),
})
except asyncio.CancelledError:
logger.info("Live monitor stream cancelled by client")
raise
except Exception as e:
logger.error("Live monitor stream error: %s", e)
yield _sse({"type": "error", "message": str(e)[:300]})
return StreamingResponse(event_stream(), media_type="text/event-stream")
<!DOCTYPE html>
<html lang="vi">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AI sinh SQL</title>
<link rel="stylesheet" href="/static/lab.css">
<style>
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
color: var(--t, #18181B);
background:
radial-gradient(circle at top right, rgba(180, 83, 9, 0.10), transparent 28%),
linear-gradient(180deg, #F7F4EE 0%, #F3EFE6 100%);
font-family: 'Outfit', sans-serif;
}
.page-shell {
width: min(1480px, calc(100vw - 32px));
margin: 0 auto;
padding: 28px 0 36px;
}
.hero {
display: grid;
grid-template-columns: 1.1fr .9fr;
gap: 18px;
margin-bottom: 18px;
}
.card {
background: rgba(255, 255, 255, 0.86);
border: 1px solid rgba(165, 142, 112, 0.18);
border-radius: 20px;
box-shadow: 0 10px 30px rgba(24, 24, 27, 0.06);
backdrop-filter: blur(12px);
}
.hero-main {
padding: 28px 30px;
position: relative;
overflow: hidden;
}
.hero-main::after {
content: "";
position: absolute;
inset: auto -40px -60px auto;
width: 220px;
height: 220px;
border-radius: 999px;
background: radial-gradient(circle, rgba(180, 83, 9, 0.14), transparent 68%);
pointer-events: none;
}
.eyebrow {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
border-radius: 999px;
border: 1px solid rgba(180, 83, 9, 0.24);
background: rgba(255, 251, 235, 0.9);
color: #9A5D0B;
font-size: 12px;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
}
.hero-title {
margin: 16px 0 10px;
font-size: clamp(34px, 4vw, 50px);
line-height: 1;
letter-spacing: -0.05em;
font-family: 'Fraunces', serif;
}
.hero-copy {
max-width: 760px;
margin: 0;
color: var(--m, #78716C);
font-size: 15px;
line-height: 1.7;
}
.hero-side {
padding: 24px 26px;
display: grid;
gap: 12px;
align-content: start;
}
.side-label {
font-size: 12px;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
color: #9A5D0B;
}
.side-list {
display: grid;
gap: 10px;
color: var(--m, #78716C);
font-size: 14px;
line-height: 1.6;
}
.side-item {
padding: 12px 14px;
border-radius: 14px;
background: rgba(250, 245, 236, 0.92);
border: 1px solid rgba(165, 142, 112, 0.16);
}
.composer {
margin-bottom: 16px;
padding: 20px;
}
.composer-grid {
display: grid;
grid-template-columns: 1fr auto;
gap: 14px;
align-items: end;
}
.composer textarea {
width: 100%;
min-height: 108px;
resize: vertical;
border: 1px solid rgba(165, 142, 112, 0.18);
border-radius: 16px;
background: rgba(255,255,255,0.92);
padding: 16px 18px;
font: inherit;
font-size: 15px;
line-height: 1.6;
color: var(--t, #18181B);
outline: none;
}
.composer textarea:focus {
border-color: rgba(180, 83, 9, 0.45);
box-shadow: 0 0 0 4px rgba(180, 83, 9, 0.08);
}
.composer-actions {
display: grid;
gap: 10px;
min-width: 240px;
}
.run-btn {
border: none;
border-radius: 16px;
padding: 15px 22px;
background: linear-gradient(135deg, #111827 0%, #374151 100%);
color: white;
font: inherit;
font-size: 14px;
font-weight: 700;
cursor: pointer;
transition: transform .18s ease, box-shadow .18s ease, opacity .18s ease;
box-shadow: 0 12px 24px rgba(17, 24, 39, 0.16);
}
.run-btn:hover:not(:disabled) {
transform: translateY(-1px);
}
.run-btn:disabled {
opacity: 0.55;
cursor: default;
box-shadow: none;
}
.hint {
color: var(--m, #78716C);
font-size: 12px;
line-height: 1.6;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 12px;
margin-bottom: 16px;
}
.stat-card {
padding: 18px 18px 16px;
}
.stat-label {
color: var(--m, #78716C);
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.stat-value {
margin-top: 10px;
font-size: 28px;
line-height: 1;
letter-spacing: -0.04em;
font-family: 'Fraunces', serif;
}
.stat-meta {
margin-top: 8px;
color: var(--m, #78716C);
font-size: 12px;
}
.workspace {
display: grid;
grid-template-columns: minmax(400px, 1fr) minmax(400px, 1.2fr);
gap: 16px;
height: calc(100vh - 40px);
}
.sessions-panel {
padding: 18px;
display: flex;
flex-direction: column;
min-height: 0;
}
.panel-head,
.trace-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 14px;
}
.panel-title,
.trace-title,
.data-title {
font-size: 16px;
font-weight: 700;
}
.panel-subtitle,
.trace-subtitle,
.data-subtitle {
color: var(--m, #78716C);
font-size: 12px;
}
.session-list {
display: flex;
flex-direction: column;
gap: 10px;
overflow-y: auto;
min-height: 0;
padding-right: 2px;
}
.session-item {
border: 1px solid rgba(165, 142, 112, 0.14);
background: rgba(250, 247, 241, 0.92);
border-radius: 16px;
padding: 14px;
cursor: pointer;
transition: border-color .16s ease, transform .16s ease, background .16s ease;
}
.data-panel {
padding: 18px;
display: flex;
flex-direction: column;
min-height: 0;
}
.data-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-bottom: 14px;
}
.data-scroll {
min-height: 0;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 14px;
padding-right: 4px;
}
.data-artifact-card {
padding: 16px;
border-radius: 16px;
background: rgba(255, 255, 255, 0.95);
border: 1px solid rgba(165, 142, 112, 0.16);
box-shadow: 0 4px 12px rgba(24, 24, 27, 0.03);
display: flex;
flex-direction: column;
animation: fadeIn 0.3s ease;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(4px); }
to { opacity: 1; transform: translateY(0); }
}
.artifact-head {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 12px;
gap: 12px;
}
.artifact-info h4 {
margin: 0 0 4px 0;
font-size: 14px;
font-weight: 700;
color: var(--t, #18181B);
}
.artifact-info p {
margin: 0;
font-size: 12px;
color: var(--m, #78716C);
line-height: 1.4;
}
.artifact-actions {
display: flex;
gap: 6px;
flex-shrink: 0;
}
.artifact-btn {
background: rgba(246, 244, 238, 0.88);
border: 1px solid rgba(165, 142, 112, 0.14);
color: var(--t, #18181B);
border-radius: 8px;
padding: 6px 10px;
font-size: 11px;
font-weight: 600;
cursor: pointer;
transition: background 0.15s;
}
.artifact-btn:hover {
background: rgba(230, 225, 215, 0.9);
}
.artifact-table-wrapper {
overflow-x: auto;
border: 1px solid rgba(165, 142, 112, 0.14);
border-radius: 8px;
}
.artifact-table-wrapper .query-preview {
margin-top: 0;
}
.session-item:hover {
transform: translateY(-1px);
border-color: rgba(180, 83, 9, 0.26);
}
.session-item.active {
background: rgba(255, 251, 235, 0.96);
border-color: rgba(180, 83, 9, 0.38);
}
.session-question {
font-size: 13px;
font-weight: 600;
line-height: 1.5;
color: var(--t, #18181B);
margin-bottom: 8px;
}
.session-meta {
display: flex;
flex-wrap: wrap;
gap: 8px;
color: var(--m, #78716C);
font-size: 11px;
}
.pill {
display: inline-flex;
align-items: center;
border-radius: 999px;
padding: 4px 10px;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.pill.running { background: #EFF6FF; color: #1D4ED8; }
.pill.completed { background: #ECFDF5; color: #047857; }
.pill.error { background: #FEF2F2; color: #B91C1C; }
.pill.pending { background: #FAF5FF; color: #7C3AED; }
.pill.needs-more { background: #FFF7ED; color: #C2410C; }
.trace-panel {
padding: 18px;
min-height: 0;
display: flex;
flex-direction: column;
gap: 14px;
flex: 1;
}
.trace-surface {
display: flex;
flex-direction: column;
gap: 14px;
min-height: 0;
flex: 1;
}
.trace-scroll {
min-height: 0;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 14px;
}
.question-card,
.summary-card,
.empty-card,
.cycle-card {
padding: 18px;
}
.question-label,
.summary-label,
.cycle-label {
color: #9A5D0B;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
}
.question-text {
margin-top: 10px;
font-size: 17px;
line-height: 1.6;
font-weight: 600;
}
.question-subline {
margin-top: 10px;
color: var(--m, #78716C);
font-size: 13px;
}
.summary-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
margin-top: 14px;
}
.summary-block {
padding: 14px;
border-radius: 16px;
background: rgba(250, 247, 241, 0.9);
border: 1px solid rgba(165, 142, 112, 0.12);
}
.summary-block .value {
margin-top: 8px;
font-size: 24px;
letter-spacing: -0.04em;
font-family: 'Fraunces', serif;
}
.summary-block .meta {
margin-top: 6px;
color: var(--m, #78716C);
font-size: 12px;
}
.cycle-head {
display: flex;
align-items: start;
justify-content: space-between;
gap: 12px;
}
.cycle-title {
margin-top: 8px;
font-size: 18px;
font-weight: 700;
letter-spacing: -0.02em;
}
.cycle-thinking,
.reflect-text,
.reflect-list,
.subtask-list,
.query-purpose,
.query-error,
.query-empty {
margin-top: 12px;
color: var(--m, #78716C);
font-size: 14px;
line-height: 1.7;
}
.subtask-list,
.reflect-list {
padding-left: 18px;
margin-bottom: 0;
}
.query-list {
display: grid;
gap: 12px;
margin-top: 16px;
}
.query-item {
padding: 16px;
border-radius: 16px;
background: rgba(246, 244, 238, 0.88);
border: 1px solid rgba(165, 142, 112, 0.14);
}
.query-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.query-name {
font-size: 13px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.query-purpose {
margin-top: 10px;
}
.query-sql {
margin-top: 12px;
padding: 14px;
border-radius: 14px;
background: #151821;
color: #E5E7EB;
font-size: 12px;
line-height: 1.7;
overflow-x: auto;
white-space: pre-wrap;
word-break: break-word;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
.query-preview {
margin-top: 14px;
overflow-x: auto;
}
.preview-table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
min-width: 440px;
}
.preview-table th,
.preview-table td {
padding: 10px 12px;
border-bottom: 1px solid rgba(165, 142, 112, 0.14);
text-align: left;
vertical-align: top;
}
.preview-table th {
color: var(--m, #78716C);
font-weight: 700;
background: rgba(255,255,255,0.72);
}
.preview-caption {
margin-top: 10px;
color: var(--m, #78716C);
font-size: 12px;
}
.reflect-box {
margin-top: 16px;
padding: 16px;
border-radius: 16px;
background: rgba(255, 251, 235, 0.82);
border: 1px solid rgba(180, 83, 9, 0.16);
}
.empty-card {
min-height: 320px;
display: grid;
place-items: center;
text-align: center;
}
.empty-title {
font-size: 20px;
font-weight: 700;
letter-spacing: -0.03em;
}
.empty-copy {
max-width: 520px;
margin: 10px auto 0;
color: var(--m, #78716C);
font-size: 14px;
line-height: 1.7;
}
.session-toolbar {
display: flex;
gap: 8px;
}
.ghost-btn {
border: 1px solid rgba(165, 142, 112, 0.16);
background: rgba(255,255,255,0.78);
color: var(--t, #18181B);
border-radius: 12px;
padding: 10px 14px;
font: inherit;
font-size: 12px;
font-weight: 700;
cursor: pointer;
}
.ghost-btn:disabled {
opacity: 0.5;
cursor: default;
}
.left-pane {
display: flex;
flex-direction: column;
gap: 16px;
min-height: 0;
}
.composer {
flex-shrink: 0;
padding: 18px;
margin-bottom: 0;
}
.composer-actions {
display: flex;
flex-direction: column;
gap: 10px;
width: 180px;
}
@media (max-width: 1180px) {
.workspace {
grid-template-columns: 1fr;
height: auto;
min-height: 100vh;
}
}
@media (max-width: 720px) {
.page-shell {
width: min(100vw - 20px, 100%);
padding-top: 18px;
}
.composer,
.trace-panel,
.data-panel,
.question-card,
.summary-card,
.empty-card,
.cycle-card {
padding: 16px;
}
.composer-grid,
.composer-actions {
grid-template-columns: 1fr;
width: 100%;
}
}
</style>
</head>
<body>
<div class="page-shell" style="padding-top: 20px;">
<section class="workspace">
<div class="left-pane">
<section class="composer card">
<div class="composer-grid">
<div>
<textarea id="questionInput" placeholder="Mô tả câu hỏi cần AI phân tích và sinh SQL (Enter để chạy)" onkeydown="if(event.key === 'Enter' && !event.shiftKey){ event.preventDefault(); runTrace(); }"></textarea>
</div>
<div class="composer-actions">
<button class="run-btn" id="runBtn" onclick="runTrace()">Chạy phân tích</button>
<select id="sessionSelect" class="ghost-btn" style="width:100%; border-color:rgba(180,83,9,0.3)" onchange="if(this.value) viewSession(this.value);">
<option value="">Lịch sử phiên</option>
</select>
</div>
</div>
</section>
<main class="trace-panel card">
<div class="trace-head">
<div>
<div class="trace-title">Trace chi tiết</div>
<div class="trace-subtitle">Câu hỏi gốc, từng cycle, từng query và reflect cuối vòng</div>
</div>
</div>
<div class="trace-surface">
<div class="trace-scroll" id="traceScroll">
<div class="empty-card card" id="emptyState">
<div>
<div class="empty-title">Chưa có trace để hiển thị</div>
<div class="empty-copy">
Nhập câu hỏi ở phía trên để chạy một phiên mới, hoặc chọn một phiên ở cột trái để xem lại timeline
phân tích đã sinh SQL như thế nào.
</div>
</div>
</div>
<div id="traceContent" style="display:none;"></div>
</div>
</div>
</main>
</div> <!-- Close left pane -->
<aside class="data-panel card">
<div class="data-head">
<div>
<div class="data-title">Nguồn dữ liệu</div>
<div class="data-subtitle">Kết quả query (Cập nhật tức thì)</div>
</div>
<div id="dataBadge" class="pill pending" style="display:none;">0 BẢNG</div>
</div>
<div class="data-scroll" id="dataScroll">
<div class="empty-copy" id="dataEmptyState" style="margin:18px 0 0; text-align: center;">Chưa có dữ liệu nào được truy xuất trong phiên này.</div>
</div>
</aside>
</section>
</div>
<script>
const questionInput = document.getElementById('questionInput');
const runBtn = document.getElementById('runBtn');
const emptyStateEl = document.getElementById('emptyState');
const traceContentEl = document.getElementById('traceContent');
const traceScrollEl = document.getElementById('traceScroll');
const dataScrollEl = document.getElementById('dataScroll');
const dataBadgeEl = document.getElementById('dataBadge');
let sessions = [];
let currentSessionId = null;
let currentSession = null;
let isRunning = false;
function escapeHTML(value) {
return String(value ?? '').replace(/[&<>"']/g, (char) => ({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;',
}[char]));
}
function truncate(text, limit = 110) {
const value = String(text || '');
return value.length > limit ? `${value.slice(0, limit - 1)}…` : value;
}
function formatDate(value) {
if (!value) return '—';
try {
return new Date(value).toLocaleString('vi-VN');
} catch (_) {
return value;
}
}
function getStatusLabel(status) {
if (status === 'running') return 'Running';
if (status === 'completed') return 'Done';
if (status === 'done') return 'Done';
if (status === 'error') return 'Error';
if (status === 'needs_more') return 'Needs More';
if (status === 'pending') return 'Pending';
return status || 'Idle';
}
function getCycleStatusClass(status) {
if (status === 'done') return 'completed';
if (status === 'needs_more') return 'needs-more';
if (status === 'running') return 'running';
if (status === 'error') return 'error';
return 'pending';
}
function ensureCurrentSessionCycle(cycleNo) {
if (!currentSession.cycles) currentSession.cycles = [];
while (currentSession.cycles.length < cycleNo) {
currentSession.cycles.push({
cycle: currentSession.cycles.length + 1,
current_task: '',
sub_tasks: [],
thinking: '',
tool_calls: [],
tool_results: [],
completed_sub_tasks: [],
drill_down_opportunities: [],
missing: [],
next_task: '',
next_sub_tasks: [],
data_sufficient: false,
reflect_thinking: '',
status: 'pending',
});
}
return currentSession.cycles[cycleNo - 1];
}
function updateTopStats() {
// Stats removed from UI for deep focus layout
}
function renderSessions() {
const selectEl = document.getElementById('sessionSelect');
if (!selectEl) return;
if (!sessions.length) {
selectEl.innerHTML = '<option value="">Lịch sử trống</option>';
return;
}
let options = '<option value="">-- Chọn lịch sử phiên --</option>';
sessions.forEach(session => {
const isSelected = session.id === currentSessionId ? 'selected' : '';
const label = `[${getStatusLabel(session.status)}] ${truncate(session.question, 45)}`;
options += `<option value="${session.id}" ${isSelected}>${escapeHTML(label)}</option>`;
});
selectEl.innerHTML = options;
}
function renderPreviewTable(previewRows, columns) {
if (!previewRows || !previewRows.length) {
return '<div class="query-empty">Không có dữ liệu preview.</div>';
}
const safeColumns = (columns && columns.length) ? columns : Object.keys(previewRows[0] || {});
const head = safeColumns.map((col) => `<th>${escapeHTML(col)}</th>`).join('');
const body = previewRows.map((row) => `
<tr>${safeColumns.map((col) => `<td>${escapeHTML(row[col] ?? '')}</td>`).join('')}</tr>
`).join('');
return `
<div class="query-preview">
<table class="preview-table">
<thead><tr>${head}</tr></thead>
<tbody>${body}</tbody>
</table>
</div>
`;
}
function renderDataArtifacts() {
if (!currentSession) {
dataScrollEl.innerHTML = '<div class="empty-copy" style="margin:18px 0 0; text-align: center;">Chưa có dữ liệu nào được truy xuất trong phiên này.</div>';
dataBadgeEl.style.display = 'none';
return;
}
const results = [];
(currentSession.cycles || []).forEach(cycle => {
(cycle.tool_results || []).forEach(result => {
const call = (cycle.tool_calls || []).find(c => c.index === result.index);
if (result.success && result.preview_rows && result.preview_rows.length > 0) {
results.push({ ...result, call });
}
});
});
if (results.length === 0) {
dataScrollEl.innerHTML = '<div class="empty-copy" style="margin:18px 0 0; text-align: center;">Chưa có dữ liệu nào được truy xuất trong phiên này.</div>';
dataBadgeEl.style.display = 'none';
return;
}
dataBadgeEl.textContent = `${results.length} BNG`;
dataBadgeEl.className = 'pill completed';
dataBadgeEl.style.display = 'inline-flex';
const html = results.map(res => {
const title = res.call?.tool || 'Query';
const purpose = truncate(res.call?.purpose || 'Dữ liệu kết quả truy vấn', 90);
const tableHtml = renderPreviewTable(res.preview_rows, res.columns);
// Support copy/export
const dataJson = escapeHTML(JSON.stringify(res.preview_rows || []));
const columnsJson = escapeHTML(JSON.stringify(res.columns || []));
return `
<div class="data-artifact-card">
<div class="artifact-head">
<div class="artifact-info">
<h4>${escapeHTML(title)}</h4>
<p>${escapeHTML(purpose)} <strong>${res.row_count} rows</strong></p>
</div>
<div class="artifact-actions">
<button class="artifact-btn" onclick="copyTableCSV(this)" data-rows="${dataJson}" data-cols="${columnsJson}">Copy CSV</button>
</div>
</div>
<div class="artifact-table-wrapper">
${tableHtml}
</div>
</div>
`;
}).join('');
dataScrollEl.innerHTML = html;
}
function copyTableCSV(btn) {
try {
const rows = JSON.parse(btn.getAttribute('data-rows') || '[]');
const cols = JSON.parse(btn.getAttribute('data-cols') || '[]');
if (!rows.length) return;
let csv = cols.join(',') + '\\n';
rows.forEach(row => {
csv += cols.map(col => {
let val = String(row[col] ?? '');
if (val.includes(',') || val.includes('"') || val.includes('\\n')) {
val = '"' + val.replace(/"/g, '""') + '"';
}
return val;
}).join(',') + '\\n';
});
navigator.clipboard.writeText(csv);
const oldText = btn.textContent;
btn.textContent = 'Copied!';
setTimeout(() => { btn.textContent = oldText; }, 2000);
} catch (e) {
console.error('Copy CSV error', e);
}
}
function renderCycleCard(cycle) {
const resultMap = new Map((cycle.tool_results || []).map((result) => [result.index, result]));
const queryItems = (cycle.tool_calls || []).map((call) => {
const result = resultMap.get(call.index);
const status = result ? (result.success ? 'completed' : 'error') : 'pending';
const previewTable = result ? renderPreviewTable(result.preview_rows, result.columns) : '<div class="query-empty">Đang chờ kết quả từ query này.</div>';
const resultCaption = result
? `<div class="preview-caption">${result.error ? escapeHTML(result.error) : `Row count: ${escapeHTML(String(result.row_count || 0))}`}</div>`
: '';
return `
<div class="query-item">
<div class="query-head">
<div class="query-name">${escapeHTML(call.tool || 'tool')}</div>
<span class="pill ${status}">${status === 'completed' ? 'Success' : status === 'error' ? 'Error' : 'Pending'}</span>
</div>
<div class="query-purpose">${escapeHTML(call.purpose || 'Không có mô tả mục đích.')}</div>
<pre class="query-sql">${escapeHTML(call.params?.sql || call.params?.expression || '(không có nội dung query)')}</pre>
${resultCaption}
${previewTable}
</div>
`;
}).join('');
const subTasks = (cycle.sub_tasks || []).length
? `<ul class="subtask-list">${cycle.sub_tasks.map((item) => `<li>${escapeHTML(item)}</li>`).join('')}</ul>`
: '<div class="query-empty">Chưa có sub-task được ghi nhận cho vòng này.</div>';
const reflectBox = (cycle.reflect_thinking || cycle.completed_sub_tasks?.length || cycle.drill_down_opportunities?.length || cycle.missing?.length || cycle.next_task)
? `
<div class="reflect-box">
<div class="cycle-label">Reflect</div>
${cycle.reflect_thinking ? `<div class="reflect-text">${escapeHTML(cycle.reflect_thinking)}</div>` : ''}
${cycle.completed_sub_tasks?.length ? `<ul class="reflect-list">${cycle.completed_sub_tasks.map((item) => `<li>${escapeHTML(item)}</li>`).join('')}</ul>` : ''}
${cycle.drill_down_opportunities?.length ? `<ul class="reflect-list">${cycle.drill_down_opportunities.map((item) => `<li>${escapeHTML(item)}</li>`).join('')}</ul>` : ''}
${cycle.missing?.length ? `<ul class="reflect-list">${cycle.missing.map((item) => `<li>${escapeHTML(item)}</li>`).join('')}</ul>` : ''}
${cycle.next_task ? `<div class="reflect-text"><strong>Next:</strong> ${escapeHTML(cycle.next_task)}</div>` : ''}
</div>
`
: '';
return `
<section class="cycle-card card">
<div class="cycle-head">
<div>
<div class="cycle-label">Cycle ${escapeHTML(String(cycle.cycle))}</div>
<div class="cycle-title">${escapeHTML(cycle.current_task || 'Chưa gán task cụ thể')}</div>
</div>
<span class="pill ${getCycleStatusClass(cycle.status)}">${escapeHTML(getStatusLabel(cycle.status || 'pending'))}</span>
</div>
${cycle.thinking ? `<div class="cycle-thinking">${escapeHTML(cycle.thinking)}</div>` : ''}
${subTasks}
<div class="query-list">
${queryItems || '<div class="query-empty">Chưa có query nào trong vòng này.</div>'}
</div>
${reflectBox}
</section>
`;
}
function renderTrace() {
updateTopStats();
renderSessions();
if (!currentSession) {
emptyStateEl.style.display = 'grid';
traceContentEl.style.display = 'none';
traceContentEl.innerHTML = '';
return;
}
emptyStateEl.style.display = 'none';
traceContentEl.style.display = 'block';
const summary = currentSession.summary || {};
const lastCycle = currentSession.cycles && currentSession.cycles.length
? currentSession.cycles[currentSession.cycles.length - 1]
: null;
const summaryHtml = `
<section class="question-card card">
<div class="question-label">Câu hi gc</div>
<div class="question-text">${escapeHTML(currentSession.question || '')}</div>
<div class="question-subline">
Phiên #${escapeHTML(String(currentSession.id))} · ${escapeHTML(currentSession.model || '-')} ·
Bt đầu ${escapeHTML(formatDate(currentSession.created_at))}
</div>
</section>
<section class="summary-card card">
<div class="summary-label">Tng quan phiên</div>
<div class="summary-grid">
<div class="summary-block">
<div class="stat-label">Trng thái</div>
<div class="value">${escapeHTML(getStatusLabel(currentSession.status))}</div>
<div class="meta">${escapeHTML(summary.data_sufficient ? 'Dữ liệu đã đủ' : 'Có thể cần thêm vòng hoặc review tay')}</div>
</div>
<div class="summary-block">
<div class="stat-label">Cycles</div>
<div class="value">${escapeHTML(String(summary.cycles_completed ?? currentSession.cycles?.length ?? 0))}</div>
<div class="meta">Tng s vòng đã thc thi</div>
</div>
<div class="summary-block">
<div class="stat-label">Queries</div>
<div class="value">${escapeHTML(String(currentSession.total_queries || 0))}</div>
<div class="meta">${escapeHTML(String(currentSession.successful_queries || 0))} thành công · ${escapeHTML(String(currentSession.failed_queries || 0))} li</div>
</div>
<div class="summary-block">
<div class="stat-label">Final Task</div>
<div class="value" style="font-size:18px;line-height:1.3;font-family:'Outfit',sans-serif;">${escapeHTML(summary.final_task || lastCycle?.current_task || '—')}</div>
<div class="meta">${escapeHTML(summary.generation_time_ms ? `${(summary.generation_time_ms / 1000).toFixed(1)} giây` : 'Chưa hoàn tất')}</div>
</div>
</div>
${(summary.final_thinking || summary.next_task || summary.missing?.length) ? `
<div class="reflect-box">
<div class="cycle-label">Kết luận cuối</div>
${summary.final_thinking ? `<div class="reflect-text">${escapeHTML(summary.final_thinking)}</div>` : ''}
${summary.missing?.length ? `<ul class="reflect-list">${summary.missing.map((item) => `<li>${escapeHTML(item)}</li>`).join('')}</ul>` : ''}
${summary.next_task ? `<div class="reflect-text"><strong>Next:</strong> ${escapeHTML(summary.next_task)}</div>` : ''}
</div>
` : ''}
</section>
`;
const cyclesHtml = (currentSession.cycles || []).map(renderCycleCard).join('');
traceContentEl.innerHTML = `${summaryHtml}${cyclesHtml}`;
renderDataArtifacts();
}
function applyEvent(evt) {
if (evt.type === 'session_started') {
currentSessionId = evt.session_id;
currentSession = {
id: evt.session_id,
question: evt.question,
model: evt.model,
max_cycles: evt.max_cycles,
status: 'running',
created_at: evt.created_at,
updated_at: evt.created_at,
completed_at: null,
cycles: [],
summary: {},
total_queries: 0,
successful_queries: 0,
failed_queries: 0,
events: [],
};
loadSessions();
renderTrace();
return;
}
if (!currentSession || evt.session_id !== currentSession.id) return;
currentSession.updated_at = new Date().toISOString();
switch (evt.type) {
case 'thinking':
if (evt.cycle) {
const cycle = ensureCurrentSessionCycle(evt.cycle);
cycle.status = cycle.status === 'pending' ? 'running' : cycle.status;
if (evt.stage === 'reflect') {
cycle.reflect_thinking = evt.step || cycle.reflect_thinking;
} else {
cycle.thinking = evt.step || cycle.thinking;
}
}
break;
case 'plan_ready': {
const cycle = ensureCurrentSessionCycle(evt.cycle);
cycle.current_task = evt.current_task || cycle.current_task;
cycle.sub_tasks = evt.sub_tasks || cycle.sub_tasks;
cycle.thinking = evt.thinking || cycle.thinking;
cycle.status = 'running';
break;
}
case 'tool_call': {
const cycle = ensureCurrentSessionCycle(evt.cycle);
const exists = cycle.tool_calls.some((item) => item.index === evt.index);
if (!exists) {
cycle.tool_calls.push({
index: evt.index,
tool: evt.tool,
params: evt.params,
purpose: evt.purpose,
});
}
cycle.status = 'running';
break;
}
case 'tool_result': {
const cycle = ensureCurrentSessionCycle(evt.cycle);
const existingIndex = cycle.tool_results.findIndex((item) => item.index === evt.index);
const resultPayload = {
index: evt.index,
tool: evt.tool,
success: evt.success,
columns: evt.preview?.columns || [],
row_count: evt.preview?.row_count || 0,
preview_rows: evt.preview?.preview_rows || [],
error: evt.preview?.error || null,
result: evt.preview?.result,
};
if (existingIndex >= 0) {
cycle.tool_results[existingIndex] = resultPayload;
} else {
cycle.tool_results.push(resultPayload);
}
currentSession.total_queries += 1;
if (evt.success) currentSession.successful_queries += 1;
else currentSession.failed_queries += 1;
break;
}
case 'reflect': {
const cycle = ensureCurrentSessionCycle(evt.cycle);
cycle.completed_sub_tasks = evt.completed_sub_tasks || [];
cycle.drill_down_opportunities = evt.drill_down_opportunities || [];
cycle.missing = evt.missing || [];
cycle.next_task = evt.next_task || '';
cycle.next_sub_tasks = evt.next_sub_tasks || [];
cycle.data_sufficient = !!evt.data_sufficient;
cycle.reflect_thinking = evt.thinking || cycle.reflect_thinking;
cycle.status = evt.data_sufficient ? 'done' : 'needs_more';
break;
}
case 'complete':
currentSession.status = 'completed';
currentSession.completed_at = new Date().toISOString();
currentSession.summary = {
generation_time_ms: evt.generation_time_ms,
cycles_completed: evt.cycles_completed,
total_queries: evt.total_queries,
successful_queries: evt.successful_queries,
failed_queries: evt.failed_queries,
data_sufficient: evt.data_sufficient,
final_task: evt.final_task,
final_thinking: evt.final_thinking,
missing: evt.missing || [],
next_task: evt.next_task || '',
};
break;
case 'error':
currentSession.status = 'error';
currentSession.summary = {
...(currentSession.summary || {}),
final_thinking: evt.message,
};
break;
case 'done':
break;
}
renderTrace();
traceScrollEl.scrollTop = traceScrollEl.scrollHeight;
if (evt.type === 'tool_result') {
dataScrollEl.scrollTop = dataScrollEl.scrollHeight;
}
}
async function loadSessions() {
try {
const res = await fetch('/api/ai-sql/sessions');
const data = await res.json();
sessions = data.sessions || [];
renderSessions();
} catch (error) {
console.error('Failed to load sessions', error);
}
}
async function viewSession(sessionId) {
try {
const res = await fetch(`/api/ai-sql/sessions/${sessionId}`);
const data = await res.json();
if (!data.session) return;
currentSessionId = Number(sessionId);
currentSession = data.session;
renderTrace();
renderSessions();
} catch (error) {
console.error('Failed to load session detail', error);
}
}
async function deleteCurrentSession() {
if (!currentSessionId) return;
if (!confirm('Xóa phiên trace SQL này?')) return;
try {
const res = await fetch(`/api/ai-sql/sessions/${currentSessionId}`, { method: 'DELETE' });
const data = await res.json();
if (!data.success) return;
sessions = sessions.filter((item) => item.id !== currentSessionId);
currentSessionId = null;
currentSession = null;
renderTrace();
renderSessions();
} catch (error) {
console.error('Failed to delete session', error);
}
}
async function runTrace() {
const question = questionInput.value.trim();
if (!question || isRunning) return;
isRunning = true;
runBtn.disabled = true;
runBtn.textContent = 'Đang chạy';
try {
const res = await fetch('/api/ai-sql/trace', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ question }),
});
if (!res.ok) throw new Error(`Server error: ${res.status}`);
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (!line.startsWith('data: ')) continue;
const jsonStr = line.slice(6).trim();
if (!jsonStr) continue;
try {
applyEvent(JSON.parse(jsonStr));
} catch (error) {
console.warn('Failed to parse SSE event', error);
}
}
}
await loadSessions();
} catch (error) {
console.error(error);
alert(`Không thể chạy trace SQL: ${error.message}`);
} finally {
isRunning = false;
runBtn.disabled = false;
runBtn.textContent = 'Chạy phân tích';
}
}
loadSessions();
renderTrace();
</script>
</body>
</html>
// ═══════════════════════════════════════════
// MOCK DATA — Canifa CDP Users
// ═══════════════════════════════════════════
const USERS = [
{
id:1, name:"Nguyễn Thị Lan", email:"lan.nguyen@gmail.com", phone:"+84 912 345 678",
userId:"CNF-20240156", gender:"Nữ", avatar_color:"#6366F1",
channels:["Zalo OA","Web","Email"],
aov:485000, total_orders:12, total_spent:5820000,
health_score:82,
rfm:{recency:90, frequency:75, monetary:68},
predictions:{next_purchase:"Quần jean nữ",next_purchase_conf:78,churn_risk:12,clv_6m:2400000,next_visit_days:3,
recommended_collab:[
{name:"Quần Jean Slim Nữ",icon:"👖",reason:"Đã xem 3 lần, chưa mua",conf:85},
{name:"Set Áo + Quần Combo",icon:"👚",reason:"Hay mua combo giảm giá",conf:72},
{name:"Túi tote canvas",icon:"👜",reason:"Cross-sell phụ kiện",conf:58}
],
recommended_basket:[
{name:"Áo khoác jean",icon:"🧥",reason:"75% mua jean cũng mua",conf:75},
{name:"Thắt lưng da nữ",icon:"🪢",reason:"Mua kèm 40% đơn jean",conf:52},
{name:"Khăn choàng cổ",icon:"🧣",reason:"Phụ kiện mùa đông",conf:45}
],
recommended_trending:[
{name:"Váy midi hoa nhí",icon:"👗",reason:"#1 trending nữ 25-35",conf:80},
{name:"Áo thun organic",icon:"👕",reason:"Hot item tháng này",conf:65},
{name:"Sandal đế xuồng",icon:"👡",reason:"Rising demand +120%",conf:55}
]
},
email_metrics:{delivered:42,opens:24,open_rate:57,click_rate:14,conversion_rate:8.5},
segments:{purchase_likelihood:"High",discount_affinity:"Medium",customer_value:"VIP",lifecycle:"Active Customer"},
top_categories:[
{name:"Áo thun nữ",pct:30,color:"#6366F1"},{name:"Váy đầm",pct:22,color:"#F59E0B"},
{name:"Quần jean",pct:18,color:"#10B981"},{name:"Áo khoác",pct:15,color:"#EF4444"},
{name:"Phụ kiện",pct:15,color:"#8B5CF6"}
],
last_attrs:{"Last Seen":"canifa.com/nu","Last Email Open":"Summer Sale 2026","Last Visited Category":"Áo thun nữ","Last Abandoned Cart":"320,000đ","Last Visited Product":"Áo thun Basic Cotton"},
products:{last_visited:{name:"Áo thun Basic Cotton",price:"249,000đ",icon:"👕"},last_purchased:{name:"Váy hoa nhí cổ V",price:"599,000đ",icon:"👗"},last_abandoned:{name:"Quần jean slim fit",price:"459,000đ",icon:"👖"}},
mobile:{app_opened:8,push_opened:12,inapp_seen:5,inapp_from_push:3},
journey:{entered:9,completed:6},
purchase_timeline:[
{date:"22/03/2026",name:"Áo thun Basic Cotton x2",price:"498,000đ",icon:"👕",channel:"Web"},
{date:"15/03/2026",name:"Váy hoa nhí cổ V",price:"599,000đ",icon:"👗",channel:"Zalo"},
{date:"28/02/2026",name:"Quần jean skinny nữ",price:"459,000đ",icon:"👖",channel:"Web"},
{date:"14/02/2026",name:"Set quà Valentine (áo+túi)",price:"750,000đ",icon:"🎁",channel:"Web"},
{date:"20/01/2026",name:"Áo khoác dạ nữ",price:"890,000đ",icon:"🧥",channel:"Zalo"}
],
next_best_actions:[
{icon:"📧",title:"Gửi email giới thiệu BST Jean mới",desc:"Conf 85% — dựa trên browsing behavior + abandoned cart jean",urgency:"urgent"},
{icon:"🎁",title:"Push combo 'Mua 2 giảm 15%'",desc:"Phù hợp hành vi mua combo, tăng AOV",urgency:"medium"},
{icon:"📱",title:"Nhắc nhở giỏ hàng bỏ quên (320k)",desc:"Abandoned 2 ngày trước, tỉ lệ recovery 40%",urgency:"urgent"}
],
engagement_heatmap:[
[0,1,2,0,1,3,2],[1,2,3,1,2,4,3],[0,0,1,0,0,2,1],[0,1,0,1,0,1,0]
],
chatbot_history:[
{role:"user",text:"Cho mình xem áo thun cotton nữ màu trắng size M"},
{role:"bot",text:"Dạ chị ơi, em có Áo Thun Basic Cotton Nữ (6TS24S003) màu trắng, size M còn hàng. Giá 249,000đ."},
{role:"user",text:"Có khuyến mãi gì không em?"},
{role:"bot",text:"Hiện tại sản phẩm đang có chương trình Mua 2 giảm 15% chị ạ!"}
]
},
{
id:2, name:"Trần Văn Minh", email:"minh.tran@outlook.com", phone:"+84 903 456 789",
userId:"CNF-20240089", gender:"Nam", avatar_color:"#059669",
channels:["Zalo OA","Web"],
aov:720000, total_orders:8, total_spent:5760000,
health_score:61,
rfm:{recency:55, frequency:50, monetary:72},
predictions:{next_purchase:"Áo sơ mi",next_purchase_conf:62,churn_risk:25,clv_6m:1800000,next_visit_days:7,
recommended_collab:[{name:"Sơ mi Oxford Slim",icon:"👔",reason:"Mua sơ mi mỗi quý",conf:70},{name:"Thắt lưng da",icon:"🪢",reason:"Cross-sell phụ kiện nam",conf:55},{name:"Polo Dry-fit",icon:"👕",reason:"Tương tự SP đã mua",conf:48}],
recommended_basket:[{name:"Quần âu slim",icon:"👖",reason:"Kèm sơ mi 65% đơn",conf:68},{name:"Tất cổ thấp",icon:"🧦",reason:"Add-on phổ biến",conf:42},{name:"Nước hoa mini",icon:"🧴",reason:"Gift set combo",conf:35}],
recommended_trending:[{name:"Polo cổ mao",icon:"👔",reason:"#2 trending nam Q1",conf:60},{name:"Quần jogger premium",icon:"👖",reason:"Rising +80%",conf:50},{name:"Áo gió nhẹ",icon:"🧥",reason:"Mùa mới hot item",conf:45}]
},
email_metrics:{delivered:28,opens:10,open_rate:36,click_rate:7,conversion_rate:3.2},
segments:{purchase_likelihood:"Medium",discount_affinity:"Low",customer_value:"Regular",lifecycle:"Active Customer"},
top_categories:[{name:"Polo nam",pct:35,color:"#059669"},{name:"Quần kaki",pct:25,color:"#6366F1"},{name:"Áo sơ mi",pct:20,color:"#F59E0B"},{name:"Giày dép",pct:12,color:"#EF4444"},{name:"Khác",pct:8,color:"#8B5CF6"}],
last_attrs:{"Last Seen":"canifa.com/nam","Last Email Open":"New Arrivals Men","Last Visited Category":"Polo nam","Last Abandoned Cart":"—","Last Visited Product":"Polo Pique Classic"},
products:{last_visited:{name:"Polo Pique Classic",price:"399,000đ",icon:"👔"},last_purchased:{name:"Quần kaki slim",price:"549,000đ",icon:"👖"},last_abandoned:{name:"—",price:"—",icon:"📦"}},
mobile:{app_opened:3,push_opened:5,inapp_seen:2,inapp_from_push:1},
journey:{entered:4,completed:3},
purchase_timeline:[
{date:"18/03/2026",name:"Polo Pique Classic x1",price:"399,000đ",icon:"👔",channel:"Web"},
{date:"02/03/2026",name:"Quần kaki slim",price:"549,000đ",icon:"👖",channel:"Web"},
{date:"15/01/2026",name:"Sơ mi Oxford trắng",price:"499,000đ",icon:"👔",channel:"Zalo"}
],
next_best_actions:[
{icon:"📧",title:"Email BST Polo mùa hè mới",desc:"Conf 70% — top category, thời điểm mua quý",urgency:"medium"},
{icon:"🔔",title:"Push thông báo New Arrivals",desc:"Open rate push cao hơn email 2x",urgency:"low"},
{icon:"🎯",title:"Upsell sơ mi premium tier",desc:"AOV cao, có thể nâng phân khúc",urgency:"medium"}
],
engagement_heatmap:[[0,0,1,0,0,1,0],[0,1,1,1,0,2,1],[0,0,0,0,0,1,0],[0,0,0,0,0,0,0]],
chatbot_history:[{role:"user",text:"Polo nam có mấy màu?"},{role:"bot",text:"Dạ anh, dòng Polo Pique Classic có 6 màu: Trắng, Đen, Navy, Xanh rêu, Xám nhạt và Hồng pastel."}]
},
{
id:3, name:"Lê Phương Anh", email:"phuonganh.le@yahoo.com", phone:"+84 987 654 321",
userId:"CNF-20250012", gender:"Nữ", avatar_color:"#EC4899",
channels:["Zalo OA","Web","Email","SMS"],
aov:380000, total_orders:22, total_spent:8360000,
health_score:95,
rfm:{recency:95, frequency:92, monetary:78},
predictions:{next_purchase:"Đồ mặc nhà",next_purchase_conf:91,churn_risk:5,clv_6m:3200000,next_visit_days:1,
recommended_collab:[{name:"Set Pyjama Lụa Mới",icon:"👚",reason:"Loyal buyer, mua set mỗi tháng",conf:92},{name:"Áo len oversized",icon:"🧶",reason:"Top category #2",conf:78},{name:"Quần legging fleece",icon:"🩳",reason:"Mùa đông sắp tới",conf:65}],
recommended_basket:[{name:"Dép lông trong nhà",icon:"🩴",reason:"Mua kèm pyjama 55%",conf:60},{name:"Bộ chăn ga cotton",icon:"🛏️",reason:"Bundle deal đồ nhà",conf:48},{name:"Nến thơm",icon:"🕯️",reason:"Lifestyle add-on",conf:38}],
recommended_trending:[{name:"Set loungewear modal",icon:"👚",reason:"#1 trending đồ nhà",conf:88},{name:"Cardigan len mỏng",icon:"🧶",reason:"Rising +150% MoM",conf:70},{name:"Áo hoodie oversize",icon:"🧥",reason:"Hot item Gen Z",conf:55}]
},
email_metrics:{delivered:68,opens:45,open_rate:66,click_rate:22,conversion_rate:14.7},
segments:{purchase_likelihood:"High",discount_affinity:"High",customer_value:"VIP",lifecycle:"Loyal Customer"},
top_categories:[{name:"Đồ mặc nhà",pct:28,color:"#EC4899"},{name:"Áo len",pct:22,color:"#6366F1"},{name:"Quần legging",pct:20,color:"#10B981"},{name:"Áo thun",pct:18,color:"#F59E0B"},{name:"Khác",pct:12,color:"#8B5CF6"}],
last_attrs:{"Last Seen":"canifa.com/nu/do-mac-nha","Last Email Open":"Flash Sale Weekend","Last Visited Category":"Đồ mặc nhà","Last Abandoned Cart":"185,000đ","Last Visited Product":"Set Pyjama Lụa"},
products:{last_visited:{name:"Set Pyjama Lụa",price:"450,000đ",icon:"👚"},last_purchased:{name:"Áo len cổ lọ",price:"399,000đ",icon:"🧶"},last_abandoned:{name:"Quần legging thể thao",price:"185,000đ",icon:"🩳"}},
mobile:{app_opened:15,push_opened:22,inapp_seen:11,inapp_from_push:8},
journey:{entered:14,completed:11},
purchase_timeline:[
{date:"23/03/2026",name:"Set Pyjama Cotton",price:"279,000đ",icon:"👚",channel:"Email"},
{date:"20/03/2026",name:"Áo len cổ lọ",price:"399,000đ",icon:"🧶",channel:"Web"},
{date:"10/03/2026",name:"Quần legging x2",price:"370,000đ",icon:"🩳",channel:"SMS"},
{date:"28/02/2026",name:"Set Pyjama Lụa",price:"450,000đ",icon:"👚",channel:"Zalo"},
{date:"14/02/2026",name:"Áo thun oversize x3",price:"447,000đ",icon:"👕",channel:"Web"},
{date:"01/02/2026",name:"Áo hoodie fleece",price:"550,000đ",icon:"🧥",channel:"Web"}
],
next_best_actions:[
{icon:"⭐",title:"Nâng hạng VIP Platinum",desc:"Chỉ còn 1.6M nữa — 22 đơn trong 3 tháng",urgency:"medium"},
{icon:"📧",title:"Email early access BST mới",desc:"Loyal customer — cho xem trước 24h",urgency:"low"},
{icon:"🎁",title:"Tặng voucher sinh nhật 01/12",desc:"Auto-trigger voucher 150k, còn 8 tháng",urgency:"low"}
],
engagement_heatmap:[[2,3,4,2,3,5,4],[3,4,5,3,4,6,5],[1,2,3,1,2,4,3],[1,1,2,1,1,2,1]],
chatbot_history:[{role:"user",text:"Có set đồ mặc nhà nào đang sale không?"},{role:"bot",text:"Dạ chị ơi! Set Pyjama Cotton giảm 30% còn 279,000đ và set Pyjama Lụa giảm 20% còn 360,000đ ạ."},{role:"user",text:"Lụa nhé, còn size S không?"},{role:"bot",text:"Dạ set Pyjama Lụa size S còn 3 bộ (Hồng pastel và Xanh mint). Free ship cho đơn từ 300k chị ạ!"}]
},
{
id:4, name:"Phạm Quốc Hùng", email:"hung.pham@canifa.vn", phone:"+84 911 222 333",
userId:"CNF-20230445", gender:"Nam", avatar_color:"#D97706",
channels:["Web"],
aov:1200000, total_orders:5, total_spent:6000000,
health_score:35,
rfm:{recency:25, frequency:30, monetary:85},
predictions:{next_purchase:"Áo khoác",next_purchase_conf:35,churn_risk:68,clv_6m:800000,next_visit_days:21,
recommended_collab:[{name:"Áo Blazer Wool",icon:"🧥",reason:"Abandoned cart x2",conf:60},{name:"Sơ mi premium",icon:"👔",reason:"Phân khúc cao cấp",conf:45},{name:"Gift card Canifa",icon:"🎁",reason:"Win-back campaign",conf:40}],
recommended_basket:[{name:"Quần âu wool blend",icon:"👖",reason:"Kèm blazer 70% đơn",conf:62},{name:"Cà vạt lụa",icon:"👔",reason:"Business set",conf:45},{name:"Giày leather",icon:"👞",reason:"Premium bundle",conf:38}],
recommended_trending:[{name:"Áo khoác bomber",icon:"🧥",reason:"Trending nam 30+",conf:50},{name:"Polo premium pima",icon:"👔",reason:"Premium tier bestseller",conf:42},{name:"Belt da Ý",icon:"🪢",reason:"Rising luxury segment",conf:35}]
},
email_metrics:{delivered:15,opens:3,open_rate:20,click_rate:3,conversion_rate:1.5},
segments:{purchase_likelihood:"Low",discount_affinity:"Low",customer_value:"Premium",lifecycle:"At-Risk"},
top_categories:[{name:"Áo khoác nam",pct:40,color:"#D97706"},{name:"Sơ mi",pct:30,color:"#6366F1"},{name:"Quần âu",pct:20,color:"#10B981"},{name:"Khác",pct:10,color:"#EF4444"}],
last_attrs:{"Last Seen":"canifa.com/nam/ao-khoac","Last Email Open":"—","Last Visited Category":"Áo khoác nam","Last Abandoned Cart":"1,290,000đ","Last Visited Product":"Áo Blazer Wool"},
products:{last_visited:{name:"Áo Blazer Wool",price:"1,290,000đ",icon:"🧥"},last_purchased:{name:"Sơ mi Oxford",price:"499,000đ",icon:"👔"},last_abandoned:{name:"Áo Blazer Wool",price:"1,290,000đ",icon:"🧥"}},
mobile:{app_opened:1,push_opened:0,inapp_seen:0,inapp_from_push:0},
journey:{entered:2,completed:1},
purchase_timeline:[
{date:"05/01/2026",name:"Sơ mi Oxford",price:"499,000đ",icon:"👔",channel:"Web"},
{date:"20/11/2025",name:"Áo khoác dạ dài",price:"1,490,000đ",icon:"🧥",channel:"Web"}
],
next_best_actions:[
{icon:"🚨",title:"Win-back campaign URGENT",desc:"68% churn risk — không mua 82 ngày, cần retarget ngay",urgency:"urgent"},
{icon:"💸",title:"Gửi voucher 200k cho blazer",desc:"Abandoned cart 1.29M x2 lần — giảm giá có thể convert",urgency:"urgent"},
{icon:"📞",title:"Gọi điện chăm sóc VIP",desc:"Premium tier — personal touch có thể giữ chân",urgency:"medium"}
],
engagement_heatmap:[[0,0,0,0,0,0,0],[0,0,0,0,0,1,0],[0,0,0,0,0,0,0],[0,0,0,0,0,0,0]],
chatbot_history:[]
},
{
id:5, name:"Đỗ Thanh Hà", email:"ha.do@gmail.com", phone:"+84 978 111 222",
userId:"CNF-20260034", gender:"Nữ", avatar_color:"#7C3AED",
channels:["Zalo OA","Web","Email"],
aov:295000, total_orders:3, total_spent:885000,
health_score:72,
rfm:{recency:80, frequency:35, monetary:30},
predictions:{next_purchase:"Áo crop top",next_purchase_conf:82,churn_risk:18,clv_6m:1500000,next_visit_days:2,
recommended_collab:[{name:"Crop Top Ribbed",icon:"👕",reason:"Abandoned cart, giá rẻ",conf:88},{name:"Quần short denim",icon:"🩳",reason:"Combo mùa hè",conf:72},{name:"Kính mát",icon:"🕶️",reason:"Phụ kiện Gen Z",conf:50}],
recommended_basket:[{name:"Sandal quai chéo",icon:"👡",reason:"Combo mùa hè 60%",conf:65},{name:"Túi đeo chéo mini",icon:"👜",reason:"Cross-sell Gen Z",conf:55},{name:"Nón bucket",icon:"🧢",reason:"Add-on trending",conf:45}],
recommended_trending:[{name:"Áo babydoll",icon:"👚",reason:"#1 Gen Z nữ",conf:78},{name:"Quần culottes",icon:"👖",reason:"Rising +200%",conf:60},{name:"Set bikini",icon:"👙",reason:"Mùa hè sắp tới",conf:48}]
},
email_metrics:{delivered:8,opens:6,open_rate:75,click_rate:25,conversion_rate:12.0},
segments:{purchase_likelihood:"High",discount_affinity:"High",customer_value:"New",lifecycle:"New Customer"},
top_categories:[{name:"Áo crop top",pct:40,color:"#7C3AED"},{name:"Quần short",pct:30,color:"#F59E0B"},{name:"Phụ kiện",pct:18,color:"#10B981"},{name:"Khác",pct:12,color:"#EF4444"}],
last_attrs:{"Last Seen":"canifa.com/nu/ao-croptop","Last Email Open":"Welcome Series #2","Last Visited Category":"Áo crop top","Last Abandoned Cart":"195,000đ","Last Visited Product":"Crop Top Ribbed"},
products:{last_visited:{name:"Crop Top Ribbed",price:"195,000đ",icon:"👕"},last_purchased:{name:"Quần short kaki",price:"299,000đ",icon:"🩳"},last_abandoned:{name:"Crop Top Ribbed",price:"195,000đ",icon:"👕"}},
mobile:{app_opened:12,push_opened:8,inapp_seen:6,inapp_from_push:4},
journey:{entered:3,completed:2},
purchase_timeline:[
{date:"21/03/2026",name:"Quần short kaki",price:"299,000đ",icon:"🩳",channel:"Web"},
{date:"15/03/2026",name:"Áo crop top basic x2",price:"298,000đ",icon:"👕",channel:"Zalo"},
{date:"10/03/2026",name:"Nón bucket cotton",price:"149,000đ",icon:"🧢",channel:"Web"}
],
next_best_actions:[
{icon:"🛒",title:"Nhắc abandoned cart Crop Top 195k",desc:"Viewed 5x, abandoned 1x — very hot lead",urgency:"urgent"},
{icon:"🎉",title:"Gửi Welcome Series #3",desc:"New customer — nurture với content mùa hè",urgency:"low"},
{icon:"📱",title:"Push notification flash sale",desc:"High mobile engagement — push > email",urgency:"medium"}
],
engagement_heatmap:[[1,2,3,1,2,4,3],[2,3,4,2,3,5,4],[0,1,2,0,1,3,2],[0,0,1,0,0,1,0]],
chatbot_history:[{role:"user",text:"Có áo croptop nào dưới 200k không?"},{role:"bot",text:"Dạ chị, em có 3 mẫu Crop Top dưới 200k: Crop Top Basic (149k), Crop Top Ribbed (195k), và Crop Henley (189k)."}]
},
{
id:6, name:"Bùi Đức Thịnh", email:"thinh.bui@company.com", phone:"+84 966 333 444",
userId:"CNF-20240201", gender:"Nam", avatar_color:"#2563EB",
channels:["Zalo OA","Web","Email"],
aov:520000, total_orders:15, total_spent:7800000,
health_score:88,
rfm:{recency:85, frequency:82, monetary:75},
predictions:{next_purchase:"Đồ thể thao",next_purchase_conf:75,churn_risk:8,clv_6m:2800000,next_visit_days:4,
recommended_collab:[{name:"Quần Jogger Tech v2",icon:"👖",reason:"Bản nâng cấp SP cũ",conf:80},{name:"Áo gió thể thao",icon:"🧥",reason:"Bổ sung set",conf:68},{name:"Giày sneaker",icon:"👟",reason:"Category #4, chưa mua",conf:52}],
recommended_basket:[{name:"Bình nước thể thao",icon:"🧴",reason:"Add-on 45% đơn sportswear",conf:50},{name:"Tất thể thao x3",icon:"🧦",reason:"Bundle deal phổ biến",conf:42},{name:"Túi gym",icon:"🎒",reason:"Cross-sell lifestyle",conf:38}],
recommended_trending:[{name:"Áo tank top dry-fit",icon:"👕",reason:"#1 sportswear nam",conf:72},{name:"Quần short chạy bộ",icon:"🩳",reason:"Rising +90%",conf:60},{name:"Áo compression",icon:"👕",reason:"New category hot",conf:48}]
},
email_metrics:{delivered:55,opens:32,open_rate:58,click_rate:18,conversion_rate:9.1},
segments:{purchase_likelihood:"High",discount_affinity:"Medium",customer_value:"VIP",lifecycle:"Loyal Customer"},
top_categories:[{name:"Áo thun nam",pct:32,color:"#2563EB"},{name:"Quần jogger",pct:24,color:"#10B981"},{name:"Đồ thể thao",pct:20,color:"#F59E0B"},{name:"Giày sneaker",pct:14,color:"#EF4444"},{name:"Khác",pct:10,color:"#8B5CF6"}],
last_attrs:{"Last Seen":"canifa.com/nam/the-thao","Last Email Open":"Member Day","Last Visited Category":"Đồ thể thao","Last Abandoned Cart":"—","Last Visited Product":"Quần Jogger Tech"},
products:{last_visited:{name:"Quần Jogger Tech",price:"429,000đ",icon:"👖"},last_purchased:{name:"Áo thun Dryfit",price:"349,000đ",icon:"👕"},last_abandoned:{name:"—",price:"—",icon:"📦"}},
mobile:{app_opened:20,push_opened:18,inapp_seen:9,inapp_from_push:7},
journey:{entered:11,completed:9},
purchase_timeline:[
{date:"22/03/2026",name:"Áo thun Dryfit x2",price:"698,000đ",icon:"👕",channel:"Web"},
{date:"18/03/2026",name:"Quần Jogger Tech",price:"429,000đ",icon:"👖",channel:"Zalo"},
{date:"05/03/2026",name:"Set đồ thể thao (áo+quần)",price:"650,000đ",icon:"🏃",channel:"Web"},
{date:"20/02/2026",name:"Giày sneaker trắng",price:"890,000đ",icon:"👟",channel:"Web"}
],
next_best_actions:[
{icon:"🏋️",title:"Email BST thể thao mùa hè",desc:"Top buyer sportswear — early access",urgency:"low"},
{icon:"⭐",title:"Mời tham gia chương trình Member Referral",desc:"Loyal + high engagement → ambassador potential",urgency:"medium"},
{icon:"📱",title:"Push deal bundle 'Gym Set'",desc:"Áo + Quần + Giày combo giảm 25%",urgency:"medium"}
],
engagement_heatmap:[[1,2,3,2,2,4,3],[2,3,4,3,3,5,4],[1,1,2,1,1,3,2],[0,1,1,1,0,1,1]],
chatbot_history:[{role:"user",text:"Quần jogger nam có size 2XL không?"},{role:"bot",text:"Dạ anh, Quần Jogger Tech hiện có từ S đến XL. Size 2XL hiện chưa có ạ. Anh có thể thử Quần Jogger Relaxed Fit, rộng hơn!"}]
}
];
// ═══════════════════════════════════════════
// USER INSIGHT — RENDER ENGINE
// ═══════════════════════════════════════════
let selectedUserId = null;
const fmt = n => new Intl.NumberFormat('vi-VN').format(n) + 'đ';
const segK = k => k.replace(/_/g,' ').replace(/\b\w/g,c=>c.toUpperCase());
const segC = v => {
if(v==='High')return'seg-high';if(v==='Medium')return'seg-medium';if(v==='Low')return'seg-low';
if(v==='VIP'||v==='Premium')return'seg-vip';if(v.includes('Active')||v.includes('Loyal'))return'seg-active';
if(v==='New'||v.includes('New'))return'seg-high';if(v.includes('At-Risk'))return'seg-low';return'';
};
// ═══ USER LIST ═══
function renderUserList(filter=''){
const el=document.getElementById('userItems');
const f=USERS.filter(u=>!filter||u.name.toLowerCase().includes(filter.toLowerCase())||u.email.toLowerCase().includes(filter.toLowerCase())||u.phone.includes(filter));
el.innerHTML=f.map(u=>`
<div class="user-item ${selectedUserId===u.id?'active':''}" onclick="selectUser(${u.id})">
<div class="user-avatar" style="background:${u.avatar_color}">${u.name.charAt(0)}</div>
<div class="user-item-info">
<div class="user-item-name">${u.name}</div>
<div class="user-item-meta">${u.email}</div>
</div>
<div class="user-item-right">
<div class="val" style="color:${u.health_score>=70?'#059669':u.health_score>=40?'#D97706':'#DC2626'}">${u.health_score}</div>
<div class="lbl">Health</div>
</div>
</div>
`).join('');
}
function filterUsers(){renderUserList(document.getElementById('searchBox').value)}
function selectUser(id){selectedUserId=id;renderUserList(document.getElementById('searchBox').value);renderProfile(USERS.find(u=>u.id===id))}
// ═══ HEALTH RING SVG ═══
function healthRing(score){
const r=52,c=2*Math.PI*r,pct=score/100,dash=c*pct,gap=c-dash;
const col=score>=70?'#059669':score>=40?'#D97706':'#DC2626';
return `<div class="health-ring"><svg viewBox="0 0 120 120">
<circle cx="60" cy="60" r="${r}" fill="none" stroke="#E5E7EB" stroke-width="12"/>
<circle cx="60" cy="60" r="${r}" fill="none" stroke="${col}" stroke-width="12"
stroke-dasharray="${dash} ${gap}" stroke-dashoffset="${c*0.25}" stroke-linecap="round"
transform="rotate(-90 60 60)" style="transition:stroke-dasharray .8s ease"/>
</svg><div class="score-text"><div class="score-num" style="color:${col}">${score}</div><div class="score-lbl">Health</div></div></div>`;
}
// ═══ DONUT CHART ═══
function buildDonut(cats){
const s=140,cx=70,cy=70,r=50,sw=20,ci=2*Math.PI*r;let off=0;
const segs=cats.map(c=>{const l=(c.pct/100)*ci;const s2=`<circle cx="${cx}" cy="${cy}" r="${r}" fill="none" stroke="${c.color}" stroke-width="${sw}" stroke-dasharray="${l} ${ci-l}" stroke-dashoffset="${-off}" transform="rotate(-90 ${cx} ${cy})"/>`;off+=l;return s2});
return `<svg class="donut-svg" viewBox="0 0 ${s} ${s}">${segs.join('')}</svg>`;
}
// ═══ FACTOR BAR ═══
function factorBar(label,score){
const col=score>=70?'#059669':score>=40?'#D97706':'#DC2626';
return `<div class="factor-card"><div class="f-head"><span class="f-name">${label}</span><span class="f-score" style="color:${col}">${score}</span></div><div class="factor-bar"><div class="factor-bar-fill" style="width:${score}%;background:${col}"></div></div></div>`;
}
// ═══ HEATMAP ═══
function buildHeatmap(data){
const days=['Mon','Tue','Wed','Thu','Fri','Sat','Sun'];
const slots=['Sáng','Trưa','Chiều','Tối'];
const maxV=Math.max(...data.flat(),1);
const color=(v)=>{if(!v)return'#F3F4F6';const t=v/maxV;if(t>.7)return'#6366F1';if(t>.4)return'#A5B4FC';return'#C7D2FE'};
let html=`<div class="heatmap-grid"><div class="h-label"></div>`;
days.forEach(d=>{html+=`<div class="h-day">${d}</div>`});
data.forEach((row,i)=>{html+=`<div class="h-label">${slots[i]}</div>`;row.forEach(v=>{html+=`<div class="h-cell" style="background:${color(v)}">${v||''}</div>`})});
return html+'</div>';
}
// ═══ RECO SECTION ═══
function recoBlock(label,emoji,items){
return `<div class="reco-section"><div class="reco-strat-label">${emoji} ${label}</div><div class="reco-grid">${items.map(r=>`
<div class="reco-item"><span class="reco-icon">${r.icon}</span><div><div class="reco-name">${r.name}</div><div class="reco-reason">${r.reason}</div></div><span class="reco-conf" style="color:${r.conf>=70?'#059669':r.conf>=50?'#D97706':'#9CA3AF'}">${r.conf}%</span></div>
`).join('')}</div></div>`;
}
// ═══ MAIN RENDER ═══
function renderProfile(u){
if(!u)return;
document.getElementById('profileEmpty').style.display='none';
const el=document.getElementById('profileContent');
el.style.display='block';
const p=u.predictions;
const chC=p.churn_risk>=50?'pred-red':p.churn_risk>=20?'pred-amber':'pred-green';
const coC=p.next_purchase_conf>=70?'pred-green':p.next_purchase_conf>=40?'pred-amber':'pred-red';
el.innerHTML=`
<div class="profile-tabs">
<button class="tab-btn active" onclick="switchTab(this, 'tab-actual')">Tổng quan & Thực tế</button>
<button class="tab-btn" onclick="switchTab(this, 'tab-ai')">
<span style="color:#6366F1">✨</span> AI Phân tích & Đề xuất
</button>
</div>
<!-- TAB 1: THỰC TẾ (ACTUAL) -->
<div id="tab-actual" class="tab-pane active">
<!-- OVERVIEW -->
<div class="section">
<div class="section-label">User Overview</div>
<div class="overview-grid">
<div class="overview-avatar" style="background:${u.avatar_color}">${u.name.charAt(0)}</div>
<div class="overview-details">
<div><div class="overview-field-label">Name</div><div class="overview-field-value">${u.name}</div></div>
<div><div class="overview-field-label">Email</div><div class="overview-field-value">${u.email}</div></div>
<div><div class="overview-field-label">User ID</div><div class="overview-field-value">${u.userId}</div></div>
<div><div class="overview-field-label">Channels</div><div>${u.channels.map(c=>`<span class="channel-tag">${c}</span>`).join('')}</div></div>
<div><div class="overview-field-label">Phone</div><div class="overview-field-value">${u.phone}</div></div>
<div><div class="overview-field-label">Tổng chi tiêu</div><div class="overview-field-value">${fmt(u.total_spent)}</div></div>
</div>
</div>
</div>
<!-- HEALTH SCORE + RFM -->
<div class="section">
<div class="section-label">Customer Health Score & RFM Analysis</div>
<div class="health-row">
${healthRing(u.health_score)}
<div class="health-factors">
${factorBar('Recency',u.rfm.recency)}
${factorBar('Frequency',u.rfm.frequency)}
${factorBar('Monetary',u.rfm.monetary)}
</div>
</div>
</div>
<!-- EMAIL METRICS -->
<div class="section">
<div class="section-label">Email Metrics (3 tháng)</div>
<div class="metrics-row">
<div class="metric-cell"><div class="m-label">Delivered</div><div class="m-value">${u.email_metrics.delivered}</div></div>
<div class="metric-cell"><div class="m-label">Opens</div><div class="m-value">${u.email_metrics.opens}</div></div>
<div class="metric-cell"><div class="m-label">Open Rate</div><div class="m-value">${u.email_metrics.open_rate}<span class="m-unit">%</span></div></div>
<div class="metric-cell"><div class="m-label">Click Rate</div><div class="m-value">${u.email_metrics.click_rate}<span class="m-unit">%</span></div></div>
<div class="metric-cell"><div class="m-label">Conversion</div><div class="m-value">${u.email_metrics.conversion_rate}<span class="m-unit">%</span></div></div>
</div>
</div>
<!-- PRODUCT CARDS -->
<div class="product-cards">
${['last_visited','last_purchased','last_abandoned'].map(k=>{const pr=u.products[k];const lbl=k==='last_visited'?'Last Visited':k==='last_purchased'?'Last Purchased':'Last Abandoned';return `<div class="product-card"><div class="product-card-icon">${pr.icon}</div><div class="product-card-info"><div class="product-card-label">${lbl}</div><div class="product-card-name">${pr.name}</div><div class="product-card-price">${pr.price}</div></div></div>`}).join('')}
</div>
<div style="height:16px"></div>
<!-- PURCHASE TIMELINE -->
<div class="section">
<div class="section-label">Lịch sử mua hàng (Purchase Timeline)</div>
<div class="timeline">
${u.purchase_timeline.map(t=>`<div class="tl-item"><div class="tl-date">${t.date} · ${t.channel}</div><div class="tl-content"><span class="tl-icon">${t.icon}</span><div class="tl-detail"><div class="tl-name">${t.name}</div></div><span class="tl-price">${t.price}</span></div></div>`).join('')}
</div>
</div>
<!-- ENGAGEMENT HEATMAP + MOBILE/JOURNEY -->
<div class="two-col">
<div class="section">
<div class="section-label">Engagement Heatmap (tuần gần nhất)</div>
${buildHeatmap(u.engagement_heatmap)}
</div>
<div class="section">
<div class="section-label">Mobile & Journey</div>
<div class="mini-metrics" style="margin-bottom:12px">
<div class="mini-cell"><div class="m-label">App</div><div class="m-value">${u.mobile.app_opened}</div></div>
<div class="mini-cell"><div class="m-label">Push</div><div class="m-value">${u.mobile.push_opened}</div></div>
<div class="mini-cell"><div class="m-label">InApp</div><div class="m-value">${u.mobile.inapp_seen}</div></div>
<div class="mini-cell"><div class="m-label">Push→InApp</div><div class="m-value">${u.mobile.inapp_from_push}</div></div>
</div>
<div class="mini-metrics cols-2">
<div class="mini-cell"><div class="m-label">Journeys Entered</div><div class="m-value">${u.journey.entered}</div></div>
<div class="mini-cell"><div class="m-label">Completed</div><div class="m-value">${u.journey.completed}</div></div>
</div>
</div>
</div>
<!-- CHAT HISTORY -->
${u.chatbot_history.length?`<div class="section"><div class="section-label">Lịch sử Chat gần nhất</div><div class="chat-history">${u.chatbot_history.map(m=>`<div class="chat-msg ${m.role}">${m.text}</div>`).join('')}</div></div>`:''}
</div>
<!-- TAB 2: AI PHÂN TÍCH (AI ANALYSIS) -->
<div id="tab-ai" class="tab-pane">
<!-- AI PREDICTIONS -->
<div class="section">
<div class="section-label">⚡ AI Core Predictions</div>
<div class="pred-grid">
<div class="pred-card ${coC}"><div class="pred-icon">🛒</div><div class="pred-val">${p.next_purchase}</div><div class="pred-lbl">Mua tiếp (${p.next_purchase_conf}%)</div></div>
<div class="pred-card ${chC}"><div class="pred-icon">⚠️</div><div class="pred-val">${p.churn_risk}%</div><div class="pred-lbl">Churn Risk</div></div>
<div class="pred-card pred-blue"><div class="pred-icon">💰</div><div class="pred-val">${fmt(p.clv_6m)}</div><div class="pred-lbl">CLV 6 tháng</div></div>
<div class="pred-card pred-blue"><div class="pred-icon">📅</div><div class="pred-val">${p.next_visit_days}d</div><div class="pred-lbl">Quay lại sau</div></div>
</div>
</div>
<!-- SEGMENTS + CATEGORIES + ATTRS -->
<div class="three-col">
<div class="section">
<div class="section-label">Predictive Segments</div>
${Object.entries(u.segments).map(([k,v])=>`<div class="segment-row"><span class="seg-label">${segK(k)}</span><span class="seg-value ${segC(v)}">${v}</span></div>`).join('')}
</div>
<div class="section">
<div class="section-label">Danh mục quan tâm</div>
<div class="donut-wrap">${buildDonut(u.top_categories)}<div class="donut-legend">${u.top_categories.map(c=>`<div class="legend-row"><span class="legend-dot" style="background:${c.color}"></span><span>${c.name}</span><span class="legend-pct">${c.pct}%</span></div>`).join('')}</div></div>
</div>
<div class="section">
<div class="section-label">Predicted Last Attributes</div>
${Object.entries(u.last_attrs).map(([k,v])=>`<div class="attr-row"><span class="attr-label">${k}</span><span class="attr-value">${v}</span></div>`).join('')}
</div>
</div>
<!-- RECOMMENDATION ENGINE -->
<div class="section">
<div class="section-label">🎯 Product Recommendation Engine</div>
${recoBlock('Vì bạn đã mua (Collaborative Filtering)','🧠',p.recommended_collab)}
${recoBlock('Thường mua cùng (Market Basket Analysis)','🛒',p.recommended_basket)}
${recoBlock('Trending trong phân khúc','🔥',p.recommended_trending)}
</div>
<!-- NEXT BEST ACTIONS -->
<div class="section">
<div class="section-label">🚀 Next Best Actions (AI Suggested)</div>
<div class="nba-grid">
${u.next_best_actions.map(a=>`<div class="nba-card nba-${a.urgency}"><div class="nba-icon">${a.icon}</div><div class="nba-title">${a.title}</div><div class="nba-desc">${a.desc}</div><span class="nba-tag">${a.urgency==='urgent'?'URGENT':a.urgency==='medium'?'MEDIUM':'LOW'}</span></div>`).join('')}
</div>
</div>
</div>
`;
document.getElementById('profilePane').scrollTop=0;
}
// ═══ TAB SWITCHING ═══
function switchTab(btn, tabId) {
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
document.querySelectorAll('.tab-pane').forEach(p => p.classList.remove('active'));
btn.classList.add('active');
document.getElementById(tabId).classList.add('active');
}
// ═══ INIT ═══
renderUserList();
<!DOCTYPE html>
<html lang="vi">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>User Insight — CDP</title>
<link rel="stylesheet" href="/static/lab.css">
<style>
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap');
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:'Inter',system-ui,sans-serif;background:#F8F9FC;min-height:100vh;color:#1a1a2e}
.app-wrap{display:flex;height:100vh;overflow:hidden}
.user-list-pane{width:320px;border-right:1px solid #E5E7EB;display:flex;flex-direction:column;background:#fff;flex-shrink:0}
.profile-pane{flex:1;overflow-y:auto;background:#F8F9FC}
.list-header{padding:20px 20px 16px;border-bottom:1px solid #E5E7EB}
.list-header h2{font-size:18px;font-weight:800;color:#1a1a2e;margin-bottom:4px}
.list-header p{font-size:12px;color:#6B7280}
.search-box{margin-top:12px;width:100%;padding:9px 14px;border:1px solid #E5E7EB;border-radius:10px;font-size:13px;outline:none;background:#F9FAFB}
.search-box:focus{border-color:#6366F1;box-shadow:0 0 0 3px rgba(99,102,241,.1)}
.user-items{flex:1;overflow-y:auto;padding:8px}
.user-item{display:flex;align-items:center;gap:12px;padding:12px 14px;border-radius:12px;cursor:pointer;transition:all .15s;border:1.5px solid transparent;margin-bottom:4px}
.user-item:hover{background:#F3F4F6}
.user-item.active{background:#EEF2FF;border-color:#6366F1}
.user-avatar{width:44px;height:44px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:16px;font-weight:800;color:#fff;flex-shrink:0}
.user-item-info{flex:1;min-width:0}
.user-item-name{font-size:14px;font-weight:700;color:#1a1a2e;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.user-item-meta{font-size:11px;color:#6B7280;margin-top:2px}
.user-item-right{text-align:right;flex-shrink:0}
.user-item-right .val{font-size:14px;font-weight:800;color:#1a1a2e}
.user-item-right .lbl{font-size:10px;color:#6B7280}
.profile-content{max-width:980px;margin:0 auto;padding:24px 28px 40px}
.profile-empty{display:flex;align-items:center;justify-content:center;height:100%;text-align:center;color:#9CA3AF;font-size:15px}
/* Tabs */
.profile-tabs{display:flex;gap:32px;border-bottom:2px solid #E5E7EB;margin-bottom:24px}
.tab-btn{padding:0 8px 16px;background:none;border:none;font-size:15px;font-weight:700;color:#6B7280;cursor:pointer;position:relative;transition:color .2s}
.tab-btn:hover{color:#1a1a2e}
.tab-btn.active{color:#6366F1}
.tab-btn.active::after{content:'';position:absolute;bottom:-2px;left:0;right:0;height:3px;background:#6366F1;border-radius:3px 3px 0 0}
.tab-pane{display:none;animation:fadeIn .3s ease}
.tab-pane.active{display:block}
@keyframes fadeIn{from{opacity:0;transform:translateY(4px)}to{opacity:1;transform:translateY(0)}}
.section{background:#fff;border:1px solid #E5E7EB;border-radius:14px;padding:20px 24px;margin-bottom:16px}
.section-label{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.08em;color:#6B7280;margin-bottom:14px}
/* Overview */
.overview-grid{display:flex;align-items:center;gap:20px}
.overview-avatar{width:72px;height:72px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:28px;font-weight:800;color:#fff;flex-shrink:0}
.overview-details{flex:1;display:grid;grid-template-columns:1fr 1fr 1fr;gap:4px 24px}
.overview-field-label{font-size:10px;color:#9CA3AF;font-weight:600}
.overview-field-value{font-size:14px;font-weight:700;color:#1a1a2e;margin-bottom:8px}
.channel-tag{display:inline-block;font-size:10px;font-weight:700;padding:3px 8px;border-radius:6px;background:#EEF2FF;color:#4338CA;margin-right:4px;margin-top:4px}
/* Health Score */
.health-row{display:grid;grid-template-columns:180px 1fr;gap:20px;align-items:center}
.health-ring{width:140px;height:140px;position:relative;margin:0 auto}
.health-ring svg{width:100%;height:100%}
.health-ring .score-text{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);text-align:center}
.health-ring .score-num{font-size:36px;font-weight:900;line-height:1}
.health-ring .score-lbl{font-size:10px;font-weight:700;color:#6B7280;text-transform:uppercase;letter-spacing:.06em}
.health-factors{display:grid;grid-template-columns:1fr 1fr 1fr;gap:14px}
.factor-card{padding:14px;border-radius:10px;border:1px solid #E5E7EB;background:#F9FAFB}
.factor-card .f-head{display:flex;align-items:center;justify-content:space-between;margin-bottom:10px}
.factor-card .f-name{font-size:11px;font-weight:700;color:#374151;text-transform:uppercase;letter-spacing:.04em}
.factor-card .f-score{font-size:18px;font-weight:900}
.factor-bar{width:100%;height:6px;background:#E5E7EB;border-radius:3px;overflow:hidden}
.factor-bar-fill{height:100%;border-radius:3px;transition:width .6s ease}
/* Metrics */
.metrics-row{display:grid;grid-template-columns:repeat(5,1fr);gap:0}
.metric-cell{text-align:center;padding:12px 8px;border-right:1px solid #F3F4F6}
.metric-cell:last-child{border-right:none}
.metric-cell .m-label{font-size:10px;color:#6B7280;font-weight:600;text-transform:uppercase;letter-spacing:.06em;margin-bottom:8px}
.metric-cell .m-value{font-size:28px;font-weight:800;color:#1a1a2e;line-height:1}
.metric-cell .m-unit{font-size:11px;color:#9CA3AF;font-weight:600}
/* Predictions */
.pred-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:14px}
.pred-card{padding:16px;border-radius:12px;text-align:center}
.pred-card .pred-icon{font-size:24px;margin-bottom:8px}
.pred-card .pred-val{font-size:22px;font-weight:800;line-height:1;margin-bottom:4px}
.pred-card .pred-lbl{font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:.06em;color:#6B7280}
.pred-green{background:#F0FDF4;border:1px solid #BBF7D0}.pred-green .pred-val{color:#059669}
.pred-amber{background:#FFFBEB;border:1px solid #FDE68A}.pred-amber .pred-val{color:#D97706}
.pred-red{background:#FEF2F2;border:1px solid #FECACA}.pred-red .pred-val{color:#DC2626}
.pred-blue{background:#EFF6FF;border:1px solid #BFDBFE}.pred-blue .pred-val{color:#2563EB}
/* Three-col */
.three-col{display:grid;grid-template-columns:1fr 1.2fr 1fr;gap:16px}
.segment-row{display:flex;justify-content:space-between;padding:8px 0;border-bottom:1px solid #F3F4F6}
.segment-row:last-child{border-bottom:none}
.seg-label{font-size:13px;color:#374151}
.seg-value{font-size:13px;font-weight:700}
.seg-high{color:#059669}.seg-medium{color:#D97706}.seg-low{color:#6B7280}.seg-vip{color:#7C3AED}.seg-active{color:#059669}
.donut-wrap{display:flex;flex-direction:column;align-items:center;gap:14px}
.donut-svg{width:140px;height:140px}
.donut-legend{display:flex;flex-direction:column;gap:5px;width:100%}
.legend-row{display:flex;align-items:center;gap:8px;font-size:12px;color:#374151}
.legend-dot{width:10px;height:10px;border-radius:50%;flex-shrink:0}
.legend-pct{margin-left:auto;font-weight:700;color:#1a1a2e}
.attr-row{display:flex;justify-content:space-between;padding:7px 0;border-bottom:1px solid #F3F4F6;font-size:13px}
.attr-row:last-child{border-bottom:none}
.attr-label{color:#6B7280}.attr-value{font-weight:700;color:#1a1a2e;text-align:right;max-width:55%}
/* Product cards */
.product-cards{display:grid;grid-template-columns:repeat(3,1fr);gap:16px}
.product-card{display:flex;align-items:center;gap:14px;padding:14px;background:#F9FAFB;border-radius:12px;border:1px solid #E5E7EB}
.product-card-icon{width:52px;height:52px;border-radius:10px;background:#EEF2FF;display:flex;align-items:center;justify-content:center;font-size:20px;flex-shrink:0}
.product-card-info{flex:1}
.product-card-label{font-size:10px;color:#6B7280;font-weight:600;text-transform:uppercase;letter-spacing:.06em;margin-bottom:3px}
.product-card-name{font-size:13px;font-weight:700;color:#1a1a2e;margin-bottom:2px}
.product-card-price{font-size:18px;font-weight:800;color:#1a1a2e}
/* Purchase Timeline */
.timeline{position:relative;padding-left:28px}
.timeline::before{content:'';position:absolute;left:10px;top:4px;bottom:4px;width:2px;background:#E5E7EB;border-radius:1px}
.tl-item{position:relative;padding:10px 0 14px}
.tl-item::before{content:'';position:absolute;left:-22px;top:14px;width:10px;height:10px;border-radius:50%;background:#6366F1;border:2px solid #fff;box-shadow:0 0 0 2px #6366F1;z-index:1}
.tl-date{font-size:11px;font-weight:600;color:#9CA3AF;margin-bottom:4px}
.tl-content{display:flex;align-items:center;gap:10px}
.tl-icon{font-size:18px}
.tl-detail{flex:1}
.tl-name{font-size:13px;font-weight:700;color:#1a1a2e}
.tl-sub{font-size:11px;color:#6B7280}
.tl-price{font-size:14px;font-weight:800;color:#1a1a2e;flex-shrink:0}
/* Recommendation Engine */
.reco-section{border-top:1px solid #E5E7EB;padding-top:16px;margin-top:6px}
.reco-strat-label{font-size:11px;font-weight:700;color:#6366F1;margin-bottom:10px;display:flex;align-items:center;gap:6px}
.reco-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:12px;margin-bottom:18px}
.reco-item{display:flex;align-items:center;gap:10px;padding:10px 14px;background:#F9FAFB;border:1px solid #E5E7EB;border-radius:10px}
.reco-item .reco-icon{font-size:18px;flex-shrink:0}
.reco-item .reco-name{font-size:13px;font-weight:700;color:#1a1a2e}
.reco-item .reco-reason{font-size:11px;color:#6B7280;margin-top:2px}
.reco-item .reco-conf{font-size:12px;font-weight:800;flex-shrink:0;margin-left:auto}
/* Next Best Action */
.nba-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:14px}
.nba-card{padding:16px;border-radius:12px;border:1px solid #E5E7EB;display:flex;flex-direction:column;gap:8px;cursor:pointer;transition:all .15s}
.nba-card:hover{box-shadow:0 4px 16px rgba(0,0,0,.08);transform:translateY(-2px)}
.nba-card .nba-icon{font-size:28px}
.nba-card .nba-title{font-size:13px;font-weight:800;color:#1a1a2e}
.nba-card .nba-desc{font-size:12px;color:#6B7280;line-height:1.4}
.nba-card .nba-tag{display:inline-block;font-size:10px;font-weight:700;padding:3px 8px;border-radius:6px;margin-top:4px}
.nba-urgent .nba-tag{background:#FEF2F2;color:#DC2626}
.nba-medium .nba-tag{background:#FFFBEB;color:#D97706}
.nba-low .nba-tag{background:#F0FDF4;color:#059669}
/* Engagement Heatmap */
.heatmap-grid{display:grid;grid-template-columns:60px repeat(7,1fr);gap:3px;font-size:11px}
.heatmap-grid .h-label{font-weight:600;color:#6B7280;display:flex;align-items:center}
.heatmap-grid .h-day{text-align:center;font-weight:700;color:#6B7280;padding:4px}
.heatmap-grid .h-cell{border-radius:4px;height:28px;display:flex;align-items:center;justify-content:center;font-weight:700;font-size:10px;color:#374151}
/* Chat history */
.chat-history{display:flex;flex-direction:column;gap:8px;max-height:280px;overflow-y:auto}
.chat-msg{padding:10px 14px;border-radius:12px;font-size:13px;line-height:1.5;max-width:85%}
.chat-msg.user{align-self:flex-end;background:#6366F1;color:#fff;border-bottom-right-radius:4px}
.chat-msg.bot{align-self:flex-start;background:#F3F4F6;color:#1a1a2e;border-bottom-left-radius:4px}
/* Two-col */
.two-col{display:grid;grid-template-columns:1fr 1fr;gap:16px}
.mini-metrics{display:grid;grid-template-columns:repeat(4,1fr);gap:0}
.mini-metrics.cols-2{grid-template-columns:repeat(2,1fr)}
.mini-cell{text-align:center;padding:12px 8px;border-right:1px solid #F3F4F6}
.mini-cell:last-child{border-right:none}
.mini-cell .m-label{font-size:10px;color:#6B7280;font-weight:600;text-transform:uppercase;letter-spacing:.06em;margin-bottom:6px}
.mini-cell .m-value{font-size:24px;font-weight:800;color:#1a1a2e;line-height:1}
@media(max-width:900px){.app-wrap{flex-direction:column}.user-list-pane{width:100%;height:260px;border-right:none;border-bottom:1px solid #E5E7EB}.three-col,.product-cards,.pred-grid,.reco-grid,.nba-grid{grid-template-columns:1fr}.overview-details{grid-template-columns:1fr 1fr}.metrics-row{grid-template-columns:repeat(3,1fr)}.two-col{grid-template-columns:1fr}}
</style>
</head>
<body>
<div class="app-wrap">
<div class="user-list-pane">
<div class="list-header">
<h2>Customer Insight</h2>
<p>Canifa CDP — Hồ sơ 360° & Dự đoán hành vi</p>
<input type="text" class="search-box" id="searchBox" placeholder="Tìm theo tên, email, SĐT..." oninput="filterUsers()">
</div>
<div class="user-items" id="userItems"></div>
</div>
<div class="profile-pane" id="profilePane">
<div class="profile-empty" id="profileEmpty">
<div>
<div style="font-size:48px;opacity:.3;margin-bottom:12px">📊</div>
<p style="font-size:16px;font-weight:700;margin-bottom:6px">Chọn một khách hàng</p>
<p style="font-size:13px">Click vào tên bên trái để xem hồ sơ 360° & AI predictions</p>
</div>
</div>
<div class="profile-content" id="profileContent" style="display:none"></div>
</div>
</div>
<script src="/static/user-insight-data.js"></script>
<script src="/static/user-insight-render.js"></script>
</body>
</html>
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment