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("")}`); 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) => ( ))}
    ); } function InsightHero({ title, body, tone = "default", meta }) { return (
    {title}

    {body}

    {meta ? {meta} : null}
    ); } 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 (