diff --git a/apps/sn/.env.local b/apps/sn/.env.local index 3d871b007..dce96bf40 100644 --- a/apps/sn/.env.local +++ b/apps/sn/.env.local @@ -12,8 +12,8 @@ NEXT_PUBLIC_LX_GEOSERVER_URL = http://con.mind-one.co.kr:11306/geoserver NEXT_PUBLIC_NGII_URL = https://map.ngii.go.kr NEXT_PUBLIC_NGII_PARAM = 04trYP9_xwLAfALjwZ-B8g&service=WMTS&request=GetTile&version=1.0.0&format=image/png&layer=korean_map&style=korean&tilematrixset=korean&tilematrix={z}&tilecol={x}&tilerow={y} SERVER_API_URL = http://dev.onasys.co.kr:8000 -# SEWERAGE_API_URL = http://localhost:8883 -SEWERAGE_API_URL = http://con.mind-one.co.kr:11083 +SEWERAGE_API_URL = http://localhost:8883 +# SEWERAGE_API_URL = http://con.mind-one.co.kr:11083 DC_URL = https://apigateway.apps.rsonesoft.com # NEXT_PUBLIC_CM_LOGIN_PAGE_URL = http://dev.onasys.co.kr:3000/auth/login NEXT_PUBLIC_CM_LOGIN_PAGE_URL = /auth/login diff --git a/apps/sn/AGENTS.md b/apps/sn/AGENTS.md index 3c369a72d..037aa402a 100644 --- a/apps/sn/AGENTS.md +++ b/apps/sn/AGENTS.md @@ -6,6 +6,39 @@ 현재 목표는 리팩토링 전용 작업이 아니라, 하수관로 운영관리 시스템 2차사업 고도화를 수행하는 것이다. 즉, 기존 `sn` 화면과 흐름을 안정적으로 유지하면서 2차 요구사항에 해당하는 화면 개발, 기능 확장, 백엔드 연동 보완을 수행해야 한다. +## 사용자 작업 범위: UI/UX 및 퍼블리싱 + +현재 사용자와 진행하는 작업의 기본 범위는 UI/UX 및 프론트엔드 퍼블리싱으로 제한한다. +사용자가 별도로 명시하지 않는 한 아래 기준을 모든 후속 작업에 적용한다. + +### 허용 범위 + +- 화면 레이아웃과 시각적 위계 조정 +- JSX 마크업, Tailwind CSS, CSS 스타일 수정 +- 색상, 여백, 간격, 크기, 타이포그래피, 아이콘, 테두리, 배경 조정 +- 반응형 화면, 접근성, 포커스와 시각적 상태 개선 +- 표시 전용 컴포넌트 구성과 퍼블리싱 품질 개선 +- 기존 화면 내용과 동작을 유지하는 범위의 UI 구조 정리 + +### 기본 금지 범위 + +- 백엔드 코드, API 계약, 요청·응답 데이터 구조 변경 +- `.env*`, middleware, 프록시, 인증과 권한 설정 변경 +- 업무 로직, 상태 모델, 데이터 가공 및 저장 흐름 변경 +- 데이터베이스, 서버 프로세스와 배포 설정 변경 +- 패키지 설치·업데이트와 의존성 구조 변경 +- 사용자 요청 없이 화면의 문구, 데이터 항목, 기능 동작 변경 + +금지 범위의 변경이 필요해 보이면 직접 수정하지 않고 원인과 필요한 작업만 사용자에게 설명한다. + +### UI 검증 안전 기준 + +- 사용자가 실행한 개발 서버가 있는지 실제 앱 포트까지 먼저 확인한다. +- 개발 서버 실행 중에는 같은 앱의 `next build`를 실행하지 않는다. +- UI 작업의 기본 검증은 실행 중인 개발 서버의 HMR, 브라우저 화면 확인, `git diff --check`로 제한한다. +- 개발 서버 종료, `.next` 정리, 전체 빌드와 프로세스 재시작은 사용자가 명시적으로 승인한 경우에만 수행한다. +- UI 변경 전후에 기존 문구, 데이터, 이벤트와 업무 동작이 유지되는지 확인한다. + --- ## 공통 시작 기준 diff --git a/apps/sn/app/(map-layout)/map/components/InputSection.tsx b/apps/sn/app/(map-layout)/map/components/InputSection.tsx index 68c4a3a83..87b904d58 100644 --- a/apps/sn/app/(map-layout)/map/components/InputSection.tsx +++ b/apps/sn/app/(map-layout)/map/components/InputSection.tsx @@ -1,3 +1,4 @@ +/** 시설 상세정보 화면의 운영정보 입력 영역을 공통 형식으로 표시한다. */ import { Table, TableHeader, diff --git a/apps/sn/app/(map-layout)/map/components/agedPipe/AgedPipeLayerSettingPanel.tsx b/apps/sn/app/(map-layout)/map/components/agedPipe/AgedPipeLayerSettingPanel.tsx index 9dda96f8f..7e29c6c4f 100644 --- a/apps/sn/app/(map-layout)/map/components/agedPipe/AgedPipeLayerSettingPanel.tsx +++ b/apps/sn/app/(map-layout)/map/components/agedPipe/AgedPipeLayerSettingPanel.tsx @@ -1,3 +1,7 @@ +/** + * 화면명: 노후관로 레이어 설정 + * 사업차수와 구조적·운영적 상태등급별 지도 표시 조건을 설정한다. + */ "use client"; import { useEffect, useState } from "react"; @@ -54,23 +58,18 @@ export default function AgedPipeLayerSettingPanel({ }; const inputClass = - "h-[20px] !rounded border border-input bg-background px-[1px] text-center text-[1.1rem] leading-none text-foreground"; - const numberInputClass = inputClass + " w-[40px]"; + "h-[26px] !rounded border border-input bg-background px-2 py-0 text-center text-[12px] leading-none text-foreground"; + const numberInputClass = inputClass + " min-w-0 flex-1"; const applyButtonClass = - "grid h-[16px] w-[16px] shrink-0 place-items-center rounded-full bg-primary text-[0.85rem] font-bold leading-none text-primary-foreground"; - const indicator = ( - - ); - + "h-[26px] shrink-0 rounded border border-[#A9C5E5] bg-[#EDF4FC] px-2 py-0 text-[12px] font-medium leading-none text-[#2463A6]"; const settingRow = ( -
-
- {indicator} +
+
-
+
- ✓ + 확인
diff --git a/apps/sn/app/(map-layout)/map/components/agedPipe/AgedPipePopupPage.tsx b/apps/sn/app/(map-layout)/map/components/agedPipe/AgedPipePopupPage.tsx index 5a8caf2d9..7fe989125 100644 --- a/apps/sn/app/(map-layout)/map/components/agedPipe/AgedPipePopupPage.tsx +++ b/apps/sn/app/(map-layout)/map/components/agedPipe/AgedPipePopupPage.tsx @@ -1,3 +1,7 @@ +/** + * 화면명: 노후관로 목록 + * 노후관로를 검색하고 목록·엑셀 다운로드·지도 위치 확인 기능을 제공한다. + */ "use client"; import { useCallback, useEffect, useMemo, useState } from "react"; @@ -57,7 +61,7 @@ export default function AgedPipePopupPage() { const sggOptions = useMemo( () => filters.ctpvCd === "0" - ? sgg + ? [] : sgg.filter((item) => String(item.val).startsWith(filters.ctpvCd.slice(0, 2)), ), diff --git a/apps/sn/app/(map-layout)/map/components/agedPipe/agedPipeExport.ts b/apps/sn/app/(map-layout)/map/components/agedPipe/agedPipeExport.ts index c915c21ee..dad972961 100644 --- a/apps/sn/app/(map-layout)/map/components/agedPipe/agedPipeExport.ts +++ b/apps/sn/app/(map-layout)/map/components/agedPipe/agedPipeExport.ts @@ -1,3 +1,4 @@ +/** 노후관로 목록의 엑셀 다운로드 데이터와 서식을 생성한다. */ "use client"; import { postBlobData } from "@/components/lib/api"; diff --git a/apps/sn/app/(map-layout)/map/components/agedPipe/agedPipeUtils.ts b/apps/sn/app/(map-layout)/map/components/agedPipe/agedPipeUtils.ts index 736bb9478..a08f997d7 100644 --- a/apps/sn/app/(map-layout)/map/components/agedPipe/agedPipeUtils.ts +++ b/apps/sn/app/(map-layout)/map/components/agedPipe/agedPipeUtils.ts @@ -1,3 +1,4 @@ +/** 노후관로 검색 조건, 표시값 변환 및 지도 연계 유틸을 제공한다. */ "use client"; import type { SelectDataType } from "@common/business_components"; diff --git a/apps/sn/app/(map-layout)/map/components/agedPipeStatusByCtpv/AgedPipeStatusByCtpvPopupPage.tsx b/apps/sn/app/(map-layout)/map/components/agedPipeStatusByCtpv/AgedPipeStatusByCtpvPopupPage.tsx index 293ae3fe3..1a9d8a220 100644 --- a/apps/sn/app/(map-layout)/map/components/agedPipeStatusByCtpv/AgedPipeStatusByCtpvPopupPage.tsx +++ b/apps/sn/app/(map-layout)/map/components/agedPipeStatusByCtpv/AgedPipeStatusByCtpvPopupPage.tsx @@ -1,15 +1,28 @@ +/** + * 화면명: 시도별 노후관로현황 + * 기준연도별 시도 단위 노후관로 현황을 조회하고 엑셀로 내려받는다. + */ "use client"; import { useCallback, useEffect, useMemo, useState } from "react"; import { Button, Input } from "@common/components"; -import { StandardPopupLayout } from "@common/business_components"; +import { + StandardDataTable, + StandardPopupLayout, +} from "@common/business_components"; +import { + getCoreRowModel, + getPaginationRowModel, + useReactTable, + type ColumnDef, + type PaginationState, +} from "@tanstack/react-table"; import { getRawJson } from "@/components/lib/api"; import LoadingOverlay from "@/components/ui/LoadingBar"; import { useAlertStore } from "@/shared/lib"; import { windowPopupDefinitions } from "@/features/windowPopup/model/popupDefinitions"; import { AGED_PIPE_STATUS_BY_CTPV_DEFAULT_ELAPSED_YEARS, - buildAgedPipeStatusByCtpvHeadList, buildAgedPipeStatusYearOptions, getAgedPipeStatusByCtpvValidationMessage, normalizeAgedPipeStatusByCtpvResponse, @@ -20,7 +33,6 @@ import { type AgedPipeStatusByCtpvRow, } from "./agedPipeStatusByCtpvUtils"; import { downloadAgedPipeStatusByCtpvExcel } from "./agedPipeStatusByCtpvExport"; -import MapPopupDataTable from "@/features/windowPopup/ui/MapPopupDataTable"; import PopupSearchPanel from "@/features/windowPopup/ui/PopupSearchPanel"; // CS 목록과 동일하게 한 페이지에는 최대 10개 시도만 표시한다. @@ -52,21 +64,48 @@ export default function AgedPipeStatusByCtpvPopupPage() { const [currentPage, setCurrentPage] = useState(1); const yearOptions = useMemo(() => buildAgedPipeStatusYearOptions(), []); - const headList = useMemo( + const columns = useMemo[]>( () => [ - { id: "rowNumber", title: "순번", widthPercent: 7 }, - ...buildAgedPipeStatusByCtpvHeadList(years), + { accessorKey: "rowNumber", header: "순번" }, + { accessorKey: "ctpvNm", header: "시도" }, + ...years.map((year) => ({ + id: `year_${year}`, + header: `${year}년`, + columns: [ + { + accessorKey: `year${year}TotalLengthKm`, + header: "전체관로연장(km)", + }, + { + accessorKey: `year${year}AgedLengthKm`, + header: "노후관로연장(km)", + }, + { + accessorKey: `year${year}AgedRate`, + header: "노후화율(%)", + }, + ], + })), ], [years], ); - const pageRows = useMemo( - () => - rows.slice( - (currentPage - 1) * AGED_PIPE_STATUS_PAGE_SIZE, - currentPage * AGED_PIPE_STATUS_PAGE_SIZE, - ), - [currentPage, rows], - ); + + const pagination: PaginationState = { + pageIndex: currentPage - 1, + pageSize: AGED_PIPE_STATUS_PAGE_SIZE, + }; + const table = useReactTable({ + data: rows, + columns, + state: { pagination }, + getCoreRowModel: getCoreRowModel(), + getPaginationRowModel: getPaginationRowModel(), + onPaginationChange: (updater) => { + const next = + typeof updater === "function" ? updater(pagination) : updater; + setCurrentPage(next.pageIndex + 1); + }, + }); const loadRows = useCallback( async (nextFilters: AgedPipeStatusByCtpvFilters) => { @@ -249,23 +288,19 @@ export default function AgedPipeStatusByCtpvPopupPage() {
- > - } - headlist={headList} + 2 ? "horizontal" : "none"} - minTableWidth={years.length > 2 ? "120rem" : "100%"} + scrollMode="horizontal" + minTableWidth={years.length > 0 ? "120rem" : "100%"} actions={ } + flushOuterMargin /> ); diff --git a/apps/sn/app/(map-layout)/map/components/agedPipeStatusByCtpv/agedPipeStatusByCtpvExport.ts b/apps/sn/app/(map-layout)/map/components/agedPipeStatusByCtpv/agedPipeStatusByCtpvExport.ts index 888e1db17..e7f797f34 100644 --- a/apps/sn/app/(map-layout)/map/components/agedPipeStatusByCtpv/agedPipeStatusByCtpvExport.ts +++ b/apps/sn/app/(map-layout)/map/components/agedPipeStatusByCtpv/agedPipeStatusByCtpvExport.ts @@ -1,32 +1,81 @@ +/** 시도별 노후관로 현황의 연도 그룹형 엑셀 데이터를 생성한다. */ "use client"; -import { postBlobData } from "@/components/lib/api"; -import { fileDownload } from "@/components/lib/utils/fileDownload"; +import * as XLSX from "xlsx"; +import { getRawJson } from "@/components/lib/api"; import { getAgedPipeStatusByCtpvValidationMessage, normalizeAgedPipeStatusByCtpvFilters, type AgedPipeStatusByCtpvFilters, + type AgedPipeStatusByCtpvResponse, } from "./agedPipeStatusByCtpvUtils"; -/** 시도별 노후관로현황 XLSX 다운로드를 요청한다. */ +/** 적용조건의 시도별 노후관로현황 전체를 조회해 연도 그룹 Excel로 생성한다. */ export const downloadAgedPipeStatusByCtpvExcel = async ( filters: AgedPipeStatusByCtpvFilters, ) => { const validationMessage = getAgedPipeStatusByCtpvValidationMessage(filters); if (validationMessage) throw new Error(validationMessage); - - const normalizedFilters = normalizeAgedPipeStatusByCtpvFilters(filters); - const { blob, fileName } = await postBlobData( - "v1/swagePpl/aged-status-by-ctpv/exportJobs/download", - { - startYear: normalizedFilters.startYear, - endYear: normalizedFilters.endYear, - elapsedYears: normalizedFilters.elapsedYears, - format: "xlsx", - }, + const normalized = normalizeAgedPipeStatusByCtpvFilters(filters); + const params = new URLSearchParams({ + startYear: String(normalized.startYear), + endYear: String(normalized.endYear), + elapsedYears: String(normalized.elapsedYears), + }); + const response: any = await getRawJson( + `v1/swagePpl/aged-status-by-ctpv?${params.toString()}`, ); + const payload = (response?.data ?? response) as AgedPipeStatusByCtpvResponse; + const years = Array.isArray(payload?.years) + ? payload.years.map(Number).filter(Number.isFinite) + : []; + const rows = Array.isArray(payload?.rows) ? payload.rows : []; + if (rows.length === 0) throw new Error("다운로드할 데이터가 없습니다."); - const resolvedFileName = fileName || "시도별_노후관로현황.xlsx"; - fileDownload(blob, resolvedFileName); - return resolvedFileName; + const headers = ["순번", "시도"]; + years.forEach((year) => headers.push(`${year}년`, "", "")); + const subHeaders = ["", ""]; + years.forEach(() => + subHeaders.push("전체관로연장(km)", "노후관로연장(km)", "노후화율(%)"), + ); + const data = rows.map((row, rowIndex) => { + const statsByYear = new Map( + (row.yearStats ?? []).map((stat) => [Number(stat.year), stat]), + ); + const values: Array = [rowIndex + 1, row.ctpvNm || "-"]; + years.forEach((year) => { + const stat = statsByYear.get(year); + values.push( + stat ? Number(stat.totalLengthKm ?? 0) : "-", + stat ? Number(stat.agedLengthKm ?? 0) : "-", + stat ? Number(stat.agedRate ?? 0) : "-", + ); + }); + return values; + }); + + const worksheet = XLSX.utils.aoa_to_sheet([headers, subHeaders, ...data]); + worksheet["!merges"] = [ + { s: { r: 0, c: 0 }, e: { r: 1, c: 0 } }, + { s: { r: 0, c: 1 }, e: { r: 1, c: 1 } }, + ...years.map((_, index) => ({ + s: { r: 0, c: 2 + index * 3 }, + e: { r: 0, c: 4 + index * 3 }, + })), + ]; + worksheet["!cols"] = [ + { wch: 8 }, + { wch: 18 }, + ...years.flatMap(() => [{ wch: 18 }, { wch: 18 }, { wch: 14 }]), + ]; + worksheet["!autofilter"] = { + ref: `A2:${XLSX.utils.encode_col(headers.length - 1)}${data.length + 2}`, + }; + const workbook = XLSX.utils.book_new(); + XLSX.utils.book_append_sheet(workbook, worksheet, "시도별 노후관로현황"); + XLSX.writeFile(workbook, "시도별_노후관로현황.xlsx", { + bookType: "xlsx", + compression: true, + cellStyles: true, + }); }; diff --git a/apps/sn/app/(map-layout)/map/components/agedPipeStatusByCtpv/agedPipeStatusByCtpvUtils.ts b/apps/sn/app/(map-layout)/map/components/agedPipeStatusByCtpv/agedPipeStatusByCtpvUtils.ts index 253395321..430e81a73 100644 --- a/apps/sn/app/(map-layout)/map/components/agedPipeStatusByCtpv/agedPipeStatusByCtpvUtils.ts +++ b/apps/sn/app/(map-layout)/map/components/agedPipeStatusByCtpv/agedPipeStatusByCtpvUtils.ts @@ -1,3 +1,4 @@ +/** 시도별 노후관로 현황의 조회 연도와 그룹 컬럼을 구성한다. */ "use client"; import type { UiHeadProps } from "@common/business_components/ui"; @@ -65,16 +66,16 @@ export const normalizeAgedPipeStatusByCtpvFilters = ( filters: Partial, ): AgedPipeStatusByCtpvFilters => { const currentYear = new Date().getFullYear(); - let startYear = Math.trunc(Number(filters.startYear ?? currentYear)); - let endYear = Math.trunc(Number(filters.endYear ?? startYear)); + let startYear = Math.trunc(Number(filters.startYear ?? currentYear - 2)); + let endYear = Math.trunc(Number(filters.endYear ?? currentYear)); let elapsedYears = Math.trunc( Number( filters.elapsedYears ?? AGED_PIPE_STATUS_BY_CTPV_DEFAULT_ELAPSED_YEARS, ), ); - if (!Number.isFinite(startYear)) startYear = currentYear; - if (!Number.isFinite(endYear)) endYear = startYear; + if (!Number.isFinite(startYear)) startYear = currentYear - 2; + if (!Number.isFinite(endYear)) endYear = currentYear; if ( !Number.isFinite(elapsedYears) || elapsedYears < 1 || diff --git a/apps/sn/app/(map-layout)/map/components/analysisMenu/AnalysisPopupMenu.tsx b/apps/sn/app/(map-layout)/map/components/analysisMenu/AnalysisPopupMenu.tsx index fa6aa96c3..0bf3b952f 100644 --- a/apps/sn/app/(map-layout)/map/components/analysisMenu/AnalysisPopupMenu.tsx +++ b/apps/sn/app/(map-layout)/map/components/analysisMenu/AnalysisPopupMenu.tsx @@ -1,9 +1,12 @@ +/** + * 화면명: 분석도구 메뉴 + * 지도에서 실행할 분석·현황 팝업 메뉴를 펼치고 선택한다. + */ "use client"; import { useEffect, useState, type ComponentType, type ReactNode } from "react"; import { ChartColumnBig, - ChevronDown, ChevronUp, PanelsTopLeft, PencilRuler, @@ -32,10 +35,10 @@ type MenuIconProps = { isActive: boolean; }; -const InftwtMenuIcon = ({ isActive }: MenuIconProps) => ( +const InftwtMenuIcon = () => (
-
-
-
- - 분석도구 +
+ + + {isOpen ? ( +
+
+
+ + 분석도구 +
+
- -
- - {isOpen ? (
{ANALYSIS_POPUP_ITEMS.map((item) => { const Icon = item.icon; @@ -208,7 +224,7 @@ export default function AnalysisPopupMenu({ ); })}
- ) : null} -
+
+ ) : null}
); } diff --git a/apps/sn/app/(map-layout)/map/components/common/codeMapper.ts b/apps/sn/app/(map-layout)/map/components/common/codeMapper.ts index 7b591b09c..df355381e 100644 --- a/apps/sn/app/(map-layout)/map/components/common/codeMapper.ts +++ b/apps/sn/app/(map-layout)/map/components/common/codeMapper.ts @@ -1,3 +1,4 @@ +/** 지도 시설 상세정보의 코드값을 업무 표시명으로 변환한다. */ import { MS_SWAGE_PPL_USE_CODES, MS_SWAGE_PPL_CODES, MS_SWAGE_MHL_CODES, MS_SWAGE_MHL_USE_CODES, DRTW_SEWAGE_PUMP_USE_CODES } from './table'; interface CodeMapperProps { @@ -159,4 +160,4 @@ export const reverseCodeMapper = ({ layerName, data }: CodeMapperProps): any => }; // 기본 내보내기 -export default { codeMapper, reverseCodeMapper }; \ No newline at end of file +export default { codeMapper, reverseCodeMapper }; diff --git a/apps/sn/app/(map-layout)/map/components/common/index.ts b/apps/sn/app/(map-layout)/map/components/common/index.ts index 377df2344..bba16ce69 100644 --- a/apps/sn/app/(map-layout)/map/components/common/index.ts +++ b/apps/sn/app/(map-layout)/map/components/common/index.ts @@ -1,3 +1,4 @@ +/** 선택한 지도 레이어 종류에 맞는 시설 상세 컴포넌트를 반환한다. */ import MsSwagePpl from "../popupComponent/MsSwagePpl"; // 하수관로 import sewagePump from "../popupComponent/sewagePump"; // 하수펌프 import smh from "../popupComponent/smh"; diff --git a/apps/sn/app/(map-layout)/map/components/common/table.ts b/apps/sn/app/(map-layout)/map/components/common/table.ts index b8ba17b4f..8a13eb830 100644 --- a/apps/sn/app/(map-layout)/map/components/common/table.ts +++ b/apps/sn/app/(map-layout)/map/components/common/table.ts @@ -1,3 +1,4 @@ +/** 지도 시설 상세 및 목록에서 사용하는 컬럼·코드 정의를 제공한다. */ // 하수관로 export const MsSwagePplCol = [ { id: 'fclt_mng_no', title: '시설관리번호' }, diff --git a/apps/sn/app/(map-layout)/map/components/exmn/ExmnLayerSettingPanel.tsx b/apps/sn/app/(map-layout)/map/components/exmn/ExmnLayerSettingPanel.tsx index a8e0a020f..a46f7a714 100644 --- a/apps/sn/app/(map-layout)/map/components/exmn/ExmnLayerSettingPanel.tsx +++ b/apps/sn/app/(map-layout)/map/components/exmn/ExmnLayerSettingPanel.tsx @@ -1,3 +1,7 @@ +/** + * 화면명: 관로조사 레이어 설정 + * 사업차수와 조사 종류·상태등급별 지도 표시 조건을 설정한다. + */ "use client"; import type { ExmnMapFilters, ExmnMapJobType } from "./exmnMapTypes"; @@ -30,7 +34,7 @@ export default function ExmnLayerSettingPanel({ }); }; const selectClass = - "h-[20px] w-[62px] rounded border border-input bg-background px-[1px] text-center text-[1.1rem] leading-none text-foreground outline-none focus:border-primary"; + "h-[26px] w-full rounded border border-input bg-background px-2 py-0 text-center text-[12px] leading-none text-foreground outline-none focus:border-primary"; const labelClass = "shrink-0 text-[1.15rem] font-medium text-label"; const title = jobType === "PPL" ? "관로조사" : "맨홀조사"; @@ -41,8 +45,8 @@ export default function ExmnLayerSettingPanel({ onEnabledChange={onEnabledChange} onClose={onClose} > -
-