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, ">")
.replace(/\*\*(.*?)\*\*/g, "$1 ");
const lines = normalized.split("\n");
const segments = [];
let paragraph = [];
let currentListType = null;
let currentList = [];
const flushParagraph = () => {
if (!paragraph.length) return;
segments.push(`
${paragraph.map((line) => escapeInline(line)).join(" ")}
`);
paragraph = [];
};
const flushList = () => {
if (!currentList.length) return;
const tag = currentListType === "ol" ? "ol" : "ul";
segments.push(`<${tag}>${currentList.map((item) => `${escapeInline(item)} `).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(`${escapeInline(headingMatch[2])} `);
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 ;
return (
{sections.map((section) => (
{section.title}
))}
);
}
function MarkdownBlock({ text }) {
return
;
}
function SectionTabs({ tabs, active, onChange }) {
return (
{tabs.map((tab) => (
onChange(tab.id)}
type="button"
>
{tab.label}
))}
);
}
function InsightHero({ title, body, tone = "default", meta }) {
return (
);
}
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 (
{
const nextOpen = event.currentTarget.open;
setOpen(nextOpen);
if (onToggle) onToggle(nextOpen);
}}
>
{title}
{children}
);
}
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 Biểu đồ sẽ hiển thị triển vọng theo ba kịch bản đầu tư.
;
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 = (
<>
`Năm ${value}`} />
{referenceValue ? : null}
>
);
if (spec.chart_type === "area") {
return (
{axes}
{series.map((key, index) => (
))}
);
}
if (spec.chart_type === "bar") {
return (
{axes}
{series.map((key) => (
))}
);
}
return (
{axes}
{series.map((key) => (
))}
);
}
function ResultSkeleton() {
return (
);
}
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 (
{name}
);
}
function KpiCard({ label, value, delta, icon, tone = "default" }) {
return (
{label}
{icon ? : null}
{value}
{delta ? {delta} : null}
);
}
function Panel({ title, children, action, className = "" }) {
const hint = Object.entries(PANEL_HINTS).find(([key]) => title.startsWith(key))?.[1];
return (
{title}
{hint ? {hint} : null}
{action}
{children}
);
}
function PageHeader({ eyebrow, title, description }) {
return (
{eyebrow}
{title}
{description}
);
}
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 (
PropertyVision BI
Decision Intelligence for real estate analysis and executive reporting.
Version {APP_VERSION}
© {new Date().getFullYear()} PropertyVision
Updated {dataTimestamp ? formatDateTime(dataTimestamp) : "Chưa cập nhật"}
{systemStatus}
);
}
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 (
PV
PropertyVision
Decision Intelligence
{pages.map((page) => (
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}`}
>
{page.label}
))}
{navTip ? (
{navTip.title}
{navTip.description}
) : null}
PROPERTYVISION / {pages.find((page) => page.id === activePage)?.short?.toUpperCase()}
{filterSummary}
{hasActiveFilters ? Xóa bộ lọc : null}
setFiltersOpen(true)}>
Bộ lọc
{loading ? Đang cập nhật : Đang hoạt động }
{runtimeStage.label}
{filtersOpen ?
setFiltersOpen(false)} /> : null}
setFiltersOpen(false)}
/>
{children}
);
}
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 (
Bộ lọc điều hành
Đóng
setFilters(emptyFilters)}>Đặt lại
{filters.city || "Toàn bộ thị trường"}
{filters.districts.length ? (
<>
{previewDistricts.map((district) => (
toggleDistrict(district)}>
{district}
))}
{remainingDistricts ? +{remainingDistricts} : null}
>
) : (
Toàn bộ quận/huyện
)}
);
}
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 (
<>
`${v.toFixed(0)} tỷ`} />
`${v.toFixed(0)}%`} />
Number(v).toFixed(2)} />
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.
{trendSnapshots.map((row) => (
Năm {row.year}
{row.price_billion.toFixed(1)} tỷ
ROI {row.roi_pct.toFixed(1)}%
))}
Tăng trưởng giả định: {growthStressPct.toFixed(1)}%
setGrowthStressPct(Number(e.target.value))} />
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.
{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"}
{growthStatus === "loading" ? (
Đang xử lý giả định tăng trưởng
) : null}
{growthStatus === "loading" ? (
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.
) : null}
{liveGrowthRecommendation?.answer || overviewRecommendation.body}
{liveGrowthRecommendation?.why ?
Lý do: {liveGrowthRecommendation.why}
: null}
{liveGrowthRecommendation?.suggestion ?
Hành động: {liveGrowthRecommendation.suggestion}
: null}
{(liveGrowthRecommendation?.risks || []).length ?
Rủi ro cần theo dõi {liveGrowthRecommendation.risks.map((risk, index) => {risk} )} : null}
{liveGrowthRecommendation ? `${liveGrowthRecommendation.model} · ${liveGrowthRecommendation.retrieval_time_ms} ms` : "Cập nhật theo từng lần kéo thanh"}
Sinh từ ROI, giá/m², thanh khoản, biến động và RAG}
>
{executiveBriefs.map((brief) => (
{brief.tag}
{brief.district}
{brief.edge}
Lợi thế: {brief.strength}
Rủi ro: {brief.risk}
))}
>
);
}
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 (
<>
Cập nhật gần nhất
{formatDateTime(lastRefresh)}
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.
{marketView !== "table" ? <>
b.roi_pct - a.roi_pct).slice(0, 12)}>
`${v}%`} />
pct(v)} />
`${Number(v).toFixed(0)}tr`} />
name === "Giá/m²" ? `${Number(value).toFixed(1)} triệu/m²` : Number(value).toLocaleString("vi-VN")} />
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.
> : null}
{marketView !== "chart" ?
: null}
Khu vực nổi bật {topScore.map((row) => `${row.district} (${row.opportunity_score.toFixed(1)})`).join(" · ")}
Giá/m² thấp nhất {underpriced.map((row) => `${row.district} (${row.price_m2_million.toFixed(1)}tr/m²)`).join(" · ")}
Mốc so sánh 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.
{marketView === "detail" ? : null}
>
);
}
function DataTable({ rows }) {
return (
Khu vực
Hành động
Số tin
Giá trung vị
ROI
Triệu/m²
Điểm
{rows.map((row) => (
{row.district}
{row.opportunity_score >= 65 ? "Mở rộng" : row.roi_pct < 10 ? "Kiểm soát" : "Gom chọn lọc"}
{row.listings?.toLocaleString("vi-VN")}
{money(row.median_price)}
{pct(row.roi_pct)}
{Number(row.price_m2_million || 0).toFixed(1)}
))}
);
}
function Score({ value }) {
const score = Number(value || 0);
return (
{score.toFixed(1)}
);
}
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 (
<>
Chiều phân tích chính setSliceConfig({ ...sliceConfig, row_dimension: e.target.value })}>{Object.entries(sliceDice?.dimensions || {}).map(([key, label]) => {label} )}
Chiều phân tích phụ setSliceConfig({ ...sliceConfig, column_dimension: e.target.value })}>{Object.entries(sliceDice?.dimensions || {}).map(([key, label]) => {label} )}
Chỉ tiêu xem xét setSliceConfig({ ...sliceConfig, metric: e.target.value })}>{Object.entries(sliceDice?.metrics || {}).map(([key, label]) => {label} )}
Segment {(sliceDice?.columns || []).map((column) => {column} )}
{(sliceDice?.matrix || []).slice(0, 18).map((row) => {row.segment} {(sliceDice?.columns || []).map((column) => {formatMetric(row[column], sliceConfig.metric)} )} )}
Segment Sub segment Records ROI Giá/m² Score
{(sliceDice?.top_segments || []).map((row, index) => {row[sliceDice.row_dimension]} {row[sliceDice.column_dimension]} openAddressDetail(row)}>{row.listings?.toLocaleString("vi-VN")} {pct(row.avg_roi)} {money(row.avg_price_m2)} {Number(row.opportunity_score || 0).toFixed(1)} )}
{addressDetail ? (
<>
Danh sách địa chỉ
{addressDetail.segment} / {addressDetail.subSegment}
copyText("list")} disabled={!visibleAddresses.length}>
{copyState === "copied_list" ? "Đã copy danh sách" : "Copy đánh số"}
copyText("table")} disabled={!visibleAddresses.length}>
{copyState === "copied_table" ? "Đã copy bảng" : "Copy bảng"}
{copyState === "downloaded_csv" ? "Đã tải CSV" : "Tải CSV"}
Đóng
setSearchQuery(e.target.value)}
/>
setSearchQuery("")} disabled={!searchQuery}>
Xóa lọc
{visibleAddresses.length.toLocaleString("vi-VN")} / {addressDetail.addresses.length.toLocaleString("vi-VN")} địa chỉ đang hiển thị
{!hasAnyHouseNumber ? (
Không có số nhà, chỉ có địa chỉ cấp xã/huyện.
) : null}
{addressDetail.records.toLocaleString("vi-VN")} bản ghi
{addressDetail.addressCount.toLocaleString("vi-VN")} địa chỉ
{visibleAddresses.length.toLocaleString("vi-VN")} địa chỉ xem nhanh
{visibleAddresses.length ? (
{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 (
{idx + 1} {item.address_display}
{houseNumber ? Số nhà {houseNumber} : null}
{propertyType ? {propertyType} : null}
{(streetName || wardName || districtDisplay) ? {[streetName, wardName, districtDisplay].filter(Boolean).join(" · ")} : null}
);
})}
) : (
Chưa có địa chỉ chi tiết cho phân đoạn này.
)}
>
) : 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 (
);
}
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 (
<>
>
);
}
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 (
<>
Rủi ro
setRiskFilter(e.target.value)}>
Tất cả
Thấp
Trung bình
Cao
ROI tối thiểu: {Number(minRoi).toFixed(1)}%
setMinRoi(Number(e.target.value))} />
Score tối thiểu: {Number(minScore).toFixed(0)}
setMinScore(Number(e.target.value))} />
{
setRiskFilter("all");
setMinRoi(0);
setMinScore(0);
}}
>
Bỏ lọc
Có thể kéo để di chuyển, lăn chuột hoặc chụm hai ngón để zoom.
{(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 (
{markerLabel}
{row.district_count > 1 ? {row.districts.join(" · ")}
: null}
Score {scoreValue.toFixed(1)}
ROI {roiValue.toFixed(2)}%
{row.planning_note}
);
})}
Cập nhật
{formatDateTime(lastRefresh)}
Số khu vực
{markers.length.toLocaleString("vi-VN")}
Các điểm trên bản đồ đang khớp với thành phố và bộ lọc hiện tại.
{leading.map((row) => (
{row.district}
{row.opportunity_score.toFixed(1)}/100
{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"}
))}
Tóm tắt điều hành
{leading[0]?.district || "Chưa có khu dẫn đầu"}
{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"}
Rủi ro thấp
Rủi ro trung bình
Rủi ro cao
Kích thước điểm tròn thể hiện điểm cơ hội
ROI TB
{pct(avgRoi)}
Score TB
{avgScore.toFixed(1)}
Top nổi bật
{leading[0]?.district || "N/A"}
Cần theo dõi
{watchlist[0]?.district || "N/A"}
ROI top khu vực
{chartRows.length ? (
`${Number(v).toFixed(0)}%`} />
pct(value)} />
) : (
Không có khu vực nào thỏa điều kiện lọc để vẽ chart.
)}
Điểm nóng
{leading.length ? leading.map((row) =>
{row.district} {row.risk_level}
{row.planning_note}
ROI {row.roi_pct.toFixed(2)}% | Score {row.opportunity_score.toFixed(1)} ) :
Không có khu nóng nào sau khi lọc.
}
Khu cần theo dõi
{(watchlist.length ? watchlist : filteredDistricts.slice(0, 4)).map((row) =>
{row.district} {row.risk_level}
{row.planning_note}
ROI {row.roi_pct.toFixed(2)}% | Score {row.opportunity_score.toFixed(1)} )}
{!watchlist.length && !filteredDistricts.length ?
Không có khu vực rủi ro cao thỏa điều kiện lọc.
: null}
>
);
}
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 (
<>
{assistant ? {assistant.mode} {assistant.model} {assistantStage.label} {assistant.retrieval_time_ms} ms
{assistantLoading ?
{assistantStage.label} : null}
: null}
>
);
}
function DataOpsPage({ etl, refreshEtl, loading, ragStatus }) {
return (
<>
{loading ? "Đang cập nhật" : "Cập nhật dữ liệu"}}>
>
);
}
function SimpleRuns({ runs }) {
return Thời điểm Hình thức Trạng thái Đã đọc Đã nạp {runs.map((run) => {run.finished_at?.slice(0, 19)} {run.mode} {run.status} {run.records_seen?.toLocaleString("vi-VN")} {run.records_inserted?.toLocaleString("vi-VN")} )}
;
}
function ExplorerPage({ analytics }) {
return (
<>
Địa chỉ Khu vực Loại Giá Diện tích ROI {(analytics?.samples || []).map((row) => {row.Location} {row.district} {row["Type of House"]} {money(row.price_vnd)} {row.area} m² {pct(row.ROI * 100)} )}
>
);
}
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 (
<>
Bản in điều hành
Định dạng sẵn sàng xuất PDF cho báo cáo công ty.
Xuất PDF
Executive Report
PropertyVision BI
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.
Cập nhật {exportTimestamp}
ROI TB {pct(kpis.avg_roi)}
{(kpis.transaction_count || 0).toLocaleString("vi-VN")} giao dịch / bản ghi
Khu dẫn đầu
{coverSummary.bestDistrict}
Giá trị tương lai
{coverSummary.projectedFutureValue ? money(coverSummary.projectedFutureValue) : "Chưa mô phỏng"}
ROI tích lũy
{coverSummary.projectedCumulativeRoi !== null ? pct(coverSummary.projectedCumulativeRoi) : "Chưa mô phỏng"}
Hoàn vốn
{coverSummary.projectedPaybackYears ? `${coverSummary.projectedPaybackYears.toFixed(1)} năm` : "Chưa mô phỏng"}
{reportBriefs.map((brief) => (
{brief.tag}
{brief.district}
{brief.edge}
Lợi thế: {brief.strength}
Rủi ro: {brief.risk}
))}
ROI trung bình: {pct(kpis.avg_roi)}
Bản ghi thị trường: {(kpis.transaction_count || 0).toLocaleString("vi-VN")}
{reportNote?.answer ? (
{reportNote.mode}
{reportNote.model}
{reportStage.label}
{reportNote.retrieval_time_ms} ms
{reportNoteLoading ?
{reportStage.label} : null}
) : (
{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."}
)}
{whatIf ?
: Chạy mô phỏng đầu tư để đưa kết quả vào báo cáo.
}
>
);
}
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 {error}
;
if (!metadata || !analytics) return Đang khởi tạo PropertyVision...
;
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 (
{activePage === "overview" && }
{activePage === "market" && }
{activePage === "slice" && }
{activePage === "decision" && }
{activePage === "gis" && }
{activePage === "ai" && }
{activePage === "ops" && }
{activePage === "explorer" && }
{activePage === "report" && }
);
}
createRoot(document.getElementById("root")).render( );