<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Clarity QBO Portal</title>
<script crossorigin src="https://cdnjs.cloudflare.com/ajax/libs/react/18.2.0/umd/react.production.min.js"></script>
<script crossorigin src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/18.2.0/umd/react-dom.production.min.js"></script>
<script crossorigin src="https://cdnjs.cloudflare.com/ajax/libs/babel-standalone/7.23.5/babel.min.js"></script>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: 'Inter', 'Segoe UI', sans-serif; background: #F7F9FB; }
@keyframes spin { to { transform: rotate(360deg); } }
select, input, button, a { font-family: inherit; }
/* QBO callback handler — shown briefly when popup redirects back */
#callback-screen {
display: none; position: fixed; inset: 0; background: #1B2A3B;
align-items: center; justify-content: center; flex-direction: column;
color: white; font-size: 16px; gap: 12px; z-index: 9999;
}
#callback-screen.active { display: flex; }
</style>
</head>
<body>
<!-- OAuth callback handler — this div shows briefly when QBO redirects back -->
<div id="callback-screen">
<div style="font-size:28px">✅</div>
<div style="font-weight:700">QuickBooks Connected</div>
<div style="font-size:13px;opacity:.6">Closing window…</div>
</div>
<div id="root"></div>
<script>
// ── OAuth Callback Detection ──────────────────────────────────────────────────
// If this page is loaded as the OAuth redirect target (i.e. URL contains ?code=)
// show the callback screen briefly — the parent portal window is polling for this.
(function() {
const params = new URLSearchParams(window.location.search);
if (params.has('code') || params.has('error')) {
document.getElementById('callback-screen').classList.add('active');
document.getElementById('root').style.display = 'none';
// Parent window is polling popup.location.href — just stay here so it can read the URL
// It will close this popup automatically once it reads the code
}
})();
</script>
<script type="text/babel">
const { useState, useCallback, useRef } = React;
// ═══════════════════════════════════════════════════════════════════════════════
// CONFIGURATION
// ═══════════════════════════════════════════════════════════════════════════════
const CONFIG = {
CLIENT_ID: "ABRF8oL45prebalM8i8Dm3YGW3eOymjP82kVZzhCiH1GNJuv3j",
PROXY_URL: "https://clarity-qbo-proxy.clarityaccounting.workers.dev",
REDIRECT_URI: window.location.origin + "/qbo-callback",
QBO_BASE: "https://quickbooks.api.intuit.com",
SCOPE: "com.intuit.quickbooks.accounting",
AUTH_URL: "https://appcenter.intuit.com/connect/oauth2",
SENTRY_DSN: "https://7e1177b2f7614902966bfcca638effaf@o4511612197273600.ingest.us.sentry.io/4511612212740096",
SUPPORT: {
name: "Clarity Business Books & Accounting",
email: "info@claritysmallbusinessaccounting.com",
phone: "818-306-2201",
hours: "Mon–Fri, 9 AM – 5 PM PT",
website: "https://claritysmallbusinessaccounting.com",
},
};
// ═══════════════════════════════════════════════════════════════════════════════
// DESIGN TOKENS
// ═══════════════════════════════════════════════════════════════════════════════
const T = {
navy:"#1B2A3B", navyMid:"#243447", navyDk:"#111D28",
green:"#2E7D5B", greenLt:"#3DA373", greenPl:"#EAF4EF",
slate:"#4A5568", mist:"#F7F9FB", border:"#D8E2EC",
text:"#1B2A3B", sub:"#637082", white:"#FFFFFF",
error:"#C0392B", errorBg:"#FDECEA",
warn:"#E67E22", warnBg:"#FEF3E2",
gold:"#B7950B", goldBg:"#FEFCE8",
};
// ═══════════════════════════════════════════════════════════════════════════════
// SENTRY
// ═══════════════════════════════════════════════════════════════════════════════
const Sentry = (() => {
function parseDsn(dsn) {
try {
const u = new URL(dsn);
return `https://${u.hostname}/api/${u.pathname.replace("/","")}/envelope/?sentry_key=${u.username}&sentry_version=7`;
} catch { return null; }
}
function send(level, message, extras = {}) {
const endpoint = parseDsn(CONFIG.SENTRY_DSN);
if (!endpoint) { console[level==="error"?"error":"log"]("[Clarity QBO]", message, extras); return; }
const eid = crypto.randomUUID().replace(/-/g,"");
const ts = new Date().toISOString();
const envelope = [
JSON.stringify({ event_id: eid, sent_at: ts }),
JSON.stringify({ type: "event" }),
JSON.stringify({ event_id:eid, timestamp:ts, level, message, tags:{app:"clarity-qbo-portal"}, extra:extras, request:{url:window.location.href} }),
].join("\n");
fetch(endpoint, { method:"POST", body:envelope }).catch(()=>{});
}
return { error:(m,e)=>send("error",m,e), warning:(m,e)=>send("warning",m,e), info:(m,e)=>send("info",m,e) };
})();
// ═══════════════════════════════════════════════════════════════════════════════
// OAUTH
// ═══════════════════════════════════════════════════════════════════════════════
const _csrfNonces = new Map();
function buildAuthUrl(clientId) {
const nonce = crypto.randomUUID();
const state = `${clientId}.${nonce}`;
_csrfNonces.set(nonce, clientId);
const params = new URLSearchParams({
client_id: CONFIG.CLIENT_ID,
redirect_uri: CONFIG.REDIRECT_URI,
response_type: "code",
scope: CONFIG.SCOPE,
state,
});
return `${CONFIG.AUTH_URL}?${params}`;
}
function openOAuthPopup(clientId) {
return new Promise((resolve, reject) => {
const url = buildAuthUrl(clientId);
const popup = window.open(url, "qbo_oauth", "width=620,height=720,left=200,top=100");
if (!popup) {
Sentry.error("OAuth popup blocked", { clientId });
reject(new Error("Popup blocked — allow popups for localhost:3000 in Chrome settings."));
return;
}
const timer = setInterval(() => {
try {
if (popup.closed) { clearInterval(timer); reject(new Error("Auth window closed before completing.")); return; }
const loc = popup.location.href;
if (loc.includes(window.location.origin) || loc.includes("localhost:3000")) {
clearInterval(timer);
popup.close();
const u = new URL(loc);
const code = u.searchParams.get("code");
const realm = u.searchParams.get("realmId");
const state = u.searchParams.get("state");
const err = u.searchParams.get("error");
if (err) { Sentry.error("OAuth error", { err, clientId }); reject(new Error(err)); return; }
if (!code){ Sentry.error("No auth code", { clientId }); reject(new Error("No auth code returned.")); return; }
const [, returnedNonce] = (state||"").split(".");
if (!returnedNonce || !_csrfNonces.has(returnedNonce)) {
Sentry.error("CSRF validation failed", { state, clientId });
reject(new Error("Security error: invalid state parameter. Please try again.")); return;
}
_csrfNonces.delete(returnedNonce);
Sentry.info("OAuth success", { clientId, hasRealm: !!realm });
resolve({ code, realmId: realm, clientId });
}
} catch (_) { /* cross-origin — keep polling */ }
}, 300);
setTimeout(() => {
clearInterval(timer);
if (!popup.closed) popup.close();
Sentry.error("OAuth timed out", { clientId });
reject(new Error("Auth timed out."));
}, 300_000);
});
}
async function exchangeCode(code) {
const res = await fetch(`${CONFIG.PROXY_URL}/token`, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ code, redirect_uri: CONFIG.REDIRECT_URI }),
});
const data = await res.json();
if (!res.ok) {
const msg = data.error || "Token exchange failed";
Sentry.error("Token exchange failed", { status: res.status, error: msg });
throw new Error(msg);
}
Sentry.info("Token exchange succeeded");
return data;
}
async function refreshAccessToken(token) {
const res = await fetch(`${CONFIG.PROXY_URL}/refresh`, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ refresh_token: token }),
});
const data = await res.json();
if (!res.ok) {
Sentry.error("Token refresh failed", { status: res.status });
throw new Error(data.error || "Token refresh failed");
}
Sentry.info("Token refresh succeeded");
return data;
}
// ═══════════════════════════════════════════════════════════════════════════════
// QBO API
// ═══════════════════════════════════════════════════════════════════════════════
async function qboReport(accessToken, realmId, reportName, params = {}) {
// Route through Worker proxy to avoid CORS restrictions on direct QBO API calls
const res = await fetch(`${CONFIG.PROXY_URL}/report`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ access_token: accessToken, realm_id: realmId, report_name: reportName, params }),
});
const data = await res.json();
const tid = data._intuit_tid || null;
if (tid) console.log(`[QBO] intuit_tid: ${tid} — ${reportName}`);
if (res.status === 401 || data?.Fault?.Error?.[0]?.code === "3200") {
Sentry.error("QBO 401 — token expired", { reportName, intuit_tid: tid });
throw Object.assign(new Error("TOKEN_EXPIRED"), { intuit_tid: tid });
}
if (!res.ok) {
const msg = data?.Fault?.Error?.[0]?.Message || `QBO error ${res.status}`;
Sentry.error("QBO API error", { reportName, status: res.status, message: msg, intuit_tid: tid });
throw Object.assign(new Error(`${msg}${tid ? ` (intuit_tid: ${tid})` : ""}`), { intuit_tid: tid });
}
Sentry.info("QBO report fetched", { reportName, intuit_tid: tid });
return data;
}
// ═══════════════════════════════════════════════════════════════════════════════
// REPORT DEFINITIONS
// ═══════════════════════════════════════════════════════════════════════════════
const REPORTS = [
{ value:"ProfitAndLoss", label:"Profit & Loss", params:(f,t)=>({start_date:f,end_date:t}) },
{ value:"BalanceSheet", label:"Balance Sheet", params:(_,t)=>({end_date:t}) },
{ value:"AgedReceivables", label:"A/R Aging Summary", params:()=>({}) },
{ value:"AgedPayables", label:"A/P Aging Summary", params:()=>({}) },
{ value:"CashFlow", label:"Statement of Cash Flows", params:(f,t)=>({start_date:f,end_date:t}) },
{ value:"GeneralLedger", label:"General Ledger", params:(f,t)=>({start_date:f,end_date:t}) },
{ value:"TransactionList", label:"Transaction List", params:(f,t)=>({start_date:f,end_date:t}) },
];
// ═══════════════════════════════════════════════════════════════════════════════
// UI COMPONENTS
// ═══════════════════════════════════════════════════════════════════════════════
function Badge({ status }) {
const map = {
connected: { bg:T.greenPl, text:T.green, dot:T.green, label:"Connected" },
pending: { bg:T.warnBg, text:T.warn, dot:T.warn, label:"Connecting…" },
disconnected: { bg:T.errorBg, text:T.error, dot:T.error, label:"Not Connected" },
expired: { bg:T.goldBg, text:T.gold, dot:T.gold, label:"Token Expired" },
};
const s = map[status] || map.disconnected;
return (
<span style={{display:"inline-flex",alignItems:"center",gap:5,background:s.bg,color:s.text,borderRadius:20,padding:"2px 10px",fontSize:12,fontWeight:600}}>
<span style={{width:6,height:6,borderRadius:"50%",background:s.dot,display:"inline-block"}}/>
{s.label}
</span>
);
}
function Btn({ onClick, variant="primary", children, small, disabled, fullWidth }) {
const vs = {
primary: {background:T.green, color:T.white, border:"none"},
secondary: {background:T.mist, color:T.navy, border:`1px solid ${T.border}`},
danger: {background:T.errorBg, color:T.error, border:"1px solid #f5c6c2"},
};
return (
<button onClick={disabled?undefined:onClick} style={{
...vs[variant]||vs.primary, borderRadius:7, cursor:disabled?"not-allowed":"pointer",
fontWeight:600, fontSize:small?12:14, padding:small?"5px 12px":"9px 18px",
opacity:disabled?.5:1, width:fullWidth?"100%":undefined,
display:"inline-flex", alignItems:"center", justifyContent:"center", gap:6,
}}>{children}</button>
);
}
function Card({ children, style }) {
return <div style={{background:T.white,border:`1px solid ${T.border}`,borderRadius:12,padding:20,...style}}>{children}</div>;
}
function Spinner() {
return <span style={{display:"inline-block",width:14,height:14,border:"2px solid rgba(255,255,255,.3)",borderTop:"2px solid white",borderRadius:"50%",animation:"spin .7s linear infinite"}}/>;
}
function ReportTable({ report }) {
if (!report?.Rows?.Row?.length) return <div style={{padding:"24px 0",textAlign:"center",color:T.sub,fontSize:14}}>No data returned for this period.</div>;
const renderRow = (row, depth=0) => {
if (!row) return null;
const pad = 16 + depth * 16;
if (row.type === "Section") return (
<div key={Math.random()}>
{row.Header?.ColData && (
<div style={{display:"flex",justifyContent:"space-between",padding:`9px 16px 9px ${pad}px`,background:depth===0?T.navyMid:"#2D4A63",color:T.white,fontSize:12,fontWeight:700,textTransform:"uppercase",letterSpacing:".7px"}}>
<span>{row.Header.ColData[0]?.value}</span>
{row.Header.ColData[1]?.value && <span>{row.Header.ColData[1].value}</span>}
</div>
)}
{row.Rows?.Row?.map((r,i) => <div key={i}>{renderRow(r, depth+1)}</div>)}
{row.Summary?.ColData && (
<div style={{display:"flex",justifyContent:"space-between",padding:`9px 16px 9px ${pad}px`,background:depth===0?T.navy:"#1E3850",color:T.white,fontWeight:700,fontSize:13,borderTop:"1px solid rgba(255,255,255,.1)"}}>
<span>{row.Summary.ColData[0]?.value}</span>
<span>{row.Summary.ColData[1]?.value||"—"}</span>
</div>
)}
</div>
);
if (row.type === "Data") {
const cols = row.ColData||[];
return (
<div style={{display:"flex",justifyContent:"space-between",padding:`8px 16px 8px ${pad}px`,borderBottom:`1px solid ${T.border}`,fontSize:13}}>
<span style={{color:T.text}}>{cols[0]?.value||"—"}</span>
<span style={{color:T.navy,fontWeight:600}}>{cols[1]?.value||cols[cols.length-1]?.value||"—"}</span>
</div>
);
}
return null;
};
return (
<div style={{background:T.mist,border:`1px solid ${T.border}`,borderRadius:10,overflow:"hidden",marginTop:16}}>
{report.Rows.Row.map((r,i) => <div key={i}>{renderRow(r)}</div>)}
</div>
);
}
function SupportPanel({ onClose }) {
const s = CONFIG.SUPPORT;
const Row = ({ icon, label, value, href, bg }) => (
<a href={href} target="_blank" rel="noreferrer" style={{display:"flex",alignItems:"center",gap:14,padding:"13px 16px",background:bg||T.mist,borderRadius:9,textDecoration:"none",border:`1px solid ${T.border}`,marginBottom:10}}>
<div style={{width:36,height:36,borderRadius:8,background:T.navy,display:"flex",alignItems:"center",justifyContent:"center",flexShrink:0,fontSize:18}}>{icon}</div>
<div>
<div style={{fontSize:11,fontWeight:700,color:T.sub,textTransform:"uppercase",letterSpacing:".5px",marginBottom:2}}>{label}</div>
<div style={{fontSize:14,fontWeight:600,color:T.navy}}>{value}</div>
</div>
</a>
);
return (
<>
<div onClick={onClose} style={{position:"fixed",inset:0,background:"rgba(17,29,40,.55)",zIndex:200,backdropFilter:"blur(2px)"}}/>
<div style={{position:"fixed",top:"50%",left:"50%",transform:"translate(-50%,-50%)",zIndex:201,width:420,maxWidth:"calc(100vw - 32px)"}}>
<Card style={{padding:0,overflow:"hidden",boxShadow:"0 12px 40px rgba(0,0,0,.22)"}}>
<div style={{background:T.navy,padding:"18px 22px",display:"flex",justifyContent:"space-between",alignItems:"center"}}>
<div>
<div style={{color:T.white,fontWeight:700,fontSize:15}}>Support</div>
<div style={{color:"#5B7A94",fontSize:12,marginTop:2}}>{s.name}</div>
</div>
<button onClick={onClose} style={{background:"none",border:"none",color:"#5B7A94",fontSize:20,cursor:"pointer"}}>✕</button>
</div>
<div style={{padding:"22px 22px 24px"}}>
<div style={{fontSize:13,color:T.sub,marginBottom:16,lineHeight:1.7}}>Having trouble? Reach out and we'll help you get sorted.</div>
<Row icon="✉️" label="Email Support" value={s.email} href={`mailto:${s.email}?subject=Clarity QBO Portal Support`} bg={T.greenPl}/>
<Row icon="📞" label="Phone" value={`${s.phone} · ${s.hours}`} href={`tel:${s.phone.replace(/[^0-9]/g,"")}`}/>
<Row icon="🌐" label="Website" value={s.website.replace("https://", "")} href={s.website}/>
<div style={{marginTop:14,padding:"10px 14px",background:"#F0F4F8",borderRadius:8,fontSize:12,color:T.sub,lineHeight:1.6}}>
<strong style={{color:T.slate}}>Built on QuickBooks API.</strong> Not affiliated with Intuit. For QBO product support visit <a href="https://quickbooks.intuit.com/learn-support/" target="_blank" rel="noreferrer" style={{color:T.green}}>quickbooks.intuit.com/learn-support</a>.
</div>
</div>
</Card>
</div>
</>
);
}
// ═══════════════════════════════════════════════════════════════════════════════
// MAIN APP
// ═══════════════════════════════════════════════════════════════════════════════
function App() {
const [clients, setClients] = useState([]);
const [activeId, setActiveId] = useState(null);
const [view, setView] = useState("dashboard");
const [newName, setNewName] = useState("");
const [newRealm, setNewRealm] = useState("");
const [report, setReport] = useState(null);
const [reportType, setReportType] = useState("ProfitAndLoss");
const [dateFrom, setDateFrom] = useState("2026-01-01");
const [dateTo, setDateTo] = useState(new Date().toISOString().slice(0,10));
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [showSupport, setShowSupport] = useState(false);
const refreshTimers = useRef({});
const activeClient = clients.find(c => c.id === activeId);
const scheduleRefresh = useCallback((clientId, refreshToken, expiresIn) => {
if (refreshTimers.current[clientId]) clearTimeout(refreshTimers.current[clientId]);
const delay = Math.max((expiresIn - 300) * 1000, 10_000);
refreshTimers.current[clientId] = setTimeout(async () => {
try {
const tokens = await refreshAccessToken(refreshToken);
setClients(prev => prev.map(c => c.id===clientId ? {
...c, accessToken:tokens.access_token, refreshToken:tokens.refresh_token||c.refreshToken,
tokenExpiry:Date.now()+tokens.expires_in*1000, status:"connected",
} : c));
scheduleRefresh(clientId, tokens.refresh_token||refreshToken, tokens.expires_in);
} catch { setClients(prev => prev.map(c => c.id===clientId ? {...c,status:"expired"} : c)); }
}, delay);
}, []);
const connectClient = useCallback(async (clientId) => {
setError("");
setClients(prev => prev.map(c => c.id===clientId ? {...c,status:"pending"} : c));
try {
const { code, realmId } = await openOAuthPopup(clientId);
const tokens = await exchangeCode(code);
setClients(prev => prev.map(c => c.id===clientId ? {
...c, realmId:realmId||c.realmId, accessToken:tokens.access_token,
refreshToken:tokens.refresh_token, tokenExpiry:Date.now()+tokens.expires_in*1000, status:"connected",
} : c));
scheduleRefresh(clientId, tokens.refresh_token, tokens.expires_in);
} catch(err) {
setClients(prev => prev.map(c => c.id===clientId ? {...c,status:"disconnected"} : c));
setError(err.message);
}
}, [scheduleRefresh]);
const disconnectClient = (clientId) => {
if (refreshTimers.current[clientId]) clearTimeout(refreshTimers.current[clientId]);
setClients(prev => prev.map(c => c.id===clientId ? {...c,accessToken:null,refreshToken:null,status:"disconnected"} : c));
if (activeId===clientId) setReport(null);
};
const removeClient = (clientId) => {
if (refreshTimers.current[clientId]) clearTimeout(refreshTimers.current[clientId]);
setClients(prev => prev.filter(c => c.id!==clientId));
if (activeId===clientId) { setActiveId(null); setReport(null); }
};
const addClient = () => {
if (!newName.trim()) return;
const id = `c_${Date.now()}`;
setClients(prev => [...prev, { id, name:newName.trim(), realmId:newRealm.trim(), status:"disconnected", accessToken:null, refreshToken:null }]);
setNewName(""); setNewRealm(""); setActiveId(id); setView("dashboard");
};
const runReport = async () => {
if (!activeClient?.accessToken) return;
setLoading(true); setError(""); setReport(null);
const def = REPORTS.find(r => r.value===reportType);
try {
const data = await qboReport(activeClient.accessToken, activeClient.realmId, reportType, def?.params(dateFrom,dateTo));
setReport(data); setView("report");
} catch(err) {
if (err.message==="TOKEN_EXPIRED") {
setClients(prev => prev.map(c => c.id===activeId ? {...c,status:"expired"} : c));
setError("Access token expired. Click Reconnect.");
} else setError(err.message);
} finally { setLoading(false); }
};
const label = (text) => (
<label style={{display:"block",fontSize:11,fontWeight:700,color:T.slate,marginBottom:4,textTransform:"uppercase",letterSpacing:".6px"}}>{text}</label>
);
return (
<div style={{minHeight:"100vh",background:T.mist,color:T.text}}>
{/* Top bar */}
<div style={{background:T.navyDk,padding:"0 24px",display:"flex",alignItems:"center",justifyContent:"space-between",height:54,borderBottom:"1px solid rgba(255,255,255,.06)"}}>
<div style={{display:"flex",alignItems:"center",gap:10}}>
<div style={{width:28,height:28,borderRadius:7,background:T.green,display:"flex",alignItems:"center",justifyContent:"center"}}>
<svg width="15" height="15" viewBox="0 0 16 16" fill="none">
<rect x="2" y="2" width="5" height="5" rx="1.2" fill="white" opacity=".9"/>
<rect x="9" y="2" width="5" height="5" rx="1.2" fill="white" opacity=".5"/>
<rect x="2" y="9" width="5" height="5" rx="1.2" fill="white" opacity=".5"/>
<rect x="9" y="9" width="5" height="5" rx="1.2" fill="white" opacity=".9"/>
</svg>
</div>
<span style={{color:T.white,fontWeight:700,fontSize:14}}>Clarity QBO Portal</span>
<span style={{color:"#4A6580",fontSize:13}}>· Accountant Client Manager</span>
</div>
<div style={{display:"flex",gap:8,alignItems:"center"}}>
<span style={{fontSize:12,color:"#4A6580"}}>{clients.filter(c=>c.status==="connected").length} connected</span>
<button onClick={()=>setShowSupport(true)} title="Support" style={{width:28,height:28,borderRadius:"50%",background:"rgba(255,255,255,.08)",border:"1px solid rgba(255,255,255,.12)",color:"#8AA8C0",fontSize:14,fontWeight:700,cursor:"pointer",display:"flex",alignItems:"center",justifyContent:"center"}}>?</button>
<Btn small onClick={()=>setView("addClient")}>+ Add Client</Btn>
</div>
</div>
{/* Body */}
<div style={{display:"flex",maxWidth:1080,margin:"0 auto",padding:"20px 16px",gap:18,alignItems:"flex-start"}}>
{/* Sidebar */}
<div style={{width:220,flexShrink:0}}>
<div style={{fontSize:10,fontWeight:700,color:T.sub,textTransform:"uppercase",letterSpacing:".8px",marginBottom:8,paddingLeft:4}}>
Clients ({clients.length})
</div>
{clients.length===0 && <div style={{fontSize:13,color:T.sub,padding:"12px 8px",lineHeight:1.6}}>No clients yet.<br/>Add one to get started.</div>}
<div style={{display:"flex",flexDirection:"column",gap:6}}>
{clients.map(c => (
<div key={c.id} onClick={()=>{setActiveId(c.id);setView("dashboard");setReport(null);setError("");}}
style={{padding:"11px 13px",borderRadius:9,cursor:"pointer",
background:activeId===c.id?T.white:"transparent",
border:activeId===c.id?`1.5px solid ${T.green}`:"1px solid transparent",
boxShadow:activeId===c.id?"0 1px 6px rgba(0,0,0,.07)":"none"}}>
<div style={{fontWeight:600,fontSize:13,color:T.navy,marginBottom:5,whiteSpace:"nowrap",overflow:"hidden",textOverflow:"ellipsis"}}>{c.name}</div>
<Badge status={c.status}/>
</div>
))}
</div>
<div style={{marginTop:12}}>
<Btn small variant="secondary" onClick={()=>setView("addClient")} fullWidth>+ Add Client</Btn>
</div>
</div>
{/* Main */}
<div style={{flex:1,minWidth:0}}>
{/* Error */}
{error && (
<div style={{background:T.errorBg,border:`1px solid ${T.error}`,borderRadius:8,padding:"11px 14px",fontSize:13,color:T.error,marginBottom:14,display:"flex",justifyContent:"space-between",alignItems:"center"}}>
<span>⚠ {error}</span>
<span onClick={()=>setError("")} style={{cursor:"pointer",fontWeight:700,marginLeft:12}}>✕</span>
</div>
)}
{/* Add client */}
{view==="addClient" && (
<Card>
<div style={{fontSize:16,fontWeight:700,color:T.navy,marginBottom:4}}>Add Client Company</div>
<div style={{fontSize:13,color:T.sub,marginBottom:18}}>After adding, click Connect to QBO to authorize via QuickBooks login.</div>
<div style={{marginBottom:12}}>
{label("Client / Company Name")}
<input type="text" value={newName} onChange={e=>setNewName(e.target.value)} placeholder="e.g. Acme Roofing LLC"
style={{width:"100%",padding:"9px 12px",border:`1px solid ${T.border}`,borderRadius:7,fontSize:14}}/>
</div>
<div style={{marginBottom:18}}>
{label("QBO Realm ID (optional)")}
<input type="text" value={newRealm} onChange={e=>setNewRealm(e.target.value)} placeholder="Auto-filled after OAuth"
style={{width:"100%",padding:"9px 12px",border:`1px solid ${T.border}`,borderRadius:7,fontSize:14}}/>
<div style={{fontSize:11,color:T.sub,marginTop:4}}>Found in QBO URL after ?companyId= — leave blank to auto-fill.</div>
</div>
<div style={{display:"flex",gap:10}}>
<Btn onClick={addClient} disabled={!newName.trim()}>Add Client</Btn>
<Btn variant="secondary" onClick={()=>setView("dashboard")}>Cancel</Btn>
</div>
</Card>
)}
{/* Client dashboard */}
{(view==="dashboard"||view==="report") && activeClient && (
<>
<Card style={{marginBottom:14}}>
<div style={{display:"flex",justifyContent:"space-between",alignItems:"flex-start"}}>
<div>
<div style={{fontSize:18,fontWeight:700,color:T.navy}}>{activeClient.name}</div>
{activeClient.realmId && <div style={{fontSize:11,color:T.sub,marginTop:2}}>Realm ID: {activeClient.realmId}</div>}
{activeClient.tokenExpiry && activeClient.status==="connected" && (
<div style={{fontSize:11,color:T.sub,marginTop:1}}>Token expires: {new Date(activeClient.tokenExpiry).toLocaleTimeString()}</div>
)}
<div style={{marginTop:8}}><Badge status={activeClient.status}/></div>
</div>
<div style={{display:"flex",gap:8,flexWrap:"wrap",justifyContent:"flex-end"}}>
{activeClient.status!=="connected" && (
<Btn onClick={()=>connectClient(activeClient.id)} disabled={activeClient.status==="pending"}>
{activeClient.status==="pending" ? <><Spinner/> Connecting…</> : activeClient.status==="expired" ? "Reconnect" : "Connect to QBO"}
</Btn>
)}
{activeClient.status==="connected" && (
<Btn variant="secondary" small onClick={()=>disconnectClient(activeClient.id)}>Disconnect</Btn>
)}
<Btn variant="danger" small onClick={()=>removeClient(activeClient.id)}>Remove</Btn>
</div>
</div>
</Card>
{activeClient.status==="connected" && (
<Card>
<div style={{fontSize:14,fontWeight:700,color:T.navy,marginBottom:14}}>Run a Report</div>
<div style={{display:"flex",gap:10,flexWrap:"wrap",alignItems:"flex-end"}}>
<div>
{label("Report Type")}
<select value={reportType} onChange={e=>{setReportType(e.target.value);setReport(null);}}
style={{padding:"8px 12px",border:`1px solid ${T.border}`,borderRadius:7,fontSize:13,minWidth:210,background:T.white}}>
{REPORTS.map(r => <option key={r.value} value={r.value}>{r.label}</option>)}
</select>
</div>
<div>
{label("From")}
<input type="date" value={dateFrom} onChange={e=>setDateFrom(e.target.value)}
style={{padding:"8px 10px",border:`1px solid ${T.border}`,borderRadius:7,fontSize:13}}/>
</div>
<div>
{label("To")}
<input type="date" value={dateTo} onChange={e=>setDateTo(e.target.value)}
style={{padding:"8px 10px",border:`1px solid ${T.border}`,borderRadius:7,fontSize:13}}/>
</div>
<Btn onClick={runReport} disabled={loading}>
{loading ? <><Spinner/> Loading…</> : "Run Report"}
</Btn>
</div>
{view==="report" && report && (
<div style={{marginTop:20}}>
<div style={{display:"flex",justifyContent:"space-between",alignItems:"flex-start",marginBottom:4}}>
<div>
<div style={{fontSize:16,fontWeight:700,color:T.navy}}>{report.Header?.ReportName}</div>
<div style={{fontSize:12,color:T.sub,marginTop:2}}>
{report.Header?.StartPeriod ? `${report.Header.StartPeriod} → ${report.Header.EndPeriod}` : report.Header?.ReportDate||""}
{report.Header?.Currency ? ` · ${report.Header.Currency}` : ""}
{report._intuit_tid && <span style={{marginLeft:8,opacity:.5,fontSize:10}}>tid: {report._intuit_tid}</span>}
</div>
</div>
<Badge status="connected"/>
</div>
<ReportTable report={report}/>
</div>
)}
</Card>
)}
{activeClient.status!=="connected" && (
<Card style={{textAlign:"center",padding:44}}>
<div style={{fontSize:36,marginBottom:10}}>🔌</div>
<div style={{fontSize:15,fontWeight:700,color:T.navy,marginBottom:8}}>Connect {activeClient.name} to QuickBooks</div>
<div style={{fontSize:13,color:T.sub,maxWidth:360,margin:"0 auto 20px",lineHeight:1.7}}>
A QuickBooks login window will open. Sign in with this client's credentials or your accountant login.
</div>
<Btn onClick={()=>connectClient(activeClient.id)} disabled={activeClient.status==="pending"}>
{activeClient.status==="pending" ? <><Spinner/> Opening QuickBooks…</> : "Connect to QuickBooks"}
</Btn>
</Card>
)}
</>
)}
{/* Empty state */}
{!activeClient && view!=="addClient" && (
<Card style={{textAlign:"center",padding:52}}>
<div style={{fontSize:32,marginBottom:10}}>📋</div>
<div style={{fontSize:16,fontWeight:700,color:T.navy,marginBottom:8}}>No client selected</div>
<div style={{fontSize:13,color:T.sub,marginBottom:20}}>Add your first client company to begin accessing their QuickBooks data.</div>
<Btn onClick={()=>setView("addClient")}>+ Add First Client</Btn>
</Card>
)}
</div>
</div>
{showSupport && <SupportPanel onClose={()=>setShowSupport(false)}/>}
{/* Footer */}
<div style={{textAlign:"center",padding:"12px 20px 28px",fontSize:11,color:"#8A9BB0",lineHeight:2}}>
Clarity Business Books & Accounting · Tokens held in session memory only · Built on QuickBooks API · Not affiliated with Intuit<br/>
Need help?{" "}
<a href={`mailto:${CONFIG.SUPPORT.email}?subject=Clarity QBO Portal Support`} style={{color:T.greenLt,textDecoration:"none"}}>{CONFIG.SUPPORT.email}</a>
{" · "}
<span onClick={()=>setShowSupport(true)} style={{cursor:"pointer",color:T.greenLt,textDecoration:"underline"}}>Contact Support</span>
</div>
</div>
);
}
ReactDOM.createRoot(document.getElementById('root')).render(<App/>);
</script>
</body>
</html>