관망도 레이어 전체켜기 기능 추가

This commit is contained in:
2026-09-23 13:50:50 +09:00
parent 35b9f813c4
commit 8818dd21c6
41 changed files with 461 additions and 51 deletions
@@ -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,
}),
@@ -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(
@@ -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 },
@@ -160,7 +160,7 @@ export default function InftwtResultPopupPage() {
return {
...(formattedItem as Record<string, unknown>),
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 ?? "-"),
@@ -334,7 +334,7 @@ export default function PipeAnalysisPopupPage() {
() =>
yearMaterialGroups.map((group, index) => ({
...group,
rowNo: yearMaterialGroups.length - index,
rowNo: index + 1,
})),
[yearMaterialGroups],
);
@@ -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
@@ -162,7 +162,7 @@ export default function RdiiResultPopupPage() {
return {
...(formattedItem as Record<string, unknown>),
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 ?? "-"),
@@ -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),
+1
View File
@@ -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}
@@ -405,7 +405,7 @@ const ReportTab: React.FC<ReportTabProps> = ({
.slice(pageStart, pageEnd)
.map((item, index) => ({
...item,
no: totalCount - (pageOffset + index),
no: pageOffset + index + 1,
}));
reportRows = await enrichReportRows(pagedReportData);
@@ -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,
@@ -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 : "-",
@@ -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,
};
},
@@ -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:
@@ -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:
@@ -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),
@@ -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,
@@ -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,
@@ -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,
}));
}
+2 -1
View File
@@ -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 (
<html lang="ko" data-theme={theme?.value}>
<body>
<body data-sn-grid-ellipsis="true">
<AppProvider>{children}</AppProvider>
</body>
</html>
@@ -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 등록 및 신규 파일만 제거한다. 다른 작업 변경은 되돌리지 않는다.
@@ -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만 역적용하고 관련 없는 변경은 유지한다.
@@ -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 변경만 역적용한다.
@@ -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,'');
});
@@ -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:[]});
});
+44
View File
@@ -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);
}
});
@@ -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 }>) => {
<QueryClientProvider client={queryClient}>
<StoreProvider>
<BusinessUserMessageBridge />
<GridCellTooltip />
<PromptProvider>
<ConfirmProvider>
<AuthWrapper>
+19
View File
@@ -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;
}
@@ -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,
@@ -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,
@@ -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,
@@ -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<T extends Record<string, unknown>>({
rows,
headlist,
@@ -69,7 +69,7 @@ export function ExmnStandardDataTable<T extends Record<string, unknown>>({
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,
@@ -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),
@@ -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),
),
);
@@ -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,
@@ -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<T extends Record<string, unknown>>({
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,
+17
View File
@@ -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;
}
@@ -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<HTMLTableCellElement, string>();
const showFullText = (event: Event) => {
if (!(event.target instanceof Element)) return;
const cell = event.target.closest<HTMLTableCellElement>(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<HTMLElement>("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;
}
@@ -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" && (
@@ -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({
<Layers size={14} strokeWidth={2} />
</span>
<h3 className="text-[1.4rem] font-bold text-foreground">레이어 관리</h3>
{showBulkLayerToggle && (
<button type="button" role="switch" aria-label="전체 레이어"
aria-checked={bulkLayerState.allChecked}
aria-description={bulkLayerState.partiallyChecked ? "일부 레이어 켜짐. 누르면 전체를 켭니다." : undefined}
title={bulkLayerState.allChecked ? "레이어 전체 끄기" : "레이어 전체 켜기"}
disabled={!gisCore || bulkLayerState.disabled}
className="ml-auto flex shrink-0 items-center gap-1 rounded-sm text-[1.1rem] focus-visible:outline focus-visible:outline-2 focus-visible:outline-primary disabled:cursor-not-allowed disabled:opacity-40"
onClick={() => handleBulkLayerToggle(!bulkLayerState.allChecked)}>
<span>{bulkLayerState.partiallyChecked ? "일부 켜짐" : "전체"}</span>
<span aria-hidden="true" className={`relative inline-flex h-[18px] w-[32px] items-center rounded-full transition-colors ${bulkLayerState.allChecked ? "bg-[#168CFF]" : "bg-gray-300"}`}>
<span className={`h-[14px] w-[14px] rounded-full bg-white shadow-sm transition-transform ${bulkLayerState.allChecked ? "translate-x-[16px]" : "translate-x-[2px]"}`} />
</span>
</button>
)}
</div>
)}
<div
@@ -0,0 +1,25 @@
/** 화면에 표시하도록 허용된 트리 노드만 전체 켜기 대상으로 선택한다. */
export function getBulkLayerSelection(nodes, enabled) {
const selectedNodes = enabled
? nodes.filter((node) => 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,
};
}