diff --git a/apps/as/docs/spec/dashboard/SPEC.md b/apps/as/docs/spec/dashboard/SPEC.md index b547d1ae7..3ce6f9800 100644 --- a/apps/as/docs/spec/dashboard/SPEC.md +++ b/apps/as/docs/spec/dashboard/SPEC.md @@ -28,18 +28,18 @@ ``` ┌────────────────────────────────┬─────────────────────────────────────────────────┐ │ [영역 A] 전국 행정지원 지도 │ [영역 B] 문서별 진행상황 카드 (2×2) │ -│ 타이틀: 전국 행정지원 지도 │ ┌───────────────┐ ┌───────────────┐ │ -│ │ │ 재원협의 │ │ 설치인가 │ │ -│ (대한민국 시도·시군구 경계) │ │ 신청/진행/완료│ │ 신청/진행/완료│ │ -│ 마커: 서울 강남구, │ ├───────────────┤ ├───────────────┤ │ -│ 대전 유성구, │ │ 설계자문 │ │ 기본계획 │ │ -│ 부산 해운대구 │ │ 신청/진행/완료│ │ 신청/진행/완료│ │ -│ │ └───────────────┘ └───────────────┘ │ +│ 타이틀: 전국 행정지원 지도 │ ┌────────────────┐ ┌────────────────┐ │ +│ │ │ 재원협의 │ │ 설치인가 │ │ +│ (대한민국 시도·시군구 경계) │ │ 신청/진행/완료 │ │ 신청/진행/완료 │ │ +│ 마커: 서울 강남구, │ ├────────────────┤ ├────────────────┤ │ +│ 대전 유성구, │ │ 설계자문 │ │ 기본계획 │ │ +│ 부산 해운대구 │ │ 신청/진행/완료 │ │ 신청/진행/완료 │ │ +│ │ └────────────────┘ └────────────────┘ │ │ ├─────────────────────────────────────────────────┤ │ │ [영역 C] 알림이력 │ -│ │ 알림이력 업데이트 HH:mm [전체보기]│ +│ │ 알림이력 업데이트 HH:mm [전체보기] │ │ │ [오늘 N건] [지연 N건] [완료대기 N건] │ -│ │ [뱃지] [일자] 제목 [처리하기]│ +│ │ [뱃지] [일자] 제목 [처리하기] │ │ │ … (최대 5건) │ │ ├─────────────────────────────────────────────────┤ │ │ [영역 D] 공지사항 [더보기] │ diff --git a/apps/as/public/images/region_map.svg b/apps/as/public/images/region_map.svg new file mode 100644 index 000000000..f327bb9fc --- /dev/null +++ b/apps/as/public/images/region_map.svg @@ -0,0 +1,63 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/apps/as/public/images/region_map_bg.png b/apps/as/public/images/region_map_bg.png new file mode 100644 index 000000000..6f59f58cc Binary files /dev/null and b/apps/as/public/images/region_map_bg.png differ diff --git a/apps/as/src/entities/fnrs-shar-se/model/fnrs-shar-se-interface.ts b/apps/as/src/entities/fnrs-shar-se/model/fnrs-shar-se-interface.ts index ab60a868f..97adb34b6 100644 --- a/apps/as/src/entities/fnrs-shar-se/model/fnrs-shar-se-interface.ts +++ b/apps/as/src/entities/fnrs-shar-se/model/fnrs-shar-se-interface.ts @@ -6,7 +6,8 @@ export const FNRS_SHAR_SE_SAVE_MODE = { DELETE: "D", } as const; -export type FnrsSharSeSaveMode = (typeof FNRS_SHAR_SE_SAVE_MODE)[keyof typeof FNRS_SHAR_SE_SAVE_MODE]; +export type FnrsSharSeSaveMode = + (typeof FNRS_SHAR_SE_SAVE_MODE)[keyof typeof FNRS_SHAR_SE_SAVE_MODE]; /** 백엔드 FnrsSharSeMngResponseDto */ export interface FnrsSharSeMngResponseDto { @@ -69,6 +70,8 @@ export interface FnrsSharBizCstTreeDto { ajmtAmt?: number; rmrkCn?: string; envoRmrkCn?: string; + chgAmt: number; + chgRt: number; } /** Controller public 메서드명·시그니처와 동일 */ diff --git a/apps/as/src/pages/dashboard/lib/national-as-map.module.css b/apps/as/src/pages/dashboard/lib/national-as-map.module.css new file mode 100644 index 000000000..459855e88 --- /dev/null +++ b/apps/as/src/pages/dashboard/lib/national-as-map.module.css @@ -0,0 +1,153 @@ +.mapArea { + display: flex; + align-items: center; + justify-content: center; + height: 100%; +} + +.bgLayer { + position: absolute; + inset: var(--map-inset); + display: flex; + align-items: center; + justify-content: center; + z-index: 1; + width: calc(100% - var(--map-inset) * 2); + height: calc(100% - var(--map-inset) * 2); + background-image: url("/images/region_map_bg.png"); + background-repeat: no-repeat; + background-size: contain; + background-position: center center; + image-rendering: -webkit-optimize-contrast; + image-rendering: crisp-edges; + image-rendering: pixelated; + opacity: 0.7; + filter: drop-shadow(0 1px 0 rgba(0, 0, 0, 0.02)); + pointer-events: none; + min-height: 200px; +} + +.svgLayer { + position: absolute; + inset: var(--map-inset); + display: flex; + align-items: center; + justify-content: center; + z-index: 2; + width: calc(100% - var(--map-inset) * 2); + height: calc(100% - var(--map-inset) * 2); +} + +.svgLayer > svg { + max-width: 100%; + max-height: 100%; + width: auto; + height: auto; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + image-rendering: -webkit-optimize-contrast; + image-rendering: crisp-edges; + shape-rendering: geometricPrecision; +} + +.svgContainer :global(svg) :global(path) { + fill: rgba(0, 150, 255, 0.2) !important; + stroke: #0066cc; + stroke-width: 1; + vector-effect: non-scaling-stroke; +} + +.svgContainer :global(svg) :global(.cls-1) { + fill: rgba(0, 150, 255, 0.2) !important; + stroke: #0066cc; + stroke-width: 1; + vector-effect: non-scaling-stroke; +} + +.svgContainer :global(svg) :global(.basin) { + opacity: 0; + pointer-events: all; + transition: opacity 0.15s ease; + cursor: pointer; +} + +.svgContainer :global(svg) :global(.basin.active), +.svgContainer :global(svg) :global(.basin:hover) { + opacity: 1; +} + +.svgContainer :global(svg) :global(.basin) :global(path), +.svgContainer :global(svg) :global(.basin) :global(polygon), +.svgContainer :global(svg) :global(.basin) :global(polyline) { + fill: rgba(0, 209, 209, 0.5); + stroke: #399ac3; + stroke-width: 1.3; + vector-effect: non-scaling-stroke; +} + +.popup { + position: absolute; + width: 300px; + height: 150px; + min-width: 220px; + background: #fff; + border: 1px solid #e5e7eb; + border-radius: 10px; + box-shadow: + 0 12px 24px rgba(0, 0, 0, 0.18), + 0 2px 0 rgba(0, 0, 0, 0.05); + opacity: 0; + visibility: hidden; + transform: translate(-50%, -8px) scale(0.98); + transition: + opacity 0.12s, + visibility 0.12s, + transform 0.12s; + pointer-events: none; + z-index: 10; +} + +.popup.show { + opacity: 1 !important; + visibility: visible !important; + transform: translate(-50%, -12px) scale(1) !important; +} + +.show { + opacity: 1; + visibility: visible; +} + +.labelsLayer { + position: absolute; + inset: var(--map-inset); + pointer-events: none; + z-index: 3; + width: 100%; + height: 100%; +} + +.basinLabel { + position: absolute; + transform: translate(-50%, -50%); + pointer-events: none; + padding: 6px 12px; + border-radius: 6px; + font-weight: 700; + font-size: 16px; + background: #ffffffee; + border: 2px solid #d0d0d0; + white-space: nowrap; + min-width: 60px; + text-align: center; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); +} + +.basinLabel.active { + background: #006bf8; + color: #fff; + border-color: #96c3ff; + box-shadow: 0 4px 12px rgba(0, 107, 248, 0.3); +} diff --git a/apps/as/src/pages/dashboard/lib/use-national-as-map.ts b/apps/as/src/pages/dashboard/lib/use-national-as-map.ts new file mode 100644 index 000000000..997c0154a --- /dev/null +++ b/apps/as/src/pages/dashboard/lib/use-national-as-map.ts @@ -0,0 +1,310 @@ +import { type CSSProperties, useCallback, useEffect, useRef, useState } from "react"; + +import { + BASIN_DATA, + BASIN_IDS, + type BasinKey, + MANUAL_LABEL_POS, + type ManualLabelPos, +} from "../model/map-data"; + +import styles from "./national-as-map.module.css"; + +const SVG_URL = "/images/region_map.svg"; +const POPUP_EDGE_PADDING_PX = 12; +const LABEL_OFFSET_Y_PX = 18; +const DEFAULT_VIEWBOX = "0 0 100 100"; + +export const PLACEHOLDER_POPUP_ITEMS = [{ header: "현황", data: "-" }] as const; + +export interface MapPopupItem { + header: string; + data: string; +} + +export interface MapPopupContent { + title: string; + items: MapPopupItem[]; +} + +const INITIAL_POPUP_CONTENT: MapPopupContent = { + title: "", + items: [...PLACEHOLDER_POPUP_ITEMS], +}; + +function isBasinKey(key: string | undefined): key is BasinKey { + return Boolean(key && key in BASIN_DATA); +} + +function resolveBasinName(group: SVGGElement) { + const key = group.dataset.key; + if (isBasinKey(key)) return BASIN_DATA[key].name; + return group.dataset.label ?? ""; +} + +function hidePopup(popup: HTMLDivElement | null) { + if (!popup) return; + if (styles.show) popup.classList.remove(styles.show); + popup.style.opacity = "0"; + popup.style.visibility = "hidden"; +} + +function showPopupAtGroup(stage: HTMLDivElement, popup: HTMLDivElement, group: SVGGElement) { + const stageRect = stage.getBoundingClientRect(); + const groupRect = group.getBoundingClientRect(); + const centerX = (groupRect.left + groupRect.right) / 2 - stageRect.left; + const topY = groupRect.top - stageRect.top; + + popup.style.left = `${centerX}px`; + popup.style.top = `${topY + 6}px`; + popup.style.opacity = "1"; + popup.style.visibility = "visible"; + if (styles.show) popup.classList.add(styles.show); + + const popupRect = popup.getBoundingClientRect(); + const clampedX = Math.max( + POPUP_EDGE_PADDING_PX + popupRect.width / 2, + Math.min(centerX, stageRect.width - POPUP_EDGE_PADDING_PX - popupRect.width / 2), + ); + popup.style.left = `${clampedX}px`; +} + +function collectBasinGroups(svg: SVGSVGElement) { + const groups: SVGGElement[] = []; + + BASIN_IDS.forEach((item) => { + let group: SVGGElement | null = null; + for (const id of item.labels) { + group = svg.getElementById(id) as SVGGElement | null; + if (group) break; + } + if (!group) return; + + group.classList.add("basin"); + group.dataset.key = item.key; + group.dataset.label = item.labels[0] ?? ""; + + group.querySelectorAll("path").forEach((path) => { + path.style.fill = "rgba(0, 150, 255, 0.2)"; + path.style.stroke = "#0066cc"; + path.style.strokeWidth = "1"; + path.style.vectorEffect = "non-scaling-stroke"; + }); + + groups.push(group); + }); + + return groups; +} + +function createLabels(labelsLayer: HTMLDivElement, groups: SVGGElement[]) { + labelsLayer.innerHTML = ""; + return groups.map((group) => { + const label = document.createElement("div"); + label.className = styles.basinLabel ?? ""; + label.textContent = group.dataset.label ?? ""; + labelsLayer.appendChild(label); + return label; + }); +} + +function applyViewBox(svg: SVGSVGElement, stage: HTMLDivElement) { + if (!svg.hasAttribute("viewBox")) { + const bbox = svg.getBBox(); + svg.setAttribute("viewBox", [bbox.x, bbox.y, bbox.width, bbox.height].join(" ")); + } + + const viewBox = (svg.getAttribute("viewBox") || "").split(/\s+/).map(Number); + if (viewBox.length === 4 && viewBox[3]! > 0) + stage.style.aspectRatio = String(viewBox[2]! / viewBox[3]!); + + svg.setAttribute("preserveAspectRatio", "xMidYMid meet"); + svg.style.width = "100%"; + svg.style.height = "100%"; +} + +function positionLabels( + stage: HTMLDivElement, + svg: SVGSVGElement, + groups: SVGGElement[], + labels: HTMLDivElement[], +) { + const stageRect = stage.getBoundingClientRect(); + const inset = parseFloat(getComputedStyle(stage).getPropertyValue("--map-inset") || "0") / 100; + const left = stageRect.width * inset; + const top = stageRect.height * inset; + const width = stageRect.width * (1 - inset * 2); + const height = stageRect.height * (1 - inset * 2); + const [viewX, viewY, viewWidth, viewHeight] = (svg.getAttribute("viewBox") || DEFAULT_VIEWBOX) + .split(/\s+/) + .map(Number); + + const svgRect = svg.getBoundingClientRect(); + const offsetX = svgRect.left - stageRect.left - left; + const offsetY = svgRect.top - stageRect.top - top; + + groups.forEach((group, index) => { + const key = group.dataset.key; + const manual = key ? (MANUAL_LABEL_POS as Record)[key] : undefined; + let posX: number; + let posY: number; + + if (manual && (("x" in manual && "y" in manual) || ("px" in manual && "py" in manual))) { + if ("px" in manual) { + posX = left + (manual.px || 0) + offsetX; + posY = top + (manual.py || 0) + offsetY; + } else { + posX = left + width * (manual.x || 0) + offsetX; + posY = top + height * (manual.y || 0) + offsetY; + } + } else { + const bbox = group.getBBox(); + const centerX = bbox.x + bbox.width / 2; + const centerY = bbox.y + bbox.height / 2; + posX = ((centerX - viewX!) / viewWidth!) * width + left + offsetX; + posY = ((centerY - viewY!) / viewHeight!) * height + top + offsetY; + } + + const label = labels[index]; + if (!label) return; + label.style.left = `${posX}px`; + label.style.top = `${posY + LABEL_OFFSET_Y_PX}px`; + }); +} + +export function useNationalAsMap() { + const stageRef = useRef(null); + const svgLayerRef = useRef(null); + const labelsLayerRef = useRef(null); + const popupRef = useRef(null); + const [popupContent, setPopupContent] = useState(INITIAL_POPUP_CONTENT); + + const showFor = useCallback((group: SVGGElement) => { + const stage = stageRef.current; + const popup = popupRef.current; + if (!stage || !popup) return; + + setPopupContent({ + title: resolveBasinName(group), + items: [...PLACEHOLDER_POPUP_ITEMS], + }); + showPopupAtGroup(stage, popup, group); + }, []); + + useEffect(() => { + const abortController = new AbortController(); + let svg: SVGSVGElement | null = null; + let groups: SVGGElement[] = []; + let labels: HTMLDivElement[] = []; + let resizeObserver: ResizeObserver | null = null; + let positionLabelsHandler: (() => void) | null = null; + let clearActive: (() => void) | null = null; + let handleStageClick: (() => void) | null = null; + + const listeners: Array<{ + target: EventTarget; + type: string; + listener: EventListener; + }> = []; + + function addListener(target: EventTarget, type: string, listener: EventListener) { + target.addEventListener(type, listener); + listeners.push({ target, type, listener }); + } + + fetch(SVG_URL, { signal: abortController.signal }) + .then((response) => { + if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`); + return response.text(); + }) + .then((svgText) => { + if (abortController.signal.aborted) return; + if (!svgLayerRef.current || !stageRef.current) return; + + svgLayerRef.current.innerHTML = svgText; + svg = svgLayerRef.current.querySelector("svg"); + if (!svg) throw new Error("SVG element not found in loaded content"); + + applyViewBox(svg, stageRef.current); + groups = collectBasinGroups(svg); + labels = labelsLayerRef.current ? createLabels(labelsLayerRef.current, groups) : []; + + clearActive = () => { + groups.forEach((group) => group.classList.remove("active")); + hidePopup(popupRef.current); + labels.forEach((label) => label.classList.remove("active")); + }; + + const revealGroup = (group: SVGGElement) => { + showFor(group); + const index = groups.indexOf(group); + labels.forEach((label) => label.classList.remove("active")); + if (index >= 0) labels[index]?.classList.add("active"); + }; + + groups.forEach((group) => { + addListener(group, "mouseenter", () => { + clearActive?.(); + group.classList.add("active"); + revealGroup(group); + }); + addListener(group, "mousemove", () => { + if (group.classList.contains("active")) revealGroup(group); + }); + addListener(group, "mouseleave", () => clearActive?.()); + addListener(group, "click", (event) => { + event.stopPropagation(); + clearActive?.(); + group.classList.add("active"); + revealGroup(group); + }); + }); + + handleStageClick = () => clearActive?.(); + addListener(stageRef.current, "mouseleave", () => clearActive?.()); + addListener(stageRef.current, "click", () => handleStageClick?.()); + + positionLabelsHandler = () => { + if (!stageRef.current || !svg) return; + positionLabels(stageRef.current, svg, groups, labels); + }; + positionLabelsHandler(); + window.addEventListener("resize", positionLabelsHandler); + resizeObserver = new ResizeObserver(positionLabelsHandler); + resizeObserver.observe(stageRef.current); + }) + .catch((error: unknown) => { + if (abortController.signal.aborted) return; + const message = error instanceof Error ? error.message : "알 수 없는 오류"; + if (svgLayerRef.current) { + svgLayerRef.current.innerHTML = `
지도를 불러올 수 없습니다. (${message})
`; + } + }); + + return () => { + abortController.abort(); + listeners.forEach(({ target, type, listener }) => { + target.removeEventListener(type, listener); + }); + if (positionLabelsHandler) window.removeEventListener("resize", positionLabelsHandler); + resizeObserver?.disconnect(); + if (svgLayerRef.current) svgLayerRef.current.innerHTML = ""; + if (labelsLayerRef.current) labelsLayerRef.current.innerHTML = ""; + }; + }, [showFor]); + + const popupGridStyle = { + minWidth: 160, + display: "grid", + gridTemplateColumns: `repeat(${Math.max(1, popupContent.items.length)}, 1fr)`, + } satisfies CSSProperties; + + return { + stageRef, + svgLayerRef, + labelsLayerRef, + popupRef, + popupContent, + popupGridStyle, + }; +} diff --git a/apps/as/src/pages/dashboard/model/map-data.ts b/apps/as/src/pages/dashboard/model/map-data.ts new file mode 100644 index 000000000..18f703161 --- /dev/null +++ b/apps/as/src/pages/dashboard/model/map-data.ts @@ -0,0 +1,29 @@ +export const MANUAL_LABEL_POS = { + han: { x: 0.52, y: 0.14 }, + geum: { x: 0.28, y: 0.47 }, + nakdong: { x: 0.64, y: 0.47 }, + yeongsan: { x: 0.18, y: 0.77 }, +} as const; + +export const BASIN_DATA = { + han: { name: "한강" }, + geum: { name: "금강" }, + nakdong: { name: "낙동강" }, + yeongsan: { name: "영산강" }, +} as const; + +export const BASIN_IDS = [ + { key: "han", labels: ["한강", "han"] }, + { key: "geum", labels: ["금강", "geum"] }, + { key: "nakdong", labels: ["낙동강", "nakdong"] }, + { key: "yeongsan", labels: ["영산강", "yeongsan"] }, +] as const; + +export type BasinKey = keyof typeof BASIN_DATA; + +export interface ManualLabelPos { + x?: number; + y?: number; + px?: number; + py?: number; +} diff --git a/apps/as/src/pages/dashboard/ui/dashboard-page.tsx b/apps/as/src/pages/dashboard/ui/dashboard-page.tsx index 5e4226ffd..0ce8e7bca 100644 --- a/apps/as/src/pages/dashboard/ui/dashboard-page.tsx +++ b/apps/as/src/pages/dashboard/ui/dashboard-page.tsx @@ -11,9 +11,11 @@ export function DashboardPage() { const { data, isPending, isError, dataUpdatedAt } = useGetMainDashboard(); return ( -
- -
+
+
+ +
+

전국 행정지원 지도

- +
+
+
+
+
+
+
+ {popupContent.title} +
+ {popupContent.items.map((item, index) => ( +
+ {item.header} +
+ ))} + {popupContent.items.map((item, index) => ( +
+ {item.data} +
+ ))} +
+
+
); diff --git a/apps/as/src/pages/design-cnstn/ui/sections/business-scale-section.tsx b/apps/as/src/pages/design-cnstn/ui/sections/business-scale-section.tsx index c46fc31be..24ab52bfd 100644 --- a/apps/as/src/pages/design-cnstn/ui/sections/business-scale-section.tsx +++ b/apps/as/src/pages/design-cnstn/ui/sections/business-scale-section.tsx @@ -33,10 +33,6 @@ function BusinessScaleSection({ bizSclList }: BusinessScaleSectionProps) { ) : ( bizSclList.map((node) => { const pl = indentPx(node.indCnstpSeCdNm); - const bscPlanQty = Number(node.bscPlanQty) || 0; - const budgetReflct = Number(node.bgtRfltQty) || 0; - const tstmDesignQty = Number(node.tstmDesignQty) || 0; - const diff = tstmDesignQty - budgetReflct; return ( @@ -44,17 +40,17 @@ function BusinessScaleSection({ bizSclList }: BusinessScaleSectionProps) { {node.cnstpSeCdNm} -
{fmtNumber(bscPlanQty)}
+
{fmtNumber(node.bscPlanQty)}
-
{fmtNumber(budgetReflct)}
+
{fmtNumber(node.bgtRfltQty)}
-
{fmtNumber(tstmDesignQty)}
+
{fmtNumber(node.tstmDesignQty)}
- {diff > 0 ? `+${fmtNumber(diff)}` : fmtNumber(diff)} + {node.chgQty > 0 ? `+${fmtNumber(node.chgQty)}` : fmtNumber(node.chgQty)}
diff --git a/apps/as/src/pages/fnrs-cnsltn/ui/sections/business-cost-section.tsx b/apps/as/src/pages/fnrs-cnsltn/ui/sections/business-cost-section.tsx index d880811b6..d892187f6 100644 --- a/apps/as/src/pages/fnrs-cnsltn/ui/sections/business-cost-section.tsx +++ b/apps/as/src/pages/fnrs-cnsltn/ui/sections/business-cost-section.tsx @@ -4,7 +4,7 @@ import { useMemo } from "react"; import { SectionCollapsible } from "@/shared/ui"; import type { BizCstTreeDto } from "@/entities/fnrs-cnsltn"; -import { calcDiffPercent, fmtNumber, indentPx } from "@/widgets/pbadms-sprt"; +import { fmtNumber, indentPx } from "@/widgets/pbadms-sprt"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@common/components"; import { sortBizCstListByTreePath } from "../../lib/business-cost-utils"; @@ -46,10 +46,6 @@ function BusinessCostSection({ bizCstList }: BusinessCostSectionProps) { ) : ( sortedBizCstList.map((node) => { const pl = indentPx(node.indAcntgSeCdNm); - const bscPlanAmt = Number(node.bscPlanAmt) || 0; - const budgetReflct = Number(node.bgtRfltAmt) || 0; - const tstmDesignAmt = Number(node.tstmDesignAmt) || 0; - const diff = tstmDesignAmt - budgetReflct; const showSubIcon = node.treeLevel >= 3; return ( @@ -63,21 +59,21 @@ function BusinessCostSection({ bizCstList }: BusinessCostSectionProps) { )} -
{fmtNumber(bscPlanAmt)}
+
{fmtNumber(node.bgtRfltAmt)}
-
{fmtNumber(budgetReflct)}
+
{fmtNumber(node.bscPlanAmt)}
-
{fmtNumber(tstmDesignAmt)}
+
{fmtNumber(node.tstmDesignAmt)}
- {diff > 0 ? `+${fmtNumber(diff)}` : fmtNumber(diff)} + {node.chgAmt > 0 ? `+${fmtNumber(node.chgAmt)}` : fmtNumber(node.chgAmt)}
-
{calcDiffPercent(diff, budgetReflct)}
+
{fmtNumber(node.chgRt)}%
); diff --git a/apps/as/src/pages/fnrs-cnsltn/ui/sections/business-scale-section.tsx b/apps/as/src/pages/fnrs-cnsltn/ui/sections/business-scale-section.tsx index c1f5a28af..46cd6418d 100644 --- a/apps/as/src/pages/fnrs-cnsltn/ui/sections/business-scale-section.tsx +++ b/apps/as/src/pages/fnrs-cnsltn/ui/sections/business-scale-section.tsx @@ -33,10 +33,6 @@ function BusinessScaleSection({ bizSclList }: BusinessScaleSectionProps) { ) : ( bizSclList.map((node) => { const pl = indentPx(node.indCnstpSeCdNm); - const bscPlanQty = Number(node.bscPlanQty) || 0; - const budgetReflct = Number(node.bgtRfltQty) || 0; - const tstmDesignQty = Number(node.tstmDesignQty) || 0; - const diff = tstmDesignQty - budgetReflct; return ( @@ -44,17 +40,17 @@ function BusinessScaleSection({ bizSclList }: BusinessScaleSectionProps) { {node.cnstpSeCdNm} -
{fmtNumber(bscPlanQty)}
+
{fmtNumber(node.bscPlanQty)}
-
{fmtNumber(budgetReflct)}
+
{fmtNumber(node.bgtRfltQty)}
-
{fmtNumber(tstmDesignQty)}
+
{fmtNumber(node.tstmDesignQty)}
- {diff > 0 ? `+${fmtNumber(diff)}` : fmtNumber(diff)} + {node.chgQty > 0 ? `+${fmtNumber(node.chgQty)}` : fmtNumber(node.chgQty)}
diff --git a/apps/as/src/pages/fnrs-cnsltn/ui/sections/fnrs-shar-section.tsx b/apps/as/src/pages/fnrs-cnsltn/ui/sections/fnrs-shar-section.tsx index ad83c53e4..3d4e3aa93 100644 --- a/apps/as/src/pages/fnrs-cnsltn/ui/sections/fnrs-shar-section.tsx +++ b/apps/as/src/pages/fnrs-cnsltn/ui/sections/fnrs-shar-section.tsx @@ -61,8 +61,14 @@ function FnrsSharSection({ fnrsSharList }: FnrsSharSectionProps) {
{fmtNumber(node.chgRqtAmt)}
- - + +
+ {node.chgAmt > 0 ? `+${fmtNumber(node.chgAmt)}` : fmtNumber(node.chgAmt)} +
+
+ +
{fmtNumber(node.chgRt)}%
+
{node.rmrkCn ?? ""} ); diff --git a/apps/as/src/pages/instl-sncn/ui/sections/business-cost-section.tsx b/apps/as/src/pages/instl-sncn/ui/sections/business-cost-section.tsx index 77b7cdd2e..7de55ee64 100644 --- a/apps/as/src/pages/instl-sncn/ui/sections/business-cost-section.tsx +++ b/apps/as/src/pages/instl-sncn/ui/sections/business-cost-section.tsx @@ -4,7 +4,7 @@ import { useMemo } from "react"; import { SectionCollapsible } from "@/shared/ui"; import type { BizCstTreeDto } from "@/entities/instl-sncn"; -import { calcDiffPercent, fmtNumber, indentPx } from "@/widgets/pbadms-sprt"; +import { fmtNumber, indentPx } from "@/widgets/pbadms-sprt"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@common/components"; import { sortBizCstListByTreePath } from "../../lib/business-cost-utils"; @@ -46,10 +46,6 @@ function BusinessCostSection({ bizCstList }: BusinessCostSectionProps) { ) : ( sortedBizCstList.map((node) => { const pl = indentPx(node.indAcntgSeCdNm); - const bscPlanAmt = Number(node.bscPlanAmt) || 0; - const budgetReflct = Number(node.bgtRfltAmt) || 0; - const tstmDesignAmt = Number(node.tstmDesignAmt) || 0; - const diff = tstmDesignAmt - budgetReflct; const showSubIcon = node.treeLevel >= 3; return ( @@ -63,21 +59,21 @@ function BusinessCostSection({ bizCstList }: BusinessCostSectionProps) { )} -
{fmtNumber(bscPlanAmt)}
+
{fmtNumber(node.bgtRfltAmt)}
-
{fmtNumber(budgetReflct)}
+
{fmtNumber(node.bscPlanAmt)}
-
{fmtNumber(tstmDesignAmt)}
+
{fmtNumber(node.tstmDesignAmt)}
- {diff > 0 ? `+${fmtNumber(diff)}` : fmtNumber(diff)} + {node.chgAmt > 0 ? `+${fmtNumber(node.chgAmt)}` : fmtNumber(node.chgAmt)}
-
{calcDiffPercent(diff, budgetReflct)}
+
{fmtNumber(node.chgRt)}%
); diff --git a/apps/as/src/pages/instl-sncn/ui/sections/business-scale-section.tsx b/apps/as/src/pages/instl-sncn/ui/sections/business-scale-section.tsx index 82f7785f8..ca78cfec9 100644 --- a/apps/as/src/pages/instl-sncn/ui/sections/business-scale-section.tsx +++ b/apps/as/src/pages/instl-sncn/ui/sections/business-scale-section.tsx @@ -33,10 +33,6 @@ function BusinessScaleSection({ bizSclList }: BusinessScaleSectionProps) { ) : ( bizSclList.map((node) => { const pl = indentPx(node.indCnstpSeCdNm); - const bscPlanQty = Number(node.bscPlanQty) || 0; - const budgetReflct = Number(node.bgtRfltQty) || 0; - const tstmDesignQty = Number(node.tstmDesignQty) || 0; - const diff = tstmDesignQty - budgetReflct; return ( @@ -44,17 +40,17 @@ function BusinessScaleSection({ bizSclList }: BusinessScaleSectionProps) { {node.cnstpSeCdNm} -
{fmtNumber(bscPlanQty)}
+
{fmtNumber(node.bscPlanQty)}
-
{fmtNumber(budgetReflct)}
+
{fmtNumber(node.bgtRfltQty)}
-
{fmtNumber(tstmDesignQty)}
+
{fmtNumber(node.tstmDesignQty)}
- {diff > 0 ? `+${fmtNumber(diff)}` : fmtNumber(diff)} + {node.chgQty > 0 ? `+${fmtNumber(node.chgQty)}` : fmtNumber(node.chgQty)}
diff --git a/apps/as/src/shared/model/pbadms-sprt.ts b/apps/as/src/shared/model/pbadms-sprt.ts index 272e6d110..10d620b84 100644 --- a/apps/as/src/shared/model/pbadms-sprt.ts +++ b/apps/as/src/shared/model/pbadms-sprt.ts @@ -28,6 +28,10 @@ export interface BizSclTreeDto { bgtRfltQty: number; /** 금회설계수량 */ tstmDesignQty: number; + /** 증감수량(금회설계수량 - 예산반영수량, C-B) */ + chgQty: number; + /** 증감율 % ((증감수량 / 기본계획수량) * 100) */ + chgRt: number; } /** 회계구분 트리와 사업비용 조합 DTO */ @@ -44,12 +48,16 @@ export interface BizCstTreeDto { treeLevel: number; /** 트리 경로 표시용 문자열 */ treePath: string; - /** 기본계획금액 */ - bscPlanAmt: number; - /** 예산반영금액 */ + /** 예산반영금액(A) */ bgtRfltAmt: number; - /** 금회설계금액 */ + /** 표준사업비(B) */ + bscPlanAmt: number; + /** 금회설계금액(C) */ tstmDesignAmt: number; + /** 증감금액(금회설계금액 - 표준사업비, C-B) */ + chgAmt: number; + /** 증감율 % ((증감금액 / 표준사업비) * 100) */ + chgRt: number; } /** 점검표 검토결과 트리 DTO */ diff --git a/apps/as/src/widgets/pbadms-sprt/lib/format-utils.ts b/apps/as/src/widgets/pbadms-sprt/lib/format-utils.ts index b67ad9860..8fa24d3cb 100644 --- a/apps/as/src/widgets/pbadms-sprt/lib/format-utils.ts +++ b/apps/as/src/widgets/pbadms-sprt/lib/format-utils.ts @@ -1,6 +1,14 @@ -export function fmtNumber(value: number | null | undefined): string { - if (typeof value === "number") return value.toLocaleString(); - if (value === null || value === undefined) return ""; +export function fmtNumber(value: number | string | null | undefined): string { + if (typeof value === "number") { + return value.toLocaleString(); + } + if (value === null || value === undefined) { + return ""; + } + const num = Number(value); + if (!isNaN(num)) { + return num.toLocaleString(); + } return String(value); } diff --git a/apps/as/src/widgets/pbadms-sprt/ui/progress-status-section.tsx b/apps/as/src/widgets/pbadms-sprt/ui/progress-status-section.tsx index 4ce4d8a0a..151494379 100644 --- a/apps/as/src/widgets/pbadms-sprt/ui/progress-status-section.tsx +++ b/apps/as/src/widgets/pbadms-sprt/ui/progress-status-section.tsx @@ -57,7 +57,7 @@ function ProgressStatusSection({ return ( - +
{columns.map((column) => (