Merge branch 'iipbs/sn/dev'

This commit is contained in:
kiminchan
2026-09-28 10:00:24 +09:00
49 changed files with 564 additions and 213 deletions
+1 -1
View File
@@ -228,7 +228,7 @@ VSCode나 별도 터미널에서 `sn` Next 개발 서버가 실행 중이면 `ne
### 프론트엔드 실행 프로세스 관리 ### 프론트엔드 실행 프로세스 관리
- 모든 세션·역할·문서/검증 섹션에 공통 D:/workspace/AGENTS.md의 테스트 임시 파일·결과 파일 정리 기준을 적용한다. 일회성 검증 스크립트·캡처·Network/서버 로그·원시 결과 파일은 최종 보고서에 요약 후 삭제한다. 정식 회귀 테스트·최종 산출물·사용자 원본/복구 백업은 유지한다. 사용자 dev server가 쓰는 .next·공유 캐시·node_modules는 임시 검증물이라는 이유로 포괄 삭제하지 않는다. - 모든 세션·역할·문서/검증 섹션에 공통 D:/workspace/AGENTS.md의 테스트 파일 정리 기준을 적용한다. 에이전트가 테스트를 위해 만든 파일은 정식 회귀 테스트·재사용 가능한 소스·fixture·mock·임시 라우트를 포함해 위치와 관계없이 결과를 요약한 뒤 작업 종료 전에 삭제한다(2026-09-23 사용자 지시). 전용 빈 폴더와 끊어진 실행 명령도 정리한다. 명시 보존 요청 파일·최종 산출물·사용자 원본/복구 백업은 유지한다. 기존 타 작업자 테스트 자산은 삭제 권한을 먼저 확인한다. 사용자 dev server의 .next·공유 캐시·node_modules는 포괄 삭제하지 않는다.
- Codex 에이전트가 작업 또는 검증을 위해 직접 기동한 `next dev`, `dev:sn`, Turborepo dev와 기타 프론트엔드 장시간 실행 프로세스는 작업 종료 전에 반드시 종료한다. - Codex 에이전트가 작업 또는 검증을 위해 직접 기동한 `next dev`, `dev:sn`, Turborepo dev와 기타 프론트엔드 장시간 실행 프로세스는 작업 종료 전에 반드시 종료한다.
- 셸, 백그라운드, 숨김 창 등 VSCode 밖에서 에이전트가 기동한 프로세스도 종료 대상이다. - 셸, 백그라운드, 숨김 창 등 VSCode 밖에서 에이전트가 기동한 프로세스도 종료 대상이다.
@@ -18,11 +18,13 @@ import {
AGED_PIPE_LIST_HEADLIST, AGED_PIPE_LIST_HEADLIST,
AGED_PIPE_PAGE_SIZE, AGED_PIPE_PAGE_SIZE,
buildAgedPipeCqlFilters, buildAgedPipeCqlFilters,
getAgedPipeManagementName,
normalizeAgedPipeFilters, normalizeAgedPipeFilters,
toDisplayPipeRow, toDisplayPipeRow,
validateElapsedYears, validateElapsedYears,
type AgedPipeFilters, type AgedPipeFilters,
type AgedPipeRow, type AgedPipeRow,
type AgedPipeManagementAgency,
} from "./agedPipeUtils"; } from "./agedPipeUtils";
import { downloadAgedPipeExcel } from "./agedPipeExport"; import { downloadAgedPipeExcel } from "./agedPipeExport";
import MapPopupDataTable from "@/features/windowPopup/ui/MapPopupDataTable"; import MapPopupDataTable from "@/features/windowPopup/ui/MapPopupDataTable";
@@ -53,6 +55,15 @@ export default function AgedPipePopupPage() {
const [appliedFilters, setAppliedFilters] = const [appliedFilters, setAppliedFilters] =
useState<AgedPipeFilters>(initialFilters); useState<AgedPipeFilters>(initialFilters);
const [rows, setRows] = useState<AgedPipeRow[]>([]); const [rows, setRows] = useState<AgedPipeRow[]>([]);
// 관리기관명 조회가 늦게 완료되어도 기존 목록을 재조회하지 않고 표시만 갱신한다.
const [managementAgencies, setManagementAgencies] = useState<AgedPipeManagementAgency[]>([]);
const displayRows = useMemo(
() => rows.map((row) => ({
...row,
mngInstNm: getAgedPipeManagementName(row, managementAgencies),
})),
[rows, managementAgencies],
);
const [total, setTotal] = useState(0); const [total, setTotal] = useState(0);
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
@@ -73,6 +84,19 @@ export default function AgedPipePopupPage() {
if (sgg.length === 0) fetchSgg(); if (sgg.length === 0) fetchSgg();
}, [ctpv.length, fetchCtpv, fetchSgg, sgg.length]); }, [ctpv.length, fetchCtpv, fetchSgg, sgg.length]);
useEffect(() => {
let active = true;
// 기존 GIS 관리 화면과 같은 기관 목록을 사용하고, 실패 시 관로 조회는 유지한다.
getData("v1/fcltPpl/mngInsts")
.then((response) => {
if (active) setManagementAgencies(Array.isArray(response) ? response : []);
})
.catch(() => {
if (active) alert("관리기관명을 불러오지 못해 관리기관 코드로 표시합니다.");
});
return () => { active = false; };
}, [alert]);
const fetchRows = useCallback( const fetchRows = useCallback(
async (page: number, nextFilters: AgedPipeFilters, size: number) => { async (page: number, nextFilters: AgedPipeFilters, size: number) => {
const normalized = normalizeAgedPipeFilters(nextFilters); const normalized = normalizeAgedPipeFilters(nextFilters);
@@ -247,31 +271,6 @@ export default function AgedPipePopupPage() {
) : null} ) : null}
<PopupSearchPanel contentClassName="flex flex-wrap items-end gap-x-5 gap-y-3"> <PopupSearchPanel contentClassName="flex flex-wrap items-end gap-x-5 gap-y-3">
<label className="flex items-center gap-2">
<span className="whitespace-nowrap text-[1.3rem] text-label">
경과년도
</span>
<Input
className="w-[12rem]"
type="text"
inputMode="numeric"
pattern="[0-9]*"
autoComplete="off"
value={String(filters.elapsedYears ?? "")}
onChange={(event) => {
const digits = event.target.value.replace(/[^0-9]/g, "");
setFilters({
...filters,
elapsedYears:
digits === "" ? ("" as unknown as number) : Number(digits),
});
}}
onKeyDown={(event) => {
if (event.key === "Enter") void handleSearch(1);
}}
/>
</label>
<label className="flex items-center gap-2"> <label className="flex items-center gap-2">
<span className="whitespace-nowrap text-[1.3rem] text-label"> <span className="whitespace-nowrap text-[1.3rem] text-label">
시도 시도
@@ -316,6 +315,31 @@ export default function AgedPipePopupPage() {
</select> </select>
</label> </label>
<label className="flex items-center gap-2">
<span className="whitespace-nowrap text-[1.3rem] text-label">
경과년도
</span>
<Input
className="w-[12rem]"
type="text"
inputMode="numeric"
pattern="[0-9]*"
autoComplete="off"
value={String(filters.elapsedYears ?? "")}
onChange={(event) => {
const digits = event.target.value.replace(/[^0-9]/g, "");
setFilters({
...filters,
elapsedYears:
digits === "" ? ("" as unknown as number) : Number(digits),
});
}}
onKeyDown={(event) => {
if (event.key === "Enter") void handleSearch(1);
}}
/>
</label>
<div className="ml-auto flex items-center gap-2"> <div className="ml-auto flex items-center gap-2">
<Button <Button
type="button" type="button"
@@ -341,7 +365,7 @@ export default function AgedPipePopupPage() {
</PopupSearchPanel> </PopupSearchPanel>
<MapPopupDataTable <MapPopupDataTable
rows={rows as Array<AgedPipeRow & Record<string, unknown>>} rows={displayRows}
headlist={AGED_PIPE_LIST_HEADLIST} headlist={AGED_PIPE_LIST_HEADLIST}
title="노후관로 목록" title="노후관로 목록"
total={total} total={total}
@@ -56,15 +56,34 @@ const normalizeRegionFilterCode = (value: unknown, prefixLength: number) => {
export const AGED_PIPE_LIST_HEADLIST = [ export const AGED_PIPE_LIST_HEADLIST = [
{ id: "rowNumber", title: "순번", widthPercent: 6 }, { id: "rowNumber", title: "순번", widthPercent: 6 },
{ id: "fcltMngNo", title: "시설관리번호", widthPercent: 18 }, { id: "fcltMngNo", title: "시설관리번호", widthPercent: 16 },
{ id: "mngInstNm", title: "관리기관", widthPercent: 14 },
{ id: "instlYmd", title: "설치일자", widthPercent: 12 }, { id: "instlYmd", title: "설치일자", widthPercent: 12 },
{ id: "pdmtVl", title: "관경(mm)", widthPercent: 10 },
{ id: "prlgVl", title: "연장(m)", widthPercent: 10 },
{ id: "usgCd", title: "용도", widthPercent: 12 },
{ id: "mqltCd", title: "재질", widthPercent: 18 },
{ id: "elapsedYears", title: "경과년도", widthPercent: 8, align: "right" }, { id: "elapsedYears", title: "경과년도", widthPercent: 8, align: "right" },
{ id: "pdmtVl", title: "관경(mm)", widthPercent: 9 },
{ id: "prlgVl", title: "연장(m)", widthPercent: 9 },
{ id: "usgCd", title: "용도", widthPercent: 10 },
{ id: "mqltCd", title: "재질", widthPercent: 16 },
]; ];
/** 기존 관리기관 조회 API의 코드와 기관명만 목록 표시용으로 사용한다. */
export type AgedPipeManagementAgency = {
mngInstCd: string;
lwrInstNm?: string | null;
};
/** 명칭이 없는 기관은 원본 코드를 표시하며, 행의 관리기관 코드는 변경하지 않는다. */
export const getAgedPipeManagementName = (
row: PipeSource,
agencies: AgedPipeManagementAgency[],
): string => {
const directName = String(row.mngInstNm ?? row.mng_inst_nm ?? "").trim();
if (directName && directName !== "-") return directName;
const code = String(row.mngInstCd ?? row.mng_inst_cd ?? "").trim();
const agency = agencies.find((item) => String(item.mngInstCd).trim() === code);
return String(agency?.lwrInstNm ?? "").trim() || code || "-";
};
export const AGED_PIPE_EXCEL_FIELDS = [ export const AGED_PIPE_EXCEL_FIELDS = [
"ftreSmbolCd", "ftreSmbolCd",
"fcltMngNo", "fcltMngNo",
@@ -4,10 +4,10 @@
*/ */
"use client"; "use client";
import { getSnRowNumber } from "@/shared/lib/row-number";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { Button, Input } from "@common/components"; import { Button, Input } from "@common/components";
import { import {
getStandardRowNumber,
StandardDataTable, StandardDataTable,
StandardPopupLayout, StandardPopupLayout,
} from "@common/business_components"; } from "@common/business_components";
@@ -71,7 +71,7 @@ export default function AgedPipeStatusByCtpvPopupPage() {
id: "rowNumber", id: "rowNumber",
header: "순번", header: "순번",
cell: ({ row }) => cell: ({ row }) =>
getStandardRowNumber({ getSnRowNumber({
total: rows.length, total: rows.length,
rowIndex: row.index, rowIndex: row.index,
}), }),
@@ -159,7 +159,7 @@ export const toFloodDngrMapTableRows = (
): FloodDngrMapTableRow[] => ): FloodDngrMapTableRow[] =>
items.map((item, index) => ({ items.map((item, index) => ({
...item, ...item,
rowNo: total - page * pageSize - index, rowNo: page * pageSize + index + 1,
ctpvNm: resolveAdministrativeName(item.ctpvCd, ctpvOptions, 2), ctpvNm: resolveAdministrativeName(item.ctpvCd, ctpvOptions, 2),
sggNm: resolveAdministrativeName(item.sggCd, sggOptions, 5), sggNm: resolveAdministrativeName(item.sggCd, sggOptions, 5),
sbmrgDpthlDisplay: toDepthRangeDisplay( sbmrgDpthlDisplay: toDepthRangeDisplay(
@@ -57,7 +57,7 @@ const GIS_UPLOAD_HISTORY_HEADLIST: UiHeadProps[] = [
]; ];
/** 오류 원본 행번호와 공통 표의 역순 순번을 별도 열로 표시한다. */ /** 오류 원본 행번호와 공통 표의 오름차순 표시 순번을 별도 열로 표시한다. */
const GIS_VALIDATION_ERROR_HEADLIST: UiHeadProps[] = [ const GIS_VALIDATION_ERROR_HEADLIST: UiHeadProps[] = [
{ id: "no", title: "순번", widthPercent: 8 }, { id: "no", title: "순번", widthPercent: 8 },
{ id: "sourceRowNo", title: "행번호", widthPercent: 10 }, { id: "sourceRowNo", title: "행번호", widthPercent: 10 },
@@ -160,7 +160,7 @@ export default function InftwtResultPopupPage() {
return { return {
...(formattedItem as Record<string, unknown>), ...(formattedItem as Record<string, unknown>),
rowNumber: Math.max( rowNumber: Math.max(
totalElements - ((page - 1) * INFTWT_RESULT_PAGE_SIZE + index), (page - 1) * INFTWT_RESULT_PAGE_SIZE + index + 1,
0, 0,
), ),
ctpvNm: ctpvOption ? ctpvOption.text : String(item?.ctpvCd ?? "-"), ctpvNm: ctpvOption ? ctpvOption.text : String(item?.ctpvCd ?? "-"),
@@ -334,7 +334,7 @@ export default function PipeAnalysisPopupPage() {
() => () =>
yearMaterialGroups.map((group, index) => ({ yearMaterialGroups.map((group, index) => ({
...group, ...group,
rowNo: yearMaterialGroups.length - index, rowNo: index + 1,
})), })),
[yearMaterialGroups], [yearMaterialGroups],
); );
@@ -158,7 +158,7 @@ export default function PipeAnalysisSummaryTable({
if (summaryRow.kind === "flat") { if (summaryRow.kind === "flat") {
const value = const value =
head.id === "rowNo" head.id === "rowNo"
? total - ((page - 1) * pageSize + summaryRow.sourceIndex) ? (page - 1) * pageSize + summaryRow.sourceIndex + 1
: summaryRow.value[head.id]; : summaryRow.value[head.id];
const onClick = const onClick =
summaryRow.value.__cellFilters[head.id] && Number(value) > 0 summaryRow.value.__cellFilters[head.id] && Number(value) > 0
@@ -162,7 +162,7 @@ export default function RdiiResultPopupPage() {
return { return {
...(formattedItem as Record<string, unknown>), ...(formattedItem as Record<string, unknown>),
rowNumber: Math.max( rowNumber: Math.max(
totalElements - ((page - 1) * RDII_RESULT_PAGE_SIZE + index), (page - 1) * RDII_RESULT_PAGE_SIZE + index + 1,
0, 0,
), ),
ctpvNm: ctpvOption ? ctpvOption.text : String(item?.ctpvCd ?? "-"), ctpvNm: ctpvOption ? ctpvOption.text : String(item?.ctpvCd ?? "-"),
@@ -139,7 +139,7 @@ export const toSbmrgDngrMapTableRows = (
): SbmrgDngrMapTableRow[] => ): SbmrgDngrMapTableRow[] =>
items.map((item, index) => ({ items.map((item, index) => ({
...item, ...item,
rowNo: total - page * pageSize - index, rowNo: page * pageSize + index + 1,
ctpvNm: resolveAdministrativeNames(item.ctpvCd, ctpvOptions, 2), ctpvNm: resolveAdministrativeNames(item.ctpvCd, ctpvOptions, 2),
sggNm: resolveAdministrativeNames(item.sggCd, sggOptions, 5), sggNm: resolveAdministrativeNames(item.sggCd, sggOptions, 5),
floodWtlvFrqVlDisplay: toDisplay(item.floodWtlvFrqVl), floodWtlvFrqVlDisplay: toDisplay(item.floodWtlvFrqVl),
+1
View File
@@ -7692,6 +7692,7 @@ const Map = () => {
detachedTreeToggleButton={true} detachedTreeToggleButton={true}
showLayerPanelTitle={false} showLayerPanelTitle={false}
sectionedTreePanel={true} sectionedTreePanel={true}
showBulkLayerToggle={true}
isPlaying={isPlaying} isPlaying={isPlaying}
setIsPlaying={setIsPlaying as any} setIsPlaying={setIsPlaying as any}
targetLayerTypes={WATER_DEPTH_LAYER_TYPES as any} targetLayerTypes={WATER_DEPTH_LAYER_TYPES as any}
@@ -405,7 +405,7 @@ const ReportTab: React.FC<ReportTabProps> = ({
.slice(pageStart, pageEnd) .slice(pageStart, pageEnd)
.map((item, index) => ({ .map((item, index) => ({
...item, ...item,
no: totalCount - (pageOffset + index), no: pageOffset + index + 1,
})); }));
reportRows = await enrichReportRows(pagedReportData); reportRows = await enrichReportRows(pagedReportData);
@@ -152,7 +152,7 @@ export default function useBasicTab() {
// 추가 처리 및 일련번호 추가 // 추가 처리 및 일련번호 추가
return { return {
...formattedItem, ...formattedItem,
rowNumber: totalCount - ((curPage - 1) * 10 + index), rowNumber: (curPage - 1) * 10 + index + 1,
scsYn: scsYn:
item.scsYn === null || item.scsYn === undefined ? "N" : item?.scsYn, // scsYn이 null이면 'N'으로 변경 item.scsYn === null || item.scsYn === undefined ? "N" : item?.scsYn, // scsYn이 null이면 'N'으로 변경
mqltCd: matchedMqltCode ? matchedMqltCode.text : item?.mqltCd, mqltCd: matchedMqltCode ? matchedMqltCode.text : item?.mqltCd,
@@ -174,7 +174,7 @@ export default function useOperationTab() {
return { return {
...formattedItem, ...formattedItem,
rowNumber: rowNumber:
totalElements - ((curPage - 1) * OPERATION_TABLE_SIZE + index), (curPage - 1) * OPERATION_TABLE_SIZE + index + 1,
bizTypeCd: matched ? matched.lwpoCdNm : item.bizTypeCd, bizTypeCd: matched ? matched.lwpoCdNm : item.bizTypeCd,
}; };
}, },
@@ -233,7 +233,7 @@ export default function useOperationTab() {
); );
return { return {
...formattedItem, ...formattedItem,
rowNumber: totalDetailElements - detailIndex, rowNumber: detailIndex + 1,
usgCd: usgCdObj ? usgCdObj.text : item.usgCd, usgCd: usgCdObj ? usgCdObj.text : item.usgCd,
mqltCd: mqltCdObj ? mqltCdObj.text : item.mqltCd, mqltCd: mqltCdObj ? mqltCdObj.text : item.mqltCd,
swtrmt_zone_nm: item.swtrmt_zone_nm ? item.swtrmt_zone_nm : "-", swtrmt_zone_nm: item.swtrmt_zone_nm ? item.swtrmt_zone_nm : "-",
@@ -325,8 +325,7 @@ export default function useOperationTab() {
return { return {
...formattedItem, ...formattedItem,
rowNumber: rowNumber:
totalElements - (detailCurPage - 1) * OPERATION_TABLE_SIZE + detailIndex + 1,
((detailCurPage - 1) * OPERATION_TABLE_SIZE + detailIndex),
usgCd: usgCdObj ? usgCdObj.text : item.usgCd, usgCd: usgCdObj ? usgCdObj.text : item.usgCd,
mqltCd: mqltCdObj ? mqltCdObj.text : item.mqltCd, mqltCd: mqltCdObj ? mqltCdObj.text : item.mqltCd,
swtrmt_zone_nm: item.swtrmt_zone_nm ? item.swtrmt_zone_nm : "-", swtrmt_zone_nm: item.swtrmt_zone_nm ? item.swtrmt_zone_nm : "-",
@@ -116,7 +116,7 @@ export default function useSearchModal() {
return { return {
...formattedItem, ...formattedItem,
rowNumber: rowNumber:
totalElements - ((curPage - 1) * OPERATION_TABLE_SIZE + index), (curPage - 1) * OPERATION_TABLE_SIZE + index + 1,
bizTypeCd: matched ? matched.lwpoCdNm : item.bizTypeCd, bizTypeCd: matched ? matched.lwpoCdNm : item.bizTypeCd,
}; };
}, },
@@ -170,7 +170,7 @@ const Result = () => {
const totalElements = Number(data?.totalElements ?? 0); const totalElements = Number(data?.totalElements ?? 0);
// 전체 건수를 기준으로 현재 페이지의 순번을 내림차순 계산한다. // 현재 페이지의 시작 위치를 기준으로 오름차순 표시 순번을 계산한다.
const dataWithRowNumber = (data.content || []) const dataWithRowNumber = (data.content || [])
.map((item: any, index: number) => { .map((item: any, index: number) => {
// 🎯 먼저 각 아이템을 포맷팅 // 🎯 먼저 각 아이템을 포맷팅
@@ -179,11 +179,11 @@ const Result = () => {
(code) => code.val.trim() === item?.ctpvCd?.trim(), (code) => code.val.trim() === item?.ctpvCd?.trim(),
); );
const sggOne = sgg.find((code) => code.val === item.sggCd); const sggOne = sgg.find((code) => code.val === item.sggCd);
// 추가 처리 및 전체 건수 기준 순번 추가 // 추가 처리 및 페이지 연속 순번 추가
return { return {
...formattedItem, ...formattedItem,
rowNumber: Math.max( rowNumber: Math.max(
totalElements - (((options?.page ?? curPage) - 1) * 10 + index), ((options?.page ?? curPage) - 1) * 10 + index + 1,
0, 0,
), ),
scsYn: scsYn:
@@ -170,7 +170,7 @@ const Result = () => {
const totalElements = Number(data?.totalElements ?? 0); const totalElements = Number(data?.totalElements ?? 0);
// 전체 건수를 기준으로 현재 페이지의 순번을 내림차순 계산한다. // 현재 페이지의 시작 위치를 기준으로 오름차순 표시 순번을 계산한다.
const dataWithRowNumber = (data.content || []) const dataWithRowNumber = (data.content || [])
.map((item: any, index: number) => { .map((item: any, index: number) => {
// 🎯 먼저 각 아이템을 포맷팅 // 🎯 먼저 각 아이템을 포맷팅
@@ -179,11 +179,11 @@ const Result = () => {
(code) => code.val.trim() === item?.ctpvCd?.trim(), (code) => code.val.trim() === item?.ctpvCd?.trim(),
); );
const sggOne = sgg.find((code) => code.val === item.sggCd); const sggOne = sgg.find((code) => code.val === item.sggCd);
// 추가 처리 및 전체 건수 기준 순번 추가 // 추가 처리 및 페이지 연속 순번 추가
return { return {
...formattedItem, ...formattedItem,
rowNumber: Math.max( rowNumber: Math.max(
totalElements - (((options?.page ?? curPage) - 1) * 10 + index), ((options?.page ?? curPage) - 1) * 10 + index + 1,
0, 0,
), ),
scsYn: scsYn:
@@ -1,105 +0,0 @@
"use client";
import React from "react";
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
BarElement,
Title,
Tooltip,
Legend,
type ChartOptions,
} from "chart.js";
import { Bar } from "react-chartjs-2";
ChartJS.register(CategoryScale, LinearScale, BarElement, Title, Tooltip, Legend);
export default function Page() {
const labels = ["한강", "낙동강", "영산강", "금강"];
// 데이터: [기타, 오수관로, 우수관로]
const datasets = [
{
label: "기타",
data: [15481, 6959, 1938, 1967],
backgroundColor: "rgba(255, 178, 150, 0.9)", // 연한 주황(파스텔톤)
borderColor: "rgba(255,178,150,1)",
},
{
label: "오수관로",
data: [15878, 16336, 5670, 8073],
backgroundColor: "rgba(180, 240, 190, 0.95)", // 연한 초록(파스텔톤)
borderColor: "rgba(180,240,190,1)",
},
{
label: "우수관로",
data: [12892, 10491, 4280, 4838],
backgroundColor: "rgba(170, 210, 245, 0.95)", // 연한 파랑(파스텔톤)
borderColor: "rgba(170,210,245,1)",
},
];
// 총합(라벨당) 계산
const totals = labels.map((_, i) =>
datasets.reduce((s, ds) => s + (ds.data[i] as number), 0),
);
const options: ChartOptions<"bar"> = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: "top",
},
tooltip: {
callbacks: {
label: (ctx) => `${ctx.dataset.label}: ${Number(ctx.parsed.y).toLocaleString()}km`,
},
},
},
scales: {
x: {
stacked: true,
},
y: {
stacked: true,
beginAtZero: true,
ticks: {
callback: (value) => `${Number(value).toLocaleString()}km`,
},
},
},
// 각 스택 위에 총합 표시
animation: {
onComplete: function (animation) {
const chart = animation.chart;
const ctx = chart.ctx;
const lastMeta = chart.getDatasetMeta(datasets.length - 1);
ctx.save();
ctx.fillStyle = "#111";
ctx.font = "gray bold 12px Arial";
ctx.textAlign = "center";
totals.forEach((total, i) => {
const bar = lastMeta.data[i];
if (!bar) return;
const x = bar.x;
// y는 마지막 스택의 y 좌표(스택의 최상단)에서 조금 위로
const y = bar.y - 8;
ctx.fillText(`총 ${total.toLocaleString()}km`, x, y);
});
ctx.restore();
},
},
};
const data = { labels, datasets };
return (
<div style={{ width: "100%", maxWidth: 800, height: 360 }}>
<Bar options={options} data={data} />
</div>
);
}
@@ -425,7 +425,7 @@ const Page = () => {
return buildElapsedYearRows(filteredRecords); return buildElapsedYearRows(filteredRecords);
return filteredRecords.map((record, index) => ({ return filteredRecords.map((record, index) => ({
rowNumber: filteredRecords.length - index, rowNumber: index + 1,
...record, ...record,
pipeLengthM: Number(record.pipeLengthM.toFixed(2)), pipeLengthM: Number(record.pipeLengthM.toFixed(2)),
pipeMaterial: resolvePipeMaterialName(record.pipeMaterial), pipeMaterial: resolvePipeMaterialName(record.pipeMaterial),
@@ -17,13 +17,13 @@
* - 첨부파일 삭제및 Soft Delete 삭제: DELETE /api/v1/atfl/attachments/{atflId} (attach-service) * - 첨부파일 삭제및 Soft Delete 삭제: DELETE /api/v1/atfl/attachments/{atflId} (attach-service)
*/ */
"use client"; "use client";
import { getSnRowNumber } from "@/shared/lib/row-number";
import { deleteData, useCookieAuth } from "@/components/lib/api"; import { deleteData, useCookieAuth } from "@/components/lib/api";
import { searchData } from "@/components/lib/common/apiHelpers"; import { searchData } from "@/components/lib/common/apiHelpers";
import { formatDate, handleDownload } from "@/components/lib/utils"; import { formatDate, handleDownload } from "@/components/lib/utils";
import LoadingOverlay from "@/components/ui/LoadingBar"; import LoadingOverlay from "@/components/ui/LoadingBar";
import { import {
StandardDataTable, StandardDataTable,
getStandardRowNumber,
} from "@common/business_components"; } from "@common/business_components";
import { Button, Input, Label } from "@common/components"; import { Button, Input, Label } from "@common/components";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
@@ -192,7 +192,7 @@ const Page = () => {
const dataWithRowNumber = (data || []).map( const dataWithRowNumber = (data || []).map(
(item: any, index: number) => ({ (item: any, index: number) => ({
...item, ...item,
rowNumber: getStandardRowNumber({ rowNumber: getSnRowNumber({
total, total,
rowIndex: index, rowIndex: index,
pageIndex: page - 1, pageIndex: page - 1,
@@ -5,6 +5,7 @@
*/ */
"use client"; "use client";
import { getSnRowNumber } from "@/shared/lib/row-number";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { import {
getCoreRowModel, getCoreRowModel,
@@ -22,7 +23,6 @@ import { openWindowPopup } from "@/features/windowPopup/lib/openWindowPopup";
import { WINDOW_POPUP_MESSAGE_TYPE } from "@/features/windowPopup/model/types"; import { WINDOW_POPUP_MESSAGE_TYPE } from "@/features/windowPopup/model/types";
import { useAlertStore } from "@/shared/lib"; import { useAlertStore } from "@/shared/lib";
import { import {
getStandardRowNumber,
StandardDataTable, StandardDataTable,
} from "@common/business_components"; } from "@common/business_components";
import { Button, Input, Label } from "@common/components"; import { Button, Input, Label } from "@common/components";
@@ -125,7 +125,7 @@ const Page = () => {
setRows( setRows(
(result.data ?? []).map((item: OldPpRow, rowIndex: number) => ({ (result.data ?? []).map((item: OldPpRow, rowIndex: number) => ({
...item, ...item,
rowNumber: getStandardRowNumber({ rowNumber: getSnRowNumber({
total: result.total, total: result.total,
rowIndex, rowIndex,
pageIndex: targetPage - 1, pageIndex: targetPage - 1,
@@ -104,7 +104,7 @@ function filterEmptyRows(data: ExcelRow[]) {
function withDescendingRowNumber(rows: ExcelRow[]) { function withDescendingRowNumber(rows: ExcelRow[]) {
return rows.map((row, index) => ({ return rows.map((row, index) => ({
...row, ...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 { AppProvider } from "./providers";
import "../src/app/styles/base.css"; import "../src/app/styles/base.css";
import "../src/app/styles/components.css"; import "../src/app/styles/components.css";
import "../src/app/styles/grid-ellipsis.css";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "디지털 하수관로", title: "디지털 하수관로",
@@ -35,7 +36,7 @@ export default async function RootLayout({
return ( return (
<html lang="ko" data-theme={theme?.value}> <html lang="ko" data-theme={theme?.value}>
<body> <body data-sn-grid-ellipsis="true">
<AppProvider>{children}</AppProvider> <AppProvider>{children}</AppProvider>
</body> </body>
</html> </html>
@@ -0,0 +1,38 @@
# SN 전체 목록 그리드 말줄임표
> 2026-09-23 후속 정리: 사용자 요청에 따라 `scripts/grid-cell-tooltip.test.cjs`를 삭제했다. 아래 테스트 명령과 결과는 삭제 전 실행 이력이며 테스트 소스 보존 문구는 더 이상 적용하지 않는다. 구현 코드와 이 최종 보고서는 유지한다.
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,69 @@
# SN 그리드 표시 순번 오름차순 통일
> 2026-09-23 후속 정리: 사용자 요청에 따라 `scripts/row-number.test.cjs`를 삭제했다. 아래 테스트 명령과 결과는 삭제 전 실행 이력이며 테스트 소스 보존 문구는 더 이상 적용하지 않는다. 구현 코드와 이 최종 보고서는 유지한다.
## 목적 및 기준
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,57 @@
# 노후관로 목록 조회조건 및 표시항목 변경
> 2026-09-23 후속 정리: 사용자 요청에 따라 `scripts/aged-pipe-list.test.cjs`를 삭제했다. 아래 테스트 명령과 결과는 삭제 전 실행 이력이며 테스트 소스 보존 문구는 더 이상 적용하지 않는다. 구현 코드와 이 최종 보고서는 유지한다.
## 목적과 근거
- 2026-09-23 사용자 요청: 조회조건을 시도, 시군구, 경과년도 순으로 변경하고 관리기관, 설치일자, 경과년도를 연속 배치한다.
- 원본 요구사항정의서 `3. (기능)요구사항정의 및 추적표` 87~90행, SFR-011-001~004 확인. 목록과 경과년도 조회는 88행 SFR-011-002와 연결된다.
- 원본 화면설계서 슬라이드 8, 화면 ID `SN_MN010102`, 노후관로관리 확인. 기존 필터는 경과년도/시도/시군구이며 관리기관 컬럼은 없다. 이번 명시 요청을 우선 적용했다.
- 원본 요구사항의 기본값 20년과 화면설계/현재 코드의 30년 차이는 기존 사항이다. 이번 요청은 배치와 표시항목 변경이므로 기본값과 계산 기준은 수정하지 않았다.
## 수정 파일과 이유
- `app/(map-layout)/map/components/agedPipe/AgedPipePopupPage.tsx`: 검색 JSX를 시도/시군구/경과년도 순으로 이동했다. 입력 이벤트, 조회조건, 초기화 및 페이지 처리 함수는 유지했다. 기존 `v1/fcltPpl/mngInsts` API를 한 번 조회해 목록 표시용 기관명을 연결한다. 기관 목록 수신 후 표시만 갱신하며 관로 목록을 다시 요청하지 않는다.
- `app/(map-layout)/map/components/agedPipe/agedPipeUtils.ts`: 헤더에 `mngInstNm`을 추가하고 설치일자 뒤로 경과년도를 옮겼다. 9개 컬럼 폭 합계를 100%로 조정했다. 원본 기관명 우선, 코드에 맞는 `lwrInstNm`, 원본 코드, `-` 순으로 표시한다. 행 식별용 관리기관 코드는 변경하지 않는다.
- `scripts/aged-pipe-list.test.cjs`: 컬럼 순서/폭, 기관명 표시/대체값, 검색조건 순서 회귀 테스트 3개를 추가했다.
```text
조회조건: 시도 → 시군구 → 경과년도
목록: 순번 → 시설관리번호 → 관리기관 → 설치일자 → 경과년도
→ 관경(mm) → 연장(m) → 용도 → 재질
```
## 영향 범위
- SN 노후관로 목록 팝업만 변경했다. 다른 앱과 공유 컴포넌트, 인증, 환경, 의존성, 배포 설정은 수정하지 않았다.
- 기존 조회 API를 재사용하며 백엔드와 DB 변경은 없다. 관리기관 조회 실패 시 안내 후 원본 코드를 표시한다.
- 엑셀은 기존 전체 속성 다운로드를 유지한다. 지도 행 선택의 식별값과 경과년도 계산도 유지한다.
## 검증
- `node --test scripts/aged-pipe-list.test.cjs`: 3개 통과.
- `pnpm exec tsc --noEmit --incremental false --pretty false`: 종료 코드 0, 오류 없음. 개발 서버가 실행 중이므로 전체 빌드는 수행하지 않았다.
- `git diff --check`: 통과(기존 Git 설정의 LF/CRLF 안내만 출력).
- 실제 로컬 브라우저 `http://localhost:3002/popup/agedPipePopup`에서 확인:
- 검색조건 시도/시군구/경과년도, 9개 목록 컬럼 순서 정상.
- 서울특별시/대전광역시 관리기관명, 설치일자, 경과년도 실데이터 표시 확인.
- 대전광역시/40년 조회 및 시군구 옵션 연계 확인.
- 유성구 선택 후 조회된 데이터 없음 상태 확인.
- 초기화 시 전체/전체/30년 복귀 및 목록 재조회 확인.
- 2페이지 이동 시 순번 11~20 및 관리기관명 유지 확인.
- 사용자 개발 서버 PID 43172(3002)는 종료·재시작하지 않았다. 별도 서버 및 임시 파일을 생성하지 않았다. 원시 캡처/로그는 파일로 저장하지 않았다. 정식 회귀 테스트와 이 결과 문서는 유지한다.
## 롤백 및 남은 사항
- 두 구현 파일에서 이번 diff만 역적용하면 이전 표시로 복구할 수 있다. 후속 변경이 있으면 파일 전체를 되돌리지 않는다.
- 실데이터 중 설치일자 `19000000`이 경과년도 126으로 표시되는 기존 날짜 검증 문제가 관찰됐다. 이번 배치 변경에서는 원본 데이터와 날짜 판정 규칙을 임의 수정하지 않았다.
- 엑셀 파일 생성 및 부모 지도창으로 위치 이동은 이번 변경에서 재검증하지 않았다.
## 후속 변경: 목록 팝업 중앙 배치
- 사용자 요청에 따라 `src/features/windowPopup/model/popupDefinitions.ts`의 `agedPipePopup`에서 `placement`, `alignTop`, `offsetLeft`, `offsetTop` 예외를 제거했다. 공통 열기 함수의 중앙 배치 기본값을 적용한다. 크기 1160×820과 다른 팝업 설정은 유지했다.
- 기존 화면설계서 슬라이드 8의 목록 기능을 유지하며, 창 위치는 이번 명시 요청을 적용했다.
- `scripts/aged-pipe-list.test.cjs`에 중앙 배치/크기 유지 검증을 추가했다. 전체 4개 테스트 및 `git diff --check` 통과.
- 로컬 브라우저에서 로그인 및 지도 진입을 확인했으나, 확인 시점의 지도에는 레이어 목록보기 버튼이 노출되지 않아 실제 새 창 위치는 미검증이다. 코드 기본값과 설정에 대한 자동 검증으로 구분해 기록한다.
- 이미 열린 이름 지정 팝업은 브라우저가 위치를 유지할 수 있으므로 창을 닫고 다시 열어 확인한다. 배포 및 사용자 서버 재시작은 수행하지 않았다.
- 롤백은 해당 팝업의 이전 위치 설정 네 항목만 복구한다. 임시 파일 생성·삭제 없음.
@@ -0,0 +1,51 @@
# 하수관망도 레이어 전체 켜기·끄기
> 2026-09-23 후속 정리: 사용자 요청에 따라 `scripts/layer-bulk-selection.test.cjs`를 삭제했다. 아래 테스트 명령과 결과는 삭제 전 실행 이력이며 테스트 소스 보존 문구는 더 이상 적용하지 않는다. 구현 코드와 이 최종 보고서는 유지한다.
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 변경만 역적용한다.
@@ -148,3 +148,21 @@
- 목록은 CMS 코드값 또는 코드명과 정확히 일치하는 값만 코드명으로 표시하고 미등록 기존 값은 그대로 표시한다. - 목록은 CMS 코드값 또는 코드명과 정확히 일치하는 값만 코드명으로 표시하고 미등록 기존 값은 그대로 표시한다.
- 영문 `lwpoCd` 저장 전환과 기존 데이터 이관은 이번 범위에서 수행하지 않았다. - 영문 `lwpoCd` 저장 전환과 기존 데이터 이관은 이번 범위에서 수행하지 않았다.
- 백엔드 API, Excel 입출력과 DB는 변경하지 않았다. - 백엔드 API, Excel 입출력과 DB는 변경하지 않았다.
## 2026-09-22 목록 사업코드·추진현황 검색조건 추가
- 사용자 유지보수 요청에 따라 목록 검색조건에 사업코드와 추진현황을 추가했다.
- 검색조건은 `사업구분 → 시도 → 시군구 → 사업코드 → 추진현황 → 사업명` 순서로 배치했다.
- 사업코드는 자유 입력으로 부분일치 조회하고, 추진현황은 기존 자유문자를 직접 입력하면서 `BIZPRGRSSTTS` 공통코드 후보를 datalist로 선택할 수 있게 했다.
- 두 조건을 목록 검색 상태에 포함해 조회 URL, 페이지 이동, 정렬, 상세·등록 후 목록 복귀와 초기화에서 동일하게 유지한다.
- Excel 다운로드도 적용된 검색 상태를 재사용하므로 현재 화면과 같은 사업코드·추진현황 조건의 전체 결과를 내려받는다.
- SN 운영 빌드와 별도 TypeScript 검사를 통과했다. 최초 빌드는 Windows 파일 핸들 한도 오류로 중단됐으나 단독 재시도에서 성공했다.
## 2026-09-22 공단의견 목록·폼·Excel 제거
- 사용자 유지보수 요청에 따라 목록의 공단의견 열과 등록·상세·수정 화면의 공단 의견 입력란을 제거했다.
- FE 저장 요청 타입에서 `pbcpOpnnCn`을 제거해 등록·수정 API 요청에 공단의견을 포함하지 않는다.
- 업로드 헤더 검증과 행 파싱에서 V열 공단의견을 제거하고 B~U의 20개 업무 열만 저장 요청으로 변환한다.
- 기존 B~V 업로드 파일의 V열이 남아 있어도 이를 읽거나 저장하지 않으며, 나머지 B~U 열은 같은 위치로 처리한다.
- FE Excel 다운로드의 V열 공단의견, 열 폭, 스타일 범위를 제거하고 B~U 양식으로 생성한다.
- DB 컬럼과 기존 값은 유지하며 화면·API·Excel에서만 제외한다.
-2
View File
@@ -8,9 +8,7 @@
"build": "next build", "build": "next build",
"start:sn": "next start -p 3002", "start:sn": "next start -p 3002",
"lint": "next lint", "lint": "next lint",
"test:refactor": "node scripts/run-refactor-tests.cjs",
"typecheck": "tsc --noEmit --incremental false --pretty false", "typecheck": "tsc --noEmit --incremental false --pretty false",
"check:boundaries": "node scripts/check-module-boundaries.cjs",
"lint:refactor": "eslint src/shared/api/http-transport.ts src/app/providers/app-provider.tsx src/features/dredging src/features/mapLayers src/features/gisManagement --rule \"@typescript-eslint/no-explicit-any:warn\" --max-warnings 22" "lint:refactor": "eslint src/shared/api/http-transport.ts src/app/providers/app-provider.tsx src/features/dredging src/features/mapLayers src/features/gisManagement --rule \"@typescript-eslint/no-explicit-any:warn\" --max-warnings 22"
}, },
"dependencies": { "dependencies": {
@@ -13,6 +13,7 @@ import { AlertDialog } from "@/shared/ui/alert-dialog";
import { PromptDialog } from "@/shared/ui/prompt-dialog"; import { PromptDialog } from "@/shared/ui/prompt-dialog";
import { ConfirmDialog } from "@/shared/ui/confirm-dialog"; import { ConfirmDialog } from "@/shared/ui/confirm-dialog";
import { BusinessUserMessageBridge } from "@/shared/ui/business-user-message-bridge"; import { BusinessUserMessageBridge } from "@/shared/ui/business-user-message-bridge";
import { GridCellTooltip } from "@/shared/ui/grid-cell-tooltip";
// 개발 도구는 실제 로컬 개발 환경에서만 별도 청크로 불러와 최초 페이지 컴파일 범위를 줄인다. // 개발 도구는 실제 로컬 개발 환경에서만 별도 청크로 불러와 최초 페이지 컴파일 범위를 줄인다.
const LazyReactQueryDevtools = dynamic( const LazyReactQueryDevtools = dynamic(
@@ -30,6 +31,7 @@ const AppProvider = ({ children }: Readonly<{ children: React.ReactNode }>) => {
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<StoreProvider> <StoreProvider>
<BusinessUserMessageBridge /> <BusinessUserMessageBridge />
<GridCellTooltip />
<PromptProvider> <PromptProvider>
<ConfirmProvider> <ConfirmProvider>
<AuthWrapper> <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"; "use client";
import { getSnRowNumber } from "@/shared/lib/row-number";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { useConfirm } from "@frontend-opensource/use-react-hooks"; import { useConfirm } from "@frontend-opensource/use-react-hooks";
@@ -10,7 +11,6 @@ import useOnsemiroAddrStore from "@/app/store/useOnsemiroAddrStore";
import { import {
StandardDataTable, StandardDataTable,
StandardPopupLayout, StandardPopupLayout,
getStandardRowNumber,
} from "@common/business_components"; } from "@common/business_components";
import { import {
Button, Button,
@@ -273,7 +273,7 @@ export default function CfmtnBgtPopupContent({
() => () =>
(Array.isArray(data.content) ? data.content : []).map((row, index) => ({ (Array.isArray(data.content) ? data.content : []).map((row, index) => ({
...row, ...row,
rowNumber: getStandardRowNumber({ rowNumber: getSnRowNumber({
total: data.totalElements, total: data.totalElements,
rowIndex: index, rowIndex: index,
pageIndex: page, pageIndex: page,
@@ -1,12 +1,12 @@
"use client"; "use client";
import { getSnRowNumber } from "@/shared/lib/row-number";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation"; import { useRouter, useSearchParams } from "next/navigation";
import { import {
StandardDataTable, StandardDataTable,
StandardTableSortHeader, StandardTableSortHeader,
getStandardRowNumber,
type UiTableSort, type UiTableSort,
} from "@common/business_components"; } from "@common/business_components";
import { import {
@@ -463,7 +463,7 @@ export function CtyFldngPrvtnBizListPage() {
result.content.map((row, i) => result.content.map((row, i) =>
flattenRow( flattenRow(
row, row,
getStandardRowNumber({ getSnRowNumber({
total: result.totalElements, total: result.totalElements,
rowIndex: i, rowIndex: i,
pageIndex: target - 1, pageIndex: target - 1,
@@ -1,5 +1,6 @@
"use client"; "use client";
import { getSnRowNumber } from "@/shared/lib/row-number";
import LoadingOverlay from "@/components/ui/LoadingBar"; import LoadingOverlay from "@/components/ui/LoadingBar";
import { fileDownload } from "@/components/lib/utils"; import { fileDownload } from "@/components/lib/utils";
import { getStartYearArray } from "@/components/ui/YearRangeSelectBox"; import { getStartYearArray } from "@/components/ui/YearRangeSelectBox";
@@ -20,7 +21,6 @@ import {
STANDARD_ROW_NUMBER_HEADER, STANDARD_ROW_NUMBER_HEADER,
StandardDataTable, StandardDataTable,
StandardTableSortHeader, StandardTableSortHeader,
getStandardRowNumber,
type UiTableSort, type UiTableSort,
} from "@common/business_components"; } from "@common/business_components";
import { import {
@@ -146,7 +146,7 @@ export function ExmnRegistrationPage() {
() => () =>
businesses.map((business, index) => ({ businesses.map((business, index) => ({
...business, ...business,
rowNumber: getStandardRowNumber({ rowNumber: getSnRowNumber({
total: totalElements, total: totalElements,
rowIndex: index, rowIndex: index,
pageIndex: pagination.pageIndex, pageIndex: pagination.pageIndex,
@@ -1,5 +1,6 @@
"use client"; "use client";
import { getSnRowNumber } from "@/shared/lib/row-number";
import { useMemo, type ReactNode } from "react"; import { useMemo, type ReactNode } from "react";
import { import {
getCoreRowModel, getCoreRowModel,
@@ -9,7 +10,6 @@ import {
} from "@tanstack/react-table"; } from "@tanstack/react-table";
import { import {
StandardDataTable, StandardDataTable,
getStandardRowNumber,
type ScrollMode, type ScrollMode,
type UiHeadProps, type UiHeadProps,
} from "@common/business_components"; } from "@common/business_components";
@@ -43,7 +43,7 @@ const formatValue = (value: unknown) => {
: String(value); : String(value);
}; };
/** 조사관리 표를 CS 목록 규격과 역순 순번 계약으로 연결한다. */ /** 조사관리 표를 CS 목록 규격과 페이지 연속 오름차순 순번으로 연결한다. */
export function ExmnStandardDataTable<T extends Record<string, unknown>>({ export function ExmnStandardDataTable<T extends Record<string, unknown>>({
rows, rows,
headlist, headlist,
@@ -69,7 +69,7 @@ export function ExmnStandardDataTable<T extends Record<string, unknown>>({
header: ROW_NUMBER_IDS.has(head.id) ? "순번" : head.title, header: ROW_NUMBER_IDS.has(head.id) ? "순번" : head.title,
cell: ({ row, getValue }) => { cell: ({ row, getValue }) => {
const value = ROW_NUMBER_IDS.has(head.id) const value = ROW_NUMBER_IDS.has(head.id)
? getStandardRowNumber({ ? getSnRowNumber({
total, total,
rowIndex: row.index, rowIndex: row.index,
pageIndex: page - 1, pageIndex: page - 1,
@@ -189,7 +189,7 @@ export function ExmnStatusDetailPanel({
const counts = getCounts(row); const counts = getCounts(row);
return { return {
trtdstId: row.trtdstId, trtdstId: row.trtdstId,
rowNumber: summaryRows.length - index, rowNumber: index + 1,
trtdstNm: row.trtdstNm, trtdstNm: row.trtdstNm,
trgtPrlgText: formatNumber(row.trgtPrlg), trgtPrlgText: formatNumber(row.trgtPrlg),
drvngDstncText: formatNumber(row.drvngDstnc), drvngDstncText: formatNumber(row.drvngDstnc),
@@ -1,12 +1,12 @@
"use client"; "use client";
import { getSnRowNumber } from "@/shared/lib/row-number";
import LoadingOverlay from "@/components/ui/LoadingBar"; import LoadingOverlay from "@/components/ui/LoadingBar";
import useStore from "@/store/useStore"; import useStore from "@/store/useStore";
import { import {
STANDARD_ROW_NUMBER_HEADER, STANDARD_ROW_NUMBER_HEADER,
StandardDataTable, StandardDataTable,
StandardTableSortHeader, StandardTableSortHeader,
getStandardRowNumber,
sortStandardTableRows, sortStandardTableRows,
type UiTableSort, type UiTableSort,
} from "@common/business_components"; } from "@common/business_components";
@@ -73,16 +73,16 @@ const createLocalKey = () =>
? `client:${crypto.randomUUID()}` ? `client:${crypto.randomUUID()}`
: `client:${Date.now()}-${Math.random().toString(36).slice(2)}`; : `client:${Date.now()}-${Math.random().toString(36).slice(2)}`;
/** 화면의 현재 행 순서를 전체 건수에서 시작하는 공통 내림차순 순번으로 정규화한다. */ /** 화면의 현재 행 순서에 1부터 오름차순 표시 순번을 붙인다. */
const withDescendingRowNumbers = (rows: TrtdstTableRow[]): TrtdstTableRow[] => const withAscendingRowNumbers = (rows: TrtdstTableRow[]): TrtdstTableRow[] =>
rows.map((row, index) => ({ rows.map((row, index) => ({
...row, ...row,
rowNumber: getStandardRowNumber({ total: rows.length, rowIndex: index }), rowNumber: getSnRowNumber({ total: rows.length, rowIndex: index }),
})); }));
/** 서버 처리분구 목록을 공통 표와 변경 추적에 필요한 행 구조로 변환한다. */ /** 서버 처리분구 목록을 공통 표와 변경 추적에 필요한 행 구조로 변환한다. */
const toTableRows = (items: TrtdstSettingDto[]): TrtdstTableRow[] => const toTableRows = (items: TrtdstSettingDto[]): TrtdstTableRow[] =>
withDescendingRowNumbers( withAscendingRowNumbers(
items.map((item) => ({ items.map((item) => ({
...item, ...item,
trtdstId: String(item.trtdstId), trtdstId: String(item.trtdstId),
@@ -103,7 +103,7 @@ const toSavedTableRows = (
rows: TrtdstTableRow[], rows: TrtdstTableRow[],
savedItems: TrtdstSettingDto[] = [], savedItems: TrtdstSettingDto[] = [],
): TrtdstTableRow[] => ): TrtdstTableRow[] =>
withDescendingRowNumbers( withAscendingRowNumbers(
rows rows
.filter((row) => row.rowStatus !== "deletePending") .filter((row) => row.rowStatus !== "deletePending")
.map((row) => { .map((row) => {
@@ -300,7 +300,7 @@ export function TrtdstManagementPage() {
setSort(undefined); setSort(undefined);
setPagination((current) => ({ ...current, pageIndex: 0 })); setPagination((current) => ({ ...current, pageIndex: 0 }));
setRows((current) => setRows((current) =>
withDescendingRowNumbers([ withAscendingRowNumbers([
...current, ...current,
{ {
trtdstId: "", trtdstId: "",
@@ -353,7 +353,7 @@ export function TrtdstManagementPage() {
if (selectedRow.rowStatus === "added") { if (selectedRow.rowStatus === "added") {
if (!(await confirm("선택한 처리분구를 삭제하시겠습니까?"))) return; if (!(await confirm("선택한 처리분구를 삭제하시겠습니까?"))) return;
setRows((current) => setRows((current) =>
withDescendingRowNumbers( withAscendingRowNumbers(
current.filter((row) => row.localKey !== selectedRow.localKey), current.filter((row) => row.localKey !== selectedRow.localKey),
), ),
); );
@@ -33,7 +33,7 @@ const SHEET_CONTRACTS: SheetContract[] = [
const DATA_START_ROW_INDEX = 7; const DATA_START_ROW_INDEX = 7;
const FIRST_DATA_COLUMN_INDEX = 1; const FIRST_DATA_COLUMN_INDEX = 1;
const DATA_COLUMN_COUNT = 21; const DATA_COLUMN_COUNT = 20;
const DOWNLOAD_FILENAME = "노후하수관로 정밀조사 및 정비사업 현황.xlsx"; const DOWNLOAD_FILENAME = "노후하수관로 정밀조사 및 정비사업 현황.xlsx";
/** API 문자열에 남은 Excel 개행 이스케이프를 실제 줄바꿈으로 정리한다. */ /** API 문자열에 남은 Excel 개행 이스케이프를 실제 줄바꿈으로 정리한다. */
@@ -63,7 +63,7 @@ const validateHeaders = (sheet: XLSX.WorkSheet, contract: SheetContract) => {
[6, 14, "증빙서류제출여부"], [5, 15, contract.progressHeader], [6, 14, "증빙서류제출여부"], [5, 15, contract.progressHeader],
[6, 15, contract.startDateHeader], [6, 16, "준공(예정)일자(yyyy.mm.dd)"], [6, 15, contract.startDateHeader], [6, 16, "준공(예정)일자(yyyy.mm.dd)"],
[5, 17, "사업추진부진사유"], [5, 18, "사업담당자(지자체)"], [5, 17, "사업추진부진사유"], [5, 18, "사업담당자(지자체)"],
[6, 18, "이름"], [6, 19, "연락처"], [5, 20, "비고"], [6, 21, "공단의견"], [6, 18, "이름"], [6, 19, "연락처"], [5, 20, "비고"],
]; ];
for (const [rowIndex, columnIndex, expected] of expectedHeaders) { for (const [rowIndex, columnIndex, expected] of expectedHeaders) {
@@ -116,7 +116,7 @@ const dateText = (value: string): string | null => {
return (digits || normalized).slice(0, 8); return (digits || normalized).slice(0, 8);
}; };
/** B~V가 모두 비어 있는 행은 데이터가 아닌 양식 여백으로 판단한다. */ /** B~U가 모두 비어 있는 행은 데이터가 아닌 양식 여백으로 판단한다. */
const isEmptyDataRow = (sheet: XLSX.WorkSheet, rowIndex: number) => const isEmptyDataRow = (sheet: XLSX.WorkSheet, rowIndex: number) =>
Array.from({ length: DATA_COLUMN_COUNT }, (_, offset) => Array.from({ length: DATA_COLUMN_COUNT }, (_, offset) =>
cellText(sheet, rowIndex, FIRST_DATA_COLUMN_INDEX + offset), cellText(sheet, rowIndex, FIRST_DATA_COLUMN_INDEX + offset),
@@ -159,7 +159,6 @@ const parseSheet = (sheet: XLSX.WorkSheet, contract: SheetContract): ObsltPplBus
bizPicNm: optionalText(valueAt(17)), bizPicNm: optionalText(valueAt(17)),
bizPicCnpl: optionalText(valueAt(18)), bizPicCnpl: optionalText(valueAt(18)),
rmrkCn: optionalText(valueAt(19)), rmrkCn: optionalText(valueAt(19)),
pbcpOpnnCn: optionalText(valueAt(20)),
}); });
} }
return result; return result;
@@ -183,27 +182,27 @@ export const parseObsltPplExcel = async (file: File): Promise<ObsltPplBusinessSa
return parseObsltPplWorkbook(await file.arrayBuffer()); return parseObsltPplWorkbook(await file.arrayBuffer());
}; };
/** 업로드 검증과 정확히 같은 B6:V7 위치에 시트별 헤더를 배치한다. */ /** 업로드 검증과 정확히 같은 B6:U7 위치에 시트별 헤더를 배치한다. */
const downloadHeaderRows = (contract: SheetContract) => { const downloadHeaderRows = (contract: SheetContract) => {
const rows = Array.from({ length: DATA_START_ROW_INDEX }, () => Array<string | number>(22).fill("")); const rows = Array.from({ length: DATA_START_ROW_INDEX }, () => Array<string | number>(21).fill(""));
const topHeaders = [ const topHeaders = [
"순번", "정밀\n조사\n차수", contract.businessCodeHeader.replace("사업코드", "\n사업코드"), "순번", "정밀\n조사\n차수", contract.businessCodeHeader.replace("사업코드", "\n사업코드"),
"시도", "시군", "환경청", "사업명", "추진현황", "착수\n년도", "준공(예정)\n년도", "시도", "시군", "환경청", "사업명", "추진현황", "착수\n년도", "준공(예정)\n년도",
contract.extensionHeader, "", "", "", contract.progressHeader.replace("추진현황", " 추진현황"), contract.extensionHeader, "", "", "", contract.progressHeader.replace("추진현황", " 추진현황"),
"", "사업추진 부진 사유", "사업 담당자(지자체)", "", "비고", "", "", "사업추진 부진 사유", "사업 담당자(지자체)", "", "비고",
]; ];
const bottomHeaders = [ const bottomHeaders = [
"", "", "", "", "", "", "", "", "", "", "계획연장(km)", "상반기(예상)연장(km)", "", "", "", "", "", "", "", "", "", "", "계획연장(km)", "상반기(예상)연장(km)",
"잔여연장(km)", "증빙서류\n제출여부", contract.startDateHeader.replace("(yyyy.mm.dd)", "\n(yyyy.mm.dd)"), "잔여연장(km)", "증빙서류\n제출여부", contract.startDateHeader.replace("(yyyy.mm.dd)", "\n(yyyy.mm.dd)"),
"준공(예정)일자\n(yyyy.mm.dd)", "", "이름", "연락처", "준공(예정)일자\n(yyyy.mm.dd)", "", "이름", "연락처",
"(필요시 사업 담당자 추가 작성)", "공단의견", "(필요시 사업 담당자 추가 작성)",
]; ];
topHeaders.forEach((value, index) => { rows[5]![index + FIRST_DATA_COLUMN_INDEX] = value; }); topHeaders.forEach((value, index) => { rows[5]![index + FIRST_DATA_COLUMN_INDEX] = value; });
bottomHeaders.forEach((value, index) => { rows[6]![index + FIRST_DATA_COLUMN_INDEX] = value; }); bottomHeaders.forEach((value, index) => { rows[6]![index + FIRST_DATA_COLUMN_INDEX] = value; });
return rows; return rows;
}; };
/** DB에 저장하지 않는 B열 순번을 시트 출력 순서로 만들고 C~V에 업무값을 배치한다. */ /** DB에 저장하지 않는 B열 순번을 시트 출력 순서로 만들고 C~U에 업무값을 배치한다. */
const downloadDataRow = (row: ObsltPplBusiness, index: number): Array<string | number> => [ const downloadDataRow = (row: ObsltPplBusiness, index: number): Array<string | number> => [
"", "",
index + 1, index + 1,
@@ -226,7 +225,6 @@ const downloadDataRow = (row: ObsltPplBusiness, index: number): Array<string | n
excelTextValue(row.bizPicNm), excelTextValue(row.bizPicNm),
excelTextValue(row.bizPicCnpl), excelTextValue(row.bizPicCnpl),
excelTextValue(row.rmrkCn), excelTextValue(row.rmrkCn),
excelTextValue(row.pbcpOpnnCn),
]; ];
/** 조회 결과를 정밀조사·정비사업 두 시트의 재업로드 가능한 위치 구조로 생성한다. */ /** 조회 결과를 정밀조사·정비사업 두 시트의 재업로드 가능한 위치 구조로 생성한다. */
@@ -266,7 +264,7 @@ export const buildObsltPplWorkbook = (businesses: ObsltPplBusiness[]): XLSX.Work
]; ];
const widths = [ const widths = [
3, 7, 9, 16, 12, 12, 16, 34, 10, 9, 11, 13, 16, 13, 11, 16, 16, 3, 7, 9, 16, 12, 12, 16, 34, 10, 9, 11, 13, 16, 13, 11, 16, 16,
34, 11, 16, 34, 24, 34, 11, 16, 34,
]; ];
worksheet["!cols"] = widths.map((wch) => ({ wch })); worksheet["!cols"] = widths.map((wch) => ({ wch }));
XLSX.utils.book_append_sheet(workbook, worksheet, contract.sheetName); XLSX.utils.book_append_sheet(workbook, worksheet, contract.sheetName);
@@ -304,7 +302,7 @@ export const downloadObsltPplExcel = async (
fill: "FFF2CC", fill: "FFF2CC",
border: true, border: true,
}); });
styledSheet.range("B6:V7").style({ styledSheet.range("B6:U7").style({
bold: true, bold: true,
fontFamily: "맑은 고딕", fontFamily: "맑은 고딕",
fontSize: 10, fontSize: 10,
@@ -315,7 +313,7 @@ export const downloadObsltPplExcel = async (
}); });
styledSheet.range("B6:H7").style("fill", "B4C6E7"); styledSheet.range("B6:H7").style("fill", "B4C6E7");
styledSheet.range("I6:Q7").style("fill", "FFF2CC"); styledSheet.range("I6:Q7").style("fill", "FFF2CC");
styledSheet.range("R6:V7").style("fill", "FCE4D6"); styledSheet.range("R6:U7").style("fill", "FCE4D6");
styledSheet.row(2).height(26); styledSheet.row(2).height(26);
styledSheet.row(3).height(26); styledSheet.row(3).height(26);
styledSheet.row(5).height(22); styledSheet.row(5).height(22);
@@ -325,7 +323,7 @@ export const downloadObsltPplExcel = async (
if (rowCount > 0) { if (rowCount > 0) {
const dataStartRow = DATA_START_ROW_INDEX + 1; const dataStartRow = DATA_START_ROW_INDEX + 1;
const dataEndRow = DATA_START_ROW_INDEX + rowCount; const dataEndRow = DATA_START_ROW_INDEX + rowCount;
styledSheet.range(`B${dataStartRow}:V${dataEndRow}`).style({ styledSheet.range(`B${dataStartRow}:U${dataEndRow}`).style({
fontFamily: "맑은 고딕", fontFamily: "맑은 고딕",
fontSize: 10, fontSize: 10,
horizontalAlignment: "center", horizontalAlignment: "center",
@@ -336,7 +334,7 @@ export const downloadObsltPplExcel = async (
styledSheet styledSheet
.range(`L${dataStartRow}:N${dataEndRow}`) .range(`L${dataStartRow}:N${dataEndRow}`)
.style("numberFormat", "#,##0.###"); .style("numberFormat", "#,##0.###");
["H", "R", "U", "V"].forEach((column) => ["H", "R", "U"].forEach((column) =>
styledSheet styledSheet
.range(`${column}${dataStartRow}:${column}${dataEndRow}`) .range(`${column}${dataStartRow}:${column}${dataEndRow}`)
.style({ horizontalAlignment: "left", wrapText: true }), .style({ horizontalAlignment: "left", wrapText: true }),
@@ -22,7 +22,6 @@ export interface ObsltPplBusiness {
bizPicNm?: string | null; bizPicNm?: string | null;
bizPicCnpl?: string | null; bizPicCnpl?: string | null;
rmrkCn?: string | null; rmrkCn?: string | null;
pbcpOpnnCn?: string | null;
regDt?: string | null; regDt?: string | null;
mdfcnDt?: string | null; mdfcnDt?: string | null;
} }
@@ -79,7 +79,6 @@ const FIELDS: Array<{
{ key: "bizPicNm", label: "담당자 이름" }, { key: "bizPicNm", label: "담당자 이름" },
{ key: "bizPicCnpl", label: "담당자 연락처" }, { key: "bizPicCnpl", label: "담당자 연락처" },
{ key: "rmrkCn", label: "비고", type: "textarea" }, { key: "rmrkCn", label: "비고", type: "textarea" },
{ key: "pbcpOpnnCn", label: "공단 의견", type: "textarea" },
]; ];
const NUMBER_KEYS = new Set<FormKey>([ const NUMBER_KEYS = new Set<FormKey>([
@@ -530,7 +529,6 @@ export function ObsltPplFormPage({ id }: { id?: string }) {
{renderField("bizPicCnpl", 2)} {renderField("bizPicCnpl", 2)}
{renderField("bizPrplDlyRsnCn", 3)} {renderField("bizPrplDlyRsnCn", 3)}
{renderField("rmrkCn", 3)} {renderField("rmrkCn", 3)}
{renderField("pbcpOpnnCn", 3)}
</StandardFormSection> </StandardFormSection>
<div className="flex justify-center gap-4 pb-8 pt-6"> <div className="flex justify-center gap-4 pb-8 pt-6">
@@ -3,13 +3,13 @@
*/ */
"use client"; "use client";
import { getSnRowNumber } from "@/shared/lib/row-number";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation"; import { useRouter, useSearchParams } from "next/navigation";
import { import {
StandardDataTable, StandardDataTable,
StandardTableSortHeader, StandardTableSortHeader,
getStandardRowNumber,
type UiTableSort, type UiTableSort,
} from "@common/business_components"; } from "@common/business_components";
import { import {
@@ -65,8 +65,10 @@ type ObsltPplTableColumn = {
}; };
type ObsltPplSearch = { type ObsltPplSearch = {
bizSe: string; bizSe: string;
bizCd: string;
ctpvNm: string; ctpvNm: string;
sigunNm: string; sigunNm: string;
prplPrst: string;
bizNm: string; bizNm: string;
}; };
@@ -120,7 +122,6 @@ const TABLE_COLUMNS: ObsltPplTableColumn[] = [
{ id: "bizPicNm", title: "담당자 이름", width: 90 }, { id: "bizPicNm", title: "담당자 이름", width: 90 },
{ id: "bizPicCnpl", title: "담당자 연락처", width: 120 }, { id: "bizPicCnpl", title: "담당자 연락처", width: 120 },
{ id: "rmrkCn", title: "비고", width: 160, align: "left" }, { id: "rmrkCn", title: "비고", width: 160, align: "left" },
{ id: "pbcpOpnnCn", title: "공단\n의견", width: 90, align: "left" },
]; ];
const TABLE_MIN_WIDTH = "280rem"; const TABLE_MIN_WIDTH = "280rem";
@@ -154,6 +155,7 @@ export function ObsltPplListPage() {
// 검색값·원본 행은 유지하고 사업구분·추진·증빙 표시명만 CMS 기준으로 변환한다. // 검색값·원본 행은 유지하고 사업구분·추진·증빙 표시명만 CMS 기준으로 변환한다.
const { const {
obsltBusinessTypeOptions, obsltBusinessTypeOptions,
businessProgressOptions,
evidenceStatusLabels, evidenceStatusLabels,
businessProgressLabels, businessProgressLabels,
} = useOperBusinessCommonCodes(); } = useOperBusinessCommonCodes();
@@ -162,8 +164,10 @@ export function ObsltPplListPage() {
const initialSearch = useMemo<ObsltPplSearch>( const initialSearch = useMemo<ObsltPplSearch>(
() => ({ () => ({
bizSe: searchParams.get("bizSe") ?? "정밀조사", bizSe: searchParams.get("bizSe") ?? "정밀조사",
bizCd: searchParams.get("bizCd") ?? "",
ctpvNm: searchParams.get("ctpvNm") ?? "", ctpvNm: searchParams.get("ctpvNm") ?? "",
sigunNm: searchParams.get("sigunNm") ?? "", sigunNm: searchParams.get("sigunNm") ?? "",
prplPrst: searchParams.get("prplPrst") ?? "",
bizNm: searchParams.get("bizNm") ?? "", bizNm: searchParams.get("bizNm") ?? "",
}), }),
[searchParams], [searchParams],
@@ -331,7 +335,7 @@ export function ObsltPplListPage() {
setRows( setRows(
result.content.map((row, index) => ({ result.content.map((row, index) => ({
...formatBusinessForDisplay(row), ...formatBusinessForDisplay(row),
rowNumber: getStandardRowNumber({ rowNumber: getSnRowNumber({
total: result.totalElements, total: result.totalElements,
rowIndex: index, rowIndex: index,
pageIndex: targetPage - 1, pageIndex: targetPage - 1,
@@ -454,8 +458,10 @@ export function ObsltPplListPage() {
const handleReset = () => { const handleReset = () => {
const nextSearch: ObsltPplSearch = { const nextSearch: ObsltPplSearch = {
bizSe: "정밀조사", bizSe: "정밀조사",
bizCd: "",
ctpvNm: "", ctpvNm: "",
sigunNm: "", sigunNm: "",
prplPrst: "",
bizNm: "", bizNm: "",
}; };
setSearch(nextSearch); setSearch(nextSearch);
@@ -599,6 +605,54 @@ export function ObsltPplListPage() {
</Select> </Select>
</div> </div>
<div className="flex items-center gap-2">
<Label
htmlFor="obslt-ppl-biz-code"
className="mr-2 whitespace-nowrap text-label"
>
사업코드
</Label>
<Input
id="obslt-ppl-biz-code"
className="w-[14rem] rounded-sm !text-[1.3rem]"
value={search.bizCd}
onChange={(event) =>
setSearch((current) => ({
...current,
bizCd: event.target.value,
}))
}
/>
</div>
<div className="flex items-center gap-2">
<Label
htmlFor="obslt-ppl-progress"
className="mr-2 whitespace-nowrap text-label"
>
추진현황
</Label>
<Input
id="obslt-ppl-progress"
className="w-[15rem] rounded-sm !text-[1.3rem]"
list="obslt-ppl-progress-suggestions"
value={search.prplPrst}
onChange={(event) =>
setSearch((current) => ({
...current,
prplPrst: event.target.value,
}))
}
/>
<datalist id="obslt-ppl-progress-suggestions">
{businessProgressOptions.map((option) => (
<option key={option.code} value={option.value}>
{option.label}
</option>
))}
</datalist>
</div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Label <Label
htmlFor="obslt-ppl-biz-name" htmlFor="obslt-ppl-biz-name"
@@ -140,10 +140,7 @@ export const windowPopupDefinitions = {
title: "노후 하수관로 목록", title: "노후 하수관로 목록",
width: 1160, width: 1160,
height: 820, height: 820,
placement: "browser-left", // 다른 목록 팝업과 동일한 공통 중앙 배치를 사용한다.
alignTop: true,
offsetLeft: 0,
offsetTop: 70,
}, },
sinkholePopup: { sinkholePopup: {
key: "sinkholePopup", key: "sinkholePopup",
@@ -1,5 +1,6 @@
"use client"; "use client";
import { getSnRowNumber } from "@/shared/lib/row-number";
import { type ReactNode, useMemo } from "react"; import { type ReactNode, useMemo } from "react";
import { import {
@@ -10,7 +11,6 @@ import {
} from "@tanstack/react-table"; } from "@tanstack/react-table";
import { import {
getStandardRowNumber,
type ScrollMode, type ScrollMode,
StandardDataTable, StandardDataTable,
StandardTableSortHeader, StandardTableSortHeader,
@@ -109,7 +109,7 @@ export default function MapPopupDataTable<T extends Record<string, unknown>>({
const rowIndex = row.index; const rowIndex = row.index;
const rendered = renderCell?.(row.original, head.id, rowIndex); const rendered = renderCell?.(row.original, head.id, rowIndex);
const value = ROW_NUMBER_IDS.has(head.id) const value = ROW_NUMBER_IDS.has(head.id)
? getStandardRowNumber({ ? getSnRowNumber({
total, total,
rowIndex, rowIndex,
pageIndex: page - 1, 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, showLayerPanelTitle = true,
// sn 관망도처럼 트리와 선택 설정을 카드 두 개로 구분해야 하는 화면에서만 활성화한다. // sn 관망도처럼 트리와 선택 설정을 카드 두 개로 구분해야 하는 화면에서만 활성화한다.
sectionedTreePanel = false, sectionedTreePanel = false,
// 전체 체크 조작은 필요한 화면에서만 명시적으로 활성화한다.
showBulkLayerToggle = false,
showZoomScaleInfo = false, showZoomScaleInfo = false,
}) { }) {
const mapRef = useRef(); const mapRef = useRef();
@@ -345,6 +347,7 @@ function Gis({
detachedToggleButton={detachedTreeToggleButton} detachedToggleButton={detachedTreeToggleButton}
showLayerPanelTitle={showLayerPanelTitle} showLayerPanelTitle={showLayerPanelTitle}
sectionedPanel={sectionedTreePanel} sectionedPanel={sectionedTreePanel}
showBulkLayerToggle={showBulkLayerToggle}
/> />
{typeof WaterDepth === "function" && ( {typeof WaterDepth === "function" && (
@@ -10,6 +10,7 @@ import React, { useEffect, useLayoutEffect, useState, useRef, useCallback } from
import { showBusinessUserMessage } from "../../lib/user-message"; import { showBusinessUserMessage } from "../../lib/user-message";
import { useGisContext } from "./Gis"; import { useGisContext } from "./Gis";
import GisApp from "./lib/gis.core"; import GisApp from "./lib/gis.core";
import { getBulkLayerSelection, getBulkLayerState } from "./layerBulkSelection";
/** /**
* GisTree (React memoized component) * GisTree (React memoized component)
@@ -63,6 +64,7 @@ export default React.memo(function GisTree({
showLayerPanelTitle = true, showLayerPanelTitle = true,
detachedToggleButton = false, detachedToggleButton = false,
sectionedPanel = false, sectionedPanel = false,
showBulkLayerToggle = false,
}) { }) {
const { gisCore, treeData } = useGisContext(); const { gisCore, treeData } = useGisContext();
@@ -71,6 +73,8 @@ export default React.memo(function GisTree({
const [expanded, setExpanded] = useState([]); const [expanded, setExpanded] = useState([]);
const [nodes, setNodes] = useState([]); const [nodes, setNodes] = useState([]);
const [activeTab, setActiveTab] = useState("layers"); const [activeTab, setActiveTab] = useState("layers");
// 전체 선택이면 ON, 부분 선택이면 OFF 위치와 별도 상태 문구로 구분한다.
const bulkLayerState = getBulkLayerState(nodes, checked);
// 자동 애니메이션 관련 ref (실행 인덱스, 대상 노드 목록, 일시정지, 타이머) // 자동 애니메이션 관련 ref (실행 인덱스, 대상 노드 목록, 일시정지, 타이머)
const currentAnimationIndexRef = useRef(0); const currentAnimationIndexRef = useRef(0);
@@ -470,6 +474,19 @@ export default React.memo(function GisTree({
isPausedRef.current = true; 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} /> <Layers size={14} strokeWidth={2} />
</span> </span>
<h3 className="text-[1.4rem] font-bold text-foreground">레이어 관리</h3> <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>
)} )}
<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,
};
}