관망도 레이어 전체켜기 기능 추가
This commit is contained in:
+2
-2
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
@@ -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:[]});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user