SpringWang08's picture
sync report fallback source
48ac51b verified
Raw
History Blame Contribute Delete
131 kB
import React, { useEffect, useMemo, useRef, useState } from "react";
import { createRoot } from "react-dom/client";
import {
Area,
AreaChart,
Bar,
BarChart,
CartesianGrid,
Cell,
ComposedChart,
Legend,
Line,
Pie,
PieChart,
ReferenceLine,
ResponsiveContainer,
Scatter,
ScatterChart,
Tooltip,
XAxis,
YAxis
} from "recharts";
import { CircleMarker, MapContainer, Popup, TileLayer } from "react-leaflet";
import "leaflet/dist/leaflet.css";
import "./styles.css";
const API_URL = import.meta.env.VITE_API_URL || "";
const PALETTE = ["#2563eb", "#0f766e", "#7c3aed", "#d97706", "#c2410c", "#0891b2", "#4f46e5"];
const DEFAULT_MODEL_LABEL = "AI provider";
const APP_VERSION = "2.0.0";
const DECISION_BASE_YEAR = 2025;
const DECISION_MAX_YEAR = 2050;
const emptyFilters = {
city: "TP Hồ Chí Minh",
districts: [],
property_types: [],
price_min: null,
price_max: null,
area_min: null,
area_max: null,
roi_min: null,
roi_max: null
};
const pages = [
{ id: "overview", path: "/overview", label: "Tổng quan điều hành", short: "Tổng quan", icon: "dashboard", description: "KPI trọng yếu và xu hướng danh mục." },
{ id: "market", path: "/market", label: "Thông tin thị trường", short: "Thị trường", icon: "query_stats", description: "So sánh thị trường và mặt bằng giá." },
{ id: "slice", path: "/slice-dice", label: "Phân tích đa chiều", short: "Đa chiều", icon: "analytics", description: "Phân tích theo nhiều chiều dữ liệu." },
{ id: "decision", path: "/decision-lab", label: "Mô phỏng đầu tư", short: "Mô phỏng", icon: "science", description: "Mô phỏng ROI và hoàn vốn." },
{ id: "gis", path: "/gis-planning", label: "Bản đồ quy hoạch", short: "Bản đồ", icon: "map", description: "Rủi ro quy hoạch và cơ hội." },
{ id: "ai", path: "/ai-analyst", label: "Trợ lý phân tích", short: "Trợ lý", icon: "psychology", description: "Hỏi nhanh, ra quyết định nhanh." },
{ id: "ops", path: "/data-ops", label: "Theo dõi dữ liệu", short: "Dữ liệu", icon: "settings_input_component", description: "Giám sát ETL và chất lượng dữ liệu." },
{ id: "explorer", path: "/explorer", label: "Chi tiết tài sản", short: "Tài sản", icon: "explore", description: "Xem sâu từng tài sản." },
{ id: "report", path: "/periodic-report", label: "Báo cáo định kỳ", short: "Báo cáo", icon: "summarize", description: "Tóm tắt điều hành theo kỳ." }
];
const PANEL_HINTS = {
"Xu hướng điều hành 2025–2050": "Theo dõi xu hướng KPI và kịch bản điều hành dài hạn.",
"Kiểm tra giả định tăng trưởng": "Điều chỉnh tăng trưởng để kiểm tra độ nhạy chiến lược.",
"Khuyến nghị điều hành": "Tóm tắt hành động ưu tiên cho ban điều hành.",
"ROI theo khu vực nổi bật": "So sánh hiệu quả đầu tư giữa các khu vực.",
"Thanh khoản và Giá/m²": "Đọc sức mua và mặt bằng giá trên cùng biểu đồ.",
"So sánh phân khúc": "Đối chiếu hiệu quả giữa các nhóm tài sản.",
"Insight phân biệt khu vực": "Rút ra điểm khác biệt nhanh giữa các khu vực.",
"Thiết lập phân tích": "Chọn chiều phân tích và chỉ tiêu đo lường.",
"Thông tin so sánh": "Hiển thị phạm vi và bối cảnh đang so sánh.",
"Ma trận hiệu quả (": "Heatmap hiệu quả để nhìn nhanh khu vực/phân khúc.",
"Phân đoạn tiềm năng cao": "Lọc ra nhóm phân khúc đáng ưu tiên.",
"Thông số tài sản": "Thông tin đầu vào của tài sản đang mô phỏng.",
"Giả định đầu tư": "Thiết lập ngân sách, tăng trưởng và thời gian nắm giữ.",
"Kết quả tài chính dự kiến": "Kết quả mô phỏng tài chính đầu ra.",
"Triển vọng giá trị theo kịch bản": "So sánh ba kịch bản xấu, cơ sở và lạc quan.",
"Mốc thời gian & tình huống thua lỗ": "Xem mốc hoàn vốn và điểm rủi ro.",
"Khuyến nghị đầu tư tương lai": "Đọc khuyến nghị AI cho quyết định đầu tư.",
"Bản đồ quy hoạch": "Xem rủi ro quy hoạch và opportunity score.",
"Chú giải và khu vực cần theo dõi": "Giải thích màu sắc và khu vực cảnh báo.",
"Câu hỏi phân tích": "Nhập câu hỏi để truy vấn RAG/LLM.",
"Thanh tra nguồn": "Kiểm tra nguồn trích dẫn và độ tin cậy.",
"Tình trạng dữ liệu": "Theo dõi trạng thái nạp và cập nhật dữ liệu.",
"Nhật ký cập nhật dữ liệu": "Xem lịch sử các lần nạp dữ liệu.",
"Trung tâm dữ liệu công cộng": "Nguồn dữ liệu và trạng thái khai thác.",
"Bảng tài sản": "Danh sách tài sản trong bộ lọc hiện tại.",
"Phát hiện chính": "Tóm tắt các phát hiện cần nhấn mạnh.",
"Ghi chú điều hành": "Ghi chú ngắn cho báo cáo định kỳ của lãnh đạo.",
"Tóm tắt mô phỏng đầu tư": "Tóm tắt ba chỉ số chính của mô phỏng.",
"Báo cáo định kỳ": "Tổng hợp kết quả để trình bày theo chu kỳ."
};
function currentPageId() {
const path = window.location.pathname === "/" ? "/overview" : window.location.pathname;
return pages.find((page) => page.path === path)?.id || "overview";
}
function money(value) {
if (value === null || value === undefined || Number.isNaN(Number(value))) return "N/A";
const number = Number(value);
if (Math.abs(number) >= 1_000_000_000) return `${(number / 1_000_000_000).toFixed(2)} tỷ`;
return `${(number / 1_000_000).toFixed(0)} triệu`;
}
function compact(value) {
return new Intl.NumberFormat("vi-VN", { notation: "compact", maximumFractionDigits: 1 }).format(value || 0);
}
function pct(value) {
if (value === null || value === undefined || Number.isNaN(Number(value))) return "N/A";
return `${Number(value).toFixed(2)}%`;
}
function riskPriority(riskLevel) {
if (riskLevel === "high") return 3;
if (riskLevel === "medium") return 2;
return 1;
}
function groupMapMarkers(rows = []) {
const groups = new Map();
rows.forEach((row) => {
const latitude = Number(row.latitude);
const longitude = Number(row.longitude);
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) return;
const key = `${latitude.toFixed(4)}:${longitude.toFixed(4)}`;
if (!groups.has(key)) groups.set(key, []);
groups.get(key).push(row);
});
return [...groups.values()]
.map((items) => {
const sortedByScore = [...items].sort((a, b) => Number(b.opportunity_score || 0) - Number(a.opportunity_score || 0));
const primary = sortedByScore[0];
const riskOrder = [...items].sort((a, b) => riskPriority(b.risk_level) - riskPriority(a.risk_level));
const dominantRisk = riskOrder[0]?.risk_level || primary?.risk_level || "medium";
return {
...primary,
district: items.length === 1 ? primary.district : `${primary.district} +${items.length - 1}`,
districts: items.map((item) => item.district),
district_count: items.length,
aggregate_opportunity_score: Number(primary.opportunity_score || 0),
aggregate_roi_pct: Number(primary.roi_pct || 0),
risk_level: dominantRisk,
planning_note: items.length > 1 ? `Có ${items.length} khu cùng tọa độ. Khu nổi bật: ${primary.district}.` : primary.planning_note
};
})
.sort((a, b) => Number(b.aggregate_opportunity_score || 0) - Number(a.aggregate_opportunity_score || 0));
}
function markerScore(row) {
return Number(row?.aggregate_opportunity_score ?? row?.opportunity_score ?? 0);
}
function markerRoi(row) {
return Number(row?.aggregate_roi_pct ?? row?.roi_pct ?? 0);
}
function formatDateTime(value) {
if (!value) return "Chưa cập nhật";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "Chưa cập nhật";
return new Intl.DateTimeFormat("vi-VN", {
dateStyle: "medium",
timeStyle: "short"
}).format(date);
}
function aiStageInfo(status, loading = false) {
const normalized = String(status || "").toLowerCase();
if (normalized === "error") return { label: "Error", tone: "high" };
if (normalized === "stopped") return { label: "Paused", tone: "medium" };
if (normalized === "done") return { label: "Ready", tone: "good" };
if (normalized === "streaming") return { label: "Model warm", tone: "good" };
if (normalized === "waiting_huggingface" || normalized === "waiting_featherless" || normalized === "waiting_provider") return { label: "Model loading", tone: "loading" };
if (loading) return { label: "Model loading", tone: "loading" };
return { label: "Ready", tone: "good" };
}
function normalizeMarkdownText(text = "") {
return String(text)
.replace(/\r\n/g, "\n")
.replace(/^Chào\s+CEO,?\s*/i, "")
.replace(/^Chào\s+bạn,?\s*/i, "")
.replace(/^\s*---+\s*$/gm, "")
.replace(/^\s*(\d+)\.\s*$/gm, "$1.")
.replace(/^\s*(\d+)\.\s*\n\s*([^\n]+)/gm, "$1. $2")
.replace(/^\s*(\d+)\.\s*(Kết luận điều hành|Cơ sở nhận định|Rủi ro cần lưu ý|Hành động tiếp theo|Phát hiện chính|Lợi thế|Rủi ro|Hành động|Tổng quan|Cơ sở|Rủi ro cần lưu ý|Hành động tiếp theo)\s*$/gim, "## $2")
.replace(/^\s*[-*]\s*\n\s*([^\n]+)/gm, "- $1")
.replace(/^\*\*(Kết luận điều hành|Cơ sở nhận định|Rủi ro cần lưu ý|Hành động tiếp theo|Phát hiện chính|Lợi thế|Rủi ro|Hành động|Rủi ro quy hoạch|Rủi ro pháp lý|Ảnh hưởng thanh khoản|Gợi ý kiểm tra tiếp theo)\*\*:\s*/gm, "## $1\n")
.replace(/^(1|2|3|4)\.\s*(Kết luận điều hành|Cơ sở nhận định|Rủi ro cần lưu ý|Hành động tiếp theo|Phát hiện chính|Lợi thế|Rủi ro|Hành động)\s*:\s*/gim, "## $2\n")
.replace(/\n{3,}/g, "\n\n")
.trim();
}
function sanitizeRecommendationText(text = "") {
return String(text)
.replace(/\r\n/g, "\n")
.replace(/\r/g, "\n")
.split("\n")
.map((rawLine) => {
let line = rawLine.trim();
if (!line) return "";
line = line.replace(/^\s*#{1,6}\s*/, "").trim();
if (!line || /^[-*_ ]+$/.test(line)) return "";
if (/\*/.test(line) && /^\*+\s*[A-Za-zÀ-ỹ_ ]{1,4}\s*$/u.test(line)) return "";
if (/^(?:[-*]\s*)?(?:\*\*)?\s*(CHART_SPEC|CHART SPEC|CHARTSPEC|chart_type|title|caption|insight|x_key|series|reference_line)(?:\*\*)?\s*:/i.test(line)) return "";
if (/^(?:\*\*)?\s*(ACTION|WHY|RISKS|SUGGESTION|BASIS|KET_LUAN|LY_DO|RUI_RO|GOI_Y|CO_SO|CHART_SPEC|CHART SPEC|CHARTSPEC)(?:\*\*)?\s*:?\s*$/i.test(line)) return "";
line = line.replace(/\*\*/g, "").trim();
line = line.replace(/^(ACTION|WHY|RISKS|SUGGESTION|BASIS|KET_LUAN|LY_DO|RUI_RO|GOI_Y|CO_SO)\s*:?\s*/i, "").trim();
line = line.replace(/^(KẾT LUẬN|LÝ DO|RỦI RO CẦN NÊU|HÀNH ĐỘNG|CƠ SỞ ĐƯA RA KHUYẾN NGHỊ)\s*:?\s*/i, "").trim();
return line;
})
.filter(Boolean)
.join("\n")
.trim();
}
function sanitizeRecommendationList(items = []) {
return items.map((item) => sanitizeRecommendationText(item)).filter(Boolean);
}
function providerWaitingCopy(status, llmMode) {
const normalizedStatus = String(status || "").toLowerCase();
const normalizedMode = String(llmMode || "").toLowerCase();
if (normalizedStatus === "waiting_featherless" || normalizedMode === "featherless-direct") {
return {
title: "Đang chờ Featherless...",
body: "Hệ thống đang chờ Featherless trả phản hồi để sinh câu ngữ nghĩa."
};
}
if (normalizedStatus === "waiting_huggingface" || normalizedMode === "hf-hosted") {
return {
title: "Đang chờ Hugging Face...",
body: "Hệ thống đang chờ phản hồi từ hosted model để sinh câu ngữ nghĩa."
};
}
return {
title: "Đang chờ AI provider...",
body: "Hệ thống đang chờ phản hồi từ model để sinh câu ngữ nghĩa."
};
}
function renderMarkdown(text = "") {
const normalized = normalizeMarkdownText(text);
if (!normalized) return "";
const escapeInline = (value) => String(value)
.replace(/&/g, "&")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/\*\*(.*?)\*\*/g, "<strong>$1</strong>");
const lines = normalized.split("\n");
const segments = [];
let paragraph = [];
let currentListType = null;
let currentList = [];
const flushParagraph = () => {
if (!paragraph.length) return;
segments.push(`<p>${paragraph.map((line) => escapeInline(line)).join("<br />")}</p>`);
paragraph = [];
};
const flushList = () => {
if (!currentList.length) return;
const tag = currentListType === "ol" ? "ol" : "ul";
segments.push(`<${tag}>${currentList.map((item) => `<li>${escapeInline(item)}</li>`).join("")}</${tag}>`);
currentList = [];
currentListType = null;
};
lines.forEach((rawLine) => {
const line = rawLine.trim();
if (!line) {
flushList();
flushParagraph();
return;
}
const headingMatch = line.match(/^(#{1,3})\s+(.*)$/);
if (headingMatch) {
flushList();
flushParagraph();
const level = Math.min(3, headingMatch[1].length);
segments.push(`<h${level}>${escapeInline(headingMatch[2])}</h${level}>`);
return;
}
const orderedMatch = line.match(/^(\d+)\.\s+(.*)$/);
if (orderedMatch) {
flushParagraph();
if (currentListType && currentListType !== "ol") flushList();
currentListType = "ol";
currentList.push(orderedMatch[2]);
return;
}
const bulletMatch = line.match(/^[-*]\s+(.*)$/);
if (bulletMatch) {
flushParagraph();
if (currentListType && currentListType !== "ul") flushList();
currentListType = "ul";
currentList.push(bulletMatch[1]);
return;
}
if (currentList.length) flushList();
paragraph.push(line);
});
flushList();
flushParagraph();
return segments.join("");
}
function parseStructuredSections(text = "", sectionTitles = []) {
const normalized = normalizeMarkdownText(text);
if (!normalized) return [];
const canonical = sectionTitles.map((title) => ({
title,
pattern: new RegExp(`^(?:#{1,3}\\s*)?(?:\\d+\\.\\s*)?(?:\\*\\*)?${title}(?:\\*\\*)?\\s*(?::|$)`, "i")
}));
const lines = normalized.split("\n");
const sections = [];
let current = null;
const startSection = (title) => {
if (current) sections.push(current);
current = { title, lines: [] };
};
lines.forEach((rawLine) => {
const line = rawLine.trim();
if (!line || /^---+$/.test(line)) return;
const matched = canonical.find((item) => item.pattern.test(line));
if (matched) {
startSection(matched.title);
return;
}
if (/^\d+\.\s+/.test(line)) {
return;
}
if (!current) {
startSection(sectionTitles[0] || "Nội dung");
}
current.lines.push(line);
});
if (current) sections.push(current);
return sections
.map((section) => ({
title: section.title,
body: section.lines.join("\n").trim()
}))
.filter((section) => section.body);
}
function StructuredResponse({ text, sectionTitles }) {
const sections = parseStructuredSections(text, sectionTitles);
if (!sections.length) return <MarkdownBlock text={text} />;
return (
<div className="structured-response">
{sections.map((section) => (
<article key={section.title} className="structured-response-card">
<strong>{section.title}</strong>
<MarkdownBlock text={section.body} />
</article>
))}
</div>
);
}
function MarkdownBlock({ text }) {
return <div className="markdown-body" dangerouslySetInnerHTML={{ __html: renderMarkdown(text) }} />;
}
function SectionTabs({ tabs, active, onChange }) {
return (
<div className="section-tabs" role="tablist" aria-label="Section tabs">
{tabs.map((tab) => (
<button
key={tab.id}
className={active === tab.id ? "active" : ""}
onClick={() => onChange(tab.id)}
type="button"
>
{tab.label}
</button>
))}
</div>
);
}
function InsightHero({ title, body, tone = "default", meta }) {
return (
<section className={`insight-hero tone-${tone}`}>
<div>
<strong>{title}</strong>
<p>{body}</p>
</div>
{meta ? <small>{meta}</small> : null}
</section>
);
}
function Disclosure({ title, children, defaultOpen = false, open: controlledOpen, onToggle, compact = false }) {
const [open, setOpen] = useState(defaultOpen);
useEffect(() => {
setOpen(defaultOpen);
}, [defaultOpen]);
useEffect(() => {
if (controlledOpen !== undefined) {
setOpen(Boolean(controlledOpen));
}
}, [controlledOpen]);
return (
<details
className={`disclosure ${compact ? "compact" : ""}`}
open={open}
onToggle={(event) => {
const nextOpen = event.currentTarget.open;
setOpen(nextOpen);
if (onToggle) onToggle(nextOpen);
}}
>
<summary>{title}</summary>
<div className="disclosure-body">{children}</div>
</details>
);
}
function buildExecutiveTrend(timeline = [], overrideGrowthPct = null) {
if (!timeline.length) return [];
const sorted = [...timeline].sort((a, b) => String(a.date).localeCompare(String(b.date)));
const yearlyActual = new Map();
sorted.forEach((row) => {
const year = Number(String(row.date).slice(0, 4));
if (!yearlyActual.has(year)) yearlyActual.set(year, []);
yearlyActual.get(year).push(row);
});
const actualRows = [...yearlyActual.entries()].map(([year, rows]) => ({
year,
price_billion: rows.reduce((sum, row) => sum + Number(row.price_billion || 0), 0) / rows.length,
roi_pct: rows.reduce((sum, row) => sum + Number(row.roi_pct || 0), 0) / rows.length,
stage: year < 2025 ? "actual" : "projection"
}));
const base2025 = actualRows.find((row) => row.year === 2025) || actualRows[actualRows.length - 1];
const recent = actualRows.slice(-3);
const firstRecent = recent[0] || base2025;
const lastRecent = recent[recent.length - 1] || base2025;
const spanYears = Math.max(1, (lastRecent.year || 2025) - (firstRecent.year || 2024));
const inferredGrowth = Math.pow((lastRecent.price_billion || 1) / Math.max(firstRecent.price_billion || 1, 0.1), 1 / spanYears) - 1;
const annualGrowth = overrideGrowthPct === null
? Math.max(0.02, Math.min(0.09, Number.isFinite(inferredGrowth) ? inferredGrowth : 0.055))
: Number(overrideGrowthPct) / 100;
const roiSlope = ((lastRecent.roi_pct || 0) - (firstRecent.roi_pct || 0)) / spanYears;
const startPrice = base2025.price_billion || lastRecent.price_billion || 0;
const startRoi = base2025.roi_pct || lastRecent.roi_pct || 0;
const projection = [];
for (let year = 2025; year <= 2050; year += 1) {
const offset = year - 2025;
projection.push({
year,
price_billion: Number((startPrice * ((1 + annualGrowth) ** offset)).toFixed(2)),
roi_pct: Number(Math.max(6, Math.min(24, startRoi + roiSlope * offset * 0.35)).toFixed(2)),
stage: year === 2025 ? "actual" : "projection"
});
}
return projection;
}
function buildTrendSnapshots(trend = []) {
if (!trend.length) return [];
const desiredYears = [2025, 2030, 2035, 2040, 2045, 2050];
const byYear = new Map(trend.map((row) => [Number(row.year), row]));
const latest = trend[trend.length - 1];
return desiredYears
.map((year) => byYear.get(year) || latest)
.filter(Boolean)
.map((row, index) => ({
year: row.year,
price_billion: Number(row.price_billion || 0),
roi_pct: Number(row.roi_pct || 0),
stage: row.stage || "projection",
emphasis: index === 0 ? "current" : index === desiredYears.length - 1 ? "target" : "forecast"
}));
}
function normalizeText(value = "") {
return String(value).trim().toLowerCase();
}
function propertyNeedsRoomFields(propertyType = "") {
const text = normalizeText(propertyType);
return !["đất", "kho", "xưởng", "bãi", "nông nghiệp", "mặt bằng"].some((keyword) => text.includes(keyword));
}
function propertyNeedsFloorField(propertyType = "") {
const text = normalizeText(propertyType);
return !["đất", "nông nghiệp", "bãi", "mặt bằng"].some((keyword) => text.includes(keyword));
}
function toNullableNumber(value) {
if (value === "" || value === null || value === undefined) return null;
const number = Number(value);
return Number.isFinite(number) ? number : null;
}
function useTypewriterText(text, enabled = true, speed = 14) {
const [displayed, setDisplayed] = useState("");
useEffect(() => {
const source = String(text || "");
if (!enabled || !source) {
setDisplayed(source);
return undefined;
}
setDisplayed("");
let index = 0;
const step = Math.max(1, Math.ceil(source.length / 120));
const timer = window.setInterval(() => {
index = Math.min(source.length, index + step);
setDisplayed(source.slice(0, index));
if (index >= source.length) {
window.clearInterval(timer);
}
}, speed);
return () => window.clearInterval(timer);
}, [text, enabled, speed]);
return displayed;
}
function buildOverviewRecommendation(kpis, riskyRows = [], growthPct = 8) {
const weakest = riskyRows[0];
if (growthPct <= 0 && weakest) {
return {
title: "Khuyến nghị phòng thủ",
body: `Giả định tăng trưởng ${growthPct.toFixed(1)}% cho thấy nên hạn chế mua mới và xem xét bán bớt hoặc cơ cấu lại tại ${weakest.district}. Đây là nhóm dễ chịu áp lực giảm giá và hiệu quả đầu tư thấp hơn mặt bằng chung.`
};
}
if (growthPct < 5 && weakest) {
return {
title: "Khuyến nghị giữ tiền mặt cao hơn",
body: `Tăng trưởng chậm làm biên an toàn thu hẹp. Nên giải ngân chọn lọc tại ${kpis.best_district || "khu dẫn đầu"} và tránh mở rộng quá nhanh ở ${weakest.district}.`
};
}
return {
title: `Ưu tiên mở rộng tại ${kpis.best_district || "khu vực dẫn đầu"}`,
body: `Với giả định tăng trưởng ${growthPct.toFixed(1)}%, danh mục vẫn phù hợp để tích lũy thêm ở khu dẫn đầu và chỉ kiểm soát rủi ro ở các khu có hiệu quả thấp hoặc biến động cao.`
};
}
function buildScenarioMilestones(whatIf) {
if (!whatIf?.projection?.length) return [];
const budget = Number(whatIf.input?.budget_vnd || 0);
const rows = whatIf.projection.map((row) => ({
...row,
status: Number(row.pessimistic || 0) < budget ? "Rủi ro lỗ" : Number(row.base || 0) < budget ? "Hoà vốn yếu" : "An toàn hơn"
}));
return rows.filter((row, index) => index === 0 || index === rows.length - 1 || index % 2 === 0).slice(0, 6);
}
function defaultDecisionChartSpec() {
return {
chart_type: "line",
title: "",
caption: "So sánh ba kịch bản xấu, cơ sở và tích cực theo từng năm.",
insight: "Đường cơ sở là trọng tâm, hai đường còn lại giúp nhìn nhanh biên an toàn.",
x_key: "calendar_year",
series: ["pessimistic", "base", "optimistic"],
reference_line: "budget_vnd"
};
}
function chartTitleForDecisionTab(tab = "whatif") {
if (tab === "scenario") return "Dải kịch bản theo từng năm";
if (tab === "asset") return "Mức hấp dẫn tài sản so với danh mục";
return "Mô phỏng tài chính theo vốn và tăng trưởng";
}
function chartTypeForDecisionTab(tab = "whatif") {
if (tab === "scenario") return "area";
if (tab === "asset") return "bar";
return "line";
}
function normalizeChartSpec(spec) {
const base = defaultDecisionChartSpec();
if (!spec || typeof spec !== "object") return base;
const series = Array.isArray(spec.series)
? spec.series.filter(Boolean)
: String(spec.series || "").split(",").map((item) => item.trim()).filter(Boolean);
return {
...base,
...spec,
chart_type: ["line", "area", "bar"].includes(String(spec.chart_type || "").toLowerCase()) ? String(spec.chart_type).toLowerCase() : base.chart_type,
x_key: spec.x_key || base.x_key,
series: series.length ? series : base.series,
reference_line: spec.reference_line || base.reference_line
};
}
function buildExecutiveBriefs(districtRows = []) {
const rows = [...(districtRows || [])].filter((row) => row && row.district);
if (!rows.length) return [];
const byScore = [...rows].sort((a, b) => Number(b.opportunity_score || 0) - Number(a.opportunity_score || 0));
const byValue = [...rows].sort((a, b) => Number(a.price_m2_million || 0) - Number(b.price_m2_million || 0) || Number(b.roi_pct || 0) - Number(a.roi_pct || 0));
const byRisk = [...rows].sort((a, b) => Number(b.volatility || 0) - Number(a.volatility || 0) || Number(a.opportunity_score || 0) - Number(b.opportunity_score || 0));
const picks = [byScore[0], byValue[0], byRisk[0]].filter(Boolean);
const unique = [];
const seen = new Set();
picks.forEach((row, index) => {
if (!seen.has(row.district)) {
unique.push({ ...row, kind: index === 0 ? "priority" : index === 1 ? "value" : "risk" });
seen.add(row.district);
}
});
if (!unique.length && byScore[0]) unique.push({ ...byScore[0], kind: "priority" });
if (unique.length < 3) {
for (const row of rows) {
if (unique.some((item) => item.district === row.district)) continue;
unique.push({ ...row, kind: unique.length === 0 ? "priority" : unique.length === 1 ? "value" : "risk" });
if (unique.length === 3) break;
}
}
return unique.slice(0, 3).map((row, index) => {
const score = Number(row.opportunity_score || 0);
const roi = Number(row.roi_pct || 0);
const price = Number(row.price_m2_million || 0);
const volatility = Number(row.volatility || 0);
const kind = row.kind || (index === 0 ? "priority" : index === 1 ? "value" : "risk");
const title = kind === "priority"
? "Ưu tiên mở rộng"
: kind === "value"
? "Tích lũy chọn lọc"
: "Theo dõi rủi ro";
const edge = kind === "priority"
? `Điểm cơ hội ${score.toFixed(1)}/100, ROI ${roi.toFixed(2)}% và thanh khoản ${Number(row.listings || 0).toLocaleString("vi-VN")} tin.`
: kind === "value"
? `Giá/m² ${price.toFixed(1)} triệu tạo biên vào lệnh tốt hơn, vẫn giữ ROI ${roi.toFixed(2)}%.`
: `Biến động ROI ${volatility.toFixed(2)} cho thấy nên giữ tỷ trọng nhỏ và theo dõi sát.`;
const strength = kind === "risk"
? `Mặc dù rủi ro cao hơn, khu vực này vẫn giúp đa dạng hóa danh mục nếu kiểm soát tỷ trọng.`
: kind === "value"
? `Phù hợp cho chiến lược gom dần vì mặt bằng giá thấp hơn mặt bằng chung.`
: `Phù hợp để dẫn danh mục vì đang nổi bật cả về hiệu quả lẫn độ quan tâm thị trường.`;
const risk = kind === "priority"
? `Cần kiểm soát pháp lý và quy hoạch vì khu dẫn đầu thường đi kèm mặt bằng giá cao hơn.`
: kind === "value"
? `Rủi ro là thanh khoản có thể thấp hơn nếu khu vực chưa đủ đông người mua.`
: `Rủi ro là biến động lợi suất lớn nên không nên giải ngân quá nhanh.`;
return {
...row,
title,
edge,
strength,
risk,
tag: kind === "priority" ? "Dẫn đầu" : kind === "value" ? "Giá tốt" : "Cảnh báo"
};
});
}
function DecisionChart({ whatIf, chartSpec }) {
const spec = normalizeChartSpec(chartSpec);
const data = (whatIf?.projection || []).map((row) => ({ ...row, calendar_year: 2025 + Number(row.year || 0) }));
if (!data.length) return <p className="muted">Biểu đồ sẽ hiển thị triển vọng theo ba kịch bản đầu tư.</p>;
const commonProps = {
data,
margin: { top: 8, right: 16, left: 0, bottom: 0 }
};
const colors = {
pessimistic: "#c2410c",
base: "#2563eb",
optimistic: "#0f766e"
};
const seriesMap = {
pessimistic: "Kịch bản xấu",
base: "Kịch bản cơ sở",
optimistic: "Kịch bản tích cực"
};
const yFormatter = (v) => `${(v / 1_000_000_000).toFixed(0)} tỷ`;
const tooltipFormatter = (v) => money(v);
const series = spec.series.filter((key) => key in seriesMap);
const referenceValue = spec.reference_line === "budget_vnd" ? whatIf?.input?.budget_vnd : null;
const axes = (
<>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey={spec.x_key || "calendar_year"} />
<YAxis tickFormatter={yFormatter} />
<Tooltip formatter={tooltipFormatter} labelFormatter={(value) => `Năm ${value}`} />
<Legend />
{referenceValue ? <ReferenceLine y={referenceValue} stroke="#64748b" strokeDasharray="4 4" label="Vốn ban đầu" /> : null}
</>
);
if (spec.chart_type === "area") {
return (
<ResponsiveContainer width="100%" height={380}>
<AreaChart {...commonProps}>
{axes}
{series.map((key, index) => (
<Area
key={key}
dataKey={key}
name={seriesMap[key]}
fill={colors[key]}
stroke={colors[key]}
fillOpacity={index === 0 ? 0.18 : index === 1 ? 0.28 : 0.2}
dot={false}
/>
))}
</AreaChart>
</ResponsiveContainer>
);
}
if (spec.chart_type === "bar") {
return (
<ResponsiveContainer width="100%" height={380}>
<BarChart {...commonProps}>
{axes}
{series.map((key) => (
<Bar key={key} dataKey={key} name={seriesMap[key]} fill={colors[key]} radius={[6, 6, 0, 0]} />
))}
</BarChart>
</ResponsiveContainer>
);
}
return (
<ResponsiveContainer width="100%" height={380}>
<ComposedChart {...commonProps}>
{axes}
{series.map((key) => (
<Line key={key} dataKey={key} name={seriesMap[key]} stroke={colors[key]} strokeWidth={key === "base" ? 3 : 2} dot={false} />
))}
</ComposedChart>
</ResponsiveContainer>
);
}
function ResultSkeleton() {
return (
<div className="result-skeleton" aria-hidden="true">
<div className="skeleton-line skeleton-lg" />
<div className="skeleton-grid">
<div className="skeleton-card" />
<div className="skeleton-card" />
<div className="skeleton-card" />
<div className="skeleton-card" />
</div>
<div className="skeleton-line" />
<div className="skeleton-line" />
</div>
);
}
function buildGrowthWhatIfPayload(predictForm, simulationForm, filters, growthPct) {
return {
district: predictForm.district,
property_type: predictForm.property_type,
legal_documents: predictForm.legal_documents,
area: Number(predictForm.area),
bedrooms: propertyNeedsRoomFields(predictForm.property_type) ? toNullableNumber(predictForm.bedrooms) : null,
toilets: propertyNeedsRoomFields(predictForm.property_type) ? toNullableNumber(predictForm.toilets) : null,
floors: propertyNeedsFloorField(predictForm.property_type) ? toNullableNumber(predictForm.floors) : null,
roi_expected: Number(predictForm.roi_expected),
budget_vnd: Number(simulationForm.budget_billion) * 1_000_000_000,
annual_growth_pct: Number(growthPct),
years: Number(simulationForm.years),
filters
};
}
function buildOverviewGrowthPayload(analytics, filters, growthPct) {
const kpis = analytics?.kpis || {};
const districts = analytics?.districts || [];
const types = analytics?.types || [];
const samples = analytics?.samples || [];
const topDistrict = kpis.best_district || districts[0]?.district || (filters?.city || "TP Hồ Chí Minh");
const bestType = types[0]?.["Type of House"] || types[0]?.type || samples[0]?.["Type of House"] || "Nhà mặt tiền";
const bestLegal = samples[0]?.["Legal Documents"] || "Sổ hồng";
const avgArea = Number(districts[0]?.avg_area || samples[0]?.area || 80);
const budgetSource = Number(kpis.avg_transaction_price || kpis.median_price || kpis.total_value / Math.max(1, kpis.listings || 1) || 0);
const budgetVnd = Math.max(1, budgetSource || 1);
const roiExpected = Number.isFinite(Number(kpis.avg_roi)) ? Number(kpis.avg_roi) / 100 : 0.14;
return {
district: topDistrict,
property_type: bestType,
legal_documents: bestLegal,
area: Math.max(1, avgArea),
bedrooms: null,
toilets: null,
floors: null,
roi_expected: roiExpected,
budget_vnd: budgetVnd,
annual_growth_pct: Number(growthPct),
years: 25,
filters: {
city: filters?.city || "TP Hồ Chí Minh",
districts: kpis.best_district ? [kpis.best_district] : [],
property_types: bestType ? [bestType] : [],
price_min: null,
price_max: null,
area_min: null,
area_max: null,
roi_min: null,
roi_max: null
}
};
}
function buildReportQuestion(analytics, filters) {
const best = analytics?.kpis?.best_district || "khu vực dẫn đầu";
const risky = (analytics?.risky || []).slice(0, 3).map((row) => row.district).filter(Boolean);
const city = filters?.city || "thị trường";
return `Viết ghi chú điều hành ngắn gọn cho báo cáo định kỳ của ${city}. Nhấn mạnh ${best}, ROI trung bình ${pct(analytics?.kpis?.avg_roi)}, các khu vực cần theo dõi gồm ${risky.join(", ") || "chưa có cảnh báo rõ"}. Nêu rõ lợi thế, rủi ro và hành động ưu tiên theo giọng điệu chuyên viên phân tích đầu tư.`;
}
function buildReportCoverSummary(analytics, whatIf) {
const kpis = analytics?.kpis || {};
const executiveTrend = buildExecutiveTrend(analytics?.timeline || [], 8);
const baseline = executiveTrend[0] || null;
const latest = executiveTrend[executiveTrend.length - 1] || null;
const bestDistrict = kpis.best_district || analytics?.districts?.[0]?.district || "Khu vực dẫn đầu";
const projectedFutureValue = whatIf?.summary?.future_value ?? (latest?.price_billion ? latest.price_billion * 1_000_000_000 : null);
const projectedCumulativeRoi = whatIf?.summary?.cumulative_roi_pct ?? (
baseline && latest && Number(baseline.price_billion) > 0
? ((Number(latest.price_billion) / Number(baseline.price_billion)) - 1) * 100
: null
);
const projectedPaybackYears = whatIf?.summary?.payback_years ?? (() => {
const roiFromKpis = Number(kpis.avg_roi);
if (Number.isFinite(roiFromKpis) && roiFromKpis > 0) return 100 / roiFromKpis;
const roiFromTrend = Number(latest?.roi_pct);
if (Number.isFinite(roiFromTrend) && roiFromTrend > 0) return 100 / roiFromTrend;
return null;
})();
const sourceLabel = whatIf ? "Từ mô phỏng đầu tư" : "Ước tính theo xu hướng điều hành";
return {
bestDistrict,
projectedFutureValue,
projectedCumulativeRoi,
projectedPaybackYears,
sourceLabel
};
}
async function request(path, options = {}) {
const response = await fetch(`${API_URL}${path}`, {
headers: { "Content-Type": "application/json" },
...options
});
if (!response.ok) {
let detail = `API ${path} failed`;
try {
const payload = await response.json();
detail = payload?.detail || detail;
} catch {
detail = (await response.text()) || detail;
}
throw new Error(detail);
}
return response.json();
}
async function requestNdjsonStream(path, options = {}, handlers = {}) {
const response = await fetch(`${API_URL}${path}`, {
headers: { "Content-Type": "application/json" },
...options
});
if (!response.ok) {
let detail = `API ${path} failed`;
try {
const payload = await response.json();
detail = payload?.detail || detail;
} catch {
detail = (await response.text()) || detail;
}
throw new Error(detail);
}
if (!response.body) throw new Error("Streaming is not supported by this browser.");
const reader = response.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 });
let newlineIndex = buffer.indexOf("\n");
while (newlineIndex >= 0) {
const rawLine = buffer.slice(0, newlineIndex).trim();
buffer = buffer.slice(newlineIndex + 1);
newlineIndex = buffer.indexOf("\n");
if (!rawLine) continue;
let event;
try {
event = JSON.parse(rawLine);
} catch {
continue;
}
if (event.type === "meta" && handlers.onMeta) handlers.onMeta(event);
if (event.type === "stage" && handlers.onStage) handlers.onStage(event);
if (event.type === "what_if" && handlers.onWhatIf) handlers.onWhatIf(event);
if (event.type === "line" && handlers.onLine) handlers.onLine(event);
if (event.type === "token" && handlers.onToken) handlers.onToken(event);
if (event.type === "done" && handlers.onDone) handlers.onDone(event);
if (event.type === "error") {
if (handlers.onError) handlers.onError(event);
throw new Error(event.detail || "Streaming error");
}
}
}
}
function Icon({ name, fill = false }) {
return (
<span className="material-symbols-outlined" style={{ fontVariationSettings: fill ? "'FILL' 1" : undefined }}>
{name}
</span>
);
}
function KpiCard({ label, value, delta, icon, tone = "default" }) {
return (
<article className={`kpi-card tone-${tone}`}>
<div className="kpi-head">
<span>{label}</span>
{icon ? <Icon name={icon} /> : null}
</div>
<strong>{value}</strong>
{delta ? <small>{delta}</small> : null}
</article>
);
}
function Panel({ title, children, action, className = "" }) {
const hint = Object.entries(PANEL_HINTS).find(([key]) => title.startsWith(key))?.[1];
return (
<section className={`panel ${className}`}>
<div className="panel-head">
<div className="panel-title-wrap">
<h3 tabIndex={0} aria-label={hint ? `${title}. ${hint}` : title}>{title}</h3>
{hint ? <span className="panel-hint-tooltip">{hint}</span> : null}
</div>
{action}
</div>
{children}
</section>
);
}
function PageHeader({ eyebrow, title, description }) {
return (
<div className="page-header">
<p>{eyebrow}</p>
<h2>{title}</h2>
<span>{description}</span>
</div>
);
}
function AppFooter({ activePage, loading, aiRuntime, analytics, etl }) {
const dataTimestamp = analytics?.kpis?.last_data_refresh || etl?.status?.finished_at || aiRuntime?.updated_at;
const systemStatus = loading ? "Data syncing" : "System ready";
return (
<footer className="app-footer">
<div className="app-footer-brand">
<strong>PropertyVision BI</strong>
<span>Decision Intelligence for real estate analysis and executive reporting.</span>
</div>
<div className="app-footer-meta">
<span>Version {APP_VERSION}</span>
<span>© {new Date().getFullYear()} PropertyVision</span>
<span>Updated {dataTimestamp ? formatDateTime(dataTimestamp) : "Chưa cập nhật"}</span>
<span>{systemStatus}</span>
</div>
</footer>
);
}
function buildFilterSummary(filters) {
const parts = [];
if (filters.city) parts.push(filters.city);
parts.push(filters.districts?.length ? `${filters.districts.length} khu vực` : "Toàn bộ khu vực");
if (filters.property_types?.length) parts.push(`${filters.property_types.length} loại tài sản`);
if (filters.roi_min !== null && filters.roi_min !== undefined) parts.push(`ROI >= ${Number(filters.roi_min).toFixed(1)}%`);
if (filters.price_max !== null && filters.price_max !== undefined) parts.push(`Giá <= ${Number(filters.price_max).toFixed(1)} tỷ`);
return parts.join(" · ");
}
function isDefaultFilters(filters) {
return (
(filters.city || emptyFilters.city) === emptyFilters.city
&& JSON.stringify(filters.districts || []) === JSON.stringify(emptyFilters.districts)
&& JSON.stringify(filters.property_types || []) === JSON.stringify(emptyFilters.property_types)
&& filters.price_min === emptyFilters.price_min
&& filters.price_max === emptyFilters.price_max
&& filters.area_min === emptyFilters.area_min
&& filters.area_max === emptyFilters.area_max
&& filters.roi_min === emptyFilters.roi_min
&& filters.roi_max === emptyFilters.roi_max
);
}
function AppShell({ activePage, setActivePage, children, loading, metadata, filters, setFilters, aiRuntime, analytics, etl }) {
const [filtersOpen, setFiltersOpen] = useState(false);
const [navTip, setNavTip] = useState(null);
function navigate(page) {
window.history.pushState({}, "", page.path);
setActivePage(page.id);
}
function showNavTip(page, event) {
const rect = event.currentTarget.getBoundingClientRect();
setNavTip({
title: page.label,
description: page.description,
top: rect.top + rect.height / 2,
});
}
const filterSummary = buildFilterSummary(filters);
const resetFilters = () => setFilters(emptyFilters);
const hasActiveFilters = !isDefaultFilters(filters);
const runtimeStage = aiStageInfo(aiRuntime?.status, !aiRuntime);
useEffect(() => {
const handler = () => setActivePage(currentPageId());
window.addEventListener("popstate", handler);
return () => window.removeEventListener("popstate", handler);
}, [setActivePage]);
useEffect(() => {
function handleEscape(event) {
if (event.key === "Escape") setFiltersOpen(false);
}
window.addEventListener("keydown", handleEscape);
return () => window.removeEventListener("keydown", handleEscape);
}, []);
return (
<div className="enterprise-shell">
<aside className="side-nav">
<div className="brand-block">
<div className="brand-mark">PV</div>
<div>
<h1>PropertyVision</h1>
<p>Decision Intelligence</p>
</div>
</div>
<nav className="main-nav">
{pages.map((page) => (
<button
key={page.id}
className={activePage === page.id ? "active" : "" }
onClick={() => navigate(page)}
onMouseEnter={(event) => showNavTip(page, event)}
onMouseLeave={() => setNavTip(null)}
onFocus={(event) => showNavTip(page, event)}
onBlur={() => setNavTip(null)}
title={page.description}
aria-label={`${page.label}. ${page.description}`}
>
<Icon name={page.icon} fill={activePage === page.id} />
<span className="nav-label">{page.label}</span>
</button>
))}
</nav>
<GlobalFilters metadata={metadata} filters={filters} setFilters={setFilters} />
</aside>
{navTip ? (
<div className="nav-tooltip" style={{ top: `${Math.round(navTip.top)}px` }}>
<strong>{navTip.title}</strong>
<span>{navTip.description}</span>
</div>
) : null}
<header className="top-appbar">
<div className="top-appbar-left">
<div className="crumb">
<Icon name="search" />
<span>PROPERTYVISION / {pages.find((page) => page.id === activePage)?.short?.toUpperCase()}</span>
</div>
<div className="filter-status-row">
<small className="filter-status-line">{filterSummary}</small>
{hasActiveFilters ? <button type="button" className="clear-filters-btn" onClick={resetFilters}>Xóa bộ lọc</button> : null}
</div>
</div>
<strong></strong>
<div className="top-actions">
<button type="button" className="filter-drawer-trigger" onClick={() => setFiltersOpen(true)}>
<Icon name="tune" />
<span>Bộ lọc</span>
</button>
{loading ? <span className="live-pill">Đang cập nhật</span> : <span className="live-pill online">Đang hoạt động</span>}
<span className={`badge ${runtimeStage.tone}`}>{runtimeStage.label}</span>
<Icon name="notifications_active" />
<Icon name="help_outline" />
<Icon name="account_circle" />
</div>
</header>
{filtersOpen ? <button type="button" className="drawer-backdrop" aria-label="Đóng bộ lọc" onClick={() => setFiltersOpen(false)} /> : null}
<GlobalFilters
metadata={metadata}
filters={filters}
setFilters={setFilters}
open={filtersOpen}
onClose={() => setFiltersOpen(false)}
/>
<main className="page-canvas">
<div className="page-content">{children}</div>
<AppFooter activePage={activePage} loading={loading} aiRuntime={aiRuntime} analytics={analytics} etl={etl} />
</main>
</div>
);
}
function GlobalFilters({ metadata, filters, setFilters, open, onClose }) {
const [districtQuery, setDistrictQuery] = useState("");
if (!metadata) return null;
const cities = metadata.cities || [];
const districtOptions = metadata.districts_by_city?.[filters.city] || [];
const normalizedQuery = districtQuery.trim().toLowerCase();
const visibleDistricts = normalizedQuery
? districtOptions.filter((district) => district.toLowerCase().includes(normalizedQuery))
: districtOptions;
function updateDistricts(nextDistricts) {
setFilters((current) => ({ ...current, districts: nextDistricts }));
}
function toggleDistrict(district) {
const selected = filters.districts.includes(district)
? filters.districts.filter((item) => item !== district)
: [...filters.districts, district];
updateDistricts(selected);
}
const previewDistricts = filters.districts.slice(0, 3);
const remainingDistricts = Math.max(0, filters.districts.length - previewDistricts.length);
useEffect(() => {
setDistrictQuery("");
}, [filters.city]);
return (
<aside className={`global-filters drawer-panel ${open ? "open" : ""}`} aria-hidden={!open}>
<div className="filter-caption">
<span>Bộ lọc điều hành</span>
<div className="filter-caption-actions">
<button type="button" onClick={onClose}>Đóng</button>
<button type="button" onClick={() => setFilters(emptyFilters)}>Đặt lại</button>
</div>
</div>
<div className="filter-summary">
<strong>{filters.city || "Toàn bộ thị trường"}</strong>
<div className="filter-chip-row">
{filters.districts.length ? (
<>
{previewDistricts.map((district) => (
<button key={district} type="button" className="filter-chip" onClick={() => toggleDistrict(district)}>
{district}
</button>
))}
{remainingDistricts ? <span className="filter-chip more">+{remainingDistricts}</span> : null}
</>
) : (
<span className="filter-chip muted">Toàn bộ quận/huyện</span>
)}
</div>
</div>
<div className="filter-controls">
<Disclosure title="Phạm vi thị trường" defaultOpen>
<label>
Thành phố
<select
value={filters.city || cities[0] || ""}
onChange={(event) => setFilters((current) => ({ ...current, city: event.target.value, districts: [] }))}
>
{cities.map((city) => <option key={city}>{city}</option>)}
</select>
</label>
</Disclosure>
<Disclosure title="Khu vực theo quận / huyện" defaultOpen>
<label>
Quận / huyện
<input
type="text"
placeholder="Tìm quận/huyện..."
value={districtQuery}
onChange={(event) => setDistrictQuery(event.target.value)}
/>
<div className="district-picker-actions">
<button type="button" onClick={() => updateDistricts(districtOptions)}>Chọn tất cả</button>
<button type="button" onClick={() => updateDistricts([])}>Bỏ chọn</button>
</div>
<div className="district-checklist">
{visibleDistricts.map((district) => (
<label key={district} className="district-option">
<input
type="checkbox"
checked={filters.districts.includes(district)}
onChange={() => toggleDistrict(district)}
/>
<span>{district}</span>
</label>
))}
{!visibleDistricts.length ? <p className="district-empty">Không tìm thấy khu vực phù hợp.</p> : null}
</div>
<small>{filters.districts.length ? `Đã chọn ${filters.districts.length} khu vực` : "Giữ trống để xem toàn bộ thành phố"}</small>
</label>
</Disclosure>
<Disclosure title="Ngưỡng đầu tư" defaultOpen>
<label>
Giá tối đa: {filters.price_max ? `${filters.price_max.toFixed(1)} tỷ` : "toàn bộ"}
<input
type="range"
min={metadata.price_range[0]}
max={metadata.price_range[1]}
step="0.1"
value={filters.price_max || metadata.price_range[1]}
onChange={(event) => setFilters((current) => ({ ...current, price_max: Number(event.target.value) }))}
/>
</label>
<label>
ROI tối thiểu: {filters.roi_min ? `${filters.roi_min.toFixed(1)}%` : "toàn bộ"}
<input
type="range"
min={metadata.roi_range[0]}
max={metadata.roi_range[1]}
step="0.5"
value={filters.roi_min || metadata.roi_range[0]}
onChange={(event) => setFilters((current) => ({ ...current, roi_min: Number(event.target.value) }))}
/>
</label>
</Disclosure>
</div>
</aside>
);
}
function OverviewPage({ activePage, analytics, typeShare, filters }) {
const kpis = analytics?.kpis || {};
const [growthStressPct, setGrowthStressPct] = useState(8);
const [liveGrowthRecommendation, setLiveGrowthRecommendation] = useState(null);
const [growthStatus, setGrowthStatus] = useState("idle");
const executiveTrend = useMemo(() => buildExecutiveTrend(analytics?.timeline || [], growthStressPct), [analytics?.timeline, growthStressPct]);
const trendSnapshots = useMemo(() => buildTrendSnapshots(executiveTrend), [executiveTrend]);
const executiveBriefs = useMemo(() => buildExecutiveBriefs(analytics?.districts || []), [analytics?.districts]);
const projected2050 = executiveTrend[executiveTrend.length - 1];
const overviewRecommendation = useMemo(
() => buildOverviewRecommendation(kpis, analytics?.risky || [], growthStressPct),
[kpis, analytics?.risky, growthStressPct]
);
const growthPayload = useMemo(
() => buildOverviewGrowthPayload(analytics || {}, filters || emptyFilters, growthStressPct),
[analytics, filters, growthStressPct]
);
useEffect(() => {
if (activePage !== "overview" || !growthPayload?.district) return;
let cancelled = false;
setGrowthStatus("loading");
const timer = window.setTimeout(async () => {
try {
const recommendation = await request("/api/recommendation/future", { method: "POST", body: JSON.stringify(growthPayload) });
if (!cancelled) {
setLiveGrowthRecommendation(recommendation);
setGrowthStatus("ready");
}
} catch {
if (!cancelled) {
setGrowthStatus("error");
}
}
}, 450);
return () => {
cancelled = true;
window.clearTimeout(timer);
};
}, [activePage, growthPayload]);
return (
<>
<PageHeader
eyebrow="Điều hành"
title="Tổng quan Quyết định Chiến lược"
description="Tóm tắt diễn biến thị trường, hiệu quả danh mục và khuyến nghị hành động dành cho lãnh đạo."
/>
<InsightHero
title={overviewRecommendation.title}
body={overviewRecommendation.body}
meta={projected2050 ? `Theo giả định hiện tại, quy giá trị thể đạt khoảng ${projected2050.price_billion.toFixed(1)} tỷ vào năm 2050 kịch bản sở.` : ""}
/>
<div className="kpi-grid">
<KpiCard label="Tổng giá trị thị trường" value={money(kpis.total_value)} delta="Theo bộ lọc hiện tại" icon="account_balance" />
<KpiCard label="Giá trung vị" value={money(kpis.median_price)} delta="Mức giá đại diện" icon="sell" />
<KpiCard label="ROI bình quân" value={pct(kpis.avg_roi)} delta="Hiệu quả danh mục" icon="monitoring" tone="good" />
<KpiCard label="Khu vực ưu tiên" value={kpis.best_district || "N/A"} delta={`${(kpis.best_score || 0).toFixed(1)}/100`} icon="location_city" />
<KpiCard label="Số bản ghi thị trường" value={(kpis.transaction_count || 0).toLocaleString("vi-VN")} delta={`Độ tin cậy ${((kpis.avg_confidence || 0) * 100).toFixed(0)}%`} icon="swap_horiz" />
</div>
<div className="grid-12">
<Panel title="Xu hướng điều hành 2025–2050" className="span-8">
<ResponsiveContainer width="100%" height={340}>
<ComposedChart data={executiveTrend}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="year" ticks={[2025, 2030, 2035, 2040, 2045, 2050]} />
<YAxis yAxisId="left" tickFormatter={(v) => `${v.toFixed(0)} tỷ`} />
<YAxis yAxisId="right" orientation="right" tickFormatter={(v) => `${v.toFixed(0)}%`} />
<Tooltip formatter={(v) => Number(v).toFixed(2)} />
<Legend />
<Area yAxisId="left" type="monotone" dataKey="price_billion" name="Giá trị cơ sở (tỷ)" fill="#dbeafe" stroke="#2563eb" />
<Line yAxisId="right" type="monotone" dataKey="roi_pct" name="ROI điều hành (%)" stroke="#0f766e" strokeWidth={2} dot={false} />
</ComposedChart>
</ResponsiveContainer>
<p className="panel-note prominent">Biểu đồ này được trình bày theo góc nhìn điều hành: tập trung vào xu hướng dài hạn và mức sinh lời chung thay vì chi tiết biến động ngắn hạn.</p>
<div className="trend-snapshot-grid">
{trendSnapshots.map((row) => (
<article className={`trend-snapshot ${row.emphasis}`} key={row.year}>
<span>Năm {row.year}</span>
<strong>{row.price_billion.toFixed(1)} tỷ</strong>
<small>ROI {row.roi_pct.toFixed(1)}%</small>
</article>
))}
</div>
</Panel>
<Panel title="Kiểm tra giả định tăng trưởng" className="span-4">
<div className="control-stack">
<label>
Tăng trưởng giả định: {growthStressPct.toFixed(1)}%
<input type="range" min="-5" max="18" step="0.5" value={growthStressPct} onChange={(e) => setGrowthStressPct(Number(e.target.value))} />
</label>
<small className="panel-note">Dữ liệu kiểm tra được lấy từ KPI và khu vực dẫn đầu đang hiển thị trên trang này.</small>
</div>
<div className={`scenario-recommendation tone-${growthStatus === "error" ? "warn" : growthStatus === "ready" ? "good" : "neutral"}`}>
<div className="scenario-recommendation-head">
<strong>{growthStatus === "ready" ? "RAG đã trả kết quả" : growthStatus === "loading" ? "RAG đang truy xuất và phân tích" : "Khuyến nghị chờ làm mới"}</strong>
{growthStatus === "loading" ? (
<span className="generation-badge scenario-badge">
<span className="streaming-dots" aria-hidden="true"><i /><i /><i /></span>
Đang xử lý giả định tăng trưởng
</span>
) : null}
</div>
{growthStatus === "loading" ? (
<div className="scenario-processing">
<p>Hệ thống đang nối dữ liệu RAG, so sánh kịch bản tăng trưởng và sinh khuyến nghị điều hành.</p>
<div className="simulation-progress"><i /></div>
</div>
) : null}
<p>{liveGrowthRecommendation?.answer || overviewRecommendation.body}</p>
{liveGrowthRecommendation?.why ? <p><b>Lý do:</b> {liveGrowthRecommendation.why}</p> : null}
{liveGrowthRecommendation?.suggestion ? <p><b>Hành động:</b> {liveGrowthRecommendation.suggestion}</p> : null}
{(liveGrowthRecommendation?.risks || []).length ? <div className="scenario-risk-list"><b>Rủi ro cần theo dõi</b><ul>{liveGrowthRecommendation.risks.map((risk, index) => <li key={index}>{risk}</li>)}</ul></div> : null}
<small>{liveGrowthRecommendation ? `${liveGrowthRecommendation.model} · ${liveGrowthRecommendation.retrieval_time_ms} ms` : "Cập nhật theo từng lần kéo thanh"}</small>
</div>
</Panel>
<Panel
title="Khuyến nghị điều hành"
className="span-12"
action={<span className="panel-hint">Sinh từ ROI, giá/m², thanh khoản, biến động và RAG</span>}
>
<div className="brief-grid">
{executiveBriefs.map((brief) => (
<article className="brief-card" key={brief.district}>
<div className="brief-head">
<span className="brief-tag">{brief.tag}</span>
<strong>{brief.district}</strong>
</div>
<p>{brief.edge}</p>
<small><b>Lợi thế:</b> {brief.strength}</small>
<small><b>Rủi ro:</b> {brief.risk}</small>
</article>
))}
</div>
</Panel>
</div>
</>
);
}
function MarketPage({ analytics, etl }) {
const districtRows = analytics?.districts || [];
const topScore = [...districtRows].sort((a, b) => b.opportunity_score - a.opportunity_score).slice(0, 3);
const underpriced = [...districtRows].sort((a, b) => a.price_m2_million - b.price_m2_million).slice(0, 3);
const liquidityReadable = [...districtRows].sort((a, b) => b.listings - a.listings).slice(0, 10);
const lastRefresh = analytics?.kpis?.last_data_refresh || etl?.status?.finished_at;
const [marketView, setMarketView] = useState("chart");
return (
<>
<PageHeader
eyebrow="Thị trường"
title="Phân tích thị trường"
description="So sánh ROI, giá/m² và mức độ quan tâm thị trường giữa các khu vực và phân khúc."
/>
<InsightHero
title={topScore[0] ? `${topScore[0].district} đang dẫn đầu về opportunity score` : "Thị trường đang phân hóa theo khu vực"}
body={topScore[0] ? `Điểm ${topScore[0].opportunity_score.toFixed(1)}/100, phù hợp để đưa vào danh sách xem xét đầu tư. Nên đọc cùng giá/m² mức độ quan tâm thị trường để tránh kết luận chỉ dựa trên ROI.` : "Dùng trang này để đọc nhanh khu nào đáng chú ý trước khi đi sâu vào phân tích đa chiều."}
meta={`Dữ liệu cập nhật: ${formatDateTime(lastRefresh)}`}
/>
<div className="status-strip">
<span className="status-strip-label">Cập nhật gần nhất</span>
<strong>{formatDateTime(lastRefresh)}</strong>
<small>Trang thị trường tự đổi theo bộ lọc và nạp lại khi dữ liệu mới được cập nhật.</small>
</div>
<SectionTabs
tabs={[
{ id: "chart", label: "Tóm tắt thị trường" },
{ id: "detail", label: "Chi tiết thêm" }
]}
active={marketView}
onChange={setMarketView}
/>
<div className="grid-12">
{marketView !== "table" ? <>
<Panel title="ROI theo khu vực nổi bật" className="span-6">
<ResponsiveContainer width="100%" height={360}>
<BarChart data={[...districtRows].sort((a, b) => b.roi_pct - a.roi_pct).slice(0, 12)}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="district" interval={0} angle={-25} textAnchor="end" height={90} />
<YAxis tickFormatter={(v) => `${v}%`} />
<Tooltip formatter={(v) => pct(v)} />
<Bar dataKey="roi_pct" fill="#0f766e" name="ROI" />
</BarChart>
</ResponsiveContainer>
</Panel>
<Panel title="Thanh khoản và Giá/m²" className="span-6">
<ResponsiveContainer width="100%" height={360}>
<ComposedChart data={liquidityReadable}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="district" interval={0} angle={-25} textAnchor="end" height={90} />
<YAxis yAxisId="left" tickFormatter={compact} />
<YAxis yAxisId="right" orientation="right" tickFormatter={(v) => `${Number(v).toFixed(0)}tr`} />
<Tooltip formatter={(value, name) => name === "Giá/m²" ? `${Number(value).toFixed(1)} triệu/m²` : Number(value).toLocaleString("vi-VN")} />
<Legend />
<Bar yAxisId="left" dataKey="listings" name="Số tin" fill="#2563eb" />
<Line yAxisId="right" type="monotone" dataKey="price_m2_million" name="Giá/m²" stroke="#d97706" strokeWidth={3} dot={false} />
</ComposedChart>
</ResponsiveContainer>
<p className="panel-note">Cách đọc nhanh: cột càng cao là khu vực được thị trường quan tâm nhiều hơn, đường càng cao là mặt bằng giá/m² càng lớn.</p>
</Panel>
</> : null}
{marketView !== "chart" ? <Panel title="So sánh phân khúc" className="span-12">
<DataTable rows={districtRows.slice(0, 12)} />
</Panel> : null}
<Panel title="Insight phân biệt khu vực" className="span-12">
<div className="insight-grid">
<div><strong>Khu vực nổi bật</strong><p>{topScore.map((row) => `${row.district} (${row.opportunity_score.toFixed(1)})`).join(" · ")}</p></div>
<div><strong>Giá/m² thấp nhất</strong><p>{underpriced.map((row) => `${row.district} (${row.price_m2_million.toFixed(1)}tr/m²)`).join(" · ")}</p></div>
<div><strong>Mốc so sánh</strong><p>Hiệu quả đầu tư nên đọc cùng điểm cơ hội, giá/m² và mức độ quan tâm thị trường để tránh kết luận sai khi các khu vực có ROI gần nhau.</p></div>
</div>
{marketView === "detail" ? <Disclosure title="Mở bảng chi tiết 12 khu vực" defaultOpen={false}><DataTable rows={districtRows.slice(0, 12)} /></Disclosure> : null}
</Panel>
</div>
</>
);
}
function DataTable({ rows }) {
return (
<div className="table-wrap">
<table>
<thead>
<tr>
<th>Khu vực</th>
<th>Hành động</th>
<th>Số tin</th>
<th>Giá trung vị</th>
<th>ROI</th>
<th>Triệu/m²</th>
<th>Điểm</th>
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr key={row.district}>
<td>{row.district}</td>
<td><span className="badge good">{row.opportunity_score >= 65 ? "Mở rộng" : row.roi_pct < 10 ? "Kiểm soát" : "Gom chọn lọc"}</span></td>
<td>{row.listings?.toLocaleString("vi-VN")}</td>
<td>{money(row.median_price)}</td>
<td>{pct(row.roi_pct)}</td>
<td>{Number(row.price_m2_million || 0).toFixed(1)}</td>
<td><Score value={row.opportunity_score} /></td>
</tr>
))}
</tbody>
</table>
</div>
);
}
function Score({ value }) {
const score = Number(value || 0);
return (
<div className="score-cell">
<span><i style={{ width: `${Math.min(100, score)}%` }} /></span>
<b>{score.toFixed(1)}</b>
</div>
);
}
function SlicePage({ sliceDice, sliceConfig, setSliceConfig }) {
const [addressDetail, setAddressDetail] = useState(null);
const [copyState, setCopyState] = useState("idle");
const [searchQuery, setSearchQuery] = useState("");
function openAddressDetail(row) {
setAddressDetail({
segment: row?.[sliceDice?.row_dimension] ?? "N/A",
subSegment: row?.[sliceDice?.column_dimension] ?? "N/A",
records: Number(row?.listings || 0),
addressCount: Number(row?.address_count || 0),
addresses: Array.isArray(row?.addresses) ? row.addresses : []
});
setSearchQuery("");
setCopyState("idle");
}
function closeAddressDetail() {
setAddressDetail(null);
}
function normalizeAddress(address) {
if (typeof address === "string") {
return {
house_number: "",
street_name: "",
ward_name: "",
district_display: "",
property_type_name: "",
address_display: address
};
}
return {
house_number: address?.house_number || "",
street_name: address?.street_name || "",
ward_name: address?.ward_name || "",
district_display: address?.district_display || "",
property_type_name: address?.property_type_name || "",
address_display: address?.address_display || "N/A"
};
}
const visibleAddresses = useMemo(() => {
const source = Array.isArray(addressDetail?.addresses) ? addressDetail.addresses : [];
const query = searchQuery.trim().toLowerCase();
if (!query) return source;
return source.filter((address) => {
const item = normalizeAddress(address);
const haystack = [
item.house_number,
item.street_name,
item.ward_name,
item.district_display,
item.property_type_name,
item.address_display
].join(" ").toLowerCase();
return haystack.includes(query);
});
}, [addressDetail, searchQuery]);
const hasAnyHouseNumber = useMemo(
() => visibleAddresses.some((address) => Boolean(normalizeAddress(address).house_number)),
[visibleAddresses]
);
function buildAddressTextRows(addresses) {
return addresses.map((address, index) => {
const item = normalizeAddress(address);
const numbered = `${index + 1}. ${item.address_display}`;
return numbered;
});
}
function buildAddressTableRows(addresses) {
return addresses.map((address, index) => {
const item = normalizeAddress(address);
const columns = [
index + 1,
item.house_number || "",
item.street_name || "",
item.ward_name || "",
item.district_display || "",
item.address_display || ""
];
return columns.join(" | ");
});
}
function escapeCsv(value) {
const text = String(value ?? "");
return `"${text.replaceAll('"', '""')}"`;
}
function buildCsvRows(addresses) {
const header = ["STT", "Số nhà", "Đường", "Phường/Xã", "Quận/Huyện", "Địa chỉ"];
const rows = addresses.map((address, index) => {
const item = normalizeAddress(address);
return [
index + 1,
item.house_number || "",
item.street_name || "",
item.ward_name || "",
item.district_display || "",
item.address_display || ""
].map(escapeCsv).join(",");
});
return [header.map(escapeCsv).join(","), ...rows];
}
function safeFilename(value) {
return String(value || "addresses")
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/[^a-zA-Z0-9]+/g, "_")
.replace(/^_+|_+$/g, "")
.toLowerCase() || "addresses";
}
async function copyText(kind) {
if (!visibleAddresses.length) return;
const lines = kind === "table"
? [
"STT | Số nhà | Đường | Phường/Xã | Quận/Huyện | Địa chỉ",
...buildAddressTableRows(visibleAddresses)
]
: buildAddressTextRows(visibleAddresses);
try {
await navigator.clipboard.writeText(lines.join("\n"));
setCopyState(kind === "table" ? "copied_table" : "copied_list");
window.setTimeout(() => setCopyState("idle"), 1800);
} catch {
setCopyState("error");
window.setTimeout(() => setCopyState("idle"), 1800);
}
}
async function downloadCsv() {
if (!visibleAddresses.length) return;
const csv = `\ufeff${buildCsvRows(visibleAddresses).join("\n")}`;
const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" });
const url = window.URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
const base = `${safeFilename(addressDetail?.segment)}_${safeFilename(addressDetail?.subSegment)}`;
anchor.download = `${base || "propertyvision_addresses"}.csv`;
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
window.URL.revokeObjectURL(url);
setCopyState("downloaded_csv");
window.setTimeout(() => setCopyState("idle"), 1800);
}
return (
<>
<PageHeader eyebrow="Đa chiều" title="Phân tích đa chiều" description="So sánh dữ liệu theo nhiều góc nhìn để tìm phân khúc đáng ưu tiên cho doanh nghiệp." />
<div className="grid-12">
<Panel title="Thiết lập phân tích" className="span-4">
<div className="control-stack">
<label>Chiều phân tích chính<select value={sliceConfig.row_dimension} onChange={(e) => setSliceConfig({ ...sliceConfig, row_dimension: e.target.value })}>{Object.entries(sliceDice?.dimensions || {}).map(([key, label]) => <option value={key} key={key}>{label}</option>)}</select></label>
<label>Chiều phân tích phụ<select value={sliceConfig.column_dimension} onChange={(e) => setSliceConfig({ ...sliceConfig, column_dimension: e.target.value })}>{Object.entries(sliceDice?.dimensions || {}).map(([key, label]) => <option value={key} key={key}>{label}</option>)}</select></label>
<label>Chỉ tiêu xem xét<select value={sliceConfig.metric} onChange={(e) => setSliceConfig({ ...sliceConfig, metric: e.target.value })}>{Object.entries(sliceDice?.metrics || {}).map(([key, label]) => <option value={key} key={key}>{label}</option>)}</select></label>
</div>
</Panel>
<Panel title="Thông tin so sánh" className="span-8">
<div className="kpi-grid compact">
<KpiCard label="Số bản ghi đang xét" value={(sliceDice?.filter_context?.filtered_records || 0).toLocaleString("vi-VN")} delta={`${(sliceDice?.filter_context?.coverage_pct || 0).toFixed(1)}% độ bao phủ`} />
<KpiCard label="ROI trong bộ lọc" value={pct(sliceDice?.benchmark?.filtered_avg_roi)} delta={`Toàn thị trường ${pct(sliceDice?.benchmark?.market_avg_roi)}`} />
<KpiCard label="Giá/m² trong bộ lọc" value={money(sliceDice?.benchmark?.filtered_avg_price_m2)} delta={`Toàn thị trường ${money(sliceDice?.benchmark?.market_avg_price_m2)}`} />
</div>
</Panel>
<Panel title={`Ma trận hiệu quả (${sliceDice?.metric_label || "Chỉ tiêu"})`} className="span-12">
<div className="table-wrap">
<table>
<thead><tr><th>Segment</th>{(sliceDice?.columns || []).map((column) => <th key={column}>{column}</th>)}</tr></thead>
<tbody>{(sliceDice?.matrix || []).slice(0, 18).map((row) => <tr key={row.segment}><td>{row.segment}</td>{(sliceDice?.columns || []).map((column) => <td key={column}>{formatMetric(row[column], sliceConfig.metric)}</td>)}</tr>)}</tbody>
</table>
</div>
</Panel>
<Panel title="Phân đoạn tiềm năng cao" className="span-12">
<div className="table-wrap">
<table>
<thead><tr><th>Segment</th><th>Sub segment</th><th>Records</th><th>ROI</th><th>Giá/m²</th><th>Score</th></tr></thead>
<tbody>{(sliceDice?.top_segments || []).map((row, index) => <tr key={index}><td>{row[sliceDice.row_dimension]}</td><td>{row[sliceDice.column_dimension]}</td><td><button type="button" className="record-link" onClick={() => openAddressDetail(row)}>{row.listings?.toLocaleString("vi-VN")}</button></td><td>{pct(row.avg_roi)}</td><td>{money(row.avg_price_m2)}</td><td>{Number(row.opportunity_score || 0).toFixed(1)}</td></tr>)}</tbody>
</table>
</div>
</Panel>
</div>
{addressDetail ? (
<>
<button type="button" className="detail-backdrop" aria-label="Đóng danh sách địa chỉ" onClick={closeAddressDetail} />
<section className="record-detail-modal" role="dialog" aria-modal="true" aria-labelledby="record-detail-title">
<div className="record-detail-head">
<div>
<p>Danh sách địa chỉ</p>
<h4 id="record-detail-title">{addressDetail.segment} / {addressDetail.subSegment}</h4>
</div>
<div className="record-detail-actions">
<button type="button" className="detail-copy-button" onClick={() => copyText("list")} disabled={!visibleAddresses.length}>
{copyState === "copied_list" ? "Đã copy danh sách" : "Copy đánh số"}
</button>
<button type="button" className="detail-copy-secondary" onClick={() => copyText("table")} disabled={!visibleAddresses.length}>
{copyState === "copied_table" ? "Đã copy bảng" : "Copy bảng"}
</button>
<button type="button" className="detail-download-button" onClick={downloadCsv} disabled={!visibleAddresses.length}>
{copyState === "downloaded_csv" ? "Đã tải CSV" : "Tải CSV"}
</button>
<button type="button" className="detail-close-button" onClick={closeAddressDetail}>Đóng</button>
</div>
</div>
<div className="record-detail-searchbar">
<div className="record-detail-search-row">
<input
type="search"
className="record-detail-search"
placeholder="Tìm số nhà, đường, phường hoặc quận..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
<button type="button" className="detail-clear-button" onClick={() => setSearchQuery("")} disabled={!searchQuery}>
Xóa lọc
</button>
</div>
<small>{visibleAddresses.length.toLocaleString("vi-VN")} / {addressDetail.addresses.length.toLocaleString("vi-VN")} địa chỉ đang hiển thị</small>
{!hasAnyHouseNumber ? (
<div className="record-detail-note">Không có số nhà, chỉ có địa chỉ cấp xã/huyện.</div>
) : null}
</div>
<div className="record-detail-meta">
<span>{addressDetail.records.toLocaleString("vi-VN")} bản ghi</span>
<span>{addressDetail.addressCount.toLocaleString("vi-VN")} địa chỉ</span>
<span>{visibleAddresses.length.toLocaleString("vi-VN")} địa chỉ xem nhanh</span>
</div>
<div className="record-detail-body">
{visibleAddresses.length ? (
<ul className="record-detail-list">
{visibleAddresses.map((address, idx) => {
const item = normalizeAddress(address);
const houseNumber = item.house_number;
const streetName = item.street_name;
const wardName = item.ward_name;
const districtDisplay = item.district_display;
const propertyType = item.property_type_name;
return (
<li key={`${item.address_display}-${idx}`}>
<div className="record-address-head">
<strong><span className="record-address-index">{idx + 1}</span>{item.address_display}</strong>
{houseNumber ? <span className="record-address-pill">Số nhà {houseNumber}</span> : null}
</div>
<div className="record-address-foot">
{propertyType ? <span>{propertyType}</span> : null}
{(streetName || wardName || districtDisplay) ? <small>{[streetName, wardName, districtDisplay].filter(Boolean).join(" · ")}</small> : null}
</div>
</li>
);
})}
</ul>
) : (
<p className="record-detail-empty">Chưa có địa chỉ chi tiết cho phân đoạn này.</p>
)}
</div>
</section>
</>
) : null}
</>
);
}
function formatMetric(value, metric) {
if (metric?.includes("price") || metric === "total_value") return money(value);
if (metric?.includes("roi")) return pct(value);
return Number(value || 0).toFixed(2);
}
function RecommendationSection({ label, children }) {
if (!children) return null;
return (
<section className="recommendation-section">
<span>{label}</span>
<div>{children}</div>
</section>
);
}
function DecisionPage({ analytics, metadata, filters, decisionView, setDecisionView, predictForm, setPredictForm, simulationForm, setSimulationForm, prediction, whatIf, aiRecommendation, runWhatIf, simulationLoading, simulationStage }) {
const availableDistricts = metadata?.districts_by_city?.[filters.city] || metadata?.districts || [];
const showRoomFields = propertyNeedsRoomFields(predictForm.property_type);
const showFloorField = propertyNeedsFloorField(predictForm.property_type);
const scenarioProjection = (whatIf?.projection || []).map((row) => ({ ...row, calendar_year: 2025 + Number(row.year || 0) }));
const scenarioMilestones = buildScenarioMilestones(whatIf ? { ...whatIf, projection: scenarioProjection } : null);
const chartSpec = normalizeChartSpec(aiRecommendation?.chart_spec);
const chartTitle = chartSpec.title || chartTitleForDecisionTab(decisionView);
const chartType = chartSpec.chart_type || chartTypeForDecisionTab(decisionView);
const cleanRecommendationAnswer = sanitizeRecommendationText(aiRecommendation?.answer || "");
const cleanRecommendationWhy = sanitizeRecommendationText(aiRecommendation?.why || "");
const cleanRecommendationSuggestion = sanitizeRecommendationText(aiRecommendation?.suggestion || "");
const cleanRecommendationRisks = sanitizeRecommendationList(aiRecommendation?.risks || []);
const cleanRecommendationBasis = sanitizeRecommendationList(aiRecommendation?.basis || []);
const recommendationTone = cleanRecommendationRisks.some((item) => /l[oỗ]|g[ii]am|rủi ro|rui ro/i.test(item)) ? "warn" : aiRecommendation?.llm_available ? "good" : "neutral";
const recommendationStage = aiStageInfo(aiRecommendation?.status || aiRecommendation?.mode, Boolean(cleanRecommendationAnswer) && !aiRecommendation?.mode?.includes("error"));
const typedRecommendation = useTypewriterText(cleanRecommendationAnswer, Boolean(cleanRecommendationAnswer), 12);
const typedWhy = useTypewriterText(cleanRecommendationWhy, Boolean(cleanRecommendationWhy), 12);
const typedSuggestion = useTypewriterText(cleanRecommendationSuggestion, Boolean(cleanRecommendationSuggestion), 12);
const recommendationIsStreaming = aiRecommendation?.status === "streaming" || simulationLoading;
const recommendationText = recommendationIsStreaming ? cleanRecommendationAnswer : typedRecommendation || cleanRecommendationAnswer;
const endYear = DECISION_BASE_YEAR + Number(simulationForm.years || 0);
return (
<>
<PageHeader eyebrow="Mô phỏng đầu tư" title="Mô phỏng quyết định đầu tư" description="Mô phỏng kết quả đầu tư theo nhiều kịch bản để hỗ trợ ra quyết định." />
<SectionTabs
tabs={[
{ id: "asset", label: "Thông số tài sản" }
]}
active={decisionView}
onChange={setDecisionView}
/>
<form className="grid-12" onSubmit={runWhatIf}>
{decisionView !== "scenario" ? <Panel title="Thông số tài sản" className="span-3">
<div className="control-stack">
<select value={predictForm.district} onChange={(e) => setPredictForm({ ...predictForm, district: e.target.value })}>{availableDistricts.map((item) => <option key={item}>{item}</option>)}</select>
<select value={predictForm.property_type} onChange={(e) => setPredictForm({ ...predictForm, property_type: e.target.value })}>{metadata?.property_types?.map((item) => <option key={item}>{item}</option>)}</select>
<select value={predictForm.legal_documents} onChange={(e) => setPredictForm({ ...predictForm, legal_documents: e.target.value })}>{metadata?.legal_documents?.map((item) => <option key={item}>{item}</option>)}</select>
<label>Diện tích<input type="number" value={predictForm.area} onChange={(e) => setPredictForm({ ...predictForm, area: e.target.value })} /></label>
{showRoomFields ? <label>Số phòng ngủ<input type="number" value={predictForm.bedrooms} onChange={(e) => setPredictForm({ ...predictForm, bedrooms: e.target.value })} /></label> : <p className="panel-note">Loại tài sản này không cần nhập số phòng ngủ và số nhà vệ sinh.</p>}
{showRoomFields ? <label>Số nhà vệ sinh<input type="number" value={predictForm.toilets} onChange={(e) => setPredictForm({ ...predictForm, toilets: e.target.value })} /></label> : null}
{showFloorField ? <label>Số tầng<input type="number" value={predictForm.floors} onChange={(e) => setPredictForm({ ...predictForm, floors: e.target.value })} /></label> : null}
<label>ROI kỳ vọng<input type="number" step="0.01" value={predictForm.roi_expected} onChange={(e) => setPredictForm({ ...predictForm, roi_expected: e.target.value })} /></label>
</div>
</Panel> : null}
<Panel title="Giả định đầu tư" className={decisionView === "scenario" ? "span-4" : "span-4"}>
<div className="control-stack">
<label>Ngân sách: {Number(simulationForm.budget_billion).toFixed(1)} tỷ<input type="range" min="1" max="100" step="0.5" value={simulationForm.budget_billion} onChange={(e) => setSimulationForm({ ...simulationForm, budget_billion: e.target.value })} /></label>
<label>Tăng trưởng/năm: {Number(simulationForm.annual_growth_pct).toFixed(1)}%<input type="range" min="-5" max="25" step="0.5" value={simulationForm.annual_growth_pct} onChange={(e) => setSimulationForm({ ...simulationForm, annual_growth_pct: e.target.value })} /></label>
<label>Nắm giữ đến năm: {endYear} ({simulationForm.years} năm)<input type="range" min="1" max={DECISION_MAX_YEAR - DECISION_BASE_YEAR} step="1" value={simulationForm.years} onChange={(e) => setSimulationForm({ ...simulationForm, years: e.target.value })} /></label>
<button className="primary-btn" type="submit" disabled={simulationLoading}>{simulationLoading ? "Đang mô phỏng..." : "Chạy mô phỏng đầu tư"}</button>
<div className={`simulation-status ${simulationLoading ? "loading" : "ready"}`}>
<span className={`badge ${simulationLoading ? "loading" : "good"}`}>{simulationLoading ? "Simulation loading" : "Ready"}</span>
<div className="simulation-progress" aria-hidden="true">
<i />
</div>
</div>
</div>
</Panel>
<Panel title="Kết quả tài chính dự kiến" className={decisionView === "scenario" ? "span-8" : "span-5"}>
{simulationLoading ? (
<div className="assistant-streaming simulation-streaming">
<span className="streaming-dots"><i /><i /><i /></span>
<div>
<strong>{simulationStage || "Đang tính toán mô phỏng tài chính..."}</strong>
<p>Hệ thống đang cập nhật số liệu, chạy kịch bản và sinh khuyến nghị theo dữ liệu hiện tại.</p>
</div>
</div>
) : null}
{simulationLoading && !whatIf ? <><ResultSkeleton /></> : whatIf ? <div className="result-grid"><KpiCard label="Giá trị tương lai" value={money(whatIf.summary.future_value)} /><KpiCard label="Lợi nhuận vốn" value={money(whatIf.summary.capital_gain)} /><KpiCard label="ROI tích lũy" value={pct(whatIf.summary.cumulative_roi_pct)} /><KpiCard label="Thời gian hoàn vốn" value={`${whatIf.summary.payback_years?.toFixed(1)} năm`} /></div> : <p className="muted">Chạy mô phỏng để xem giá trị tương lai, ROI và thời gian hoàn vốn.</p>}
{simulationLoading ? <span className="generation-badge">Mô hình đang xử lý kịch bản và khuyến nghị</span> : null}
{prediction ? <p className="model-note">Mức giá tham chiếu: <b>{money(prediction.predicted_price)}</b> | Sai số bình quân {money(prediction.model.mae)}</p> : null}
</Panel>
<Panel title="Triển vọng giá trị theo kịch bản" className="span-12">
{whatIf ? <DecisionChart whatIf={whatIf} chartSpec={{ ...chartSpec, chart_type: chartType }} /> : <p className="muted">Biểu đồ sẽ hiển thị triển vọng theo ba kịch bản đầu tư.</p>}
<p className="panel-note">Biểu đồ dùng mốc năm lịch để người xem thấy rõ từ năm nào danh mục có thể chịu áp lực, hoà vốn hay tăng trưởng tốt.</p>
</Panel>
<Panel title="Mốc thời gian & tình huống thua lỗ" className="span-7">
{scenarioMilestones.length ? <div className="table-wrap"><table><thead><tr><th>Năm</th><th>Xấu</th><th>Cơ sở</th><th>Trạng thái</th></tr></thead><tbody>{scenarioMilestones.map((row) => <tr key={row.calendar_year}><td>{row.calendar_year}</td><td>{money(row.pessimistic)}</td><td>{money(row.base)}</td><td><span className={`badge ${row.status === "Rủi ro lỗ" ? "high" : row.status === "Hoà vốn yếu" ? "medium" : "good"}`}>{row.status}</span></td></tr>)}</tbody></table></div> : <p className="muted">Chạy mô phỏng để xem mốc năm cụ thể.</p>}
</Panel>
<Panel title="Khuyến nghị đầu tư tương lai" className="span-5">
{aiRecommendation ? (
<div className={`scenario-recommendation tone-${recommendationTone}`}>
<div className="meta-row compact">
<strong>{aiRecommendation.mode === "error" ? "AI gián đoạn" : "Khuyến nghị từ trợ lý phân tích"}</strong>
<span className={`badge ${recommendationStage.tone}`}>{recommendationStage.label}</span>
</div>
<p className="panel-note">Khuyến nghị này chỉ bám đúng Thông tin tài sản và Giả định đầu tư đang chọn trong cùng trang mô phỏng.</p>
{recommendationIsStreaming ? <span className="generation-badge">Đang stream khuyến nghị từ Qwen...</span> : cleanRecommendationAnswer && typedRecommendation !== cleanRecommendationAnswer ? <span className="generation-badge">Đang sinh câu trả lời...</span> : null}
{recommendationText?.trim() ? <RecommendationSection label="Kết luận"><MarkdownBlock text={recommendationText} /></RecommendationSection> : null}
{cleanRecommendationWhy ? <RecommendationSection label="Lý do"><MarkdownBlock text={typedWhy} /></RecommendationSection> : null}
{cleanRecommendationSuggestion ? <RecommendationSection label="Hành động"><MarkdownBlock text={typedSuggestion} /></RecommendationSection> : null}
{cleanRecommendationRisks.length ? <RecommendationSection label="Rủi ro cần nêu"><ul className="scenario-bullet-list">{cleanRecommendationRisks.map((risk, index) => <li key={index}>{risk}</li>)}</ul></RecommendationSection> : null}
{cleanRecommendationBasis.length ? <RecommendationSection label="Cơ sở đưa ra khuyến nghị"><ul className="scenario-bullet-list">{cleanRecommendationBasis.map((item, index) => <li key={index}>{item}</li>)}</ul></RecommendationSection> : null}
<small>{aiRecommendation.model} · {aiRecommendation.retrieval_time_ms} ms</small>
</div>
) : <p className="muted">Chạy mô phỏng để nhận khuyến nghị mua thêm, giữ hay bán bớt theo kịch bản tương lai.</p>}
</Panel>
</form>
</>
);
}
function GisPage({ mapData, analytics }) {
const [riskFilter, setRiskFilter] = useState("all");
const [minRoi, setMinRoi] = useState(0);
const [minScore, setMinScore] = useState(0);
const center = mapData?.center || { latitude: 10.78, longitude: 106.7, zoom: 10 };
const lastRefresh = analytics?.kpis?.last_data_refresh;
const districts = mapData?.districts || [];
const markers = useMemo(() => groupMapMarkers(districts), [districts]);
const maxRoi = markers.length ? Math.max(...markers.map((row) => markerRoi(row))) : 0;
const maxScore = markers.length ? Math.max(...markers.map((row) => markerScore(row))) : 0;
const filteredDistricts = useMemo(
() => markers.filter((row) => {
const roi = markerRoi(row);
const score = markerScore(row);
const riskMatch = riskFilter === "all" || row.risk_level === riskFilter;
return riskMatch && roi >= Number(minRoi || 0) && score >= Number(minScore || 0);
}),
[markers, riskFilter, minRoi, minScore]
);
const leading = [...filteredDistricts].sort((a, b) => markerScore(b) - markerScore(a)).slice(0, 3);
const watchlist = [...filteredDistricts].filter((row) => row.risk_level === "high").sort((a, b) => markerScore(a) - markerScore(b)).slice(0, 4);
const avgRoi = markers.length ? markers.reduce((sum, row) => sum + markerRoi(row), 0) / markers.length : 0;
const avgScore = markers.length ? markers.reduce((sum, row) => sum + markerScore(row), 0) / markers.length : 0;
const chartRows = [...filteredDistricts].sort((a, b) => markerRoi(b) - markerRoi(a)).slice(0, 5);
return (
<>
<PageHeader eyebrow="Bản đồ quy hoạch" title="Bản đồ và quy hoạch" description="Phân tích điểm cơ hội và mức độ rủi ro theo không gian." />
<div className="grid-12">
<Panel title="Bản đồ quy hoạch" className="span-8 map-panel-host">
<div className="map-filter-bar">
<label>
<span>Rủi ro</span>
<select value={riskFilter} onChange={(e) => setRiskFilter(e.target.value)}>
<option value="all">Tất cả</option>
<option value="low">Thấp</option>
<option value="medium">Trung bình</option>
<option value="high">Cao</option>
</select>
</label>
<label>
<span>ROI tối thiểu: {Number(minRoi).toFixed(1)}%</span>
<input type="range" min="0" max={Math.max(20, Math.ceil(maxRoi))} step="0.5" value={minRoi} onChange={(e) => setMinRoi(Number(e.target.value))} />
</label>
<label>
<span>Score tối thiểu: {Number(minScore).toFixed(0)}</span>
<input type="range" min="0" max={Math.max(100, Math.ceil(maxScore))} step="1" value={minScore} onChange={(e) => setMinScore(Number(e.target.value))} />
</label>
<button
type="button"
className="secondary-btn map-filter-reset"
onClick={() => {
setRiskFilter("all");
setMinRoi(0);
setMinScore(0);
}}
>
Bỏ lọc
</button>
</div>
<p className="map-interaction-hint">Có thể kéo để di chuyển, lăn chuột hoặc chụm hai ngón để zoom.</p>
<MapContainer
key={mapData?.city || "default-city"}
center={[center.latitude, center.longitude]}
zoom={center.zoom || 10}
scrollWheelZoom
dragging
touchZoom
doubleClickZoom
boxZoom
zoomControl
className="leaflet-panel main-map"
>
<TileLayer attribution="&copy; OpenStreetMap contributors" url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" />
{(filteredDistricts.length ? filteredDistricts : markers).map((row) => {
const scoreValue = markerScore(row);
const roiValue = markerRoi(row);
const markerLabel = row.district_count > 1 ? `${row.district} · ${row.district_count} khu` : row.district;
return (
<CircleMarker
key={`${row.latitude}-${row.longitude}-${row.district}`}
center={[row.latitude, row.longitude]}
radius={Math.max(8, Math.min(30, scoreValue / 3))}
pathOptions={{ color: row.risk_level === "low" ? "#0f766e" : row.risk_level === "high" ? "#c2410c" : "#d97706", fillOpacity: 0.62 }}
>
<Popup>
<strong>{markerLabel}</strong>
{row.district_count > 1 ? <p>{row.districts.join(" · ")}</p> : null}
<p>Score {scoreValue.toFixed(1)}</p>
<p>ROI {roiValue.toFixed(2)}%</p>
<p>{row.planning_note}</p>
</Popup>
</CircleMarker>
);
})}
</MapContainer>
<div className="map-footer-grid">
<article className="map-fact-card">
<span>Cập nhật</span>
<strong>{formatDateTime(lastRefresh)}</strong>
</article>
<article className="map-fact-card">
<span>Số khu vực</span>
<strong>{markers.length.toLocaleString("vi-VN")}</strong>
<small>Các điểm trên bản đồ đang khớp với thành phố và bộ lọc hiện tại.</small>
</article>
{leading.map((row) => (
<article className="map-fact-card" key={row.district}>
<span>{row.district}</span>
<strong>{row.opportunity_score.toFixed(1)}/100</strong>
<small>{row.risk_level === "high" ? "Cần theo dõi quy hoạch" : row.risk_level === "medium" ? "Rủi ro trung bình" : "Mức rủi ro thấp hơn"}</small>
</article>
))}
</div>
</Panel>
<Panel title="Chú giải và khu vực cần theo dõi" className="span-4">
<div className="map-side-stack">
<div className="map-executive-card">
<span>Tóm tắt điều hành</span>
<strong>{leading[0]?.district || "Chưa có khu dẫn đầu"}</strong>
<small>{filteredDistricts.length ? `${filteredDistricts.length} khu đang khớp bộ lọc` : "Không có khu vực nào khớp bộ lọc hiện tại"}</small>
</div>
<div className="map-legend">
<span><i className="dot low" /> Rủi ro thấp</span>
<span><i className="dot medium" /> Rủi ro trung bình</span>
<span><i className="dot high" /> Rủi ro cao</span>
<small>Kích thước điểm tròn thể hiện điểm cơ hội</small>
</div>
<div className="map-metrics-grid">
<article className="map-metric-card">
<span>ROI TB</span>
<strong>{pct(avgRoi)}</strong>
</article>
<article className="map-metric-card">
<span>Score TB</span>
<strong>{avgScore.toFixed(1)}</strong>
</article>
<article className="map-metric-card">
<span>Top nổi bật</span>
<strong>{leading[0]?.district || "N/A"}</strong>
</article>
<article className="map-metric-card">
<span>Cần theo dõi</span>
<strong>{watchlist[0]?.district || "N/A"}</strong>
</article>
</div>
<div className="map-subsection">
<span className="map-subsection-title">ROI top khu vực</span>
{chartRows.length ? (
<ResponsiveContainer width="100%" height={190}>
<BarChart data={chartRows}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="district" interval={0} angle={-20} textAnchor="end" height={58} tick={{ fontSize: 11 }} />
<YAxis tickFormatter={(v) => `${Number(v).toFixed(0)}%`} />
<Tooltip formatter={(value) => pct(value)} />
<Bar dataKey="roi_pct" fill="#0f766e" radius={[6, 6, 0, 0]} name="ROI" />
</BarChart>
</ResponsiveContainer>
) : (
<p className="map-empty-state">Không có khu vực nào thỏa điều kiện lọc để vẽ chart.</p>
)}
</div>
<div className="map-subsection">
<span className="map-subsection-title">Điểm nóng</span>
<div className="map-list compact">
{leading.length ? leading.map((row) => <article className="risk-card compact" key={`lead-${row.district}`}><div><strong>{row.district}</strong><span className={`badge ${row.risk_level}`}>{row.risk_level}</span></div><p>{row.planning_note}</p><small>ROI {row.roi_pct.toFixed(2)}% | Score {row.opportunity_score.toFixed(1)}</small></article>) : <p className="map-empty-state">Không có khu nóng nào sau khi lọc.</p>}
</div>
</div>
<div className="map-subsection">
<span className="map-subsection-title">Khu cần theo dõi</span>
<div className="map-list compact">
{(watchlist.length ? watchlist : filteredDistricts.slice(0, 4)).map((row) => <article className="risk-card compact" key={`watch-${row.district}`}><div><strong>{row.district}</strong><span className={`badge ${row.risk_level}`}>{row.risk_level}</span></div><p>{row.planning_note}</p><small>ROI {row.roi_pct.toFixed(2)}% | Score {row.opportunity_score.toFixed(1)}</small></article>)}
{!watchlist.length && !filteredDistricts.length ? <p className="map-empty-state">Không có khu vực rủi ro cao thỏa điều kiện lọc.</p> : null}
</div>
</div>
</div>
</Panel>
</div>
</>
);
}
function AiPage({ question, setQuestion, assistant, assistantLoading, askAssistant }) {
const [sourcesOpen, setSourcesOpen] = useState(false);
const assistantStage = aiStageInfo(assistant?.status || assistant?.mode, assistantLoading);
const assistantWaitingCopy = providerWaitingCopy(assistant?.status, assistant?.llm_mode);
useEffect(() => {
if (assistant?.sources?.length) {
setSourcesOpen(true);
}
}, [assistant]);
return (
<>
<PageHeader eyebrow="Trợ lý phân tích" title="Trợ lý phân tích" description="Tổng hợp dữ liệu thị trường, pháp lý và quy hoạch để hỗ trợ quyết định đầu tư." />
<div className="grid-12">
<Panel title="Câu hỏi phân tích" className="span-7">
<div className="chat-box">
<textarea value={question} onChange={(e) => setQuestion(e.target.value)} />
<div className="prompt-row">{["Quận Tân Bình có rủi ro quy hoạch gì?", "Nên ưu tiên khu nào nếu ROI tốt?", "So sánh Bình Chánh và Thủ Đức"].map((prompt) => <button key={prompt} onClick={() => setQuestion(prompt)}>{prompt}</button>)}</div>
<div className="assistant-action-row">
<button className="primary-btn" onClick={askAssistant} disabled={assistantLoading}>{assistantLoading ? "Đang phân tích..." : "Phân tích"}</button>
<button className="secondary-btn" onClick={() => askAssistant("stop")} disabled={!assistantLoading}>Dừng sinh</button>
</div>
{assistantLoading ? (
<div className="assistant-streaming">
<span className="streaming-dots"><i /><i /><i /></span>
<div>
<strong>{assistant?.status?.startsWith("waiting_") ? assistantWaitingCopy.title : "RAG đang truy xuất nguồn và Qwen đang sinh câu trả lời..."}</strong>
<p>{assistant?.status?.startsWith("waiting_") ? assistantWaitingCopy.body : "Kết quả sẽ hiện ra ngay khi mô hình trả lời xong."}</p>
</div>
</div>
) : null}
</div>
{assistant ? <div className="answer-panel"><div className="meta-row"><span>{assistant.mode}</span><span>{assistant.model}</span><span className={`badge ${assistantStage.tone}`}>{assistantStage.label}</span><span>{assistant.retrieval_time_ms} ms</span></div>{assistantLoading ? <span className="generation-badge">{assistantStage.label}</span> : null}<StructuredResponse text={assistant.answer || ""} sectionTitles={["Kết luận điều hành", " sở nhận định", "Rủi ro cần lưu ý", "Hành động tiếp theo"]} /></div> : null}
</Panel>
<Panel title="Thanh tra nguồn" className="span-5">
<Disclosure title="Nguồn trích dẫn" open={sourcesOpen} defaultOpen={false} compact onToggle={setSourcesOpen}>
<div className="source-list">{(assistant?.sources || []).map((source) => <a key={source.title} href={source.source_url} target="_blank" rel="noreferrer" className="source-card"><strong>{source.title}</strong><span>{source.content}</span><small>{source.source_name} · score {source.score?.toFixed(2)}</small></a>)}</div>
</Disclosure>
</Panel>
</div>
</>
);
}
function DataOpsPage({ etl, refreshEtl, loading, ragStatus }) {
return (
<>
<PageHeader eyebrow="Theo dõi dữ liệu" title="Giám sát vận hành dữ liệu" description="Theo dõi trạng thái nạp dữ liệu, quy hoạch và kho tri thức AI." />
<div className="grid-12">
<Panel title="Tình trạng dữ liệu" className="span-12" action={<button className="primary-btn small" onClick={refreshEtl}>{loading ? "Đang cập nhật" : "Cập nhật dữ liệu"}</button>}>
<div className="kpi-grid compact"><KpiCard label="Bản ghi thị trường" value={(etl?.transaction_records || 0).toLocaleString("vi-VN")} /><KpiCard label="Lớp quy hoạch" value={(etl?.planning_zones || 0).toLocaleString("vi-VN")} /><KpiCard label="Tài liệu pháp lý" value={(etl?.legal_documents || 0).toLocaleString("vi-VN")} /><KpiCard label="Metro impact" value={(etl?.metro_impacts || 0).toLocaleString("vi-VN")} /><KpiCard label="Kho tri thức AI" value={ragStatus ? `${ragStatus.documents} tài liệu` : "Sẵn sàng"} /></div>
</Panel>
<Panel title="Nhật ký cập nhật dữ liệu" className="span-7"><SimpleRuns runs={etl?.runs || []} /></Panel>
<Panel title="Trung tâm dữ liệu công cộng" className="span-5"><div className="source-list">{(etl?.sources || []).map((source) => <a className="source-card" href={source.url} target="_blank" rel="noreferrer" key={source.url}><strong>{source.name}</strong><span>{source.type}</span><small>{source.status}</small></a>)}</div></Panel>
</div>
</>
);
}
function SimpleRuns({ runs }) {
return <div className="table-wrap"><table><thead><tr><th>Thời điểm</th><th>Hình thức</th><th>Trạng thái</th><th>Đã đọc</th><th>Đã nạp</th></tr></thead><tbody>{runs.map((run) => <tr key={run.run_id}><td>{run.finished_at?.slice(0, 19)}</td><td>{run.mode}</td><td><span className="badge good">{run.status}</span></td><td>{run.records_seen?.toLocaleString("vi-VN")}</td><td>{run.records_inserted?.toLocaleString("vi-VN")}</td></tr>)}</tbody></table></div>;
}
function ExplorerPage({ analytics }) {
return (
<>
<PageHeader eyebrow="Chi tiết tài sản" title="Danh sách tài sản" description="Xem chi tiết các tài sản có ROI cao trong bộ lọc hiện tại." />
<Panel title="Bảng tài sản">
<div className="table-wrap"><table><thead><tr><th>Địa chỉ</th><th>Khu vực</th><th>Loại</th><th>Giá</th><th>Diện tích</th><th>ROI</th></tr></thead><tbody>{(analytics?.samples || []).map((row) => <tr key={`${row.Location}-${row.date}`}><td>{row.Location}</td><td>{row.district}</td><td>{row["Type of House"]}</td><td>{money(row.price_vnd)}</td><td>{row.area} m²</td><td>{pct(row.ROI * 100)}</td></tr>)}</tbody></table></div>
</Panel>
</>
);
}
function ReportPage({ analytics, whatIf, reportNote, reportNoteLoading }) {
const kpis = analytics?.kpis || {};
const reportBriefs = useMemo(() => buildExecutiveBriefs(analytics?.districts || []), [analytics?.districts]);
const coverSummary = useMemo(() => buildReportCoverSummary(analytics || {}, whatIf), [analytics, whatIf]);
const reportStage = aiStageInfo(reportNote?.status || reportNote?.mode, reportNoteLoading);
const exportTimestamp = analytics?.kpis?.last_data_refresh ? formatDateTime(analytics.kpis.last_data_refresh) : formatDateTime(new Date().toISOString());
function exportPdf() {
window.print();
}
return (
<>
<PageHeader eyebrow="Báo cáo định kỳ" title="Báo cáo định kỳ" description="Tóm tắt phát hiện chính, khuyến nghị và căn cứ tham chiếu để trình bày theo chu kỳ." />
<div className="report-toolbar no-print">
<div>
<strong>Bản in điều hành</strong>
<span>Định dạng sẵn sàng xuất PDF cho báo cáo công ty.</span>
</div>
<button type="button" className="primary-btn" onClick={exportPdf}>Xuất PDF</button>
</div>
<section className="report-cover">
<div className="report-cover-main">
<span>Executive Report</span>
<h3>PropertyVision BI</h3>
<p>Báo cáo định kỳ tập trung vào phát hiện chính, khuyến nghị hành động và cơ sở dữ liệu làm căn cứ trình bày cho ban điều hành.</p>
<div className="report-cover-meta">
<span>Cập nhật {exportTimestamp}</span>
<span>ROI TB {pct(kpis.avg_roi)}</span>
<span>{(kpis.transaction_count || 0).toLocaleString("vi-VN")} giao dịch / bản ghi</span>
</div>
</div>
<div className="report-cover-stats">
<article>
<span>Khu dẫn đầu</span>
<strong>{coverSummary.bestDistrict}</strong>
</article>
<article>
<span>Giá trị tương lai</span>
<strong>{coverSummary.projectedFutureValue ? money(coverSummary.projectedFutureValue) : "Chưa mô phỏng"}</strong>
</article>
<article>
<span>ROI tích lũy</span>
<strong>{coverSummary.projectedCumulativeRoi !== null ? pct(coverSummary.projectedCumulativeRoi) : "Chưa mô phỏng"}</strong>
</article>
<article>
<span>Hoàn vốn</span>
<strong>{coverSummary.projectedPaybackYears ? `${coverSummary.projectedPaybackYears.toFixed(1)} năm` : "Chưa mô phỏng"}</strong>
</article>
</div>
</section>
<div className="grid-12 report-grid">
<Panel title="Phát hiện chính" className="span-6">
<div className="brief-grid compact">
{reportBriefs.map((brief) => (
<article className="brief-card" key={brief.district}>
<div className="brief-head">
<span className="brief-tag">{brief.tag}</span>
<strong>{brief.district}</strong>
</div>
<p>{brief.edge}</p>
<small><b>Lợi thế:</b> {brief.strength}</small>
<small><b>Rủi ro:</b> {brief.risk}</small>
</article>
))}
</div>
<div className="report-meta-row">
<span>ROI trung bình: <b>{pct(kpis.avg_roi)}</b></span>
<span>Bản ghi thị trường: <b>{(kpis.transaction_count || 0).toLocaleString("vi-VN")}</b></span>
</div>
</Panel>
<Panel title="Ghi chú điều hành" className="span-6">
{reportNote?.answer ? (
<div className="answer-panel">
<div className="meta-row">
<span>{reportNote.mode}</span>
<span>{reportNote.model}</span>
<span className={`badge ${reportStage.tone}`}>{reportStage.label}</span>
<span>{reportNote.retrieval_time_ms} ms</span>
</div>
{reportNoteLoading ? <span className="generation-badge">{reportStage.label}</span> : null}
<StructuredResponse text={reportNote.answer || ""} sectionTitles={["Kết luận điều hành", " sở nhận định", "Rủi ro cần lưu ý", "Hành động tiếp theo"]} />
</div>
) : (
<p>{reportNoteLoading ? "Đang làm mới ghi chú điều hành..." : "Ghi chú điều hành sẽ tự làm mới theo dữ liệu hiện tại để không phải chờ thao tác thủ công."}</p>
)}
</Panel>
<Panel title="Tóm tắt mô phỏng đầu tư" className="span-12">{whatIf ? <div className="kpi-grid compact"><KpiCard label="Giá trị tương lai" value={money(whatIf.summary.future_value)} /><KpiCard label="ROI tích lũy" value={pct(whatIf.summary.cumulative_roi_pct)} /><KpiCard label="Thời gian hoàn vốn" value={`${whatIf.summary.payback_years?.toFixed(1)} năm`} /></div> : <p className="muted">Chạy mô phỏng đầu tư để đưa kết quả vào báo cáo.</p>}</Panel>
</div>
</>
);
}
function App() {
const [activePage, setActivePage] = useState(currentPageId());
const [metadata, setMetadata] = useState(null);
const [analytics, setAnalytics] = useState(null);
const [filters, setFilters] = useState(emptyFilters);
const [loading, setLoading] = useState(true);
const [aiRuntime, setAiRuntime] = useState(null);
const [error, setError] = useState("");
const [prediction, setPrediction] = useState(null);
const [whatIf, setWhatIf] = useState(null);
const [aiRecommendation, setAiRecommendation] = useState(null);
const [simulationLoading, setSimulationLoading] = useState(false);
const [simulationStage, setSimulationStage] = useState("Ready");
const [decisionView, setDecisionView] = useState("whatif");
const [assistant, setAssistant] = useState(null);
const [assistantLoading, setAssistantLoading] = useState(false);
const [reportNote, setReportNote] = useState(null);
const [reportNoteLoading, setReportNoteLoading] = useState(false);
const [mapData, setMapData] = useState(null);
const [etl, setEtl] = useState(null);
const [ragStatus, setRagStatus] = useState(null);
const [sliceDice, setSliceDice] = useState(null);
const [sliceConfig, setSliceConfig] = useState({ row_dimension: "district", column_dimension: "Type of House", metric: "avg_roi" });
const [question, setQuestion] = useState("Nên ưu tiên khu vực nào để đầu tư với ROI tốt và rủi ro vừa phải?");
const [predictForm, setPredictForm] = useState({ district: "", property_type: "", legal_documents: "", area: 70, bedrooms: 3, toilets: 3, floors: 3, roi_expected: 0.14 });
const [simulationForm, setSimulationForm] = useState({ budget_billion: 10, annual_growth_pct: 8, years: 25 });
const assistantAbortRef = useRef(null);
const reportNoteAbortRef = useRef(null);
const runtimeModelLabel = aiRuntime?.model || DEFAULT_MODEL_LABEL;
useEffect(() => {
request("/api/metadata")
.then((data) => {
setMetadata(data);
const initialCity = data.cities?.[0] || emptyFilters.city;
const initialDistrict = data.districts_by_city?.[initialCity]?.[0] || data.districts?.[0] || "";
setFilters((current) => ({ ...current, city: initialCity }));
setPredictForm((current) => ({ ...current, district: initialDistrict, property_type: data.property_types[0], legal_documents: data.legal_documents[0] }));
})
.catch(() => setError("Không kết nối được backend FastAPI. Hãy chạy uvicorn backend.main:app --reload."))
.finally(() => setLoading(false));
}, []);
useEffect(() => {
request("/api/etl/status")
.then((etlPayload) => { setEtl(etlPayload); })
.catch(() => setError("Không tải được dữ liệu vận hành từ backend."));
}, []);
useEffect(() => {
let cancelled = false;
async function refreshAiRuntime() {
try {
const payload = await request("/api/ai/status");
if (!cancelled) setAiRuntime(payload);
} catch {
if (!cancelled) setAiRuntime({ status: "error", label: "Error", message: "Không đọc được trạng thái AI." });
}
}
refreshAiRuntime();
const timer = window.setInterval(refreshAiRuntime, 3000);
return () => {
cancelled = true;
window.clearInterval(timer);
};
}, []);
useEffect(() => {
if (!filters.city) return;
request(`/api/map/districts?city=${encodeURIComponent(filters.city)}`)
.then((payload) => setMapData(payload))
.catch(() => setError("Không tải được dữ liệu bản đồ theo thành phố đã chọn."));
}, [filters.city]);
useEffect(() => {
if (!metadata) return;
setLoading(true);
Promise.all([
request("/api/analytics", { method: "POST", body: JSON.stringify(filters) }),
request("/api/slice-dice", { method: "POST", body: JSON.stringify({ filters, ...sliceConfig }) })
])
.then(([analyticsPayload, slicePayload]) => { setAnalytics(analyticsPayload); setSliceDice(slicePayload); })
.catch(() => setError("Không tải được dữ liệu phân tích."))
.finally(() => setLoading(false));
}, [filters, metadata, sliceConfig]);
useEffect(() => {
if (!metadata || !analytics || activePage !== "report") return;
let cancelled = false;
setReportNoteLoading(true);
if (reportNoteAbortRef.current) reportNoteAbortRef.current.abort();
const controller = new AbortController();
reportNoteAbortRef.current = controller;
const timer = window.setTimeout(async () => {
try {
let streamedNote = "";
await requestNdjsonStream("/api/assistant/stream", {
method: "POST",
body: JSON.stringify({
question: buildReportQuestion(analytics, filters),
filters,
task: "executive_brief"
}),
signal: controller.signal
}, {
onMeta: (event) => {
if (cancelled) return;
setReportNote((current) => ({
...current,
answer: streamedNote,
sources: event.sources || [],
model: event.model || current?.model || runtimeModelLabel,
mode: event.mode || current?.mode || "hf-qwen",
llm_available: Boolean(event.llm_available),
llm_mode: event.llm_mode || current?.llm_mode,
status: event.status || current?.status
}));
},
onLine: (event) => {
if (cancelled) return;
streamedNote += `${event.text || ""}\n`;
setReportNote((current) => ({
...current,
answer: streamedNote,
llm_available: true,
mode: "hf-qwen",
status: "streaming"
}));
},
onDone: (event) => {
if (cancelled) return;
streamedNote = event.answer || streamedNote;
setReportNote((current) => ({
...current,
answer: streamedNote,
model: event.model || current?.model || runtimeModelLabel,
mode: event.mode || current?.mode || "hf-qwen",
llm_available: true,
llm_mode: event.llm_mode || current?.llm_mode,
status: "done"
}));
}
});
if (!cancelled) {
setReportNoteLoading(false);
}
} catch (error) {
if (error?.name === "AbortError") {
if (!cancelled) setReportNoteLoading(false);
return;
}
if (!cancelled) {
setReportNote({
answer: `AI gián đoạn: ${error instanceof Error ? error.message : "AI provider chưa phản hồi trong thời gian quy định."}`,
sources: [],
model: runtimeModelLabel,
mode: "error",
llm_available: false,
retrieval_time_ms: 0,
llm_mode: "featherless-direct",
status: "error"
});
setReportNoteLoading(false);
}
}
}, 500);
return () => {
cancelled = true;
window.clearTimeout(timer);
controller.abort();
setReportNoteLoading(false);
};
}, [activePage, analytics, filters, metadata]);
useEffect(() => {
if (!metadata) return;
const availableDistricts = metadata.districts_by_city?.[filters.city] || metadata.districts || [];
if (!availableDistricts.length) return;
if (!availableDistricts.includes(predictForm.district)) {
setPredictForm((current) => ({ ...current, district: availableDistricts[0] }));
}
}, [metadata, filters.city, predictForm.district]);
const typeRows = analytics?.types || [];
const typeShare = useMemo(() => {
const total = typeRows.reduce((sum, row) => sum + row.avg_price * row.listings, 0);
return typeRows.map((row) => ({ name: row["Type of House"], value: total ? (row.avg_price * row.listings) / total * 100 : 0 }));
}, [typeRows]);
async function runWhatIf(event) {
event.preventDefault();
const payload = {
...predictForm,
area: Number(predictForm.area),
bedrooms: propertyNeedsRoomFields(predictForm.property_type) ? toNullableNumber(predictForm.bedrooms) : null,
toilets: propertyNeedsRoomFields(predictForm.property_type) ? toNullableNumber(predictForm.toilets) : null,
floors: propertyNeedsFloorField(predictForm.property_type) ? toNullableNumber(predictForm.floors) : null,
roi_expected: Number(predictForm.roi_expected),
budget_vnd: Number(simulationForm.budget_billion) * 1_000_000_000,
annual_growth_pct: Number(simulationForm.annual_growth_pct),
years: Number(simulationForm.years)
};
const recommendationPayloadBody = {
...payload,
filters,
task: "decision_memo",
decision_tab: decisionView
};
try {
setSimulationLoading(true);
setPrediction(null);
setWhatIf(null);
setSimulationStage("Đang gửi yêu cầu mô phỏng...");
setAiRecommendation({
answer: "",
why: "",
suggestion: "",
risks: [],
basis: [],
sources: [],
model: runtimeModelLabel,
mode: "loading",
status: "loading",
llm_available: false,
retrieval_time_ms: 0,
llm_mode: "featherless-direct"
});
let streamedRecommendation = "";
await requestNdjsonStream("/api/recommendation/future/stream", {
method: "POST",
body: JSON.stringify(recommendationPayloadBody)
}, {
onMeta: (eventPayload) => {
setAiRecommendation((current) => ({
...current,
model: eventPayload.model || current?.model || runtimeModelLabel,
mode: eventPayload.mode || current?.mode || "loading",
status: eventPayload.status || current?.status || "loading",
llm_available: Boolean(eventPayload.llm_available),
llm_mode: eventPayload.llm_mode || current?.llm_mode || "featherless-direct"
}));
if (eventPayload.status === "waiting_featherless") {
setSimulationStage("Đang chờ Featherless...");
} else if (eventPayload.status === "waiting_huggingface") {
setSimulationStage("Đang chờ Hugging Face...");
}
},
onStage: (eventPayload) => {
setSimulationStage(eventPayload.text || "Đang xử lý mô phỏng...");
setAiRecommendation((current) => ({
...current,
status: "streaming",
mode: current?.mode || "hf-qwen-future-recommendation",
llm_available: true,
llm_mode: current?.llm_mode || "featherless-direct"
}));
},
onWhatIf: (eventPayload) => {
setPrediction(eventPayload.prediction || eventPayload.what_if?.asset_prediction || null);
setWhatIf(eventPayload.what_if || null);
setSimulationStage("Đang sinh khuyến nghị AI...");
},
onLine: (eventPayload) => {
const text = sanitizeRecommendationText(eventPayload.text || "");
if (!text) return;
const section = eventPayload.section || "answer";
if (section === "answer") {
streamedRecommendation = `${streamedRecommendation}${text}\n`;
}
setAiRecommendation((current) => ({
...current,
answer: section === "answer" ? streamedRecommendation : current?.answer || "",
why: section === "why" ? `${current?.why || ""}${text}\n` : current?.why || "",
suggestion: section === "suggestion" ? `${current?.suggestion || ""}${text}\n` : current?.suggestion || "",
risks: section === "risks" ? [...(current?.risks || []), text] : current?.risks || [],
basis: section === "basis" ? [...(current?.basis || []), text] : current?.basis || [],
mode: "hf-qwen-future-recommendation",
status: "streaming",
llm_available: true,
llm_mode: current?.llm_mode || "featherless-direct"
}));
},
onDone: (eventPayload) => {
setPrediction(eventPayload.what_if?.asset_prediction || null);
setWhatIf(eventPayload.what_if || null);
const doneAnswer = sanitizeRecommendationText(eventPayload.answer || "");
const doneWhy = sanitizeRecommendationText(eventPayload.why || "");
const doneSuggestion = sanitizeRecommendationText(eventPayload.suggestion || "");
const doneRisks = sanitizeRecommendationList(eventPayload.risks || []);
const doneBasis = sanitizeRecommendationList(eventPayload.basis || []);
setAiRecommendation((current) => ({
...current,
...eventPayload,
answer: doneAnswer,
why: doneWhy,
suggestion: doneSuggestion,
risks: doneRisks,
basis: doneBasis,
chart_caption: sanitizeRecommendationText(eventPayload.chart_caption || current?.chart_caption || ""),
mode: eventPayload.mode || "hf-qwen-future-recommendation",
status: "done",
llm_available: true,
llm_mode: eventPayload.llm_mode || current?.llm_mode || "featherless-direct"
}));
setSimulationStage("Hoàn tất mô phỏng.");
}
});
} catch (error) {
setAiRecommendation({
answer: `AI gián đoạn: ${error instanceof Error ? error.message : "AI provider chưa phản hồi trong thời gian quy định."}`,
why: "",
suggestion: "",
risks: ["Khuyến nghị không thể sinh đúng thời gian vì mô hình hosted chưa phản hồi."],
basis: [],
sources: [],
model: runtimeModelLabel,
mode: "error",
status: "error",
llm_available: false,
retrieval_time_ms: 0,
llm_mode: "featherless-direct"
});
setSimulationStage("Mô phỏng bị gián đoạn.");
} finally {
setSimulationLoading(false);
}
}
async function askAssistant(action = "run") {
if (action === "stop") {
assistantAbortRef.current?.abort();
setAssistantLoading(false);
return;
}
setAssistantLoading(true);
setAssistant({
answer: "",
sources: [],
model: runtimeModelLabel,
mode: "streaming",
llm_available: true,
retrieval_time_ms: 0,
llm_mode: "featherless-direct",
status: "waiting_featherless"
});
if (assistantAbortRef.current) assistantAbortRef.current.abort();
const controller = new AbortController();
assistantAbortRef.current = controller;
try {
let streamedAnswer = "";
let streamBuffer = "";
await requestNdjsonStream("/api/assistant/stream", {
method: "POST",
body: JSON.stringify({ question, filters, task: "assistant_question" }),
signal: controller.signal
}, {
onMeta: (event) => {
setAssistant((current) => ({
...current,
answer: streamedAnswer,
sources: event.sources || [],
model: event.model || current.model || runtimeModelLabel,
mode: event.mode || current.mode,
llm_available: Boolean(event.llm_available),
llm_mode: event.llm_mode || current.llm_mode,
status: event.status || current.status
}));
},
onLine: (event) => {
streamBuffer = `${streamBuffer}${event.text || ""}\n`;
streamedAnswer += event.text || "";
setAssistant((current) => ({
...current,
answer: streamBuffer,
llm_available: true,
mode: "hf-qwen",
status: "streaming"
}));
},
onDone: (event) => {
streamedAnswer = event.answer || streamedAnswer;
setAssistant((current) => ({
...current,
answer: streamedAnswer.split("\n").filter(Boolean).join("\n"),
model: event.model || current.model || runtimeModelLabel,
mode: event.mode || "hf-qwen",
llm_available: true,
llm_mode: event.llm_mode || current.llm_mode,
status: "done"
}));
}
});
} catch (error) {
if (error?.name === "AbortError") {
setAssistant((current) => ({
...current,
mode: "stopped",
llm_available: false,
status: "stopped"
}));
} else {
setAssistant({
answer: `AI gián đoạn: ${error instanceof Error ? error.message : "AI provider chưa phản hồi trong thời gian quy định."}`,
sources: [],
model: runtimeModelLabel,
mode: "error",
llm_available: false,
retrieval_time_ms: 0,
llm_mode: "featherless-direct",
status: "error"
});
}
} finally {
if (assistantAbortRef.current === controller) assistantAbortRef.current = null;
setAssistantLoading(false);
}
}
async function refreshEtl() {
setLoading(true);
const payload = await request("/api/etl/run", { method: "POST", body: JSON.stringify({}) });
setEtl(payload.status);
setMapData(await request(`/api/map/districts?city=${encodeURIComponent(filters.city || emptyFilters.city)}`));
setRagStatus(await request("/api/rag/reindex", { method: "POST", body: JSON.stringify({}) }));
setAnalytics(await request("/api/analytics", { method: "POST", body: JSON.stringify(filters) }));
setLoading(false);
}
if (error) return <div className="boot-error">{error}</div>;
if (!metadata || !analytics) return <div className="boot">Đang khởi tạo PropertyVision...</div>;
const sharedPageProps = { activePage, analytics, typeShare, sliceDice, sliceConfig, setSliceConfig, metadata, filters, mapData, etl, refreshEtl, loading, ragStatus, question, setQuestion, assistant, assistantLoading, askAssistant, reportNote, reportNoteLoading };
const decisionPageProps = { ...sharedPageProps, decisionView, setDecisionView, predictForm, setPredictForm, simulationForm, setSimulationForm, prediction, whatIf, aiRecommendation, runWhatIf, simulationLoading, simulationStage };
return (
<AppShell activePage={activePage} setActivePage={setActivePage} loading={loading} metadata={metadata} filters={filters} setFilters={setFilters} aiRuntime={aiRuntime} analytics={analytics} etl={etl}>
{activePage === "overview" && <OverviewPage {...sharedPageProps} />}
{activePage === "market" && <MarketPage {...sharedPageProps} />}
{activePage === "slice" && <SlicePage {...sharedPageProps} />}
{activePage === "decision" && <DecisionPage {...decisionPageProps} />}
{activePage === "gis" && <GisPage {...sharedPageProps} />}
{activePage === "ai" && <AiPage {...sharedPageProps} />}
{activePage === "ops" && <DataOpsPage {...sharedPageProps} />}
{activePage === "explorer" && <ExplorerPage {...sharedPageProps} />}
{activePage === "report" && <ReportPage {...sharedPageProps} />}
</AppShell>
);
}
createRoot(document.getElementById("root")).render(<App />);