diff --git a/PTC/index.html b/PTC/index.html index 7e15664..4fa333c 100644 --- a/PTC/index.html +++ b/PTC/index.html @@ -15,10 +15,35 @@ if (detail) detail.textContent = window.__ptcBootStatus.reason; } } + window.addEventListener("error", function (event) { + const parts = []; + if (event?.message) parts.push(event.message); + if (event?.filename) parts.push(event.filename); + if (event?.lineno) parts.push(`line ${event.lineno}`); + if (event?.colno) parts.push(`col ${event.colno}`); + ptcBootFail(`화면 실행 중 오류: ${parts.filter(Boolean).join(" / ") || "알 수 없는 오류"}`); + }); + window.addEventListener("unhandledrejection", function (event) { + const reason = event?.reason; + const message = typeof reason === "string" ? reason : reason?.message; + ptcBootFail(`화면 실행 중 오류: ${message || "알 수 없는 오류"}`); + }); - - - + + + @@ -931,6 +1549,62 @@ return debouncedValue; } + function getStatusBandColor(key) { + if (key === "normal") return "#1b7f5a"; + if (key === "upfront") return "#2563eb"; + if (key === "delay") return "#f59e0b"; + return "#d14343"; + } + + function getStatusBandLabel(statusBands, key) { + return (statusBands || []).find((band) => band.key === key)?.label || key; + } + function getMarginGradeKey(marginRate) { + const rate = Number(marginRate || 0); + if (rate < 0) return "deficit"; + if (rate < 10) return "caution"; + if (rate < 20) return "good"; + return "excellent"; + } + function getMarginGradeLabel(key) { + if (key === "deficit") return "적자"; + if (key === "caution") return "주의"; + if (key === "good") return "양호"; + return "우수"; + } + function getMarginGradeColor(key) { + if (key === "deficit") return "#d14343"; + if (key === "caution") return "#f59e0b"; + if (key === "good") return "#1b7f5a"; + return "#0f8f66"; + } + + function getFamilyPaletteColor(index) { + const colors = ["#1e5e95", "#4aa7d1", "#7bc96f", "#f59e0b", "#d14343", "#7c5cff", "#00a3a3", "#9b59b6"]; + return colors[index % colors.length]; + } + + function getDominantStatusKey(counts = {}) { + return Object.entries(counts).sort((a, b) => (b[1] || 0) - (a[1] || 0))[0]?.[0] || "normal"; + } + + function buildDonutBackground(items, getColor) { + const validItems = (items || []).filter((item) => Number(item?.ratio || 0) > 0); + if (!validItems.length) return "#e6eef6"; + let cursor = 0; + const segments = validItems.map((item) => { + const start = cursor; + cursor += Number(item.ratio || 0); + return `${getColor(item.key)} ${start}% ${cursor}%`; + }); + return `conic-gradient(${segments.join(", ")})`; + } + + function fmtEok(value) { + const amount = Number(value) || 0; + return `${(amount / 100000000).toFixed(1)}억`; + } + function buildBudgetCompareGroups(rows) { const groups = [ { key: "수입", label: "수입", matcher: (item) => item.section === "수입" }, @@ -1013,11 +1687,26 @@ } function App() { + const initialPageParams = new URLSearchParams(window.location.search); + const initialTabParam = (initialPageParams.get("tab") || "").trim(); + const initialProjectCodeParam = (initialPageParams.get("project_code") || "").trim(); + const initialPopupModeParam = (initialPageParams.get("popup") || "").trim(); + const initialDashboardFamilyParam = (initialPageParams.get("dashboard_family") || "").trim(); + const initialDashboardMethodParam = (initialPageParams.get("dashboard_method") || "").trim(); + const initialStatusKeyParam = (initialPageParams.get("status_key") || "").trim(); + const initialStatusLabelParam = (initialPageParams.get("status_label") || "").trim(); + const initialGradeParam = (initialPageParams.get("grade") || "").trim(); + const isStatusPopupWindow = initialPopupModeParam === "status_projects"; + const STATUS_POPUP_WINDOW_NAME = "ptc_status_projects_popup"; const [loading, setLoading] = useState(true); const [detailLoading, setDetailLoading] = useState(false); const [saving, setSaving] = useState(false); const [error, setError] = useState(""); - const [currentTab, setCurrentTab] = useState("project"); + const [currentTab, setCurrentTab] = useState( + initialPopupModeParam === "status_projects" + ? "dashboard" + : ["dashboard", "project", "vendor"].includes(initialTabParam) ? initialTabParam : "dashboard" + ); const [projectKeyword, setProjectKeyword] = useState(""); const [projectType, setProjectType] = useState("전체"); const [projectMethodFamily, setProjectMethodFamily] = useState("전체"); @@ -1062,7 +1751,7 @@ const [issueRowSaving, setIssueRowSaving] = useState(false); const [issueCheckedRows, setIssueCheckedRows] = useState([]); const [issueBulkTargetCode, setIssueBulkTargetCode] = useState(""); - const [selectedProjectCode, setSelectedProjectCode] = useState(""); + const [selectedProjectCode, setSelectedProjectCode] = useState(initialProjectCodeParam); const [vendors, setVendors] = useState([]); const [vendorLoading, setVendorLoading] = useState(false); const [vendorDetailLoading, setVendorDetailLoading] = useState(false); @@ -1087,6 +1776,23 @@ const [projectEditModalOpen, setProjectEditModalOpen] = useState(false); const [detail, setDetail] = useState(null); const [overallSummary, setOverallSummary] = useState(null); + const [dashboardData, setDashboardData] = useState(null); + const [dashboardLoading, setDashboardLoading] = useState(false); + const [selectedDashboardFamily, setSelectedDashboardFamily] = useState(initialDashboardFamilyParam || "전체"); + const [selectedDashboardMethod, setSelectedDashboardMethod] = useState(initialDashboardMethodParam || ""); + const [dashboardInfoModal, setDashboardInfoModal] = useState(""); + const [dashboardStatusProjectModal, setDashboardStatusProjectModal] = useState( + initialPopupModeParam === "status_projects" && initialStatusKeyParam + ? { + statusKey: initialStatusKeyParam, + statusLabel: initialStatusLabelParam || initialStatusKeyParam, + openedAt: Date.now(), + } + : null + ); + const [dashboardMarginGradeFilter, setDashboardMarginGradeFilter] = useState( + ["all", "deficit", "caution", "good", "excellent"].includes(initialGradeParam) ? initialGradeParam : "all" + ); const [editor, setEditor] = useState({ project_name: "", project_type: "", @@ -1227,6 +1933,34 @@ return () => { ignore = true; }; }, [overallSummaryQuery]); + useEffect(() => { + let ignore = false; + async function loadDashboard() { + if (currentTab !== "dashboard") return; + setDashboardLoading(true); + try { + const params = new URLSearchParams(); + if (projectType && projectType !== "전체") params.set("project_type", projectType); + const suffix = params.toString() ? `?${params.toString()}` : ""; + const res = await fetch(`${API_BASE}/api/dashboard-prototype${suffix}`); + if (!res.ok) throw new Error("dashboard load failed"); + const data = await res.json(); + if (!ignore) setDashboardData(data); + } catch (err) { + if (!ignore) setError("대시보드 시안을 불러오지 못했습니다."); + } finally { + if (!ignore) setDashboardLoading(false); + } + } + loadDashboard(); + return () => { ignore = true; }; + }, [currentTab, projectType]); + + useEffect(() => { + if (isStatusPopupWindow && initialDashboardFamilyParam) return; + setSelectedDashboardFamily("전체"); + }, [projectType, dashboardData?.projects?.length, isStatusPopupWindow, initialDashboardFamilyParam]); + useEffect(() => { let ignore = false; async function loadDetail() { @@ -1278,6 +2012,47 @@ return () => { ignore = true; }; }, [detailQuery, selectedProjectCode]); + useEffect(() => { + function handleProjectOpenMessage(event) { + if (event.origin !== window.location.origin) return; + const payload = event.data || {}; + if (payload?.type !== "ptc-open-project" || !payload?.projectCode) return; + setProjectKeyword(""); + setProjectType("전체"); + setProjectMethodFamily("전체"); + setProjectMethod("전체"); + setCurrentTab("project"); + setSelectedProjectCode(payload.projectCode); + } + window.addEventListener("message", handleProjectOpenMessage); + return () => window.removeEventListener("message", handleProjectOpenMessage); + }, []); + + useEffect(() => { + function handleStatusPopupMessage(event) { + if (event.origin !== window.location.origin) return; + const payload = event.data || {}; + if (payload?.type !== "ptc-open-status-popup") return; + + if (payload.dashboardFamily) setSelectedDashboardFamily(payload.dashboardFamily); + setSelectedDashboardMethod(payload.dashboardMethod || ""); + setDashboardMarginGradeFilter(payload.grade || "all"); + + if (payload.statusKey) { + setDashboardStatusProjectModal({ + statusKey: payload.statusKey, + statusLabel: payload.statusLabel || payload.statusKey, + openedAt: Date.now(), + }); + } else { + setDashboardStatusProjectModal(null); + } + } + + window.addEventListener("message", handleStatusPopupMessage); + return () => window.removeEventListener("message", handleStatusPopupMessage); + }, []); + useEffect(() => { let ignore = false; async function loadVendors() { @@ -1455,6 +2230,445 @@ marginRate, }; }, [detail?.budget_analysis]); + const dashboardProjectsBase = useMemo(() => ( + (dashboardData?.projects || []).filter((item) => { + const projectType = String(item?.project_type || "").trim(); + const projectCode = String(item?.project_code || "").trim(); + return ( + projectCode.includes("-시공-") && + !projectCode.includes("-관리-") && + !projectCode.includes("-설계-") && + projectType !== "관리" && + projectType !== "설계" + ); + }) + ), [dashboardData]); + const dashboardMethodBaseItems = useMemo(() => { + const groups = {}; + dashboardProjectsBase.forEach((project) => { + const method = (project.construction_method || "").trim() || "공법미지정"; + const family = (project.construction_family || "").trim() || "기타/미지정"; + if (!groups[method]) { + groups[method] = { + method, + family, + project_count: 0, + income_supply: 0, + expense_supply: 0, + profit_supply: 0, + status_counts: { normal: 0, upfront: 0, delay: 0, risk: 0 }, + margin_grade_counts: { deficit: 0, caution: 0, good: 0, excellent: 0 }, + }; + } + groups[method].project_count += 1; + groups[method].income_supply += Number(project.income_supply || 0); + groups[method].expense_supply += Number(project.expense_supply || 0); + groups[method].profit_supply += Number(project.profit_supply || 0); + const statusKey = project.status_key || "normal"; + if (Object.prototype.hasOwnProperty.call(groups[method].status_counts, statusKey)) { + groups[method].status_counts[statusKey] += 1; + } + const gradeKey = getMarginGradeKey(project.margin_rate); + groups[method].margin_grade_counts[gradeKey] += 1; + }); + return Object.values(groups) + .map((item) => ({ + ...item, + margin_rate: item.income_supply > 0 ? (item.profit_supply / item.income_supply) * 100 : 0, + })) + .sort((a, b) => b.income_supply - a.income_supply); + }, [dashboardProjectsBase]); + const dashboardFamilyMethodStatusMap = useMemo(() => { + const groups = {}; + dashboardMethodBaseItems.forEach((method) => { + const family = (method.family || "").trim() || "기타/미지정"; + if (!groups[family]) { + groups[family] = { + normal: 0, + upfront: 0, + delay: 0, + risk: 0, + }; + } + groups[family].normal += Number(method.status_counts?.normal || 0); + groups[family].upfront += Number(method.status_counts?.upfront || 0); + groups[family].delay += Number(method.status_counts?.delay || 0); + groups[family].risk += Number(method.status_counts?.risk || 0); + }); + return groups; + }, [dashboardMethodBaseItems]); + const dashboardFamilyItems = useMemo(() => { + const groups = {}; + dashboardProjectsBase.forEach((project) => { + const family = (project.construction_family || "").trim() || "기타/미지정"; + if (!groups[family]) { + groups[family] = { + family, + project_count: 0, + income_supply: 0, + expense_supply: 0, + profit_supply: 0, + status_counts: { normal: 0, upfront: 0, delay: 0, risk: 0 }, + margin_grade_counts: { deficit: 0, caution: 0, good: 0, excellent: 0 }, + }; + } + groups[family].project_count += 1; + groups[family].income_supply += Number(project.income_supply || 0); + groups[family].expense_supply += Number(project.expense_supply || 0); + groups[family].profit_supply += Number(project.profit_supply || 0); + const statusKey = project.status_key || "normal"; + if (Object.prototype.hasOwnProperty.call(groups[family].status_counts, statusKey)) { + groups[family].status_counts[statusKey] += 1; + } + const gradeKey = getMarginGradeKey(project.margin_rate); + groups[family].margin_grade_counts[gradeKey] += 1; + }); + + const items = Object.values(groups).map((item) => { + const methodStatusCounts = dashboardFamilyMethodStatusMap[item.family]; + const status_counts = methodStatusCounts + ? { + normal: Number(methodStatusCounts.normal || 0), + upfront: Number(methodStatusCounts.upfront || 0), + delay: Number(methodStatusCounts.delay || 0), + risk: Number(methodStatusCounts.risk || 0), + } + : item.status_counts; + return { + ...item, + status_counts, + margin_grade_counts: item.margin_grade_counts || { deficit: 0, caution: 0, good: 0, excellent: 0 }, + margin_rate: item.income_supply > 0 ? (item.profit_supply / item.income_supply) * 100 : 0, + }; + }).sort((a, b) => b.income_supply - a.income_supply); + + const total = { + family: "전체", + project_count: items.reduce((sum, item) => sum + item.project_count, 0), + income_supply: items.reduce((sum, item) => sum + item.income_supply, 0), + expense_supply: items.reduce((sum, item) => sum + item.expense_supply, 0), + profit_supply: items.reduce((sum, item) => sum + item.profit_supply, 0), + status_counts: items.reduce((acc, item) => { + Object.keys(acc).forEach((key) => { + acc[key] += Number(item.status_counts?.[key] || 0); + }); + return acc; + }, { normal: 0, upfront: 0, delay: 0, risk: 0 }), + margin_grade_counts: items.reduce((acc, item) => { + Object.keys(acc).forEach((key) => { + acc[key] += Number(item.margin_grade_counts?.[key] || 0); + }); + return acc; + }, { deficit: 0, caution: 0, good: 0, excellent: 0 }), + }; + total.margin_rate = total.income_supply > 0 ? (total.profit_supply / total.income_supply) * 100 : 0; + return [total, ...items]; + }, [dashboardProjectsBase, dashboardFamilyMethodStatusMap]); + const dashboardFamilyLegend = useMemo(() => { + const items = dashboardFamilyItems.filter((item) => item.family !== "전체"); + const total = items.reduce((sum, item) => sum + Number(item.project_count || 0), 0); + return items.map((item, index) => ({ + key: item.family, + label: item.family, + count: Number(item.project_count || 0), + ratio: total > 0 ? (Number(item.project_count || 0) / total) * 100 : 0, + color: getFamilyPaletteColor(index), + income_supply: Number(item.income_supply || 0), + margin_rate: Number(item.margin_rate || 0), + status_counts: item.status_counts || { normal: 0, upfront: 0, delay: 0, risk: 0 }, + })); + }, [dashboardFamilyItems]); + const dashboardFamilyMap = useMemo(() => { + const map = {}; + dashboardFamilyItems.forEach((item) => { + map[item.family] = item; + }); + return map; + }, [dashboardFamilyItems]); + const dashboardFamilyColorMap = useMemo(() => { + const map = {}; + dashboardFamilyLegend.forEach((item) => { + map[item.key] = item.color; + }); + return map; + }, [dashboardFamilyLegend]); + const dashboardFamilyBarItems = useMemo(() => { + const items = dashboardFamilyItems.filter((item) => item.family !== "전체"); + const maxIncome = items.reduce((max, item) => Math.max(max, Number(item.income_supply || 0)), 0); + return items.map((item) => ({ + ...item, + width: maxIncome > 0 ? (Number(item.income_supply || 0) / maxIncome) * 100 : 0, + })); + }, [dashboardFamilyItems]); + const visibleDashboardMethods = useMemo(() => { + if (selectedDashboardFamily === "전체") return dashboardMethodBaseItems; + return dashboardMethodBaseItems.filter((method) => ((method.family || "").trim() || "기타/미지정") === selectedDashboardFamily); + }, [dashboardMethodBaseItems, selectedDashboardFamily]); + const overallDashboardScope = useMemo(() => { + const items = dashboardFamilyItems.filter((item) => item.family !== "전체"); + const income = items.reduce((sum, item) => sum + Number(item.income_supply || 0), 0); + const expense = items.reduce((sum, item) => sum + Number(item.expense_supply || 0), 0); + const profit = items.reduce((sum, item) => sum + Number(item.profit_supply || 0), 0); + const marginRate = income > 0 ? (profit / income) * 100 : 0; + const statusCounts = items.reduce((acc, item) => { + Object.keys(acc).forEach((key) => { + acc[key] += Number(item.status_counts?.[key] || 0); + }); + return acc; + }, { normal: 0, upfront: 0, delay: 0, risk: 0 }); + return { + projectCount: items.reduce((sum, item) => sum + Number(item.project_count || 0), 0), + income, + expense, + profit, + marginRate, + statusCounts, + }; + }, [dashboardFamilyItems]); + const dashboardStatusLegend = useMemo(() => { + const counts = overallDashboardScope.statusCounts || {}; + const total = Object.values(counts).reduce((sum, value) => sum + Number(value || 0), 0); + return (dashboardData?.status_bands || []).map((band) => { + const count = Number(counts?.[band.key] || 0); + return { + ...band, + count, + ratio: total > 0 ? (count / total) * 100 : 0, + }; + }); + }, [dashboardData, overallDashboardScope]); + const visibleDashboardProjects = useMemo(() => { + const filtered = selectedDashboardFamily === "전체" + ? dashboardProjectsBase + : dashboardProjectsBase.filter((item) => ((item.construction_family || "").trim() || "기타/미지정") === selectedDashboardFamily); + return [...filtered].sort((a, b) => (b.income_supply || 0) - (a.income_supply || 0)); + }, [dashboardProjectsBase, selectedDashboardFamily]); + const dashboardProjectMap = useMemo(() => { + const map = {}; + dashboardProjectsBase.forEach((item) => { + if (item?.project_code) map[item.project_code] = item; + }); + return map; + }, [dashboardProjectsBase]); + const selectedDashboardProjects = useMemo(() => { + if (!selectedDashboardMethod) return visibleDashboardProjects; + return visibleDashboardProjects + .filter((item) => ((item.construction_method || "").trim() || "공법미지정") === selectedDashboardMethod) + .sort((a, b) => (b.income_supply || 0) - (a.income_supply || 0)); + }, [selectedDashboardMethod, visibleDashboardProjects]); + const dashboardMethodItems = useMemo(() => { + return visibleDashboardMethods.map((method) => ({ + ...method, + donutItems: (dashboardData?.status_bands || []).map((band) => { + const count = Number(method.status_counts?.[band.key] || 0); + const total = Number(method.project_count || 0); + return { + key: band.key, + ratio: total > 0 ? (count / total) * 100 : 0, + }; + }), + })); + }, [visibleDashboardMethods, dashboardData]); + const selectedDashboardScope = useMemo(() => { + const projects = selectedDashboardProjects || []; + const income = projects.reduce((sum, item) => sum + Number(item.income_supply || 0), 0); + const expense = projects.reduce((sum, item) => sum + Number(item.expense_supply || 0), 0); + const profit = income - expense; + const marginRate = income > 0 ? (profit / income) * 100 : 0; + const statusCounts = { normal: 0, upfront: 0, delay: 0, risk: 0 }; + projects.forEach((item) => { + const key = item.status_key || "normal"; + if (Object.prototype.hasOwnProperty.call(statusCounts, key)) statusCounts[key] += 1; + }); + const total = projects.length; + const statusLegend = (dashboardData?.status_bands || []).map((band) => { + const count = Number(statusCounts?.[band.key] || 0); + return { + ...band, + count, + ratio: total > 0 ? (count / total) * 100 : 0, + }; + }); + const compareBase = Math.max(income, expense, Math.abs(profit), 1); + return { + projectCount: total, + income, + expense, + profit, + marginRate, + statusLegend, + compareBars: [ + { key: "income", label: "수입", value: income, width: (income / compareBase) * 100, color: "#1e5e95" }, + { key: "expense", label: "지출", value: expense, width: (expense / compareBase) * 100, color: "#77c4df" }, + { key: "profit", label: "수익", value: Math.abs(profit), displayValue: profit, width: (Math.abs(profit) / compareBase) * 100, color: profit < 0 ? "#d14343" : "#1ca64b" }, + ], + }; + }, [selectedDashboardProjects, dashboardData]); + const dashboardStatusProjectModalItems = useMemo(() => { + if (!dashboardStatusProjectModal?.statusKey) return []; + return [...selectedDashboardProjects] + .filter((item) => (item.status_key || "normal") === dashboardStatusProjectModal.statusKey) + .filter((item) => dashboardMarginGradeFilter === "all" ? true : getMarginGradeKey(item.margin_rate) === dashboardMarginGradeFilter) + .sort((a, b) => (b.project_code || "").localeCompare(a.project_code || "", "ko")); + }, [dashboardStatusProjectModal, selectedDashboardProjects, dashboardMarginGradeFilter]); + function closeDashboardStatusProjectModal() { + if (initialPopupModeParam === "status_projects") { + window.close(); + return; + } + setDashboardStatusProjectModal(null); + } + function openDashboardStatusProjectModal(statusKey, statusLabel, count) { + if (!count) return; + const nextUrl = new URL(`${window.location.origin}/PTC-lab/`); + nextUrl.searchParams.set("popup", "status_projects"); + nextUrl.searchParams.set("dashboard_family", selectedDashboardFamily || "전체"); + if (selectedDashboardMethod) nextUrl.searchParams.set("dashboard_method", selectedDashboardMethod); + nextUrl.searchParams.set("status_key", statusKey); + nextUrl.searchParams.set("status_label", statusLabel); + const popupWindow = window.open( + "", + STATUS_POPUP_WINDOW_NAME, + "popup=yes,width=1320,height=920,scrollbars=yes,resizable=yes" + ); + if (popupWindow) { + try { + const sameOriginReady = + popupWindow.location && + popupWindow.location.origin === window.location.origin && + popupWindow.location.pathname === "/PTC-lab/" && + popupWindow.location.search.includes("popup=status_projects"); + + if (sameOriginReady) { + popupWindow.postMessage({ + type: "ptc-open-status-popup", + dashboardFamily: selectedDashboardFamily || "전체", + dashboardMethod: selectedDashboardMethod || "", + statusKey, + statusLabel, + grade: "all", + }, window.location.origin); + } else { + popupWindow.location.href = nextUrl.toString(); + } + } catch (err) { + popupWindow.location.href = nextUrl.toString(); + } + popupWindow.focus(); + return; + } + setDashboardMarginGradeFilter("all"); + setDashboardStatusProjectModal({ + statusKey, + statusLabel, + openedAt: Date.now(), + }); + } + + const dashboardStatusProjectModalContent = dashboardStatusProjectModal ? ( +