const { useState, useEffect, useCallback, useRef } = React;
const EMPTY_TEASER = {
companyName: "", ticker: "", exchange: "", sector: "", tagline: "",
profile: "", asOfDate: "",
metrics: [
{ label: "Market Cap", value: "", unit: "" },
{ label: "Revenue (LTM)", value: "", unit: "" },
{ label: "Net Profit", value: "", unit: "" },
{ label: "Revenue Growth", value: "", unit: "" },
{ label: "ROE", value: "", unit: "" },
{ label: "Net Margin", value: "", unit: "" },
],
shareholders: [],
financialTrend: [],
operational: [],
highlights: [],
peers: [],
dealInfo: { type: "", size: "", useOfProceeds: "" },
};
async function apiJSON(method, path, body) {
const opts = {
method,
headers: { "Content-Type": "application/json" },
credentials: "same-origin",
};
if (body !== undefined) opts.body = JSON.stringify(body);
const r = await fetch(path, opts);
const data = await r.json().catch(() => ({}));
if (!r.ok) {
const err = new Error(data.error || `HTTP ${r.status}`);
err.status = r.status;
throw err;
}
return data;
}
function App() {
const [me, setMe] = useState(null); // null = not loaded, false = not authed, object = authed
const [booting, setBooting] = useState(true);
const bootstrap = useCallback(async () => {
try {
const data = await apiJSON("GET", "/api/me");
setMe(data);
} catch (e) {
if (e.status === 401) setMe(false);
else setMe(false);
} finally {
setBooting(false);
}
}, []);
useEffect(() => { bootstrap(); }, [bootstrap]);
if (booting) return
;
if (!me) return setMe(u)} />;
return ;
}
function AuthScreen({ onAuthed }) {
const [mode, setMode] = useState("login"); // login | register
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [err, setErr] = useState("");
const [busy, setBusy] = useState(false);
const submit = async (e) => {
e.preventDefault();
setErr("");
setBusy(true);
try {
const path = mode === "login" ? "/api/auth/login" : "/api/auth/register";
const data = await apiJSON("POST", path, { username: username.trim(), password });
onAuthed(data);
} catch (e) {
setErr(e.message);
} finally {
setBusy(false);
}
};
return (
);
}
function MainApp({ me, setMe }) {
const [apiKey, setApiKey] = useState("");
const [urlsInput, setUrlsInput] = useState("");
const [sources, setSources] = useState([]);
const [teaser, setTeaser] = useState(EMPTY_TEASER);
const [teaserId, setTeaserId] = useState(null);
const [status, setStatus] = useState("");
const [statusErr, setStatusErr] = useState(false);
const [history, setHistory] = useState([]);
const [tab, setTab] = useState("sources");
const [loading, setLoading] = useState(false);
const fileRef = useRef(null);
const previewRef = useRef(null);
const setMsg = (m, err = false) => { setStatus(m); setStatusErr(err); };
const refreshMe = useCallback(async () => {
try { setMe(await apiJSON("GET", "/api/me")); } catch (e) { /* */ }
}, [setMe]);
const loadHistory = useCallback(async () => {
try {
const data = await apiJSON("GET", "/api/teasers");
setHistory(data.teasers || []);
} catch (e) { /* ignore */ }
}, []);
useEffect(() => { loadHistory(); }, [loadHistory]);
const logout = async () => {
try { await apiJSON("POST", "/api/auth/logout"); } catch (e) { /* ignore */ }
setMe(false);
};
const saveAPIKey = async () => {
if (!apiKey.trim()) return;
try {
await apiJSON("PUT", "/api/me/apikey", { apiKey: apiKey.trim() });
setApiKey("");
setMsg("API key đã lưu (mã hoá AES-GCM).");
refreshMe();
} catch (e) { setMsg(e.message, true); }
};
const extractSources = async () => {
const urls = urlsInput.split(/\s+/).map(s => s.trim()).filter(Boolean);
const files = fileRef.current?.files || [];
if (urls.length === 0 && files.length === 0) {
setMsg("Nhập URL hoặc chọn file PDF.", true);
return;
}
setLoading(true);
setMsg("Đang trích text từ PDF...");
try {
const fd = new FormData();
if (urls.length > 0) fd.append("urls", JSON.stringify(urls));
for (const f of files) fd.append("files", f);
const r = await fetch("/api/pdf/extract", { method: "POST", body: fd, credentials: "same-origin" });
const data = await r.json();
if (!r.ok) throw new Error(data.error || `HTTP ${r.status}`);
setSources(data.results || []);
const ok = (data.results || []).filter(x => !x.error).length;
setMsg(`Trích ${ok}/${(data.results || []).length} nguồn thành công.`);
if (fileRef.current) fileRef.current.value = "";
} catch (e) {
setMsg(e.message, true);
} finally {
setLoading(false);
}
};
const extractTeaser = async () => {
const texts = sources.filter(s => !s.error && s.text).map(s => `=== ${s.sourceRef} ===\n${s.text}`);
if (texts.length === 0) {
setMsg("Không có nguồn text nào để gọi Claude.", true);
return;
}
setLoading(true);
setMsg("Đang gọi Claude Opus 4.7 (adaptive thinking)...");
try {
const combinedText = texts.join("\n\n").slice(0, 60000);
const sourceRefs = sources.filter(s => !s.error).map(s => s.sourceRef);
const data = await apiJSON("POST", "/api/teasers/extract", { combinedText, sourceRefs });
setTeaser({ ...EMPTY_TEASER, ...data.payload });
setTeaserId(data.id);
const u = data.usage || {};
setMsg(`Trích teaser OK. tokens in/out=${u.InputTokens}/${u.OutputTokens}, latency=${u.LatencyMS}ms`);
loadHistory();
} catch (e) {
setMsg(e.message, true);
} finally {
setLoading(false);
}
};
const saveEdit = async () => {
if (!teaserId) return;
try {
await apiJSON("PUT", `/api/teasers/${teaserId}`, teaser);
setMsg("Đã lưu chỉnh sửa.");
loadHistory();
} catch (e) { setMsg(e.message, true); }
};
const loadTeaser = async (id) => {
try {
const data = await apiJSON("GET", `/api/teasers/${id}`);
setTeaser({ ...EMPTY_TEASER, ...data.payload });
setTeaserId(data.id);
setMsg(`Đã tải teaser #${id}.`);
setTab("edit");
} catch (e) { setMsg(e.message, true); }
};
const deleteTeaser = async (id) => {
if (!confirm(`Xoá teaser #${id}?`)) return;
try {
await apiJSON("DELETE", `/api/teasers/${id}`);
if (teaserId === id) { setTeaser(EMPTY_TEASER); setTeaserId(null); }
loadHistory();
} catch (e) { setMsg(e.message, true); }
};
const exportPDF = async () => {
const el = previewRef.current;
if (!el) return;
setLoading(true);
setMsg("Đang render PDF...");
try {
const canvas = await html2canvas(el, { scale: 2, backgroundColor: "#ffffff" });
const imgData = canvas.toDataURL("image/png");
const { jsPDF } = window.jspdf;
const pdf = new jsPDF({ unit: "mm", format: "a4", orientation: "portrait" });
const pageW = pdf.internal.pageSize.getWidth();
const pageH = pdf.internal.pageSize.getHeight();
const ratio = Math.min(pageW / canvas.width, pageH / canvas.height);
const w = canvas.width * ratio;
const h = canvas.height * ratio;
pdf.addImage(imgData, "PNG", (pageW - w) / 2, 0, w, h);
const fname = `${(teaser.ticker || "TEASER").toUpperCase()}_ECM_Teaser.pdf`;
pdf.save(fname);
setMsg("PDF tải xong.");
} catch (e) {
setMsg("Export PDF lỗi: " + e.message, true);
} finally {
setLoading(false);
}
};
return (
Người dùng
{me?.name}
{me?.hasApiKey ? " • API key ✓" : " • chưa có API key"}
{tab === "sources" && (
PDF nguồn
)}
{tab === "edit" && (
)}
{tab === "history" && (
Teaser đã tạo
{history.length === 0 &&
Chưa có teaser nào.
}
)}
{status || (loading ? "..." : "")}
);
}
function EditPanel({ teaser, setTeaser, onSave, teaserId }) {
const upd = (k, v) => setTeaser(t => ({ ...t, [k]: v }));
const updMetric = (i, k, v) => setTeaser(t => ({ ...t, metrics: t.metrics.map((m, idx) => idx === i ? { ...m, [k]: v } : m) }));
return (
);
}
function TeaserPreview({ teaser, refEl }) {
const t = teaser || EMPTY_TEASER;
const maxRev = Math.max(1, ...(t.financialTrend || []).map(f => Math.max(f.revenue || 0, f.profit || 0)));
return (
TCBSTechcombank Securities — ECM Teaser
{t.companyName || "Company Name"}{t.ticker ? ` (${t.ticker})` : ""}
{[t.exchange, t.sector].filter(Boolean).join(" • ") || "Exchange • Sector"}
{t.asOfDate ? `As of ${t.asOfDate}` : ""}
{t.tagline &&
{t.tagline}
}
{(t.metrics || []).map((m, i) => (
{m.label}
{m.value || "—"}{m.unit}
))}
Company profile
{t.profile || "—"}
{(t.highlights || []).length > 0 && (
<>
Key highlights
{t.highlights.map((h, i) => - {h}
)}
>
)}
Shareholder structure
{(t.shareholders || []).length === 0 ?
—
: t.shareholders.map((s, i) => (
{s.name}
{(s.pct || 0).toFixed(1)}%
))}
Operational footprint
{(t.operational || []).length === 0 ?
—
: (
{t.operational.map((o, i) => (
))}
)}
Financial trend (VND bn)
{(t.financialTrend || []).length === 0 ?
—
: (
{t.financialTrend.map((f, i) => (
))}
)}
Revenue
Profit
Peer comparison
{(t.peers || []).length === 0 ?
—
: (
| Peer | P/E | P/B | ROE % |
{t.peers.map((p, i) => (
| {p.name} |
{(p.pe || 0).toFixed(1)} |
{(p.pb || 0).toFixed(1)} |
{(p.roe || 0).toFixed(1)} |
))}
)}
{(t.dealInfo?.type || t.dealInfo?.size || t.dealInfo?.useOfProceeds) && (
Deal type
{t.dealInfo.type || "—"}
Size
{t.dealInfo.size || "—"}
Use of proceeds
{t.dealInfo.useOfProceeds || "—"}
)}
Techcombank Securities — ECM
Confidential — for institutional investors only
);
}
const root = ReactDOM.createRoot(document.getElementById("root"));
root.render();