<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>FlatPay — Call Dashboard</title>
<script src="https://unpkg.com/react@18/umd/react.production.min.js" crossorigin></script>
<script src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js" crossorigin></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/7.8.5/d3.min.js"></script>
<script src="https://unpkg.com/recharts/umd/Recharts.js"></script>
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
<style>
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;background:#f7f6f4;color:#1a1a1a}
input,button,select{font-family:inherit;font-size:13px;outline:none}
input,select{padding:6px 10px;border:1px solid #e4e2dc;border-radius:8px;background:#fff;color:#1a1a1a}
button{cursor:pointer;padding:6px 14px;border:1px solid #e4e2dc;border-radius:8px;background:#fff;color:#1a1a1a;transition:background .15s}
button:hover{background:#f0efea}
button:disabled{opacity:.4;cursor:not-allowed}
table{width:100%;border-collapse:collapse;font-size:12px}
th{padding:8px 12px;font-weight:500;color:#999;font-size:11px;border-bottom:1px solid #e4e2dc;white-space:nowrap;text-align:left;background:#fafafa}
td{padding:8px 12px;border-bottom:1px solid #f0efe9;color:#1a1a1a}
tr:last-child td{border-bottom:none}
.nr{text-align:right!important}
@keyframes spin{to{transform:rotate(360deg)}}
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.3}}
.spin{display:inline-block;width:16px;height:16px;border:2px solid #e4e2dc;border-top-color:#2a6080;border-radius:50%;animation:spin .8s linear infinite;vertical-align:middle}
::-webkit-scrollbar{width:5px;height:5px}
::-webkit-scrollbar-thumb{background:#d3d1c7;border-radius:3px}
</style>
</head>
<body>
<div id="root"></div>
<script type="text/babel">
const {useState,useEffect,useRef,useCallback,useMemo}=React;
const {LineChart,Line,BarChart,Bar,XAxis,YAxis,Tooltip,ResponsiveContainer,PieChart,Pie,Cell,CartesianGrid,Legend}=Recharts;
// ── Config ────────────────────────────────────────────────────────────────
const PROXY="https://rough-frost-3472.olfr.workers.dev";
const Q={agents:22,overview:16,contactLists:23,agentGroups:24,map:4,agents6:6};
const FP={black:"#1a1a1a",white:"#fff",lavender:"#c8c8e0",blue:"#b8d8e8",pink:"#f0c8d0",mint:"#b8e0d0",yellow:"#f0e8c0",peach:"#f0d0b8",lavenDark:"#5a5a8a",blueDark:"#2a6080",pinkDark:"#8a3a50",mintDark:"#1a6050",yellowDark:"#7a6820",peachDark:"#8a4820",pageBg:"#f7f6f4",sideBg:"#1a1a1a",sideText:"#e8e8e8",sideActive:"#383838",border:"#e4e2dc"};
const CARD_PALETTES=[
{bg:FP.blue,fg:FP.blueDark},{bg:FP.mint,fg:FP.mintDark},{bg:FP.lavender,fg:FP.lavenDark},
{bg:FP.peach,fg:FP.peachDark},{bg:FP.pink,fg:FP.pinkDark},{bg:FP.yellow,fg:FP.yellowDark},
];
const ALL_TEAMS=["Team Krishna","Team Kaan","Team Marcus","Team Alberto","MCC Marketing"];
const TEAM_COLORS={"Team Krishna":FP.blue,"Team Kaan":FP.mint,"Team Marcus":FP.lavender,"Team Alberto":FP.peach,"MCC Marketing":FP.pink};
const TEAM_TEXT={"Team Krishna":FP.blueDark,"Team Kaan":FP.mintDark,"Team Marcus":FP.lavenDark,"Team Alberto":FP.peachDark,"MCC Marketing":FP.pinkDark};
const PRESETS=[["today","Today"],["yesterday","Yesterday"],["week","This week"],["lastweek","Last week"],["month","This month"],["lastmonth","Last month"],["custom","Custom"]];
// ── Date utilities ────────────────────────────────────────────────────────
const pad=n=>String(n).padStart(2,"0");
const fmtD=d=>`${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}`;
function getRange(preset,cFrom,cTo){
const n=new Date(),t=fmtD(n);
if(preset==="today") return{from:t+" 00:00",to:t+" 23:59"};
if(preset==="yesterday"){const y=new Date(n);y.setDate(n.getDate()-1);const yd=fmtD(y);return{from:yd+" 00:00",to:yd+" 23:59"};}
if(preset==="week"){const day=n.getDay()||7;const m=new Date(n);m.setDate(n.getDate()-day+1);return{from:fmtD(m)+" 00:00",to:t+" 23:59"};}
if(preset==="lastweek"){const day=n.getDay()||7;const lm=new Date(n);lm.setDate(n.getDate()-day-6);const ls=new Date(n);ls.setDate(n.getDate()-day);return{from:fmtD(lm)+" 00:00",to:fmtD(ls)+" 23:59"};}
if(preset==="month"){const m1=new Date(n.getFullYear(),n.getMonth(),1);return{from:fmtD(m1)+" 00:00",to:t+" 23:59"};}
if(preset==="lastmonth"){const lm1=new Date(n.getFullYear(),n.getMonth()-1,1);const lme=new Date(n.getFullYear(),n.getMonth(),0);return{from:fmtD(lm1)+" 00:00",to:fmtD(lme)+" 23:59"};}
if(preset==="custom"&&cFrom&&cTo) return{from:cFrom+" 00:00",to:cTo+" 23:59"};
return{from:t+" 00:00",to:t+" 23:59"};
}
function getPrevRange(from,to){
const ps=new Date(from.replace(" ","T")),pe=new Date(to.replace(" ","T"));
const diff=pe-ps;
const prevE=new Date(ps-60000),prevS=new Date(prevE-diff);
return{from:fmtD(prevS)+" 00:00",to:fmtD(prevE)+" 23:59"};
}
function getPeriodLabel(p){return{today:"vs yesterday",yesterday:"vs day before",week:"vs same days last week",lastweek:"vs week before",month:"vs comparable days last month",lastmonth:"vs month before"}[p]||"vs prev period";}
// ── Redash API ────────────────────────────────────────────────────────────
async function rQuery(qid,params,key){
const r=await fetch(`${PROXY}/redash/api/queries/${qid}/results`,{
method:"POST",
headers:{"Content-Type":"application/json","X-Redash-Key":key},
body:JSON.stringify({parameters:params||{},max_age:600})
});
const d=await r.json();
if(!r.ok) throw new Error(d.message||"Query failed");
if(d.job) return pollJob(d.job.id,key);
return d.query_result?.data?.rows||[];
}
async function pollJob(jid,key){
for(let i=0;i<40;i++){
await new Promise(r=>setTimeout(r,800));
const r=await fetch(`${PROXY}/redash/api/jobs/${jid}`,{headers:{"X-Redash-Key":key}});
const d=await r.json();
if(d.job.status===3){
const qr=await fetch(`${PROXY}/redash/api/query_results/${d.job.query_result_id}`,{headers:{"X-Redash-Key":key}});
const qd=await qr.json();
return qd.query_result?.data?.rows||[];
}
if(d.job.status>=4) return[];
}
return[];
}
// ── Formatters ────────────────────────────────────────────────────────────
const fn=n=>n==null?"—":Number(n).toLocaleString();
const fp=(n,d=1)=>n==null?"—":parseFloat(n).toFixed(d)+"%";
const fmtTalk=m=>{if(!m)return"0m";const dy=Math.floor(m/480),h=Math.floor((m%480)/60),mn=Math.round(m%60);return[dy>0?dy+"d":"",h>0?h+"h":"",mn>0?mn+"m":""].filter(Boolean).join(" ")||"0m";};
const chg=(a,b)=>b===0?0:Math.round((a-b)/b*100*10)/10;
const safePct=(a,b)=>b>0?Math.round(a/b*1000)/10:0;
// ── Small UI components ───────────────────────────────────────────────────
function Spin(){return <span className="spin"/>;}
function ChgBdg({cur,prev}){const d=chg(cur,prev);if(!prev) return null;return <span style={{fontSize:10,fontWeight:500,padding:"1px 5px",borderRadius:5,background:d>=0?"#dcfce7":"#fee2e2",color:d>=0?"#166534":"#991b1b",whiteSpace:"nowrap",marginLeft:4}}>{d>=0?"↑":"↓"}{Math.abs(d)}%</span>;}
function Bdg({c="lavender",children,sm}){const m={lavender:{bg:FP.lavender,fg:FP.lavenDark},blue:{bg:FP.blue,fg:FP.blueDark},pink:{bg:FP.pink,fg:FP.pinkDark},mint:{bg:FP.mint,fg:FP.mintDark},yellow:{bg:FP.yellow,fg:FP.yellowDark},peach:{bg:FP.peach,fg:FP.peachDark},green:{bg:"#dcfce7",fg:"#166534"},gray:{bg:"#ebebeb",fg:"#555"}};const s=m[c]||m.lavender;return <span style={{fontSize:sm?10:11,fontWeight:500,padding:sm?"1px 5px":"2px 8px",borderRadius:6,background:s.bg,color:s.fg,display:"inline-block",whiteSpace:"nowrap"}}>{children}</span>;}
function KCard({label,value,bg,fg,trend,sub,perBdr,loading}){return <div style={{background:bg,borderRadius:14,padding:"1rem"}}>
<div style={{fontSize:11,color:fg,opacity:.7,marginBottom:5,fontWeight:500}}>{label}</div>
<div style={{fontSize:20,fontWeight:500,color:fg,marginBottom:2}}>{loading?<Spin/>:value}</div>
{perBdr!=null&&<div style={{fontSize:11,color:fg,opacity:.7,marginBottom:3}}>{loading?"…":perBdr} per BDR</div>}
<div style={{display:"flex",alignItems:"center",gap:6,flexWrap:"wrap"}}>
{sub&&<span style={{fontSize:10,color:fg,opacity:.65}}>{sub}</span>}
{trend!=null&&!loading&&<span style={{fontSize:10,fontWeight:500,padding:"1px 5px",borderRadius:5,background:"rgba(0,0,0,0.09)",color:fg}}>{trend>=0?"↑":"↓"}{Math.abs(trend)}%</span>}
</div>
</div>;}
function Card({title,children,action,noPad}){return <div style={{background:FP.white,border:`1px solid ${FP.border}`,borderRadius:14,padding:noPad?"0":"1rem",marginBottom:12,overflow:noPad?"hidden":"visible"}}>
{(title||action)&&<div style={{display:"flex",justifyContent:"space-between",alignItems:"center",marginBottom:10,padding:noPad?"1rem 1rem 0":0}}>
{title&&<div style={{fontSize:12,fontWeight:500,color:"#666"}}>{title}</div>}
{action}
</div>}
{children}
</div>;}
function PBtn({active,onClick,children}){return <button onClick={onClick} style={{fontSize:11,padding:"4px 11px",borderRadius:20,border:`1px solid ${active?FP.black:FP.border}`,background:active?FP.black:FP.white,color:active?FP.white:"#888",cursor:"pointer",fontWeight:active?500:400,whiteSpace:"nowrap"}}>{children}</button>;}
const TT=({active,payload,label})=>{if(!active||!payload?.length)return null;return <div style={{background:FP.white,border:`1px solid ${FP.border}`,borderRadius:10,padding:"8px 12px",fontSize:11}}><div style={{fontWeight:500,marginBottom:4}}>{label}</div>{payload.map((p,i)=><div key={i} style={{color:p.color||"#333"}}>{p.name}: <strong>{typeof p.value==="number"?Number(p.value).toLocaleString():p.value}</strong></div>)}</div>;};
// ── Team filter ───────────────────────────────────────────────────────────
function TeamFilter({sel,setSel,allTeams}){
const teams=allTeams||ALL_TEAMS;
return <div style={{display:"flex",alignItems:"center",gap:6,flexWrap:"wrap"}}>
<span style={{fontSize:12,color:"#888",fontWeight:500}}>Teams:</span>
<button onClick={()=>setSel([...teams])} style={{fontSize:11,padding:"3px 10px",borderRadius:20,border:`1px solid ${FP.border}`,background:"#f5f5f5",color:"#666",cursor:"pointer"}}>All</button>
<button onClick={()=>setSel([])} style={{fontSize:11,padding:"3px 10px",borderRadius:20,border:`1px solid ${FP.border}`,background:"#f5f5f5",color:"#666",cursor:"pointer"}}>None</button>
{teams.map(t=>{const on=sel.includes(t),c=TEAM_COLORS[t]||FP.blue,fg=TEAM_TEXT[t]||FP.blueDark;return <button key={t} onClick={()=>setSel(on?sel.filter(x=>x!==t):[...sel,t])} style={{fontSize:11,padding:"3px 10px",borderRadius:20,border:`1px solid ${on?fg:FP.border}`,background:on?c:"#f5f5f5",color:on?fg:"#888",cursor:"pointer",fontWeight:on?500:400}}>{t}</button>;})}
</div>;
}
// ── Date bar ─────────────────────────────────────────────────────────────
function DateBar({preset,setPreset,cFrom,setCFrom,cTo,setCTo}){return <div style={{display:"flex",alignItems:"center",gap:6,flexWrap:"wrap"}}>
{PRESETS.map(([p,l])=><PBtn key={p} active={preset===p} onClick={()=>setPreset(p)}>{l}</PBtn>)}
{preset==="custom"&&<><input type="date" value={cFrom} onChange={e=>setCFrom(e.target.value)} style={{fontSize:11,padding:"3px 8px",borderRadius:8,border:`1px solid ${FP.border}`}}/><span style={{fontSize:11,color:"#aaa"}}>→</span><input type="date" value={cTo} onChange={e=>setCTo(e.target.value)} style={{fontSize:11,padding:"3px 8px",borderRadius:8,border:`1px solid ${FP.border}`}}/></>}
</div>;}
function FilterBar({preset,setPreset,cFrom,setCFrom,cTo,setCTo,teamSel,setTeamSel,allTeams}){return <div style={{display:"flex",flexDirection:"column",gap:10,marginBottom:"1rem"}}>
<DateBar preset={preset} setPreset={setPreset} cFrom={cFrom} setCFrom={setCFrom} cTo={cTo} setCTo={setCTo}/>
<TeamFilter sel={teamSel} setSel={setTeamSel} allTeams={allTeams}/>
</div>;}
// ── WoW line chart ────────────────────────────────────────────────────────
function WowLine({data,cur,prev,color,prevColor,fmt}){return <ResponsiveContainer width="100%" height={130}>
<LineChart data={data} margin={{top:5,right:8,bottom:0,left:0}}>
<CartesianGrid strokeDasharray="3 3" stroke="#f0f0f0"/>
<XAxis dataKey="d" tick={{fontSize:10}}/><YAxis tick={{fontSize:9}} width={36} tickFormatter={fmt||(v=>v>=1000?Math.round(v/1000)+"k":v)}/>
<Tooltip content={TT}/>
<Legend iconSize={8} formatter={v=><span style={{fontSize:9}}>{v}</span>}/>
<Line type="monotone" dataKey={prev} name="Prev" stroke={prevColor} strokeWidth={1.5} strokeDasharray="4 3" dot={{r:2}} activeDot={{r:4}}/>
<Line type="monotone" dataKey={cur} name="Current" stroke={color} strokeWidth={2} dot={{r:2}} activeDot={{r:4}}/>
</LineChart>
</ResponsiveContainer>;}
// ── Aggregate overview rows ───────────────────────────────────────────────
function aggRows(rows){
return rows.reduce((a,r)=>({
calls:a.calls+(parseInt(r["Total calls"])||0),
answered:a.answered+(parseInt(r["Answered calls"])||0),
booked:a.booked+(parseInt(r["Booked"])||0),
finalized:a.finalized+(parseInt(r["Finalized"])||0),
voicemails:a.voicemails+(parseInt(r["Voicemails"])||0),
}),{calls:0,answered:0,booked:0,finalized:0,voicemails:0});
}
function aggAgentRows(rows,teamSel){
return rows.filter(r=>!teamSel||teamSel.length===0||teamSel.includes(r.team));
}
// ── OVERVIEW TAB ─────────────────────────────────────────────────────────
function Overview({apiKey,...fp}){
const {preset,setPreset,cFrom,setCFrom,cTo,setCTo,teamSel,setTeamSel}=fp;
const [curRows,setCurRows]=useState([]);
const [prevRows,setPrevRows]=useState([]);
const [agentRows,setAgentRows]=useState([]);
const [ldg,setLdg]=useState(false);
const load=useCallback(async()=>{
setLdg(true);
try{
const r=getRange(preset,cFrom,cTo),pr=getPrevRange(r.from,r.to);
const p={from:r.from,to:r.to,"Week/Month":"Week",city:"Not selected",district_code:"Not selected"};
const pp={...p,from:pr.from,to:pr.to};
const [cr,prr,ar]=await Promise.all([
rQuery(Q.overview,p,apiKey),
rQuery(Q.overview,pp,apiKey),
rQuery(Q.agents,{from:r.from,to:r.to},apiKey),
]);
setCurRows(cr);setPrevRows(prr);setAgentRows(ar);
}catch(e){console.error(e);}
setLdg(false);
},[apiKey,preset,cFrom,cTo]);
useEffect(()=>{load();},[load]);
const cur=aggRows(curRows),prev=aggRows(prevRows);
const filteredAgents=aggAgentRows(agentRows,teamSel);
const activeBDRs=new Set(filteredAgents.filter(r=>parseInt(r.total_calls)>0).map(r=>r.agent)).size;
const gr=cur.calls>0?(cur.booked/cur.calls*100).toFixed(2):"0";
const ac=cur.answered>0?(cur.booked/cur.answered*100).toFixed(2):"0";
const grP=prev.calls>0?(prev.booked/prev.calls*100).toFixed(2):"0";
const acP=prev.answered>0?(prev.booked/prev.answered*100).toFixed(2):"0";
const talkMins=filteredAgents.reduce((s,r)=>s+(parseFloat(r.total_talk_mins)||0),0);
// Build WoW data from rows
const wowData=curRows.map((r,i)=>({
d:r.Period||("P"+(i+1)),
calls_cur:parseInt(r["Total calls"])||0,
calls_prev:parseInt(prevRows[i]?.["Total calls"])||0,
ans_cur:parseInt(r["Answered calls"])||0,
ans_prev:parseInt(prevRows[i]?.["Answered calls"])||0,
book_cur:parseInt(r["Booked"])||0,
book_prev:parseInt(prevRows[i]?.["Booked"])||0,
reach_cur:safePct(parseInt(r["Answered calls"]),parseInt(r["Total calls"])),
reach_prev:safePct(parseInt(prevRows[i]?.["Answered calls"]),parseInt(prevRows[i]?.["Total calls"])),
gross_cur:safePct(parseInt(r["Booked"]),parseInt(r["Total calls"])),
gross_prev:safePct(parseInt(prevRows[i]?.["Booked"]),parseInt(prevRows[i]?.["Total calls"])),
}));
return <div>
<FilterBar preset={preset} setPreset={setPreset} cFrom={cFrom} setCFrom={setCFrom} cTo={cTo} setCTo={setCTo} teamSel={teamSel} setTeamSel={setTeamSel}/>
{ldg&&<div style={{textAlign:"center",padding:"1rem",color:"#888",fontSize:12}}><Spin/> Loading data…</div>}
<div style={{display:"grid",gridTemplateColumns:"repeat(auto-fit,minmax(120px,1fr))",gap:10,marginBottom:"1.25rem"}}>
<KCard label="Calls" value={fn(cur.calls)} bg={FP.blue} fg={FP.blueDark} trend={chg(cur.calls,prev.calls)} sub={getPeriodLabel(preset)} perBdr={activeBDRs>0?Math.round(cur.calls/activeBDRs).toLocaleString():null} loading={ldg}/>
<KCard label="Talk time" value={fmtTalk(talkMins)} bg={FP.mint} fg={FP.mintDark} loading={ldg}/>
<KCard label="Answered" value={fn(cur.answered)} bg={FP.lavender} fg={FP.lavenDark} trend={chg(cur.answered,prev.answered)} perBdr={activeBDRs>0?Math.round(cur.answered/activeBDRs).toLocaleString():null} loading={ldg}/>
<KCard label="Reach %" value={fp(safePct(cur.answered,cur.calls))} bg={FP.yellow} fg={FP.yellowDark} trend={chg(safePct(cur.answered,cur.calls),safePct(prev.answered,prev.calls))} loading={ldg}/>
<KCard label="Bookings" value={fn(cur.booked)} bg={FP.peach} fg={FP.peachDark} trend={chg(cur.booked,prev.booked)} perBdr={activeBDRs>0?Math.round(cur.booked/activeBDRs).toLocaleString():null} loading={ldg}/>
<KCard label="Gross conv." value={gr+"%"} bg={FP.pink} fg={FP.pinkDark} trend={chg(parseFloat(gr),parseFloat(grP))} loading={ldg}/>
<KCard label="Ans. conv." value={ac+"%"} bg="#e8e0f8" fg={FP.lavenDark} trend={chg(parseFloat(ac),parseFloat(acP))} loading={ldg}/>
<KCard label="Active BDRs" value={activeBDRs} bg="#f0f0f0" fg="#444" sub={`of ${filteredAgents.length} total`} loading={ldg}/>
</div>
{wowData.length>0&&<>
<div style={{display:"grid",gridTemplateColumns:"repeat(3,1fr)",gap:10,marginBottom:12}}>
<Card title="Calls"><WowLine data={wowData} cur="calls_cur" prev="calls_prev" color={FP.blueDark} prevColor={FP.blue}/></Card>
<Card title="Answered"><WowLine data={wowData} cur="ans_cur" prev="ans_prev" color={FP.mintDark} prevColor={FP.mint}/></Card>
<Card title="Bookings"><WowLine data={wowData} cur="book_cur" prev="book_prev" color={FP.lavenDark} prevColor={FP.lavender}/></Card>
</div>
<div style={{display:"grid",gridTemplateColumns:"repeat(2,1fr)",gap:10,marginBottom:12}}>
<Card title="Reach %"><WowLine data={wowData} cur="reach_cur" prev="reach_prev" color={FP.yellowDark} prevColor={FP.yellow} fmt={v=>v+"%"}/></Card>
<Card title="Gross conv. %"><WowLine data={wowData} cur="gross_cur" prev="gross_prev" color={FP.peachDark} prevColor={FP.peach} fmt={v=>v+"%"}/></Card>
</div>
</>}
<Card title={`Period breakdown · ${getPeriodLabel(preset)}`} noPad>
<div style={{overflowX:"auto"}}>
<table>
<thead><tr><th>Period</th><th className="nr">Calls</th><th className="nr">Δ</th><th className="nr">Answered</th><th className="nr">Reach %</th><th className="nr">Bookings</th><th className="nr">Δ</th><th className="nr">Finalized</th><th className="nr">Voicemails</th></tr></thead>
<tbody>{curRows.map((r,i)=>{const p2=prevRows[i]||{};const cc=parseInt(r["Total calls"])||0,pc=parseInt(p2["Total calls"])||0,cb=parseInt(r["Booked"])||0,pb=parseInt(p2["Booked"])||0;return(
<tr key={i}>
<td style={{fontWeight:500}}>{r.Period||("P"+(i+1))}</td>
<td className="nr">{fn(cc)}</td><td className="nr"><ChgBdg cur={cc} prev={pc}/></td>
<td className="nr">{fn(r["Answered calls"])}</td>
<td className="nr">{fp(safePct(parseInt(r["Answered calls"]),cc))}</td>
<td className="nr"><Bdg c="lavender">{fn(cb)}</Bdg></td>
<td className="nr"><ChgBdg cur={cb} prev={pb}/></td>
<td className="nr">{fn(r["Finalized"])}</td>
<td className="nr">{fn(r["Voicemails"])}</td>
</tr>
);})}
{!ldg&&curRows.length===0&&<tr><td colSpan={9} style={{textAlign:"center",color:"#aaa",padding:"2rem"}}>No data for this period</td></tr>}
</tbody>
</table>
</div>
</Card>
</div>;
}
// ── AGENTS TAB ────────────────────────────────────────────────────────────
function Agents({apiKey,...fp}){
const {preset,setPreset,cFrom,setCFrom,cTo,setCTo,teamSel,setTeamSel}=fp;
const [rows,setRows]=useState([]);
const [prevRows,setPrevRows]=useState([]);
const [expanded,setExpanded]=useState(null);
const [sort,setSort]=useState("booked");
const [ldg,setLdg]=useState(false);
const load=useCallback(async()=>{
setLdg(true);
try{
const r=getRange(preset,cFrom,cTo),pr=getPrevRange(r.from,r.to);
const [cr,prr]=await Promise.all([
rQuery(Q.agents,{from:r.from,to:r.to},apiKey),
rQuery(Q.agents,{from:pr.from,to:pr.to},apiKey),
]);
setRows(cr);setPrevRows(prr);
}catch(e){console.error(e);}
setLdg(false);
},[apiKey,preset,cFrom,cTo]);
useEffect(()=>{load();},[load]);
const filtered=rows.filter(r=>teamSel.length===0||teamSel.includes(r.team));
const sortKey={booked:"booked",total:"total_calls",reach:"reach_pct",gross:"gross_pct",ansCon:"answered_conv_pct"}[sort]||"booked";
const sorted=[...filtered].sort((a,b)=>(parseFloat(b[sortKey])||0)-(parseFloat(a[sortKey])||0));
const prevMap={};prevRows.forEach(r=>{prevMap[r.agent]={total:parseInt(r.total_calls)||0,booked:parseInt(r.booked)||0};});
return <div>
<FilterBar preset={preset} setPreset={setPreset} cFrom={cFrom} setCFrom={setCFrom} cTo={cTo} setCTo={setCTo} teamSel={teamSel} setTeamSel={setTeamSel}/>
{ldg&&<div style={{textAlign:"center",padding:"1rem",color:"#888",fontSize:12}}><Spin/> Loading agent data…</div>}
<Card title={`${sorted.length} agents · click to expand`} action={<div style={{display:"flex",gap:4,flexWrap:"wrap"}}>{[["booked","Bookings"],["total","Calls"],["reach","Reach"],["gross","Gross"],["ansCon","Ans. conv."]].map(([s,l])=><PBtn key={s} active={sort===s} onClick={()=>setSort(s)}>{l}</PBtn>)}</div>} noPad>
<div style={{overflowX:"auto"}}>
<table>
<thead><tr><th>Agent</th><th>Team</th><th className="nr">Calls</th><th className="nr">Δ</th><th className="nr">Talk time</th><th className="nr">Answered</th><th className="nr">Reach %</th><th className="nr">Bookings</th><th className="nr">Δ</th><th className="nr">Gross %</th><th className="nr">Ans. conv.</th></tr></thead>
<tbody>{sorted.map((r,i)=>{
const ag=r.agent||"—",tc=TEAM_COLORS[r.team]||FP.blue,tfg=TEAM_TEXT[r.team]||FP.blueDark,isExp=expanded===ag;
const prev=prevMap[ag]||{};
const talkMins=parseFloat(r.total_talk_mins)||0;
return <>
<tr key={ag} onClick={()=>setExpanded(isExp?null:ag)} style={{cursor:"pointer",background:isExp?FP.blue+"22":"transparent"}}>
<td style={{fontWeight:500}}>{isExp?"▾ ":""}{ag}</td>
<td><span style={{fontSize:11,fontWeight:500,padding:"2px 8px",borderRadius:6,background:tc,color:tfg}}>{r.team||"—"}</span></td>
<td className="nr">{fn(r.total_calls)}</td>
<td className="nr"><ChgBdg cur={parseInt(r.total_calls)||0} prev={prev.total||0}/></td>
<td className="nr">{fmtTalk(talkMins)}</td>
<td className="nr">{fn(r.answered)}</td>
<td className="nr">{fp(r.reach_pct)}</td>
<td className="nr"><Bdg c="mint">{fn(r.booked)}</Bdg></td>
<td className="nr"><ChgBdg cur={parseInt(r.booked)||0} prev={prev.booked||0}/></td>
<td className="nr">{fp(r.gross_pct,2)}</td>
<td className="nr">{fp(r.answered_conv_pct,2)}</td>
</tr>
{isExp&&<tr key={ag+"_x"}><td colSpan={11} style={{padding:"0 12px 12px 36px",background:"#f8f8fc"}}>
<div style={{fontSize:11,color:"#888",fontWeight:500,margin:"10px 0 6px"}}>Period summary</div>
<div style={{display:"grid",gridTemplateColumns:"repeat(4,1fr)",gap:8}}>
{[["Total calls",fn(r.total_calls)],["Avg talk",((parseFloat(r.avg_talk_secs)||0).toFixed(0))+"s"],["Finalized",fn(r.finalized)],["Fin. %",fp(r.finalized_pct)]].map(([l,v])=>(
<div key={l} style={{background:"#f0f0f8",borderRadius:8,padding:"8px 10px"}}>
<div style={{fontSize:10,color:"#888",marginBottom:2}}>{l}</div>
<div style={{fontSize:14,fontWeight:500}}>{v}</div>
</div>
))}
</div>
</td></tr>}
</>;
})}
{!ldg&&sorted.length===0&&<tr><td colSpan={11} style={{textAlign:"center",color:"#aaa",padding:"2rem"}}>No data — try adjusting the filters</td></tr>}
</tbody>
</table>
</div>
</Card>
</div>;
}
// ── TEAMS TAB ─────────────────────────────────────────────────────────────
function Teams({apiKey,...fp}){
const {preset,setPreset,cFrom,setCFrom,cTo,setCTo,teamSel,setTeamSel}=fp;
const [rows,setRows]=useState([]);
const [ldg,setLdg]=useState(false);
const [sel,setSel]=useState(null);
const load=useCallback(async()=>{
setLdg(true);
try{const r=getRange(preset,cFrom,cTo);const cr=await rQuery(Q.agents,{from:r.from,to:r.to},apiKey);setRows(cr);}
catch(e){console.error(e);}
setLdg(false);
},[apiKey,preset,cFrom,cTo]);
useEffect(()=>{load();},[load]);
const filtered=rows.filter(r=>teamSel.length===0||teamSel.includes(r.team));
const teamMap={};
filtered.forEach(r=>{
const t=r.team||"Unknown";
if(!teamMap[t]) teamMap[t]={name:t,agents:[],total:0,answered:0,booked:0,talkMins:0};
teamMap[t].agents.push(r.agent);
teamMap[t].total+=parseInt(r.total_calls)||0;
teamMap[t].answered+=parseInt(r.answered)||0;
teamMap[t].booked+=parseInt(r.booked)||0;
teamMap[t].talkMins+=parseFloat(r.total_talk_mins)||0;
});
const teams=Object.values(teamMap).map(t=>({...t,
activeBDRs:t.agents.filter(ag=>rows.find(r=>r.agent===ag&&parseInt(r.total_calls)>0)).length,
reach:safePct(t.answered,t.total).toFixed(1),
gross:t.total>0?(t.booked/t.total*100).toFixed(2):"0",
ac:t.answered>0?(t.booked/t.answered*100).toFixed(2):"0",
}));
const maxBook=Math.max(...teams.map(x=>x.booked),1);
const displayAgents=sel?filtered.filter(r=>r.team===sel):filtered;
return <div>
<FilterBar preset={preset} setPreset={setPreset} cFrom={cFrom} setCFrom={setCFrom} cTo={cTo} setCTo={setCTo} teamSel={teamSel} setTeamSel={setTeamSel}/>
{ldg&&<div style={{textAlign:"center",padding:"1rem",color:"#888",fontSize:12}}><Spin/> Loading team data…</div>}
<div style={{display:"grid",gridTemplateColumns:"repeat(auto-fit,minmax(200px,1fr))",gap:10,marginBottom:"1.25rem"}}>
{teams.map((t,i)=>{
const c=TEAM_COLORS[t.name]||Object.values(TEAM_COLORS)[i%5];
const fg=TEAM_TEXT[t.name]||Object.values(TEAM_TEXT)[i%5];
const active=sel===t.name;
return <div key={t.name} onClick={()=>setSel(active?null:t.name)} style={{background:c,border:active?`2px solid ${fg}`:`1px solid ${FP.border}`,borderRadius:14,padding:"1rem",cursor:"pointer"}}>
<div style={{fontSize:13,fontWeight:500,color:fg,marginBottom:8}}>{t.name}</div>
<div style={{display:"grid",gridTemplateColumns:"1fr 1fr",gap:6,marginBottom:8}}>
<div><div style={{fontSize:10,color:fg,opacity:.65}}>Calls</div><div style={{fontSize:16,fontWeight:500,color:fg}}>{fn(t.total)}</div><div style={{fontSize:10,color:fg,opacity:.6}}>{t.activeBDRs>0?Math.round(t.total/t.activeBDRs).toLocaleString():"—"} per BDR</div></div>
<div><div style={{fontSize:10,color:fg,opacity:.65}}>Bookings</div><div style={{fontSize:16,fontWeight:500,color:fg}}>{fn(t.booked)}</div><div style={{fontSize:10,color:fg,opacity:.6}}>{t.activeBDRs>0?Math.round(t.booked/t.activeBDRs).toLocaleString():"—"} per BDR</div></div>
<div><div style={{fontSize:10,color:fg,opacity:.65}}>Talk time</div><div style={{fontSize:13,fontWeight:500,color:fg}}>{fmtTalk(t.talkMins)}</div><div style={{fontSize:10,color:fg,opacity:.6}}>{fmtTalk(t.activeBDRs>0?t.talkMins/t.activeBDRs:0)} per BDR</div></div>
<div><div style={{fontSize:10,color:fg,opacity:.65}}>Active BDRs</div><div style={{fontSize:16,fontWeight:500,color:fg}}>{t.activeBDRs}</div></div>
</div>
<div style={{display:"grid",gridTemplateColumns:"1fr 1fr 1fr",gap:4,marginBottom:10}}>
{[["Reach",t.reach+"%"],["Gross",t.gross+"%"],["Ans.conv.",t.ac+"%"]].map(([l,v])=>(
<div key={l} style={{background:"rgba(0,0,0,0.07)",borderRadius:7,padding:"4px 6px",textAlign:"center"}}>
<div style={{fontSize:9,color:fg,opacity:.65}}>{l}</div>
<div style={{fontSize:12,fontWeight:500,color:fg}}>{v}</div>
</div>
))}
</div>
<div style={{height:5,background:"rgba(0,0,0,0.08)",borderRadius:3,overflow:"hidden"}}><div style={{height:"100%",width:(t.booked/maxBook*100)+"%",background:fg,borderRadius:3,opacity:.6}}/></div>
</div>;
})}
</div>
<Card title={sel?`${sel} — agents`:"All agents"} action={sel&&<PBtn onClick={()=>setSel(null)}>← All teams</PBtn>} noPad>
<div style={{overflowX:"auto"}}>
<table>
<thead><tr><th>Agent</th><th>Team</th><th className="nr">Calls</th><th className="nr">Talk time</th><th className="nr">Answered</th><th className="nr">Reach %</th><th className="nr">Bookings</th><th className="nr">Gross %</th><th className="nr">Ans. conv.</th></tr></thead>
<tbody>{displayAgents.map((r,i)=>{const tc=TEAM_COLORS[r.team]||FP.blue,tfg=TEAM_TEXT[r.team]||FP.blueDark;return(
<tr key={i}><td style={{fontWeight:500}}>{r.agent||"—"}</td>
<td><span style={{fontSize:11,fontWeight:500,padding:"2px 8px",borderRadius:6,background:tc,color:tfg}}>{r.team||"—"}</span></td>
<td className="nr">{fn(r.total_calls)}</td><td className="nr">{fmtTalk(parseFloat(r.total_talk_mins)||0)}</td>
<td className="nr">{fn(r.answered)}</td><td className="nr">{fp(r.reach_pct)}</td>
<td className="nr"><Bdg c="mint">{fn(r.booked)}</Bdg></td>
<td className="nr">{fp(r.gross_pct,2)}</td><td className="nr">{fp(r.answered_conv_pct,2)}</td>
</tr>
);})}
{!ldg&&displayAgents.length===0&&<tr><td colSpan={9} style={{textAlign:"center",color:"#aaa",padding:"2rem"}}>No data</td></tr>}
</tbody>
</table>
</div>
</Card>
</div>;
}
// ── CONTACT LISTS TAB ─────────────────────────────────────────────────────
function ContactLists({apiKey,...fp}){
const {preset,setPreset,cFrom,setCFrom,cTo,setCTo,teamSel,setTeamSel}=fp;
const [rows,setRows]=useState([]);
const [ldg,setLdg]=useState(false);
const [sort,setSort]=useState("calls");
const [expanded,setExpanded]=useState(null);
const load=useCallback(async()=>{
setLdg(true);
try{const cr=await rQuery(Q.contactLists,{},apiKey);setRows(cr);}
catch(e){console.error(e);}
setLdg(false);
},[apiKey]);
useEffect(()=>{load();},[load]);
const sortKey={calls:"call_attempts",contacts:"contacts_total"}[sort]||"call_attempts";
const sorted=[...rows].sort((a,b)=>(parseInt(b[sortKey])||0)-(parseInt(a[sortKey])||0));
return <div>
<FilterBar preset={preset} setPreset={setPreset} cFrom={cFrom} setCFrom={setCFrom} cTo={cTo} setCTo={setCTo} teamSel={teamSel} setTeamSel={setTeamSel}/>
{ldg&&<div style={{textAlign:"center",padding:"1rem",color:"#888",fontSize:12}}><Spin/> Loading contact lists…</div>}
<Card title={`${sorted.length} active contact lists`} action={<div style={{display:"flex",gap:4}}>{[["calls","Calls"],["contacts","Contacts"]].map(([s,l])=><PBtn key={s} active={sort===s} onClick={()=>setSort(s)}>{l}</PBtn>)}</div>} noPad>
<div style={{overflowX:"auto"}}>
<table>
<thead><tr><th>List name</th><th>Campaign</th><th className="nr">Contacts</th><th className="nr">Added</th><th className="nr">Remaining</th><th className="nr">Calls</th><th className="nr">% Called</th><th className="nr">Starts</th><th className="nr">Ends</th></tr></thead>
<tbody>{sorted.map((r,i)=>{
const isExp=expanded===i,pct=parseFloat(r.pct_called)||0;
return <>
<tr key={i} onClick={()=>setExpanded(isExp?null:i)} style={{cursor:"pointer",background:isExp?FP.blue+"22":"transparent"}}>
<td style={{fontWeight:500,maxWidth:200,overflow:"hidden",textOverflow:"ellipsis",whiteSpace:"nowrap"}}>{isExp?"▾ ":""}{r.list_name||"—"}</td>
<td style={{color:"#888"}}>{r.campaign||"—"}</td>
<td className="nr">{fn(r.contacts_total)}</td>
<td className="nr">{fn(r.contacts_added)}</td>
<td className="nr">{fn(r.contacts_remaining)}</td>
<td className="nr">{fn(r.call_attempts)}</td>
<td className="nr">
<div style={{display:"flex",alignItems:"center",gap:6,justifyContent:"flex-end"}}>
<div style={{width:50,height:5,background:"#f0f0f0",borderRadius:3,overflow:"hidden"}}><div style={{height:"100%",width:pct+"%",background:FP.blueDark,borderRadius:3}}/></div>
<span>{pct.toFixed(1)}%</span>
</div>
</td>
<td className="nr" style={{fontSize:11,color:"#888"}}>{r.calling_starts?new Date(r.calling_starts).toLocaleDateString():"—"}</td>
<td className="nr" style={{fontSize:11,color:"#888"}}>{r.calling_ends?new Date(r.calling_ends).toLocaleDateString():"—"}</td>
</tr>
{isExp&&<tr key={i+"_x"}><td colSpan={9} style={{padding:"0 12px 12px 36px",background:"#f8f8fc"}}>
<div style={{display:"grid",gridTemplateColumns:"repeat(3,1fr)",gap:8,marginTop:10}}>
{[["Contacts total",fn(r.contacts_total)],["Contacts remaining",fn(r.contacts_remaining)],["% called",pct.toFixed(1)+"%"],["Call attempts",fn(r.call_attempts)],["Expiry",r.expiry_date?new Date(r.expiry_date).toLocaleDateString():"—"],["Campaign",r.campaign||"—"]].map(([l,v])=>(
<div key={l} style={{background:"#f0f0f8",borderRadius:8,padding:"8px 10px"}}>
<div style={{fontSize:10,color:"#888",marginBottom:2}}>{l}</div>
<div style={{fontSize:13,fontWeight:500}}>{v}</div>
</div>
))}
</div>
</td></tr>}
</>;
})}
{!ldg&&sorted.length===0&&<tr><td colSpan={9} style={{textAlign:"center",color:"#aaa",padding:"2rem"}}>No active contact lists found</td></tr>}
</tbody>
</table>
</div>
</Card>
</div>;
}
// ── MAP VIEW ──────────────────────────────────────────────────────────────
function MapView({apiKey,...fp}){
const {preset,setPreset,cFrom,setCFrom,cTo,setCTo,teamSel,setTeamSel}=fp;
const svgRef=useRef(null);
const [rows,setRows]=useState([]);
const [metric,setMetric]=useState("calls");
const [ldg,setLdg]=useState(false);
const [hov,setHov]=useState(null);
const [hovXY,setHovXY]=useState({x:0,y:0});
const METRICS={calls:{label:"Calls",get:d=>d.answered_outbound_calls||0,fmt:fn,lo:"#e8f4fb",hi:"#1a4a6a"},};
useEffect(()=>{
setLdg(true);
rQuery(Q.map,{},apiKey).then(r=>{setRows(r);setLdg(false);}).catch(()=>setLdg(false));
},[apiKey]);
const CENTROIDS=[["EC1",-0.0979,51.5219],["EC2",-0.0892,51.5177],["WC1",-0.1207,51.5228],["WC2",-0.1244,51.5122],["N1",-0.0992,51.5376],["N4",-0.1090,51.5721],["N5",-0.1009,51.5653],["N7",-0.1224,51.5588],["N8",-0.1247,51.5886],["N15",-0.0743,51.5788],["N16",-0.0765,51.5619],["N22",-0.0983,51.5987],["NW1",-0.1422,51.5387],["NW2",-0.2158,51.5572],["NW3",-0.1757,51.5570],["NW5",-0.1472,51.5573],["NW6",-0.1937,51.5461],["NW10",-0.2640,51.5327],["E1",-0.0591,51.5159],["E2",-0.0623,51.5264],["E3",-0.0270,51.5266],["E5",-0.0562,51.5617],["E6",0.0451,51.5264],["E7",0.0249,51.5497],["E8",-0.0577,51.5424],["E10",-0.0143,51.5694],["E11",0.0228,51.5697],["E13",0.0291,51.5215],["E14",-0.0169,51.5031],["E15",0.0008,51.5434],["E16",0.0243,51.5072],["E17",-0.0215,51.5848],["W1",-0.1440,51.5143],["W2",-0.1820,51.5133],["W5",-0.2987,51.5097],["W6",-0.2275,51.4915],["W9",-0.1909,51.5226],["W10",-0.2150,51.5218],["W12",-0.2283,51.5026],["SW1",-0.1357,51.4975],["SW2",-0.1154,51.4567],["SW4",-0.1340,51.4566],["SW6",-0.1985,51.4733],["SW8",-0.1258,51.4750],["SW9",-0.1121,51.4652],["SW11",-0.1618,51.4637],["SW12",-0.1464,51.4456],["SW15",-0.2309,51.4590],["SW16",-0.1223,51.4231],["SW17",-0.1598,51.4235],["SW18",-0.1939,51.4473],["SW19",-0.2019,51.4226],["SE1",-0.0924,51.5011],["SE4",-0.0303,51.4562],["SE5",-0.0775,51.4690],["SE6",-0.0214,51.4398],["SE8",-0.0228,51.4796],["SE10",-0.0038,51.4821],["SE11",-0.1065,51.4902],["SE13",-0.0178,51.4608],["SE15",-0.0600,51.4726],["SE16",-0.0521,51.4975],["SE17",-0.0881,51.4839],["SE19",-0.0777,51.4127],["SE22",-0.0673,51.4538],["SE25",-0.0479,51.3966],["CR0",-0.0853,51.3726],["CR2",-0.0793,51.3471],["CR4",-0.1660,51.4041],["CR7",-0.0836,51.4001],["BR1",0.0227,51.4113],["BR2",0.0208,51.3803],["BR3",-0.0163,51.4023],["HA1",-0.3350,51.5797],["HA3",-0.3195,51.5850],["EN1",-0.0720,51.6578],["EN2",-0.0947,51.6698],["KT1",-0.3063,51.4119],["KT2",-0.2898,51.4201],["SM1",-0.2000,51.3678],["SM4",-0.1948,51.3958],["TW1",-0.3260,51.4467],["TW3",-0.3681,51.4680],["TW7",-0.3390,51.4804],["DA1",0.2155,51.4446],["DA6",0.1358,51.4423],["IG1",0.0920,51.5582],["IG11",0.1085,51.5332],["RM1",0.2056,51.5790],["RM7",0.1514,51.5666]];
// Build data map from Q4 rows + fallback to centroids
const areaData=useMemo(()=>{
const m={};
rows.forEach(r=>{if(r.london_postal_area) m[r.london_postal_area]={calls:parseInt(r.answered_outbound_calls)||0};});
// Q4 returns broad areas (E,EC,N,NW,SE,SW,W,WC) — distribute to districts proportionally using seed
const seed=c=>{let h=0;for(let x of c)h=(h*31+x.charCodeAt(0))&0xffff;return h/65535;};
CENTROIDS.forEach(([code])=>{
if(!m[code]){
const area=Object.keys(m).find(a=>code.startsWith(a))||null;
if(area){const s=seed(code);m[code]={calls:Math.round((m[area].calls||0)*s*0.15)};}
else m[code]={calls:0};
}
});
return m;
},[rows]);
useEffect(()=>{
if(!svgRef.current||Object.keys(areaData).length===0) return;
const W=580,H=500,svg=d3.select(svgRef.current);
svg.selectAll("*").remove();
const proj=d3.geoMercator().center([-0.09,51.505]).scale(36000).translate([W/2,H/2]);
const allVals=CENTROIDS.map(([c])=>(areaData[c]?.calls||0));
const maxV=Math.max(...allVals,1);
const colorSc=d3.scaleSequential().domain([0,maxV]).interpolator(d3.interpolate("#e8f4fb","#1a4a6a"));
const pts=CENTROIDS.map(([code,lon,lat])=>{const p=proj([lon,lat]);return{code,px:p[0],py:p[1],calls:areaData[code]?.calls||0};});
svg.append("rect").attr("width",W).attr("height",H).attr("fill","#f0f4f8").attr("rx",12);
const del=d3.Delaunay.from(pts,p=>p.px,p=>p.py);
const vor=del.voronoi([0,0,W,H]);
svg.selectAll(".vc").data(pts).join("path").attr("class","vc")
.attr("d",(_,i)=>vor.renderCell(i)).attr("fill",p=>colorSc(p.calls)).attr("stroke","#fff").attr("stroke-width",1.2).style("cursor","pointer")
.on("mouseenter",(ev,p)=>{const r=svgRef.current.getBoundingClientRect();setHov(p);setHovXY({x:ev.clientX-r.left,y:ev.clientY-r.top});d3.select(ev.currentTarget).attr("stroke","#1a1a1a").attr("stroke-width",2.5).raise();})
.on("mousemove",ev=>{const r=svgRef.current.getBoundingClientRect();setHovXY({x:ev.clientX-r.left,y:ev.clientY-r.top});})
.on("mouseleave",ev=>{setHov(null);d3.select(ev.currentTarget).attr("stroke","#fff").attr("stroke-width",1.2);});
svg.append("path").datum([[-0.50,51.49],[-0.30,51.48],[-0.13,51.499],[-0.07,51.503],[0.0,51.503],[0.18,51.498],[0.30,51.492]])
.attr("d",d3.line().x(d=>proj(d)[0]).y(d=>proj(d)[1]).curve(d3.curveCatmullRom))
.attr("fill","none").attr("stroke","rgba(180,210,240,0.8)").attr("stroke-width",6);
pts.filter(p=>p.calls>100).forEach(p=>{svg.append("text").attr("x",p.px).attr("y",p.py+3.5).attr("text-anchor","middle").attr("font-size",8).attr("font-weight","600").attr("fill","rgba(10,20,40,0.6)").attr("pointer-events","none").text(p.code);});
const grad=svg.append("defs").append("linearGradient").attr("id","lg");
grad.append("stop").attr("offset","0%").attr("stop-color","#e8f4fb");
grad.append("stop").attr("offset","100%").attr("stop-color","#1a4a6a");
svg.append("rect").attr("x",W-112).attr("y",H-22).attr("width",100).attr("height",8).attr("fill","url(#lg)").attr("rx",4);
svg.append("text").attr("x",W-112).attr("y",H-25).attr("font-size",8).attr("fill","rgba(0,0,0,0.45)").text("Low");
svg.append("text").attr("x",W-12).attr("y",H-25).attr("font-size",8).attr("fill","rgba(0,0,0,0.45)").attr("text-anchor","end").text("High");
},[areaData]);
const top8=[...CENTROIDS].map(([c])=>({code:c,calls:areaData[c]?.calls||0})).sort((a,b)=>b.calls-a.calls).slice(0,8);
const maxV2=Math.max(...top8.map(d=>d.calls),1);
return <div>
<FilterBar preset={preset} setPreset={setPreset} cFrom={cFrom} setCFrom={setCFrom} cTo={cTo} setCTo={setCTo} teamSel={teamSel} setTeamSel={setTeamSel}/>
<div style={{marginBottom:"1rem",fontSize:12,color:"#888"}}>Showing answered outbound calls by London postcode district (all-time data from Query 4). {ldg&&<Spin/>}</div>
<div style={{display:"grid",gridTemplateColumns:"3fr 2fr",gap:12}}>
<Card>
<div style={{position:"relative"}}>
<svg ref={svgRef} viewBox="0 0 580 500" style={{display:"block",width:"100%"}}/>
{hov&&<div style={{position:"absolute",left:Math.min(hovXY.x+12,370),top:Math.max(hovXY.y-60,4),background:FP.white,border:`1px solid ${FP.border}`,borderRadius:10,padding:"10px 14px",fontSize:12,pointerEvents:"none",zIndex:10,minWidth:140,boxShadow:"0 4px 12px rgba(0,0,0,0.08)"}}>
<div style={{fontWeight:600,fontSize:14,marginBottom:6}}>{hov.code}</div>
<div style={{color:"#999",fontSize:10}}>Answered calls</div>
<div style={{fontWeight:500,fontSize:16}}>{fn(hov.calls)}</div>
</div>}
</div>
</Card>
<Card title="Top 8 districts">
{top8.map((d,i)=>{
const w=Math.round(d.calls/maxV2*100);
const col=d3.scaleSequential().domain([0,maxV2]).interpolator(d3.interpolate("#e8f4fb","#1a4a6a"))(d.calls);
return <div key={d.code} style={{marginBottom:10}}>
<div style={{display:"flex",justifyContent:"space-between",marginBottom:3,fontSize:12}}>
<span style={{fontWeight:500}}>{d.code}</span><span style={{color:"#888"}}>{fn(d.calls)}</span>
</div>
<div style={{height:7,background:"#f0f0f0",borderRadius:4,overflow:"hidden"}}><div style={{height:"100%",width:w+"%",background:col,borderRadius:4}}/></div>
</div>;
})}
<div style={{marginTop:12,paddingTop:10,borderTop:`1px solid ${FP.border}`,fontSize:11,color:"#aaa",textAlign:"center"}}>{CENTROIDS.length} postcode districts · hover for details</div>
</Card>
</div>
</div>;
}
// ── FLATPAY LOGO ──────────────────────────────────────────────────────────
function Logo(){return <svg width={110} height={24} viewBox="0 0 110 24" fill="none">
<rect x="0" y="7" width="3.5" height="17" rx="1" fill="#fff" opacity=".9"/>
<rect x="6" y="3" width="3.5" height="21" rx="1" fill="#fff"/>
<rect x="12" y="10" width="3.5" height="14" rx="1" fill="#fff" opacity=".9"/>
<text x="21" y="18" fontFamily="-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif" fontSize="14" fontWeight="500" fill="#fff" letterSpacing="-0.2">Flatpay</text>
</svg>;}
// ── LOGIN ─────────────────────────────────────────────────────────────────
function Login({onLogin}){
const [key,setKey]=useState("");
const [err,setErr]=useState("");
const [ldg,setLdg]=useState(false);
async function doLogin(){
if(!key){setErr("Please enter your Redash API key.");return;}
setLdg(true);setErr("");
try{
// Validate key by fetching agent groups
const r=await rQuery(Q.agentGroups,{},key);
onLogin(key);
}catch(e){setErr("Could not connect: "+e.message);setLdg(false);}
}
return <div style={{display:"flex",alignItems:"center",justifyContent:"center",minHeight:"100vh",padding:"1rem"}}>
<div style={{background:FP.white,border:`1px solid ${FP.border}`,borderRadius:16,padding:"2rem",width:"100%",maxWidth:400}}>
<div style={{marginBottom:"1.5rem"}}>
<svg width={110} height={24} viewBox="0 0 110 24" fill="none" style={{marginBottom:12}}>
<rect x="0" y="7" width="3.5" height="17" rx="1" fill={FP.black} opacity=".9"/>
<rect x="6" y="3" width="3.5" height="21" rx="1" fill={FP.black}/>
<rect x="12" y="10" width="3.5" height="14" rx="1" fill={FP.black} opacity=".9"/>
<text x="21" y="18" fontFamily="-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif" fontSize="14" fontWeight="500" fill={FP.black} letterSpacing="-0.2">Flatpay</text>
</svg>
<div style={{fontSize:18,fontWeight:500,marginBottom:4}}>Call Dashboard</div>
<div style={{fontSize:13,color:"#888"}}>Enter your Redash API key to connect</div>
</div>
<div style={{marginBottom:12}}>
<label style={{display:"block",fontSize:12,color:"#666",marginBottom:4}}>Redash API key</label>
<input type="password" value={key} onChange={e=>setKey(e.target.value)} placeholder="Your API key" style={{width:"100%"}} onKeyDown={e=>e.key==="Enter"&&doLogin()}/>
</div>
{err&&<div style={{fontSize:12,color:"#991b1b",marginBottom:8}}>{err}</div>}
<button onClick={doLogin} disabled={ldg} style={{width:"100%",padding:"9px",background:FP.black,color:FP.white,border:"none",borderRadius:10,fontWeight:500,cursor:ldg?"not-allowed":"pointer",fontSize:13}}>
{ldg?<><Spin/> Connecting…</>:"Connect"}
</button>
<p style={{fontSize:11,color:"#bbb",marginTop:"1rem",textAlign:"center"}}>Your key is used only to query Redash and is never stored.</p>
</div>
</div>;
}
// ── APP ───────────────────────────────────────────────────────────────────
const TABS=[["overview","Overview"],["agents","Agents"],["teams","Teams"],["lists","Contact lists"],["map","Map view"]];
function App(){
const [apiKey,setApiKey]=useState(null);
const [tab,setTab]=useState("overview");
const [preset,setPreset]=useState("week");
const [cFrom,setCFrom]=useState("");
const [cTo,setCTo]=useState("");
const [teamSel,setTeamSel]=useState([...ALL_TEAMS]);
if(!apiKey) return <Login onLogin={k=>{setApiKey(k);}}/>;
const shared={preset,setPreset,cFrom,setCFrom,cTo,setCTo,teamSel,setTeamSel};
const tabContent={
overview:<Overview apiKey={apiKey} {...shared}/>,
agents:<Agents apiKey={apiKey} {...shared}/>,
teams:<Teams apiKey={apiKey} {...shared}/>,
lists:<ContactLists apiKey={apiKey} {...shared}/>,
map:<MapView apiKey={apiKey} {...shared}/>,
};
return <div style={{display:"flex",minHeight:"100vh"}}>
<div style={{width:185,background:FP.sideBg,padding:"1.25rem 1rem",display:"flex",flexDirection:"column",flexShrink:0,position:"fixed",top:0,bottom:0,overflowY:"auto"}}>
<div style={{marginBottom:"2rem",padding:"0 0.25rem"}}><Logo/></div>
{TABS.map(([t,l])=><button key={t} onClick={()=>setTab(t)} style={{display:"flex",alignItems:"center",gap:8,width:"100%",textAlign:"left",padding:"8px 12px",border:"none",borderRadius:8,background:tab===t?FP.sideActive:"none",fontSize:13,color:tab===t?FP.white:FP.sideText,cursor:"pointer",marginBottom:2,fontWeight:tab===t?500:400,opacity:tab===t?1:0.75}}>{l}</button>)}
<div style={{marginTop:"auto"}}>
<button onClick={()=>setApiKey(null)} style={{fontSize:11,color:"#555",border:"none",background:"none",cursor:"pointer",padding:"4px 0",textAlign:"left"}}>Disconnect</button>
</div>
</div>
<div style={{flex:1,marginLeft:185,display:"flex",flexDirection:"column",minWidth:0}}>
<div style={{padding:"0.875rem 1.5rem",borderBottom:`1px solid ${FP.border}`,background:FP.white,display:"flex",alignItems:"center",justifyContent:"space-between",gap:12,position:"sticky",top:0,zIndex:10}}>
<div style={{fontSize:15,fontWeight:500}}>{TABS.find(([t])=>t===tab)?.[1]}</div>
<div style={{fontSize:11,color:"#aaa"}}>{getPeriodLabel(preset)}</div>
</div>
<div style={{flex:1,padding:"1.25rem",background:FP.pageBg}}>{tabContent[tab]}</div>
</div>
</div>;
}
ReactDOM.createRoot(document.getElementById("root")).render(<App/>);
</script>
</body>
</html>