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 f1917de0e..726b77bf3 100644 --- a/apps/sn/app/(map-layout)/map/components/agedPipeStatusByCtpv/AgedPipeStatusByCtpvPopupPage.tsx +++ b/apps/sn/app/(map-layout)/map/components/agedPipeStatusByCtpv/AgedPipeStatusByCtpvPopupPage.tsx @@ -4,10 +4,10 @@ */ "use client"; +import { getSnRowNumber } from "@/shared/lib/row-number"; import { useCallback, useEffect, useMemo, useState } from "react"; import { Button, Input } from "@common/components"; import { - getStandardRowNumber, StandardDataTable, StandardPopupLayout, } from "@common/business_components"; @@ -71,7 +71,7 @@ export default function AgedPipeStatusByCtpvPopupPage() { id: "rowNumber", header: "순번", cell: ({ row }) => - getStandardRowNumber({ + getSnRowNumber({ total: rows.length, rowIndex: row.index, }), diff --git a/apps/sn/app/(map-layout)/map/components/floodDngrMap/floodDngrMapTypes.ts b/apps/sn/app/(map-layout)/map/components/floodDngrMap/floodDngrMapTypes.ts index d6356607b..6ccc8a62e 100644 --- a/apps/sn/app/(map-layout)/map/components/floodDngrMap/floodDngrMapTypes.ts +++ b/apps/sn/app/(map-layout)/map/components/floodDngrMap/floodDngrMapTypes.ts @@ -159,7 +159,7 @@ export const toFloodDngrMapTableRows = ( ): FloodDngrMapTableRow[] => items.map((item, index) => ({ ...item, - rowNo: total - page * pageSize - index, + rowNo: page * pageSize + index + 1, ctpvNm: resolveAdministrativeName(item.ctpvCd, ctpvOptions, 2), sggNm: resolveAdministrativeName(item.sggCd, sggOptions, 5), sbmrgDpthlDisplay: toDepthRangeDisplay( diff --git a/apps/sn/app/(map-layout)/map/components/gisManagement/GisManagementPopupPage.tsx b/apps/sn/app/(map-layout)/map/components/gisManagement/GisManagementPopupPage.tsx index 182ea6aba..fa4331bc4 100644 --- a/apps/sn/app/(map-layout)/map/components/gisManagement/GisManagementPopupPage.tsx +++ b/apps/sn/app/(map-layout)/map/components/gisManagement/GisManagementPopupPage.tsx @@ -57,7 +57,7 @@ const GIS_UPLOAD_HISTORY_HEADLIST: UiHeadProps[] = [ ]; -/** 오류 원본 행번호와 공통 표의 역순 순번을 별도 열로 표시한다. */ +/** 오류 원본 행번호와 공통 표의 오름차순 표시 순번을 별도 열로 표시한다. */ const GIS_VALIDATION_ERROR_HEADLIST: UiHeadProps[] = [ { id: "no", title: "순번", widthPercent: 8 }, { id: "sourceRowNo", title: "행번호", widthPercent: 10 }, diff --git a/apps/sn/app/(map-layout)/map/components/inftwtResult/InftwtResultPopupPage.tsx b/apps/sn/app/(map-layout)/map/components/inftwtResult/InftwtResultPopupPage.tsx index e3772768a..25c2b5977 100644 --- a/apps/sn/app/(map-layout)/map/components/inftwtResult/InftwtResultPopupPage.tsx +++ b/apps/sn/app/(map-layout)/map/components/inftwtResult/InftwtResultPopupPage.tsx @@ -160,7 +160,7 @@ export default function InftwtResultPopupPage() { return { ...(formattedItem as Record), rowNumber: Math.max( - totalElements - ((page - 1) * INFTWT_RESULT_PAGE_SIZE + index), + (page - 1) * INFTWT_RESULT_PAGE_SIZE + index + 1, 0, ), ctpvNm: ctpvOption ? ctpvOption.text : String(item?.ctpvCd ?? "-"), diff --git a/apps/sn/app/(map-layout)/map/components/pipeAnalysis/PipeAnalysisPopupPage.tsx b/apps/sn/app/(map-layout)/map/components/pipeAnalysis/PipeAnalysisPopupPage.tsx index f6ed27fa2..ad07fc886 100644 --- a/apps/sn/app/(map-layout)/map/components/pipeAnalysis/PipeAnalysisPopupPage.tsx +++ b/apps/sn/app/(map-layout)/map/components/pipeAnalysis/PipeAnalysisPopupPage.tsx @@ -334,7 +334,7 @@ export default function PipeAnalysisPopupPage() { () => yearMaterialGroups.map((group, index) => ({ ...group, - rowNo: yearMaterialGroups.length - index, + rowNo: index + 1, })), [yearMaterialGroups], ); diff --git a/apps/sn/app/(map-layout)/map/components/pipeAnalysis/PipeAnalysisSummaryTable.tsx b/apps/sn/app/(map-layout)/map/components/pipeAnalysis/PipeAnalysisSummaryTable.tsx index 841d8ac0e..08251627f 100644 --- a/apps/sn/app/(map-layout)/map/components/pipeAnalysis/PipeAnalysisSummaryTable.tsx +++ b/apps/sn/app/(map-layout)/map/components/pipeAnalysis/PipeAnalysisSummaryTable.tsx @@ -158,7 +158,7 @@ export default function PipeAnalysisSummaryTable({ if (summaryRow.kind === "flat") { const value = head.id === "rowNo" - ? total - ((page - 1) * pageSize + summaryRow.sourceIndex) + ? (page - 1) * pageSize + summaryRow.sourceIndex + 1 : summaryRow.value[head.id]; const onClick = summaryRow.value.__cellFilters[head.id] && Number(value) > 0 diff --git a/apps/sn/app/(map-layout)/map/components/rdiiResult/RdiiResultPopupPage.tsx b/apps/sn/app/(map-layout)/map/components/rdiiResult/RdiiResultPopupPage.tsx index eea62cc47..1b1427d1e 100644 --- a/apps/sn/app/(map-layout)/map/components/rdiiResult/RdiiResultPopupPage.tsx +++ b/apps/sn/app/(map-layout)/map/components/rdiiResult/RdiiResultPopupPage.tsx @@ -162,7 +162,7 @@ export default function RdiiResultPopupPage() { return { ...(formattedItem as Record), rowNumber: Math.max( - totalElements - ((page - 1) * RDII_RESULT_PAGE_SIZE + index), + (page - 1) * RDII_RESULT_PAGE_SIZE + index + 1, 0, ), ctpvNm: ctpvOption ? ctpvOption.text : String(item?.ctpvCd ?? "-"), diff --git a/apps/sn/app/(map-layout)/map/components/sbmrgDngrMap/sbmrgDngrMapTypes.ts b/apps/sn/app/(map-layout)/map/components/sbmrgDngrMap/sbmrgDngrMapTypes.ts index c21c749cf..b64c321b8 100644 --- a/apps/sn/app/(map-layout)/map/components/sbmrgDngrMap/sbmrgDngrMapTypes.ts +++ b/apps/sn/app/(map-layout)/map/components/sbmrgDngrMap/sbmrgDngrMapTypes.ts @@ -139,7 +139,7 @@ export const toSbmrgDngrMapTableRows = ( ): SbmrgDngrMapTableRow[] => items.map((item, index) => ({ ...item, - rowNo: total - page * pageSize - index, + rowNo: page * pageSize + index + 1, ctpvNm: resolveAdministrativeNames(item.ctpvCd, ctpvOptions, 2), sggNm: resolveAdministrativeNames(item.sggCd, sggOptions, 5), floodWtlvFrqVlDisplay: toDisplay(item.floodWtlvFrqVl), diff --git a/apps/sn/app/(map-layout)/map/page.tsx b/apps/sn/app/(map-layout)/map/page.tsx index 90dffbe5f..981120398 100644 --- a/apps/sn/app/(map-layout)/map/page.tsx +++ b/apps/sn/app/(map-layout)/map/page.tsx @@ -7692,6 +7692,7 @@ const Map = () => { detachedTreeToggleButton={true} showLayerPanelTitle={false} sectionedTreePanel={true} + showBulkLayerToggle={true} isPlaying={isPlaying} setIsPlaying={setIsPlaying as any} targetLayerTypes={WATER_DEPTH_LAYER_TYPES as any} diff --git a/apps/sn/app/(map-layout)/map/searchModal/reportTab.tsx b/apps/sn/app/(map-layout)/map/searchModal/reportTab.tsx index 636ae4355..385fa99c7 100644 --- a/apps/sn/app/(map-layout)/map/searchModal/reportTab.tsx +++ b/apps/sn/app/(map-layout)/map/searchModal/reportTab.tsx @@ -405,7 +405,7 @@ const ReportTab: React.FC = ({ .slice(pageStart, pageEnd) .map((item, index) => ({ ...item, - no: totalCount - (pageOffset + index), + no: pageOffset + index + 1, })); reportRows = await enrichReportRows(pagedReportData); diff --git a/apps/sn/app/(map-layout)/map/searchModal/useBasicTab.ts b/apps/sn/app/(map-layout)/map/searchModal/useBasicTab.ts index 77042c0c1..6a35bf112 100644 --- a/apps/sn/app/(map-layout)/map/searchModal/useBasicTab.ts +++ b/apps/sn/app/(map-layout)/map/searchModal/useBasicTab.ts @@ -152,7 +152,7 @@ export default function useBasicTab() { // 추가 처리 및 일련번호 추가 return { ...formattedItem, - rowNumber: totalCount - ((curPage - 1) * 10 + index), + rowNumber: (curPage - 1) * 10 + index + 1, scsYn: item.scsYn === null || item.scsYn === undefined ? "N" : item?.scsYn, // scsYn이 null이면 'N'으로 변경 mqltCd: matchedMqltCode ? matchedMqltCode.text : item?.mqltCd, diff --git a/apps/sn/app/(map-layout)/map/searchModal/useOperationTab.ts b/apps/sn/app/(map-layout)/map/searchModal/useOperationTab.ts index fbe13aa43..11130a4a8 100644 --- a/apps/sn/app/(map-layout)/map/searchModal/useOperationTab.ts +++ b/apps/sn/app/(map-layout)/map/searchModal/useOperationTab.ts @@ -174,7 +174,7 @@ export default function useOperationTab() { return { ...formattedItem, rowNumber: - totalElements - ((curPage - 1) * OPERATION_TABLE_SIZE + index), + (curPage - 1) * OPERATION_TABLE_SIZE + index + 1, bizTypeCd: matched ? matched.lwpoCdNm : item.bizTypeCd, }; }, @@ -233,7 +233,7 @@ export default function useOperationTab() { ); return { ...formattedItem, - rowNumber: totalDetailElements - detailIndex, + rowNumber: detailIndex + 1, usgCd: usgCdObj ? usgCdObj.text : item.usgCd, mqltCd: mqltCdObj ? mqltCdObj.text : item.mqltCd, swtrmt_zone_nm: item.swtrmt_zone_nm ? item.swtrmt_zone_nm : "-", @@ -325,8 +325,7 @@ export default function useOperationTab() { return { ...formattedItem, rowNumber: - totalElements - - ((detailCurPage - 1) * OPERATION_TABLE_SIZE + detailIndex), + (detailCurPage - 1) * OPERATION_TABLE_SIZE + detailIndex + 1, usgCd: usgCdObj ? usgCdObj.text : item.usgCd, mqltCd: mqltCdObj ? mqltCdObj.text : item.mqltCd, swtrmt_zone_nm: item.swtrmt_zone_nm ? item.swtrmt_zone_nm : "-", diff --git a/apps/sn/app/(map-layout)/map/searchModal/useSearchModal.ts b/apps/sn/app/(map-layout)/map/searchModal/useSearchModal.ts index f2972440d..6ee9ac429 100644 --- a/apps/sn/app/(map-layout)/map/searchModal/useSearchModal.ts +++ b/apps/sn/app/(map-layout)/map/searchModal/useSearchModal.ts @@ -116,7 +116,7 @@ export default function useSearchModal() { return { ...formattedItem, rowNumber: - totalElements - ((curPage - 1) * OPERATION_TABLE_SIZE + index), + (curPage - 1) * OPERATION_TABLE_SIZE + index + 1, bizTypeCd: matched ? matched.lwpoCdNm : item.bizTypeCd, }; }, diff --git a/apps/sn/app/(with-base-layout)/ii/rdiiResult/page.tsx b/apps/sn/app/(with-base-layout)/ii/rdiiResult/page.tsx index f42f040ee..aefbb5917 100644 --- a/apps/sn/app/(with-base-layout)/ii/rdiiResult/page.tsx +++ b/apps/sn/app/(with-base-layout)/ii/rdiiResult/page.tsx @@ -170,7 +170,7 @@ const Result = () => { const totalElements = Number(data?.totalElements ?? 0); - // 전체 건수를 기준으로 현재 페이지의 순번을 내림차순 계산한다. + // 현재 페이지의 시작 위치를 기준으로 오름차순 표시 순번을 계산한다. const dataWithRowNumber = (data.content || []) .map((item: any, index: number) => { // 🎯 먼저 각 아이템을 포맷팅 @@ -179,11 +179,11 @@ const Result = () => { (code) => code.val.trim() === item?.ctpvCd?.trim(), ); const sggOne = sgg.find((code) => code.val === item.sggCd); - // 추가 처리 및 전체 건수 기준 순번 추가 + // 추가 처리 및 페이지 연속 순번 추가 return { ...formattedItem, rowNumber: Math.max( - totalElements - (((options?.page ?? curPage) - 1) * 10 + index), + ((options?.page ?? curPage) - 1) * 10 + index + 1, 0, ), scsYn: diff --git a/apps/sn/app/(with-base-layout)/ii/result/page.tsx b/apps/sn/app/(with-base-layout)/ii/result/page.tsx index 550bbca25..78f151630 100644 --- a/apps/sn/app/(with-base-layout)/ii/result/page.tsx +++ b/apps/sn/app/(with-base-layout)/ii/result/page.tsx @@ -170,7 +170,7 @@ const Result = () => { const totalElements = Number(data?.totalElements ?? 0); - // 전체 건수를 기준으로 현재 페이지의 순번을 내림차순 계산한다. + // 현재 페이지의 시작 위치를 기준으로 오름차순 표시 순번을 계산한다. const dataWithRowNumber = (data.content || []) .map((item: any, index: number) => { // 🎯 먼저 각 아이템을 포맷팅 @@ -179,11 +179,11 @@ const Result = () => { (code) => code.val.trim() === item?.ctpvCd?.trim(), ); const sggOne = sgg.find((code) => code.val === item.sggCd); - // 추가 처리 및 전체 건수 기준 순번 추가 + // 추가 처리 및 페이지 연속 순번 추가 return { ...formattedItem, rowNumber: Math.max( - totalElements - (((options?.page ?? curPage) - 1) * 10 + index), + ((options?.page ?? curPage) - 1) * 10 + index + 1, 0, ), scsYn: diff --git a/apps/sn/app/(with-base-layout)/oper/dredging/page.tsx b/apps/sn/app/(with-base-layout)/oper/dredging/page.tsx index 7e041a273..3565bacbf 100644 --- a/apps/sn/app/(with-base-layout)/oper/dredging/page.tsx +++ b/apps/sn/app/(with-base-layout)/oper/dredging/page.tsx @@ -425,7 +425,7 @@ const Page = () => { return buildElapsedYearRows(filteredRecords); return filteredRecords.map((record, index) => ({ - rowNumber: filteredRecords.length - index, + rowNumber: index + 1, ...record, pipeLengthM: Number(record.pipeLengthM.toFixed(2)), pipeMaterial: resolvePipeMaterialName(record.pipeMaterial), diff --git a/apps/sn/app/(with-base-layout)/oper/floodPre/page.tsx b/apps/sn/app/(with-base-layout)/oper/floodPre/page.tsx index 5867e70cc..98561a9e3 100644 --- a/apps/sn/app/(with-base-layout)/oper/floodPre/page.tsx +++ b/apps/sn/app/(with-base-layout)/oper/floodPre/page.tsx @@ -17,13 +17,13 @@ * - 첨부파일 삭제및 Soft Delete 삭제: DELETE /api/v1/atfl/attachments/{atflId} (attach-service) */ "use client"; +import { getSnRowNumber } from "@/shared/lib/row-number"; import { deleteData, useCookieAuth } from "@/components/lib/api"; import { searchData } from "@/components/lib/common/apiHelpers"; import { formatDate, handleDownload } from "@/components/lib/utils"; import LoadingOverlay from "@/components/ui/LoadingBar"; import { StandardDataTable, - getStandardRowNumber, } from "@common/business_components"; import { Button, Input, Label } from "@common/components"; import { useEffect, useMemo, useState } from "react"; @@ -192,7 +192,7 @@ const Page = () => { const dataWithRowNumber = (data || []).map( (item: any, index: number) => ({ ...item, - rowNumber: getStandardRowNumber({ + rowNumber: getSnRowNumber({ total, rowIndex: index, pageIndex: page - 1, diff --git a/apps/sn/app/(with-base-layout)/oper/oldPp/page.tsx b/apps/sn/app/(with-base-layout)/oper/oldPp/page.tsx index 55d9f6667..e5c040ba1 100644 --- a/apps/sn/app/(with-base-layout)/oper/oldPp/page.tsx +++ b/apps/sn/app/(with-base-layout)/oper/oldPp/page.tsx @@ -5,6 +5,7 @@ */ "use client"; +import { getSnRowNumber } from "@/shared/lib/row-number"; import { useEffect, useMemo, useState } from "react"; import { getCoreRowModel, @@ -22,7 +23,6 @@ import { openWindowPopup } from "@/features/windowPopup/lib/openWindowPopup"; import { WINDOW_POPUP_MESSAGE_TYPE } from "@/features/windowPopup/model/types"; import { useAlertStore } from "@/shared/lib"; import { - getStandardRowNumber, StandardDataTable, } from "@common/business_components"; import { Button, Input, Label } from "@common/components"; @@ -125,7 +125,7 @@ const Page = () => { setRows( (result.data ?? []).map((item: OldPpRow, rowIndex: number) => ({ ...item, - rowNumber: getStandardRowNumber({ + rowNumber: getSnRowNumber({ total: result.total, rowIndex, pageIndex: targetPage - 1, diff --git a/apps/sn/app/(with-base-layout)/oper/oldPp/tableModal.tsx b/apps/sn/app/(with-base-layout)/oper/oldPp/tableModal.tsx index 1d0524114..0471872fb 100644 --- a/apps/sn/app/(with-base-layout)/oper/oldPp/tableModal.tsx +++ b/apps/sn/app/(with-base-layout)/oper/oldPp/tableModal.tsx @@ -104,7 +104,7 @@ function filterEmptyRows(data: ExcelRow[]) { function withDescendingRowNumber(rows: ExcelRow[]) { return rows.map((row, index) => ({ ...row, - rowNumber: rows.length - index, + rowNumber: index + 1, })); } diff --git a/apps/sn/app/layout.tsx b/apps/sn/app/layout.tsx index b8b4897c8..41fdb4057 100644 --- a/apps/sn/app/layout.tsx +++ b/apps/sn/app/layout.tsx @@ -17,6 +17,7 @@ import { cookies } from "next/headers"; import { AppProvider } from "./providers"; import "../src/app/styles/base.css"; import "../src/app/styles/components.css"; +import "../src/app/styles/grid-ellipsis.css"; export const metadata: Metadata = { title: "디지털 하수관로", @@ -35,7 +36,7 @@ export default async function RootLayout({ return ( - + {children} diff --git a/apps/sn/docs/작업계획/SN-그리드말줄임표/001-fe-SN-그리드말줄임표-결과.md b/apps/sn/docs/작업계획/SN-그리드말줄임표/001-fe-SN-그리드말줄임표-결과.md new file mode 100644 index 000000000..6cfddb1c5 --- /dev/null +++ b/apps/sn/docs/작업계획/SN-그리드말줄임표/001-fe-SN-그리드말줄임표-결과.md @@ -0,0 +1,36 @@ +# SN 전체 목록 그리드 말줄임표 + +2026-09-23 사용자 요청: 노후 하수관로 정비사업 화면처럼 긴 내용이 행 높이를 늘리는 현상을 SN 전체 그리드에서 정리한다. +기존 원본 화면의 표 컬럼·업무값은 유지하고 이번 요청을 표시 방식 변경 근거로 적용했다. + +## 변경 + +- app/layout.tsx: SN 전용 body 속성과 grid-ellipsis.css 등록. +- src/app/styles/grid-ellipsis.css: 헤더가 있는 목록 표의 일반 데이터 셀을 한 줄·overflow hidden·text-overflow ellipsis로 처리. 중첩 span/div의 whitespace-normal/pre-line도 정리. +- src/shared/ui/grid-cell-tooltip.tsx: 넘치는 셀에 pointerover/focusin 시 원문 title 제공. 기존 셀·자식 title은 우선 보존. 동적으로 교체된 셀의 이전 생성 title은 다시 계산. +- src/app/providers/app-provider.tsx: SN 문서와 포털의 표에서 툴팁 처리를 공통 사용. +- scripts/grid-cell-tooltip.test.cjs: 정식 회귀 테스트 3개. + +table 레이아웃·열 너비·데이터·정렬·엑셀 생성은 변경하지 않았다. 원문을 잘라 저장하지 않고 CSS로만 생략한다. +thead가 없는 폼/상세 표, 입력창·버튼·체크박스·이미지·중첩 표를 가진 셀, rowspan/colspan 병합 셀, tfoot은 기존 표시를 유지한다. +예외적으로 원래 줄바꿈이 필요한 셀은 data-sn-cell-wrap 속성으로 제외할 수 있다. +공용 패키지와 다른 서비스는 수정하지 않았다. 앞선 순번·레이어 토글 변경도 보존했다. + +## 검증 + +- TypeScript 검사 및 git diff --check 통과. +- 단위 테스트: CSS/JS 대상 셀 일치·입력 예외, 원문 툴팁 및 셀 재사용 시 갱신·정리, 기존 title 보존 — 3개 통과. +- 실행 중인 사용자 로컬 서버(3002)를 재시작하거나 종료하지 않고 HMR로 검증했다. +- 실제 StandardDataTable과 일반 HTML 표에 사진과 유사한 장문 테스트 데이터를 연결한 브라우저 확인: + - 긴 셀과 짧은 셀의 행 높이 동일(약 40.59px). + - 일반 td 및 block/whitespace-normal span 모두 한 줄 말줄임표 확인. + - 948자 원문이 DOM에 보존되고 생성 title이 원문과 일치함을 확인. + - 입력값 변경·버튼 클릭 횟수 증가 및 병합 합계 셀 보존 확인. + - 중첩 셀 렌더러의 테스트 표도 추가해 표시 경로를 확인했으나 전체 개별 화면을 실데이터로 전수 검증한 것은 아니다. +- 원본 데이터·API·DB 및 엑셀 출력 코드 변경 없음. 실제 업무 화면·엑셀 전체 회귀는 별도 확인이 필요하다. + +## 정리 및 복구 + +임시 검증 페이지 1개를 삭제했다. 원시 캡처·응답 파일은 만들지 않았으며 정식 테스트·본 문서는 보존한다. +사용자 서버는 계속 실행 중이며 커밋·push·배포는 하지 않았다. +복구 시 이번 CSS import·body 속성·GridCellTooltip 등록 및 신규 파일만 제거한다. 다른 작업 변경은 되돌리지 않는다. diff --git a/apps/sn/docs/작업계획/SN-그리드순번정순통일/001-fe-SN-그리드순번정순통일-결과.md b/apps/sn/docs/작업계획/SN-그리드순번정순통일/001-fe-SN-그리드순번정순통일-결과.md new file mode 100644 index 000000000..60141fc63 --- /dev/null +++ b/apps/sn/docs/작업계획/SN-그리드순번정순통일/001-fe-SN-그리드순번정순통일-결과.md @@ -0,0 +1,67 @@ +# SN 그리드 표시 순번 오름차순 통일 + +## 목적 및 기준 + +2026-09-23 사용자 지시: 하수관로 SN 전체 그리드의 표시 순번을 1, 2, 3…으로 통일한다. +서버 데이터 정렬, 업무 ID, 저장용 순번과 검색·페이지 계약은 변경하지 않는다. + +- 페이지 목록: `(현재 페이지 - 1) × 페이지 크기 + 행 인덱스 + 1`. +- 0부터 시작하는 서버 페이지: `page × pageSize + index + 1`. +- 비페이지 목록·전체 데이터: `index + 1`. +- GIS 오류의 원본 행번호, 사업순번, 시설물 식별자, 합계·소계 및 병합 표의 빈 셀은 기존 의미를 보존한다. + +원본 요구사항정의서.xlsx의 시트 구조와 화면설계서.pptx의 관련 화면 텍스트를 열람했다. +대표 근거는 슬라이드 35의 SFR-012-002 / SN_MN010302 GIS 관리 업로드 이력 목록, 슬라이드 43의 SFR-010-004 조사결과 등록이다. +원본 화면의 목록·업무 항목은 유지하며, 전체 표에 대한 오름차순 표시 정책은 이번 사용자 명시 지시를 적용한다. + +## 수정 범위 + +앱의 기존 파일 26개와 SN 전용 순번 함수·정식 회귀 테스트 각 1개를 변경·추가했다. +공용 business_components의 기존 getStandardRowNumber 구현 및 다른 앱은 수정하지 않았다. + +| 구분 | 변경 대상 | +| --- | --- | +| SN 전용 공통 계산 | src/shared/lib/row-number.ts의 getSnRowNumber | +| 공통 표 연결부 | MapPopupDataTable, ExmnStandardDataTable | +| 조사관리 | ExmnRegistrationPage, ExmnStatusDetailPanel, TrtdstManagementPage | +| 운영 목록·상세 | oldPp page/tableModal, floodPre page, dredging page, ObsltPplListPage, CtyFldngPrvtnBizListPage, CfmtnBgtPopupContent | +| I/I 분석 | ii/result, ii/rdiiResult, InftwtResultPopupPage, RdiiResultPopupPage | +| 지도 검색·상세 | useSearchModal, useOperationTab, useBasicTab, reportTab | +| 지도 분석 | floodDngrMapTypes, sbmrgDngrMapTypes, AgedPipeStatusByCtpvPopupPage, PipeAnalysisPopupPage, PipeAnalysisSummaryTable | +| GIS 관리 | 공통 표 경유 순번 변경 및 오류 원본 행번호와 구분하는 주석 정비 | + +### 코드 변경 이유 + +기존 공통 함수는 `total - (pageIndex * pageSize + rowIndex)`로 전체 건수부터 역순 표시했다. +공용 패키지의 기본 동작을 바꾸면 다른 앱에 영향을 줄 수 있으므로 SN 전용 함수를 추가하고 SN 호출부 10개 파일을 연결했다. + +```ts +export function getSnRowNumber({ rowIndex, pageIndex = 0, pageSize = 0 }) { + return pageIndex * pageSize + rowIndex + 1; +} +``` + +개별 화면에 남은 `total - offset - index`, `rows.length - index` 계산도 정순으로 변경했다. +이미 정순인 지반침하·침수이력·노후관로 등의 기존 데이터 가공은 유지하면서 공통 표시부에서 덮어쓰던 역순을 제거했다. +처리분구의 withDescendingRowNumbers는 실제 동작에 맞게 withAscendingRowNumbers로 변경했다. + +## 검증 + +- `node --test scripts/row-number.test.cjs`: 4개 테스트 통과. + - 비페이지 1부터 시작, 전체 건수에 비의존. + - 1페이지 1~10, 2페이지 11~20, 마지막 부분 페이지 21~23. + - 페이지 크기 변경 및 검색 초기화. + - SN 전체 app/src 소스에서 공용 역순 함수와 기존 역순 산식 재유입 검사. +- `pnpm exec tsc --noEmit --incremental false --pretty false`: 변경 후 통과. +- `git diff --check`: 통과. +- 실데이터 화면: localhost:3002 로그인 후 침입수 결과 조회를 시도했으나 localhost:8883 백엔드 ECONNREFUSED로 조회 실패. +- 컴포넌트 브라우저 검증: 실제 MapPopupDataTable·ExmnStandardDataTable에 테스트 데이터 23건을 연결하여 1/2/3페이지 순번 확인. 데이터 순서 반전 시 순번 1부터, 검색 3건 전환 시 1~3 확인. +- 이 브라우저 시험은 테스트 데이터이며 실제 업무 데이터 전체 검증을 대체하지 않는다. 모든 개별 화면의 실데이터 회귀와 Excel 결과 전체 대조는 미완료다. +- Next 전체 빌드는 실행하지 않았다. API·DB·인증·환경설정·배포 및 데이터 정렬 조건은 수정하지 않았다. + +## 정리 및 복구 + +- 일회성 브라우저 검증 페이지 1개는 확인 후 삭제했다. 화면 캡처·원시 응답 파일은 생성하지 않았다. +- 정식 회귀 테스트는 후속 검증용 소스이므로 보존한다. +- 직접 기동한 SN 개발서버(next dev PID 37840 및 하위 프로세스)는 종료했다. 사용자 서버는 종료하지 않았다. +- 커밋·push·개발서버 배포는 수행하지 않았다. 복구가 필요하면 이번 SN 변경 diff만 역적용하고 관련 없는 변경은 유지한다. diff --git a/apps/sn/docs/작업계획/SN-레이어전체표시/001-fe-SN-레이어전체표시-결과.md b/apps/sn/docs/작업계획/SN-레이어전체표시/001-fe-SN-레이어전체표시-결과.md new file mode 100644 index 000000000..1f76aff50 --- /dev/null +++ b/apps/sn/docs/작업계획/SN-레이어전체표시/001-fe-SN-레이어전체표시-결과.md @@ -0,0 +1,49 @@ +# 하수관망도 레이어 전체 켜기·끄기 + +2026-09-23 사용자 요청으로 트리의 '레이어 관리' 제목 오른쪽에 전체 표시 토글을 추가했다. +후속 요청에 따라 두 버튼을 단일 스위치로 바꿨다. 모두 선택이면 ON, 일부 선택이면 OFF 위치와 '일부 켜짐' 문구로 표시한다. +OFF 또는 부분 선택 상태에서 누르면 전체 켜기, ON 상태에서 누르면 전체 끄기가 실행된다. +화면설계서의 하수관망도 및 GIS 관리 영역(SN_MN010302, 슬라이드 35)을 유지하고 이번 명시 요청을 기능 추가 근거로 적용한다. + +## 변경 + +- SN map/page.tsx: showBulkLayerToggle=true 전달. +- business_components Gis.js, GisTree.js: 선택적 옵션 전달, 헤더 버튼, 전체 체크 이벤트. +- layerBulkSelection.js: exprsYn=Y 노드의 전체 선택을 계산하는 순수 함수. +- apps/sn/scripts/layer-bulk-selection.test.cjs: 정식 회귀 테스트 3개(전체·부분·꺼짐·빈 목록 상태 포함). + +전체 켜기는 표시 허용 노드만 체크하고 숨김 노드는 켜지 않는다. 전체 끄기는 현재 트리 노드의 체크·표시를 해제한다. +gisCore.setLayerOnMap에 트리 ID만 전달하므로 배경지도나 별도 편집 오버레이를 일괄 끄는 API는 호출하지 않는다. +전체 이벤트에서 자동재생 타이머를 중지하고 재생 상태를 false로 변경한 뒤 체크 상태·지도 제어·업무 콜백을 한 번에 반영한다. +기존 개별 체크·트리 펼침·레이어 설정·클릭 동작은 유지한다. + +## 다른 서비스 영향 + +Gis는 FO·WR·DM 및 SN의 다른 지도에서도 사용한다. showBulkLayerToggle 기본값은 false이고 SN 주 지도만 활성화했다. +공통 패키지의 허용 범위 안에서 수정했으며 다른 앱 코드·서버·DB·API 계약은 변경하지 않았다. +앞선 SN 순번 정순 변경은 별도 작업이며 유지했다. + +## 검증 + +- TypeScript 검사 통과. +- 단위 테스트 3개: 표시 허용 부모·자식 선택, 숨김 제외, 전체 해제, 빈 목록 및 입력 비변경, 토글 상태 확인. +- 단일 토글 브라우저 재검증: 부분 선택 → ON(모두 체크) → OFF(모두 해제), 250px 헤더 배치 확인. 테스트용 지도 stub을 사용했으며 실데이터 네트워크 검증과 구분한다. +- 실제 GisTree와 GisContext에 지도 제어 stub 및 테스트 트리를 연결한 브라우저 검증: + - 초기 관로만 선택 → 전체 켜기 시 부모·관로·맨홀 체크 및 지도 제어 상태 true. + - 숨김 노드는 false 유지. + - 전체 끄기 시 모든 체크·제어 상태 false. + - 전체 끄기 후 관로 개별 체크 정상. + - 옵션 false일 때 버튼 미표시 및 기존 체크 유지. + - 250px 패널에서 제목 오른쪽 버튼 배치 확인. +- 테스트 데이터 기반 검증이며 실제 GeoServer·업무별 비동기 레이어 로딩 전체를 검증한 것은 아니다. 앞선 실데이터 검증에서 로컬 백엔드 8883 연결 거부가 확인되어 환경설정은 변경하지 않았다. +- 기존 개별 체크 경로는 상태 갱신 함수 안에서 업무 콜백을 호출하는 구조이므로 개발 모드 경고가 보일 수 있다. 이번 전체 체크 경로는 해당 방식으로 구현하지 않았다. + +## 정리·복구 + +임시 검증 페이지 2개는 삭제하고 직접 기동한 개발서버는 종료한다. 캡처·원시 로그 파일은 생성하지 않았다. +토글 후속 검증에서는 개발서버 기동이 포트 사용 중으로 실패한 상태였으나 정리 시 기존 3002 프로세스를 종료하는 실수가 있었다. +이를 사용자에게 알리고 복원 기동을 시도했으며, 이미 다른 실행에서 3002가 재기동되어 중복 기동은 실패했다. +최종 확인 시 3002는 PID 47412(Next 명령 부모 PID 48580)로 LISTENING 상태다. 이 프로세스는 추가 종료하지 않았다. +최종 임시 검증 파일 삭제 후 TypeScript 검사 통과를 확인했다. +정식 테스트와 본 보고서는 보존한다. 커밋·push·배포는 수행하지 않았다. +복구하려면 SN의 showBulkLayerToggle 전달을 제거해 버튼 노출부터 해제할 수 있다. 전체 복구 시 이번 공통 GIS 변경만 역적용한다. diff --git a/apps/sn/scripts/grid-cell-tooltip.test.cjs b/apps/sn/scripts/grid-cell-tooltip.test.cjs new file mode 100644 index 000000000..048318445 --- /dev/null +++ b/apps/sn/scripts/grid-cell-tooltip.test.cjs @@ -0,0 +1,48 @@ +const assert = require('node:assert/strict'); +const test = require('node:test'); +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); +const ts = require('typescript'); +const root = path.resolve(__dirname, '..'); +const source = fs.readFileSync(path.join(root, 'src/shared/ui/grid-cell-tooltip.tsx'), 'utf8'); +const css = fs.readFileSync(path.join(root, 'src/app/styles/grid-ellipsis.css'), 'utf8'); + +function mount() { + const handlers = {}; + let cleanup; + class Cell { + constructor(text, overflowing = true) { this.textContent = text; this.title = ''; this.clientWidth = 100; this.scrollWidth = overflowing ? 300 : 100; } + closest() { return this; } + querySelector() { return null; } + querySelectorAll() { return []; } + hasAttribute() { return Boolean(this.title); } + removeAttribute() { this.title = ''; } + } + const module = {exports:{}}; + vm.runInNewContext(ts.transpileModule(source, {compilerOptions:{module:ts.ModuleKind.CommonJS}}).outputText, { + module, exports:module.exports, Element:Cell, + require:()=>({useEffect:fn=>{cleanup=fn();}}), + document:{addEventListener:(name,handler)=>{handlers[name]=handler;},removeEventListener:name=>{delete handlers[name];}}, + }); + module.exports.GridCellTooltip(); + return {Cell,handlers,cleanup}; +} +test('CSS and tooltip target the same SN text cells and preserve controls', () => { + const selector = source.match(/const CELL_SELECTOR = '([^']+)'/)[1]; + assert.ok(css.includes(selector + ' {')); + for (const token of ['input','textarea','button','[colspan]','[rowspan]','data-sn-cell-wrap']) assert.ok(selector.includes(token)); +}); +test('overflow tooltip retains full text and updates reused cells', () => { + const {Cell,handlers,cleanup}=mount(); const cell=new Cell('긴 원문\n내용'); + handlers.pointerover({target:cell}); assert.equal(cell.title,cell.textContent); + cell.textContent='변경된 원문'; handlers.pointerover({target:cell}); assert.equal(cell.title,cell.textContent); + cell.scrollWidth=100; handlers.pointerover({target:cell}); assert.equal(cell.title,''); + cleanup(); assert.deepEqual(handlers,{}); +}); +test('existing explicit tooltip and editable-cell exclusions remain authoritative', () => { + const {Cell,handlers}=mount(); const cell=new Cell('원문'); cell.title='기존 설명'; + handlers.pointerover({target:cell}); assert.equal(cell.title,'기존 설명'); + const excluded=new Cell('입력'); excluded.closest=()=>null; + handlers.pointerover({target:excluded}); assert.equal(excluded.title,''); +}); diff --git a/apps/sn/scripts/layer-bulk-selection.test.cjs b/apps/sn/scripts/layer-bulk-selection.test.cjs new file mode 100644 index 000000000..745525189 --- /dev/null +++ b/apps/sn/scripts/layer-bulk-selection.test.cjs @@ -0,0 +1,30 @@ +const assert = require('node:assert/strict'); +const test = require('node:test'); +const { pathToFileURL } = require('node:url'); +const path = require('node:path'); +const moduleUrl = pathToFileURL(path.resolve(__dirname, '../../../packages/common/business_components/src/ui/map/layerBulkSelection.js')); + +test('toggle state follows all, partial, off and empty selections', async () => { + const { getBulkLayerState } = await import(moduleUrl.href); + const nodes = [{id:'a',data:{exprsYn:'Y'}},{id:'b',data:{exprsYn:'Y'}},{id:'h',data:{exprsYn:'N'}}]; + assert.deepEqual(getBulkLayerState(nodes, []), {disabled:false,allChecked:false,partiallyChecked:false}); + assert.deepEqual(getBulkLayerState(nodes, ['a']), {disabled:false,allChecked:false,partiallyChecked:true}); + assert.deepEqual(getBulkLayerState(nodes, ['a','b']), {disabled:false,allChecked:true,partiallyChecked:false}); + assert.deepEqual(getBulkLayerState([], []), {disabled:true,allChecked:false,partiallyChecked:false}); +}); + +test('bulk on checks visible parents and children, excluding hidden nodes', async () => { + const { getBulkLayerSelection } = await import(moduleUrl.href); + const nodes = [{id:1,data:{exprsYn:'Y'}},{id:2,parent:1,data:{exprsYn:'Y'}},{id:3,data:{exprsYn:'N'}}]; + const result = getBulkLayerSelection(nodes, true); + assert.deepEqual(result.checkedIds, [1,2]); + assert.deepEqual(result.uncheckedIds, [3]); + assert.deepEqual(result.selectedNodes, nodes.slice(0,2)); +}); +test('bulk off clears every registered tree layer, without mutating input', async () => { + const { getBulkLayerSelection } = await import(moduleUrl.href); + const nodes = [{id:'a',data:{exprsYn:'Y'}},{id:'hidden',data:{exprsYn:'N'}}]; + assert.deepEqual(getBulkLayerSelection(nodes, false), {checkedIds:[],selectedNodes:[],uncheckedIds:['a','hidden']}); + assert.equal(nodes[0].data.exprsYn, 'Y'); + assert.deepEqual(getBulkLayerSelection([], true), {checkedIds:[],selectedNodes:[],uncheckedIds:[]}); +}); diff --git a/apps/sn/scripts/row-number.test.cjs b/apps/sn/scripts/row-number.test.cjs new file mode 100644 index 000000000..aa0a12665 --- /dev/null +++ b/apps/sn/scripts/row-number.test.cjs @@ -0,0 +1,44 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); +const test = require('node:test'); +const ts = require('typescript'); + +const appRoot = path.resolve(__dirname, '..'); +const source = fs.readFileSync(path.join(appRoot, 'src/shared/lib/row-number.ts'), 'utf8'); +const compiled = ts.transpileModule(source, { + compilerOptions: { module: ts.ModuleKind.CommonJS }, +}).outputText; +const moduleValue = { exports: {} }; +vm.runInNewContext(compiled, { exports: moduleValue.exports, module: moduleValue }); +const { getSnRowNumber } = moduleValue.exports; + +test('non-paged rows start at one and do not depend on total count', () => { + assert.deepEqual([0, 1, 2].map(rowIndex => getSnRowNumber({total: 50, rowIndex})), [1, 2, 3]); + assert.equal(getSnRowNumber({total: 0, rowIndex: 0}), 1); +}); +test('server pages continue numbering and preserve the final partial page', () => { + assert.equal(getSnRowNumber({total: 23, rowIndex: 0, pageIndex: 0, pageSize: 10}), 1); + assert.equal(getSnRowNumber({total: 23, rowIndex: 0, pageIndex: 1, pageSize: 10}), 11); + assert.equal(getSnRowNumber({total: 23, rowIndex: 2, pageIndex: 2, pageSize: 10}), 23); +}); +test('page-size changes and refreshed result order use the new positions', () => { + assert.equal(getSnRowNumber({rowIndex: 0, pageIndex: 1, pageSize: 20}), 21); + assert.equal(getSnRowNumber({rowIndex: 0, pageIndex: 0, pageSize: 20}), 1); +}); + +function sourceFiles(directory) { + return fs.readdirSync(directory, {withFileTypes: true}).flatMap(entry => { + const full = path.join(directory, entry.name); + return entry.isDirectory() ? sourceFiles(full) : /\.tsx?$/.test(entry.name) ? [full] : []; + }); +} +test('SN does not use the shared descending helper or legacy descending row formulas', () => { + for (const file of [...sourceFiles(path.join(appRoot, 'src')), ...sourceFiles(path.join(appRoot, 'app'))]) { + const text = fs.readFileSync(file, 'utf8'); + assert.doesNotMatch(text, /getStandardRowNumber/, file); + assert.doesNotMatch(text, /(?:rowNumber|rowNo|no)\s*:\s*(?:[\w.]+\s*-\s*(?:index|detailIndex)|total\w*\s*-)/, file); + assert.doesNotMatch(text, /total(?:Elements|Count)?\s*-\s*\(\(?[^;{}\n]*(?:index|sourceIndex)/, file); + } +}); diff --git a/apps/sn/src/app/providers/app-provider.tsx b/apps/sn/src/app/providers/app-provider.tsx index f665ac235..1c00693db 100644 --- a/apps/sn/src/app/providers/app-provider.tsx +++ b/apps/sn/src/app/providers/app-provider.tsx @@ -13,6 +13,7 @@ import { AlertDialog } from "@/shared/ui/alert-dialog"; import { PromptDialog } from "@/shared/ui/prompt-dialog"; import { ConfirmDialog } from "@/shared/ui/confirm-dialog"; import { BusinessUserMessageBridge } from "@/shared/ui/business-user-message-bridge"; +import { GridCellTooltip } from "@/shared/ui/grid-cell-tooltip"; // 개발 도구는 실제 로컬 개발 환경에서만 별도 청크로 불러와 최초 페이지 컴파일 범위를 줄인다. const LazyReactQueryDevtools = dynamic( @@ -30,6 +31,7 @@ const AppProvider = ({ children }: Readonly<{ children: React.ReactNode }>) => { + diff --git a/apps/sn/src/app/styles/grid-ellipsis.css b/apps/sn/src/app/styles/grid-ellipsis.css new file mode 100644 index 000000000..45f45f457 --- /dev/null +++ b/apps/sn/src/app/styles/grid-ellipsis.css @@ -0,0 +1,19 @@ +/* SN 목록 그리드의 데이터 텍스트만 한 줄로 제한한다. 폼·병합·액션 셀은 보존한다. */ +body[data-sn-grid-ellipsis="true"] table:has(> thead) > tbody > tr > td:not([colspan]):not([rowspan]):not([data-sn-cell-wrap]):not(:has(input, textarea, select, button, [role="checkbox"], [role="switch"], [contenteditable="true"], table, img, svg)) { + max-width: 0; + overflow: hidden; + white-space: nowrap !important; + text-overflow: ellipsis; +} + +/* 셀 안의 block/whitespace-normal·pre-line 텍스트도 실제 열 너비 안에서 생략한다. */ +body[data-sn-grid-ellipsis="true"] table:has(> thead) > tbody > tr > td:not([colspan]):not([rowspan]):not([data-sn-cell-wrap]):not(:has(input, textarea, select, button, [role="checkbox"], [role="switch"], [contenteditable="true"], table, img, svg)) :is(div, span, a, p) { + display: block; + min-width: 0; + max-width: 100%; + overflow: hidden; + white-space: nowrap !important; + overflow-wrap: normal; + word-break: normal; + text-overflow: ellipsis; +} diff --git a/apps/sn/src/features/cfmtnBgt/ui/CfmtnBgtPopupContent.tsx b/apps/sn/src/features/cfmtnBgt/ui/CfmtnBgtPopupContent.tsx index 4784372bb..8c3e7d03a 100644 --- a/apps/sn/src/features/cfmtnBgt/ui/CfmtnBgtPopupContent.tsx +++ b/apps/sn/src/features/cfmtnBgt/ui/CfmtnBgtPopupContent.tsx @@ -3,6 +3,7 @@ */ "use client"; +import { getSnRowNumber } from "@/shared/lib/row-number"; import { useEffect, useMemo, useState } from "react"; import { useConfirm } from "@frontend-opensource/use-react-hooks"; @@ -10,7 +11,6 @@ import useOnsemiroAddrStore from "@/app/store/useOnsemiroAddrStore"; import { StandardDataTable, StandardPopupLayout, - getStandardRowNumber, } from "@common/business_components"; import { Button, @@ -273,7 +273,7 @@ export default function CfmtnBgtPopupContent({ () => (Array.isArray(data.content) ? data.content : []).map((row, index) => ({ ...row, - rowNumber: getStandardRowNumber({ + rowNumber: getSnRowNumber({ total: data.totalElements, rowIndex: index, pageIndex: page, diff --git a/apps/sn/src/features/ctyFldngPrvtnBiz/ui/CtyFldngPrvtnBizListPage.tsx b/apps/sn/src/features/ctyFldngPrvtnBiz/ui/CtyFldngPrvtnBizListPage.tsx index d5d753ff8..9d4107ddd 100644 --- a/apps/sn/src/features/ctyFldngPrvtnBiz/ui/CtyFldngPrvtnBizListPage.tsx +++ b/apps/sn/src/features/ctyFldngPrvtnBiz/ui/CtyFldngPrvtnBizListPage.tsx @@ -1,12 +1,12 @@ "use client"; +import { getSnRowNumber } from "@/shared/lib/row-number"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useRouter, useSearchParams } from "next/navigation"; import { StandardDataTable, StandardTableSortHeader, - getStandardRowNumber, type UiTableSort, } from "@common/business_components"; import { @@ -463,7 +463,7 @@ export function CtyFldngPrvtnBizListPage() { result.content.map((row, i) => flattenRow( row, - getStandardRowNumber({ + getSnRowNumber({ total: result.totalElements, rowIndex: i, pageIndex: target - 1, diff --git a/apps/sn/src/features/exmn/ui/ExmnRegistrationPage.tsx b/apps/sn/src/features/exmn/ui/ExmnRegistrationPage.tsx index 35eee27f8..7143ce61a 100644 --- a/apps/sn/src/features/exmn/ui/ExmnRegistrationPage.tsx +++ b/apps/sn/src/features/exmn/ui/ExmnRegistrationPage.tsx @@ -1,5 +1,6 @@ "use client"; +import { getSnRowNumber } from "@/shared/lib/row-number"; import LoadingOverlay from "@/components/ui/LoadingBar"; import { fileDownload } from "@/components/lib/utils"; import { getStartYearArray } from "@/components/ui/YearRangeSelectBox"; @@ -20,7 +21,6 @@ import { STANDARD_ROW_NUMBER_HEADER, StandardDataTable, StandardTableSortHeader, - getStandardRowNumber, type UiTableSort, } from "@common/business_components"; import { @@ -146,7 +146,7 @@ export function ExmnRegistrationPage() { () => businesses.map((business, index) => ({ ...business, - rowNumber: getStandardRowNumber({ + rowNumber: getSnRowNumber({ total: totalElements, rowIndex: index, pageIndex: pagination.pageIndex, diff --git a/apps/sn/src/features/exmn/ui/ExmnStandardDataTable.tsx b/apps/sn/src/features/exmn/ui/ExmnStandardDataTable.tsx index 81d32e112..4d865d627 100644 --- a/apps/sn/src/features/exmn/ui/ExmnStandardDataTable.tsx +++ b/apps/sn/src/features/exmn/ui/ExmnStandardDataTable.tsx @@ -1,5 +1,6 @@ "use client"; +import { getSnRowNumber } from "@/shared/lib/row-number"; import { useMemo, type ReactNode } from "react"; import { getCoreRowModel, @@ -9,7 +10,6 @@ import { } from "@tanstack/react-table"; import { StandardDataTable, - getStandardRowNumber, type ScrollMode, type UiHeadProps, } from "@common/business_components"; @@ -43,7 +43,7 @@ const formatValue = (value: unknown) => { : String(value); }; -/** 조사관리 표를 CS 목록 규격과 역순 순번 계약으로 연결한다. */ +/** 조사관리 표를 CS 목록 규격과 페이지 연속 오름차순 순번으로 연결한다. */ export function ExmnStandardDataTable>({ rows, headlist, @@ -69,7 +69,7 @@ export function ExmnStandardDataTable>({ header: ROW_NUMBER_IDS.has(head.id) ? "순번" : head.title, cell: ({ row, getValue }) => { const value = ROW_NUMBER_IDS.has(head.id) - ? getStandardRowNumber({ + ? getSnRowNumber({ total, rowIndex: row.index, pageIndex: page - 1, diff --git a/apps/sn/src/features/exmn/ui/ExmnStatusDetailPanel.tsx b/apps/sn/src/features/exmn/ui/ExmnStatusDetailPanel.tsx index 2276ab421..e2e3d3d3c 100644 --- a/apps/sn/src/features/exmn/ui/ExmnStatusDetailPanel.tsx +++ b/apps/sn/src/features/exmn/ui/ExmnStatusDetailPanel.tsx @@ -189,7 +189,7 @@ export function ExmnStatusDetailPanel({ const counts = getCounts(row); return { trtdstId: row.trtdstId, - rowNumber: summaryRows.length - index, + rowNumber: index + 1, trtdstNm: row.trtdstNm, trgtPrlgText: formatNumber(row.trgtPrlg), drvngDstncText: formatNumber(row.drvngDstnc), diff --git a/apps/sn/src/features/exmn/ui/TrtdstManagementPage.tsx b/apps/sn/src/features/exmn/ui/TrtdstManagementPage.tsx index 3ade6952d..8ec83c2c5 100644 --- a/apps/sn/src/features/exmn/ui/TrtdstManagementPage.tsx +++ b/apps/sn/src/features/exmn/ui/TrtdstManagementPage.tsx @@ -1,12 +1,12 @@ "use client"; +import { getSnRowNumber } from "@/shared/lib/row-number"; import LoadingOverlay from "@/components/ui/LoadingBar"; import useStore from "@/store/useStore"; import { STANDARD_ROW_NUMBER_HEADER, StandardDataTable, StandardTableSortHeader, - getStandardRowNumber, sortStandardTableRows, type UiTableSort, } from "@common/business_components"; @@ -73,16 +73,16 @@ const createLocalKey = () => ? `client:${crypto.randomUUID()}` : `client:${Date.now()}-${Math.random().toString(36).slice(2)}`; -/** 화면의 현재 행 순서를 전체 건수에서 시작하는 공통 내림차순 순번으로 정규화한다. */ -const withDescendingRowNumbers = (rows: TrtdstTableRow[]): TrtdstTableRow[] => +/** 화면의 현재 행 순서에 1부터 오름차순 표시 순번을 붙인다. */ +const withAscendingRowNumbers = (rows: TrtdstTableRow[]): TrtdstTableRow[] => rows.map((row, index) => ({ ...row, - rowNumber: getStandardRowNumber({ total: rows.length, rowIndex: index }), + rowNumber: getSnRowNumber({ total: rows.length, rowIndex: index }), })); /** 서버 처리분구 목록을 공통 표와 변경 추적에 필요한 행 구조로 변환한다. */ const toTableRows = (items: TrtdstSettingDto[]): TrtdstTableRow[] => - withDescendingRowNumbers( + withAscendingRowNumbers( items.map((item) => ({ ...item, trtdstId: String(item.trtdstId), @@ -103,7 +103,7 @@ const toSavedTableRows = ( rows: TrtdstTableRow[], savedItems: TrtdstSettingDto[] = [], ): TrtdstTableRow[] => - withDescendingRowNumbers( + withAscendingRowNumbers( rows .filter((row) => row.rowStatus !== "deletePending") .map((row) => { @@ -300,7 +300,7 @@ export function TrtdstManagementPage() { setSort(undefined); setPagination((current) => ({ ...current, pageIndex: 0 })); setRows((current) => - withDescendingRowNumbers([ + withAscendingRowNumbers([ ...current, { trtdstId: "", @@ -353,7 +353,7 @@ export function TrtdstManagementPage() { if (selectedRow.rowStatus === "added") { if (!(await confirm("선택한 처리분구를 삭제하시겠습니까?"))) return; setRows((current) => - withDescendingRowNumbers( + withAscendingRowNumbers( current.filter((row) => row.localKey !== selectedRow.localKey), ), ); diff --git a/apps/sn/src/features/obsltPpl/ui/ObsltPplListPage.tsx b/apps/sn/src/features/obsltPpl/ui/ObsltPplListPage.tsx index ee64be2d9..8148c63a9 100644 --- a/apps/sn/src/features/obsltPpl/ui/ObsltPplListPage.tsx +++ b/apps/sn/src/features/obsltPpl/ui/ObsltPplListPage.tsx @@ -3,13 +3,13 @@ */ "use client"; +import { getSnRowNumber } from "@/shared/lib/row-number"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useRouter, useSearchParams } from "next/navigation"; import { StandardDataTable, StandardTableSortHeader, - getStandardRowNumber, type UiTableSort, } from "@common/business_components"; import { @@ -331,7 +331,7 @@ export function ObsltPplListPage() { setRows( result.content.map((row, index) => ({ ...formatBusinessForDisplay(row), - rowNumber: getStandardRowNumber({ + rowNumber: getSnRowNumber({ total: result.totalElements, rowIndex: index, pageIndex: targetPage - 1, diff --git a/apps/sn/src/features/windowPopup/ui/MapPopupDataTable.tsx b/apps/sn/src/features/windowPopup/ui/MapPopupDataTable.tsx index ace929129..b96b19fab 100644 --- a/apps/sn/src/features/windowPopup/ui/MapPopupDataTable.tsx +++ b/apps/sn/src/features/windowPopup/ui/MapPopupDataTable.tsx @@ -1,5 +1,6 @@ "use client"; +import { getSnRowNumber } from "@/shared/lib/row-number"; import { type ReactNode, useMemo } from "react"; import { @@ -10,7 +11,6 @@ import { } from "@tanstack/react-table"; import { - getStandardRowNumber, type ScrollMode, StandardDataTable, StandardTableSortHeader, @@ -109,7 +109,7 @@ export default function MapPopupDataTable>({ const rowIndex = row.index; const rendered = renderCell?.(row.original, head.id, rowIndex); const value = ROW_NUMBER_IDS.has(head.id) - ? getStandardRowNumber({ + ? getSnRowNumber({ total, rowIndex, pageIndex: page - 1, diff --git a/apps/sn/src/shared/lib/row-number.ts b/apps/sn/src/shared/lib/row-number.ts new file mode 100644 index 000000000..3524bdc51 --- /dev/null +++ b/apps/sn/src/shared/lib/row-number.ts @@ -0,0 +1,17 @@ +/** SN 표시 순번 계산용 인자. pageIndex와 rowIndex는 0부터 시작한다. */ +type SnRowNumberParams = { + /** 기존 호출부의 전체 건수는 페이지 계산에 사용하지 않는다. */ + total?: number; + rowIndex: number; + pageIndex?: number; + pageSize?: number; +}; + +/** 데이터 정렬·식별자는 유지하고 현재 조회 순서에 1부터 연속 순번을 붙인다. */ +export function getSnRowNumber({ + rowIndex, + pageIndex = 0, + pageSize = 0, +}: SnRowNumberParams): number { + return pageIndex * pageSize + rowIndex + 1; +} diff --git a/apps/sn/src/shared/ui/grid-cell-tooltip.tsx b/apps/sn/src/shared/ui/grid-cell-tooltip.tsx new file mode 100644 index 000000000..9edebbfe3 --- /dev/null +++ b/apps/sn/src/shared/ui/grid-cell-tooltip.tsx @@ -0,0 +1,38 @@ +"use client"; + +import { useEffect } from "react"; + +/** CSS 말줄임표 대상과 같은 범위를 사용하며 입력/액션/병합 셀의 툴팁은 건드리지 않는다. */ +const CELL_SELECTOR = 'body[data-sn-grid-ellipsis="true"] table:has(> thead) > tbody > tr > td:not([colspan]):not([rowspan]):not([data-sn-cell-wrap]):not(:has(input, textarea, select, button, [role="checkbox"], [role="switch"], [contenteditable="true"], table, img, svg))'; + +/** 동적 목록·포털에도 공통 적용하며 기존 title과 실제 데이터는 변경하지 않는다. */ +export function GridCellTooltip() { + useEffect(() => { + const generatedTitles = new WeakMap(); + const showFullText = (event: Event) => { + if (!(event.target instanceof Element)) return; + const cell = event.target.closest(CELL_SELECTOR); + if (!cell) return; + const previous = generatedTitles.get(cell); + // 화면에서 이미 지정한 상세 툴팁을 우선한다. + if ((cell.hasAttribute("title") && cell.title !== previous) || cell.querySelector("[title]")) return; + const overflowed = [cell, ...cell.querySelectorAll("div, span, a, p")] + .some((element) => element.scrollWidth > element.clientWidth + 1); + if (overflowed) { + const fullText = cell.textContent?.trim() ?? ""; + cell.title = fullText; + generatedTitles.set(cell, fullText); + } else if (previous !== undefined) { + cell.removeAttribute("title"); + generatedTitles.delete(cell); + } + }; + document.addEventListener("pointerover", showFullText); + document.addEventListener("focusin", showFullText); + return () => { + document.removeEventListener("pointerover", showFullText); + document.removeEventListener("focusin", showFullText); + }; + }, []); + return null; +} diff --git a/packages/common/business_components/src/ui/map/Gis.js b/packages/common/business_components/src/ui/map/Gis.js index 13a5de5f3..823a9b7ca 100644 --- a/packages/common/business_components/src/ui/map/Gis.js +++ b/packages/common/business_components/src/ui/map/Gis.js @@ -93,6 +93,8 @@ function Gis({ showLayerPanelTitle = true, // sn 관망도처럼 트리와 선택 설정을 카드 두 개로 구분해야 하는 화면에서만 활성화한다. sectionedTreePanel = false, + // 전체 체크 조작은 필요한 화면에서만 명시적으로 활성화한다. + showBulkLayerToggle = false, showZoomScaleInfo = false, }) { const mapRef = useRef(); @@ -345,6 +347,7 @@ function Gis({ detachedToggleButton={detachedTreeToggleButton} showLayerPanelTitle={showLayerPanelTitle} sectionedPanel={sectionedTreePanel} + showBulkLayerToggle={showBulkLayerToggle} /> {typeof WaterDepth === "function" && ( diff --git a/packages/common/business_components/src/ui/map/GisTree.js b/packages/common/business_components/src/ui/map/GisTree.js index 3862ad61e..47762ec6f 100644 --- a/packages/common/business_components/src/ui/map/GisTree.js +++ b/packages/common/business_components/src/ui/map/GisTree.js @@ -10,6 +10,7 @@ import React, { useEffect, useLayoutEffect, useState, useRef, useCallback } from import { showBusinessUserMessage } from "../../lib/user-message"; import { useGisContext } from "./Gis"; import GisApp from "./lib/gis.core"; +import { getBulkLayerSelection, getBulkLayerState } from "./layerBulkSelection"; /** * GisTree (React memoized component) @@ -63,6 +64,7 @@ export default React.memo(function GisTree({ showLayerPanelTitle = true, detachedToggleButton = false, sectionedPanel = false, + showBulkLayerToggle = false, }) { const { gisCore, treeData } = useGisContext(); @@ -71,6 +73,8 @@ export default React.memo(function GisTree({ const [expanded, setExpanded] = useState([]); const [nodes, setNodes] = useState([]); const [activeTab, setActiveTab] = useState("layers"); + // 전체 선택이면 ON, 부분 선택이면 OFF 위치와 별도 상태 문구로 구분한다. + const bulkLayerState = getBulkLayerState(nodes, checked); // 자동 애니메이션 관련 ref (실행 인덱스, 대상 노드 목록, 일시정지, 타이머) const currentAnimationIndexRef = useRef(0); @@ -470,6 +474,19 @@ export default React.memo(function GisTree({ isPausedRef.current = true; }, []); + /** 전체 조작은 자동재생을 멈추고 트리·지도·업무 콜백에 같은 선택을 한 번 반영한다. */ + const handleBulkLayerToggle = useCallback((enabled) => { + if (!gisCore || nodes.length === 0) return; + pauseAnimation(); + setIsPlaying(false); + const { checkedIds, uncheckedIds, selectedNodes } = getBulkLayerSelection(nodes, enabled); + // 배경지도나 편집 오버레이가 아닌 현재 트리에 등록된 레이어만 변경한다. + gisCore.setLayerOnMap(uncheckedIds, false); + gisCore.setLayerOnMap(checkedIds, true); + setChecked(checkedIds); + onCheckedChange?.(checkedIds, selectedNodes); + }, [gisCore, nodes, onCheckedChange, pauseAnimation, setIsPlaying]); + /** * 재생/정지/대상/간격 변경에 반응하여 애니메이션을 관리합니다. */ @@ -626,6 +643,20 @@ export default React.memo(function GisTree({

레이어 관리

+ {showBulkLayerToggle && ( + + )} )}
node.data?.exprsYn === "Y") + : []; + const checkedIds = selectedNodes.map((node) => node.id); + const selected = new Set(checkedIds); + return { + checkedIds, + selectedNodes, + uncheckedIds: nodes.filter((node) => !selected.has(node.id)).map((node) => node.id), + }; +} + +/** 개별 체크 변경도 전체 토글에 반영하며 숨김 노드는 집계에서 제외한다. */ +export function getBulkLayerState(nodes, checkedIds) { + const visibleIds = nodes.filter((node) => node.data?.exprsYn === "Y").map((node) => node.id); + const checked = new Set(checkedIds); + const count = visibleIds.filter((id) => checked.has(id)).length; + return { + disabled: visibleIds.length === 0, + allChecked: visibleIds.length > 0 && count === visibleIds.length, + partiallyChecked: count > 0 && count < visibleIds.length, + }; +}