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 ? ( +
e.stopPropagation()} style={isStatusPopupWindow ? { padding: 20 } : undefined}> +
+
+
+ {dashboardStatusProjectModal.statusLabel} 프로젝트 +
+
+ {selectedDashboardMethod + ? `${selectedDashboardFamily} · ${selectedDashboardMethod} 범위` + : `${selectedDashboardFamily} 대분류 범위`} · {fmt(dashboardStatusProjectModalItems.length)}개 +
+
+ +
+
+ {[ + { key: "all", label: "전체" }, + { key: "deficit", label: "적자" }, + { key: "caution", label: "주의" }, + { key: "good", label: "양호" }, + { key: "excellent", label: "우수" }, + ].map((grade) => ( + + ))} +
+
+ {dashboardStatusProjectModalItems.map((item) => ( + + ))} + {!dashboardStatusProjectModalItems.length && ( +
해당 상태의 프로젝트가 없습니다.
+ )} +
+
+ ) : null; + + useEffect(() => { + if (!selectedDashboardMethod) return; + if (!visibleDashboardMethods.some((method) => method.method === selectedDashboardMethod)) { + setSelectedDashboardMethod(""); + } + }, [selectedDashboardFamily, selectedDashboardMethod, visibleDashboardMethods]); const allVisibleSelected = useMemo(() => ( filteredProjects.length > 0 && filteredProjects.every((item) => selectedProjectCodes.includes(item.project_code)) @@ -1999,15 +3213,26 @@ }); } + if (isStatusPopupWindow) { + return ( +
+ {dashboardStatusProjectModalContent} +
+ ); + } + return (
- {currentTab === "vendor" ? "거래내역확인" : "프로젝트 관리"} + {currentTab === "vendor" ? "거래내역확인" : currentTab === "dashboard" ? "대시보드 시안" : "프로젝트 관리"}
+ @@ -2017,6 +3242,257 @@
{error &&
{error}
} + {currentTab === "dashboard" && ( +
+
+
+
+
+
+
+
+
프로젝트 수
+
{fmt(overallDashboardScope.projectCount || 0)}개
+
+
+
수입
+
{fmtEok(overallDashboardScope.income || 0)}
+
+
+
지출
+
{fmtEok(overallDashboardScope.expense || 0)}
+
+
+
수익
+
+ {fmtEok(overallDashboardScope.profit || 0)} +
+
+
+
수익률
+
+ {(overallDashboardScope.marginRate || 0).toFixed(1)}% +
+
+
+
원가위험
+
{fmt(overallDashboardScope.statusCounts?.risk || 0)}개
+
+
+
선투입
+
{fmt(overallDashboardScope.statusCounts?.upfront || 0)}개
+
+
+
+ +
+
+ {dashboardLoading ? ( +
대시보드 데이터를 불러오는 중입니다.
+ ) : ( + <> +
+
dashboardFamilyLegend.find((item) => item.key === key)?.color || "#e6eef6") }}> +
+
+
전체
+ {fmt(overallDashboardScope.projectCount || 0)} +
+
+
+
+
+ {dashboardFamilyLegend.map((band) => ( +
+ ))} +
+
+ {dashboardFamilyItems.filter((item) => item.family !== "전체").map((band) => ( + + ))} +
+
+
+ + )} +
+ + {selectedDashboardFamily !== "전체" && ( +
+
+
세부 공법 상태 분포
+
+ {selectedDashboardFamily} 안에 포함된 공법별로 상태 프로젝트 수를 봅니다. +
+ {dashboardLoading ? ( +
대시보드 데이터를 불러오는 중입니다.
+ ) : selectedDashboardFamily === "전체" ? ( +
먼저 위에서 대분류를 선택해 주세요.
+ ) : ( +
+ {dashboardMethodItems.map((method) => ( + + ))} + {!dashboardMethodItems.length && ( +
선택한 대분류에 해당하는 세부 공법이 없습니다.
+ )} +
+ )} +
+
+
선택 조건 그래프
+
+ {selectedDashboardMethod + ? `${selectedDashboardFamily} · ${selectedDashboardMethod} 선택 결과입니다. 프로젝트 상세는 프로젝트 관리에서 확인하면 됩니다.` + : `${selectedDashboardFamily} 대분류 기준 요약입니다. 아래 세부 공법 행을 누르면 해당 공법 기준으로 그래프가 바뀝니다.`} +
+
+
+
프로젝트 수
+
{fmt(selectedDashboardScope.projectCount)}개
+
+
+
수입
+
{fmtEok(selectedDashboardScope.income)}
+
+
+
지출
+
{fmtEok(selectedDashboardScope.expense)}
+
+
+
수익
+
+ {fmtEok(selectedDashboardScope.profit)} +
+
+
+
수익률
+
+ {selectedDashboardScope.marginRate.toFixed(1)}% +
+
+
+
+
+
선택 범위 상태 분포
+
+
+
+
+
선택
+ {fmt(selectedDashboardScope.projectCount)} +
프로젝트
+
+
+
+
+ {selectedDashboardScope.statusLegend.map((band) => ( + + ))} +
+
+
+
+
+
+ )} +
+
+ )} + {dashboardStatusProjectModal && ( +
+ {dashboardStatusProjectModalContent} +
+ )} {currentTab === "project" && (
{!!editor.note && ( -
{editor.note}
+
{editor.note}
)} @@ -2202,9 +3678,9 @@
-
기간
+
계약기간
- {detail?.summary?.min_date || "-"} {detail?.summary?.max_date ? `~ ${detail?.summary?.max_date}` : ""} + {(editor.start_date || detail?.summary?.start_date || "-")} {(editor.end_date || detail?.summary?.end_date) ? `~ ${editor.end_date || detail?.summary?.end_date}` : ""}
@@ -2857,6 +4333,54 @@ )} + {dashboardInfoModal && ( +
setDashboardInfoModal("")}> +
e.stopPropagation()}> +
+
+ {dashboardInfoModal === "guide" ? "읽는 방법" : dashboardInfoModal === "status" ? "상태 구분" : "공법 표기 기준"} +
+ +
+ {dashboardInfoModal === "guide" && ( +
+ 먼저 공법 대분류 카드에서 어떤 사업군이 많은지 보고, 아래에서 선택된 대분류 안의 세부 공법과 계약금 구간을 비교합니다. +
+ 카드 아래 프로젝트 목록이 뜨면 해당 프로젝트를 눌러 기존 프로젝트 관리 화면으로 바로 이동할 수 있습니다. +
+ )} + {dashboardInfoModal === "status" && ( +
+
+ {(dashboardData?.status_bands || []).map((band) => ( +
+ + {band.label} +
+ ))} +
+
+ 정상: 입금과 집행 흐름이 현재 기준으로 크게 무리 없는 상태 +
+ 선투입: 수입이 아직 0원인데 자재비나 외주비가 먼저 나간 상태 +
+ 회수지연: 수입은 일부 들어왔지만 집행이 더 앞서 있고, 공정이나 기성 흐름상 회수 지연으로 보는 상태 +
+ 원가위험: 선투입이나 회수지연으로 보기보다 원가 부담이 큰 상태 +
+
+ )} + {dashboardInfoModal === "method" && ( +
+ 원본 데이터에 문자열 `NULL` 로 들어온 값과 빈값은 모두 미지정으로 묶어 표시합니다. +
+ 이 대시보드에서는 `NULL` 과 `공법미지정`을 따로 보지 않고 같은 의미로 취급합니다. +
+ )} +
+
+ )} + {pileProgressModalOpen && (
setPileProgressModalOpen(false)}>
e.stopPropagation()}> @@ -3061,7 +4585,7 @@
-
시공 시작일
+
계약 시작일
-
시공 종료일
+
계약 종료일
메모
- setEditor(prev => ({ ...prev, note: e.target.value }))} placeholder="프로젝트 설명 또는 관리 메모" + rows={3} + style={{ minHeight: 96, resize: "vertical", paddingTop: 10, paddingBottom: 10 }} />
@@ -3591,7 +5117,14 @@ ); } - ReactDOM.createRoot(document.getElementById("root")).render(); + try { + ReactDOM.createRoot(document.getElementById("root")).render(); + const fallback = document.getElementById("ptc-boot-fallback"); + if (fallback) fallback.style.display = "none"; + } catch (err) { + ptcBootFail(`화면 렌더링 실패: ${err?.message || err}`); + throw err; + }