Compare commits

...
76 changed files with 1937 additions and 1432 deletions
+125 -39
View File
@@ -2,7 +2,7 @@
import { client } from "@/shared/api";
import { create } from "zustand";
import { ENDPOINT } from "@/shared/config";
import type { PplLengthContent, SewerageService } from "../model/sn-interface";
import type { BasinStofcbrCountContent, PplLengthContent, SewerageService } from "../model/sn-interface";
import type { OldPplChartData, mapPplOldType, mapPplOldItem } from "../model/sn-interface";
export type SnState = {
@@ -35,26 +35,7 @@ const initialUsePplChartData: OldPplChartData = {
};
const initialOldPplChartData: OldPplChartData = {
labels: ["금강", "한강", "낙동강", "영산강"],
datasets: [
{
label: "20-25년",
data: [1140, 3181, 3100, 1662],
backgroundColor: "#3B82F6",
borderRadius: 4
},
{
label: "25-30년",
data: [1391, 3615, 1814, 1411],
backgroundColor: "#EF4444",
borderRadius: 4
},
{
label: "30년이상",
data: [2014, 10303, 9831, 2000],
backgroundColor: "#F87171",
borderRadius: 4
}
]
datasets: []
};
type OldPplStoreState = {
chartData: OldPplChartData;
@@ -70,23 +51,122 @@ export const useUsePplStore = create<OldPplStoreState>((set) => ({
}));
export const initailMapOldPplData: mapPplOldType = {
colTitle: { totalLen: "총길이", pplLen2025: "20~25년", pplLen2530: "25~30년", pplLen30: "30년 이상" }, //헤더 영역
colTitle: { stofcbrCount: "우수토실개소수", totalLen: "하수관로총연장" },
data: [
{ name: "금강", totalLen: 4545, pplLen2025: 1140, pplLen2530: 1391, pplLen30: 2014 },
{ name: "한강", totalLen: 17099, pplLen2025: 3181, pplLen2530: 3615, pplLen30: 10303 },
{ name: "낙동강", totalLen: 14745, pplLen2025: 3100, pplLen2530: 1814, pplLen30: 9831 },
{ name: "영산강", totalLen: 5073, pplLen2025: 1662, pplLen2530: 1411, pplLen30: 2000 },
{ name: "금강", totalLen: 0, stofcbrCount: 0 },
{ name: "한강", totalLen: 0, stofcbrCount: 0 },
{ name: "낙동강", totalLen: 0, stofcbrCount: 0 },
{ name: "영산강", totalLen: 0, stofcbrCount: 0 },
]
};
type mapPplOldState = {
mapData: mapPplOldItem[];
setMapData: (mapData: mapPplOldItem[]) => void;
setStofcbrCounts: (counts: BasinStofcbrCountContent[]) => void;
};
export const useMapOldPplStore = create<mapPplOldState>((set) => ({
mapData: initailMapOldPplData.data,
setMapData: (newMapData) => set({ mapData: newMapData }),
setMapData: (newMapData) => set((state) => ({
mapData: newMapData.map((item) => ({
...item,
stofcbrCount: state.mapData.find((current) => current.name === item.name)?.stofcbrCount ?? item.stofcbrCount,
})),
})),
setStofcbrCounts: (counts) => set((state) => ({
mapData: state.mapData.map((item) => ({
...item,
stofcbrCount: counts.find((count) => count.zoneNm === item.name)?.stofcbrCount ?? item.stofcbrCount,
})),
})),
}));
const DEFAULT_BASIN_ORDER = ["금강", "한강", "낙동강", "영산강"];
const YEAR_GROUP_START_COLOR = "#FACC15";
const YEAR_GROUP_END_COLOR = "#F87171";
/**
* 시작색과 종료색 사이를 RGB 기준으로 보간해 구간 수만큼 색상을 생성합니다.
* 구간이 하나면 시작색을, 둘 이상이면 첫 구간은 파란색·마지막 구간은 빨간색으로 고정합니다.
*/
function buildYearGroupColors(count: number): string[] {
if (count <= 0) return [];
if (count === 1) return [YEAR_GROUP_START_COLOR];
const parseHex = (color: string) => [
Number.parseInt(color.slice(1, 3), 16),
Number.parseInt(color.slice(3, 5), 16),
Number.parseInt(color.slice(5, 7), 16),
];
const toHex = (value: number) => Math.round(value).toString(16).padStart(2, "0");
const start = parseHex(YEAR_GROUP_START_COLOR);
const end = parseHex(YEAR_GROUP_END_COLOR);
return Array.from({ length: count }, (_, index) => {
const ratio = index / (count - 1);
return `#${start.map((channel, channelIndex) => {
const endChannel = end[channelIndex] ?? channel;
return toHex(channel + (endChannel - channel) * ratio);
}).join("")}`;
});
}
/**
* 별도 정렬순서가 없는 MV 계약에서 구간명의 첫 숫자를 기준으로 표시순서를 결정합니다.
* 숫자가 없는 비정형 구간은 숫자 구간 뒤에 한글 오름차순으로 배치합니다.
*/
function compareYearGroups(left: string, right: string): number {
const leftNumber = left.match(/\d+/)?.[0];
const rightNumber = right.match(/\d+/)?.[0];
if (leftNumber !== undefined && rightNumber !== undefined) {
const numberDifference = Number(leftNumber) - Number(rightNumber);
if (numberDifference !== 0) return numberDifference;
} else if (leftNumber !== undefined) {
return -1;
} else if (rightNumber !== undefined) {
return 1;
}
return left.localeCompare(right, "ko");
}
/**
* MV 응답의 yearGroup 목록으로 노후관로 차트 데이터를 동적으로 생성합니다.
* 구간명이나 구간 개수가 바뀌어도 고정 매핑 없이 동일한 로직을 사용합니다.
*/
export function buildOldPplChartData(items: PplLengthContent[]): OldPplChartData {
const responseZones = [...new Set(items.map((item) => item.zoneNm).filter(Boolean))];
const labels = [
...DEFAULT_BASIN_ORDER,
...responseZones.filter((zone) => !DEFAULT_BASIN_ORDER.includes(zone)).sort((left, right) => left.localeCompare(right, "ko")),
];
const yearGroups = [
...new Set(
items
.map((item) => item.yearGroup?.trim())
.filter((yearGroup): yearGroup is string => Boolean(yearGroup)),
),
].sort(compareYearGroups);
const colors = buildYearGroupColors(yearGroups.length);
const lengthByZoneAndGroup = new Map<string, number>();
items.forEach((item) => {
const yearGroup = item.yearGroup?.trim();
if (!item.zoneNm || !yearGroup) return;
lengthByZoneAndGroup.set(`${item.zoneNm}\u0000${yearGroup}`, Number(item.sumPrlgVl) || 0);
});
return {
labels,
datasets: yearGroups.map((yearGroup, index) => ({
label: yearGroup,
data: labels.map((zone) => lengthByZoneAndGroup.get(`${zone}\u0000${yearGroup}`) ?? 0),
backgroundColor: colors[index] ?? YEAR_GROUP_START_COLOR,
borderRadius: 4,
})),
};
}
/**
* OldPplChartData를 지도 팝업 데이터 형태인 mapPplOldItem[]으로 변환합니다.
* @param chartData 차트 데이터
@@ -96,22 +176,16 @@ export function transformChartDataToMapData(chartData: OldPplChartData): mapPplO
const mapData: mapPplOldItem[] = [];
const { labels, datasets } = chartData;
const pplLen2025Data = datasets.find(d => d.label === '20-25년')?.data || [];
const pplLen2530Data = datasets.find(d => d.label === '25-30년')?.data || [];
const pplLen30Data = datasets.find(d => d.label === '30년이상')?.data || [];
labels.forEach((label, index) => {
const pplLen2025 = pplLen2025Data[index] || 0;
const pplLen2530 = pplLen2530Data[index] || 0;
const pplLen30 = pplLen30Data[index] || 0;
const totalLen = pplLen2025 + pplLen2530 + pplLen30;
const totalLen = datasets.reduce((sum, dataset) => {
const value = dataset.data[index];
return sum + (typeof value === "number" ? value : 0);
}, 0);
mapData.push({
name: label,
totalLen,
pplLen2025,
pplLen2530,
pplLen30,
stofcbrCount: 0,
});
});
@@ -171,7 +245,7 @@ export class SewerageApiService implements SewerageService {
}));
return list;
}
// 유역별 노후도별 관로 길이(km) MV조회 : yearGroup - 20년~25년미만,25년~30년미만,30년이상
// 유역별 노후도별 관로 길이(km) MV조회: yearGroup은 MV가 제공하는 동적 구간명
public async getMvOldPplLength(): Promise<PplLengthContent[]> {
const response = await client.get<{
PplLengthList: PplLengthContent[];
@@ -184,6 +258,18 @@ export class SewerageApiService implements SewerageService {
return list;
}
// CM 대시보드 유역별 우수토실 개소수 조회
public async getBasinStofcbrCount(): Promise<BasinStofcbrCountContent[]> {
const response = await client.get<{
basinStofcbrCountList: BasinStofcbrCountContent[];
}>(ENDPOINT.SEWERAGE_SERVICE.BASIN_STOFCBR_COUNT);
return (response.data.basinStofcbrCountList ?? []).map((item) => ({
...item,
stofcbrCount: Number(item.stofcbrCount),
}));
}
}
export const SewerageApi = new SewerageApiService();
@@ -12,7 +12,7 @@ import { svcInfo } from "../model/map-data";
import type { mapOperFcltType, mapReuseMapType} from "../model/fo-interface";
import type { mapPplOldType } from "../model/sn-interface";
import { useMapOperFcltStore, OperationApi, useMapReuseMapStore } from "../api/fo-service";
import { useMapOldPplStore } from "../api/sn-service";
import { SewerageApi, useMapOldPplStore } from "../api/sn-service";
import {Button} from "@common/components";
const SVG_URL = "/images/region_map.svg"; // public 폴더에 region_map.svg 위치 필요
@@ -31,12 +31,12 @@ const mapOperFcltData: mapOperFcltType = {
]
};
const mapPplOldData: mapPplOldType = {
colTitle: {totalLen: "총길이",pplLen2025: "20~25년",pplLen2530: "25~30년",pplLen30 : "30년 이상" }, //헤더 영역
colTitle: {stofcbrCount: "우수토실개소수", totalLen: "하수관로총연장" },
data: [
{name: "금강", totalLen: 4545, pplLen2025: 1140, pplLen2530: 1391, pplLen30 : 2014},
{name: "한강", totalLen: 17099, pplLen2025: 3181, pplLen2530: 3615, pplLen30 : 10303},
{name: "낙동강", totalLen: 14745, pplLen2025: 3100, pplLen2530: 1814, pplLen30 : 9831},
{name: "영산강", totalLen: 5073, pplLen2025: 1662, pplLen2530: 1411, pplLen30 : 2000},
{name: "금강", totalLen: 0, stofcbrCount: 0},
{name: "한강", totalLen: 0, stofcbrCount: 0},
{name: "낙동강", totalLen: 0, stofcbrCount: 0},
{name: "영산강", totalLen: 0, stofcbrCount: 0},
]
};
const mapReuseMapData: mapReuseMapType = {
@@ -62,17 +62,18 @@ export function KoreaBasinMap() {
const { selectedBasin, setSelectedBasin } = useStore(basinStore);
const selectedBasinKey = basinKeyMap[selectedBasin];
const {mapOperFclt} = useMapOperFcltStore();
const {mapData: mapPplOld} = useMapOldPplStore();
const {mapData: mapPplOld, setStofcbrCounts} = useMapOldPplStore();
const {setMapData: setMapReuseMapData} = useMapReuseMapStore();
mapOperFcltData.data = mapOperFclt;
mapPplOldData.data = mapPplOld;
// mapReuseMapData.data = mapReuseMap;
let standardYear = '2024';
const [standardYear, setStandardYear] = useState('2024');
useEffect(() => {
const fetchData = async () => {
try {
standardYear = await OperationApi.getStandardYear();
const mapReuseMapRslt = await OperationApi.getReuseMap(standardYear);
const fetchedStandardYear = await OperationApi.getStandardYear();
setStandardYear(fetchedStandardYear);
const mapReuseMapRslt = await OperationApi.getReuseMap(fetchedStandardYear);
setMapReuseMapData(mapReuseMapRslt);
mapReuseMapData.data = mapReuseMapRslt;
} catch (e) {
@@ -80,7 +81,13 @@ export function KoreaBasinMap() {
}
};
fetchData();
}, []);
}, [setMapReuseMapData]);
useEffect(() => {
SewerageApi.getBasinStofcbrCount()
.then(setStofcbrCounts)
.catch((e) => console.error("유역별 우수토실 개소수 조회 실패", e));
}, [setStofcbrCounts]);
// 유역정보
const showFor = useCallback((g: SVGGElement) => {
const activeService = services.find(s => s.active);
@@ -104,24 +111,16 @@ export function KoreaBasinMap() {
});
} else if (activeService.svcId === 'sn') { // 하수관로
const item = mapPplOldData.data.find(d => d.name === basinName);
newPopupContent.title = `${standardYear}년도 ${basinName}유역 노후하수관로 현황`;
newPopupContent.title = `${standardYear}년도 ${basinName}유역 하수관로 현황`;
const colTitles = mapPplOldData.colTitle;
newPopupContent.items.push({
header: colTitles.stofcbrCount,
data: item ? `${fmt(item.stofcbrCount)}개소` : '-',
});
newPopupContent.items.push({
header: colTitles.totalLen,
data: item ? `${fmt(item.totalLen)}km` : '-',
});
newPopupContent.items.push({
header: colTitles.pplLen2025,
data: item ? `${fmt(item.pplLen2025)}km` : '-',
});
newPopupContent.items.push({
header: colTitles.pplLen2530,
data: item ? `${fmt(item.pplLen2530)}km` : '-',
});
newPopupContent.items.push({
header: colTitles.pplLen30,
data: item ? `${fmt(item.pplLen30)}km` : '-',
});
} else if (activeService.svcId === 'wr') { // 물재이용
const item = mapReuseMapData.data.find(d => d.name === basinName);
newPopupContent.title = `${standardYear}년도 ${basinName}유역 물재이용 현황`;
@@ -160,7 +159,7 @@ export function KoreaBasinMap() {
);
popupRef.current.style.left = x + "px";
}
}, [services, mapOperFclt]);
}, [services, mapOperFclt, mapPplOld, standardYear]);
// selectedBasinKey prop이 변경될 때 지도를 업데이트하는 useEffect
useEffect(() => {
@@ -410,8 +409,14 @@ export function KoreaBasinMap() {
};
// 스타일은 필요에 따라 tailwind, css 등으로 커스터마이즈하세요.
return (
<div className="col-span-3 !p-0 relative z-10" style={{ height: "765px", background: "rgba(255, 255, 255, 0.6)" }}>
<div className="card card-border" style={{ height: "100%" }}>
<div
className="absolute inset-0 z-10 col-span-3 h-full !p-0"
style={{ background: "rgba(255, 255, 255, 0.6)" }}
>
<div
className="card card-border"
style={{ height: "calc(100% - 1.2rem)" }}
>
<div className="card-header text-foreground">
<h3 className="text-2xl font-medium">유역별 정보</h3>
@@ -6,11 +6,17 @@ export interface PplLengthContent {
yearGroup?: string;
}
export interface BasinStofcbrCountContent {
zoneNm: string;
stofcbrCount: number;
}
export interface SewerageService {
getPplLength(ymdFrom: string, ymdTo: string, useCd: string): Promise<PplLengthContent[]>;
getPplUseLength(ymdFrom: string, ymdTo: string, useCd: string): Promise<PplLengthContent[]>;
getMvUsgPplLength(): Promise<PplLengthContent[]>;
getMvOldPplLength(): Promise<PplLengthContent[]>;
getBasinStofcbrCount(): Promise<BasinStofcbrCountContent[]>;
}
export interface ChartDataSet {
@@ -37,17 +43,13 @@ type SnOldPplLength = {
export type mapPplOldItem = {
name: string;
totalLen: number;
pplLen2025: number;
pplLen2530: number;
pplLen30: number;
stofcbrCount: number;
};
export type mapPplOldType = {
colTitle: {
totalLen: string; // 총길이
pplLen2025: string; // 20~25년
pplLen2530: string; // 25~30년
pplLen30: string; // 30년 이상
stofcbrCount: string; // 우수토실개소수
totalLen: string; // 총연장
};
data: mapPplOldItem[];
};
@@ -82,4 +84,4 @@ export function buildSnOldPplLength(
});
return result;
}
}
@@ -135,7 +135,7 @@ function DashboardPage() {
<div className="p-2 sm:p-4 md:p-6">
<div className="flex flex-col gap-1">
{/* 첫번째 라인 - 좌측 6개 패널(2x3) + 우측 지도/사용자 정보 */}
<div className="flex flex-col gap-1 lg:flex-row">
<div className="flex flex-col gap-1 lg:flex-row lg:items-stretch">
{/* 좌측: 드래그 가능한 6개 패널 (2x3 그리드, 화면의 50%) */}
<div className="w-full lg:w-1/2">
{isMounted ? (
@@ -173,9 +173,9 @@ function DashboardPage() {
</div>
{/* 우측: 고정된 지도와 사용자 정보 (화면의 50%) */}
<div className="flex w-full gap-1 lg:w-1/2">
<div className="flex w-full flex-col items-stretch gap-1 lg:w-1/2 lg:flex-row">
{/* 지도영역 - 고정 (드래그 불가) */}
<div className="flex-1">
<div className="relative min-h-[765px] w-full self-stretch lg:min-h-0 lg:flex-1">
<KoreaBasinMap />
</div>
+4 -44
View File
@@ -15,8 +15,8 @@ import {
} from "chart.js";
import { Bar } from "react-chartjs-2";
import { Button } from "@common/components";
import type { PplLengthContent, ChartDataSet, OldPplChartData } from "../model/sn-interface";
import { SewerageApi, useOldPplStore,useMapOldPplStore, transformChartDataToMapData } from "../api/sn-service";
import type { PplLengthContent } from "../model/sn-interface";
import { buildOldPplChartData, SewerageApi, useOldPplStore,useMapOldPplStore, transformChartDataToMapData } from "../api/sn-service";
import { useDragHandle } from "./sortable-panel";
@@ -41,45 +41,7 @@ export default function SnOldPpl() {
useEffect(() => {
if (oldPplLenData.length === 0) return;
// 유역 목록 추출 (중복 제거)
const allZones = [...new Set(oldPplLenData.map(d => d.zoneNm))];
// yearGroup 매핑: API 응답값 -> 차트 label
const yearGroupMap: { [key: string]: { label: string; color: string } } = {
"20년~25년미만": { label: "20-25년", color: "#3B82F6" },
"25년~30년미만": { label: "25-30년", color: "#EF4444" },
"30년이상": { label: "30년이상", color: "#F87171" },
};
// 유역별, 노후년도별 데이터 구조화
const dataByZone: { [zone: string]: { [yearGroup: string]: number } } = {};
allZones.forEach(zone => {
dataByZone[zone] = {};
});
oldPplLenData.forEach(item => {
let zoneData = dataByZone[item.zoneNm];
if (!zoneData) {
zoneData = {};
dataByZone[item.zoneNm] = zoneData;
}
if (item.yearGroup) {
zoneData[item.yearGroup] = item.sumPrlgVl;
}
});
// 데이터셋 생성
const newDatasets: ChartDataSet[] = Object.entries(yearGroupMap).map(([yearGroup, { label, color }]) => ({
label,
data: allZones.map(zone => dataByZone[zone]?.[yearGroup] ?? 0),
backgroundColor: color,
borderRadius: 4,
}));
const newChartData: OldPplChartData = {
labels: allZones,
datasets: newDatasets,
};
const newChartData = buildOldPplChartData(oldPplLenData);
setChartData(newChartData);
@@ -110,9 +72,7 @@ export default function SnOldPpl() {
y: {
beginAtZero: true,
min: 0,
max: 10000,
ticks: {
stepSize: 2000,
callback: function (value) {
return Number(value).toLocaleString() + 'km';
}
@@ -192,4 +152,4 @@ export default function SnOldPpl() {
</div>
</div>
);
}
}
+1
View File
@@ -80,6 +80,7 @@ export const ENDPOINT = {
OLD_PPL_COUNTS: "/sewerage-service/api/v1/ms/oldPplCounts",
MV_USG_PPL_LENGTH: "/sewerage-service/api/v1/ms/mvUsgPplLength",
MV_OLD_PPL_LENGTH: "/sewerage-service/api/v1/ms/mvOldPplLength",
BASIN_STOFCBR_COUNT: "/sewerage-service/api/v1/ms/basinStofcbrCount",
},
OPERATION_SERVICE: {
MAIN_YEAR: "/operation-service/api/v1/dashBoard/main/year",
+3 -1
View File
@@ -134,6 +134,8 @@ Codex 작업 루트는 가능하면 `../../../../../`로 잡고, 공통 시작
## 작업 원칙
- 일반 목록·모든 팝업은 공통 AGENTS.md의 서버 페이징·지도·Excel 계약을 따른다. 지도 전체 표출에 현재 그리드 페이지 배열을 재사용하지 않는다. Excel은 적용조건 전체를 사용하고 대량 원시 자료는 서버 생성으로 분리한다. 화면 페이징/페이징 없음 예외는 사유와 검증 범위를 메뉴 > 화면명별로 기록한다.
1. 모든 변경은 원본 요구사항정의서와 화면설계서, 2차 요구사항, 기능 명세 또는 승인된 작업 문서를 근거로 한다.
2. `apps/sn`과 `packages/common/business_components` 범위를 넘는 수정은 하지 않는다.
3. 공통 패키지 수정은 `sn` 요구사항을 충족하기 위한 최소 범위로 제한한다.
@@ -228,7 +230,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와 기타 프론트엔드 장시간 실행 프로세스는 작업 종료 전에 반드시 종료한다.
- 셸, 백그라운드, 숨김 창 등 VSCode 밖에서 에이전트가 기동한 프로세스도 종료 대상이다.
@@ -18,11 +18,13 @@ import {
AGED_PIPE_LIST_HEADLIST,
AGED_PIPE_PAGE_SIZE,
buildAgedPipeCqlFilters,
getAgedPipeManagementName,
normalizeAgedPipeFilters,
toDisplayPipeRow,
validateElapsedYears,
type AgedPipeFilters,
type AgedPipeRow,
type AgedPipeManagementAgency,
} from "./agedPipeUtils";
import { downloadAgedPipeExcel } from "./agedPipeExport";
import MapPopupDataTable from "@/features/windowPopup/ui/MapPopupDataTable";
@@ -53,6 +55,15 @@ export default function AgedPipePopupPage() {
const [appliedFilters, setAppliedFilters] =
useState<AgedPipeFilters>(initialFilters);
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 [currentPage, setCurrentPage] = useState(1);
const [isLoading, setIsLoading] = useState(false);
@@ -73,6 +84,19 @@ export default function AgedPipePopupPage() {
if (sgg.length === 0) fetchSgg();
}, [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(
async (page: number, nextFilters: AgedPipeFilters, size: number) => {
const normalized = normalizeAgedPipeFilters(nextFilters);
@@ -247,31 +271,6 @@ export default function AgedPipePopupPage() {
) : null}
<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">
<span className="whitespace-nowrap text-[1.3rem] text-label">
시도
@@ -316,6 +315,31 @@ export default function AgedPipePopupPage() {
</select>
</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">
<Button
type="button"
@@ -341,7 +365,7 @@ export default function AgedPipePopupPage() {
</PopupSearchPanel>
<MapPopupDataTable
rows={rows as Array<AgedPipeRow & Record<string, unknown>>}
rows={displayRows}
headlist={AGED_PIPE_LIST_HEADLIST}
title="노후관로 목록"
total={total}
@@ -56,15 +56,34 @@ const normalizeRegionFilterCode = (value: unknown, prefixLength: number) => {
export const AGED_PIPE_LIST_HEADLIST = [
{ 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: "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: "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 = [
"ftreSmbolCd",
"fcltMngNo",
@@ -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,
}),
@@ -529,6 +529,15 @@ export const FldngDpthMapCol = [
// { id: 'shape_area', title: '형상면적(㎡)' },
// { id: 'shape_len', title: '형상길이' },
];
/** 기본정보 목록 API의 camelCase 속성을 침수흔적심선 그리드에 연결한다. */
export const FldngDpthMapCol2 = [
{ id: 'rowNumber', title: '순번' },
{ id: 'fldngYr', title: '침수년도' },
{ id: 'ctpvNm', title: '시도명' },
{ id: 'sggNm', title: '시군구명' },
{ id: 'fldngDpth', title: '침수깊이(m)' },
{ id: 'fldngArea', title: '침수면적(㎡)' },
];
export const FldngWtlvMapExcelCol = {
'fldngYr': '침수년도',
'fldngDstrNm': '침수재해명',
@@ -8,10 +8,9 @@ import { useCallback, useEffect, useMemo, useState } from "react";
import { Button, Input } from "@common/components";
import { StandardPopupLayout } from "@common/business_components";
import { getRawJson } from "@/components/lib/api";
import { downloadExcelWithKoreanHeaders } from "@/shared/lib/download-excel";
import { requestGridExcel } from "@/shared/api/grid-export";
import { WINDOW_POPUP_MESSAGE_TYPE } from "@/features/windowPopup/model/types";
import { useAlertStore } from "@/shared/lib";
import { fetchAllPages } from "@/shared/lib/fetch-all-pages";
import useStore from "@/store/useStore";
import LoadingOverlay from "@/components/ui/LoadingBar";
import MapPopupDataTable from "@/features/windowPopup/ui/MapPopupDataTable";
@@ -19,8 +18,6 @@ import PopupSearchPanel from "@/features/windowPopup/ui/PopupSearchPanel";
import {
FLDNG_HSTRY_LAYER_TYPE,
FLDNG_HSTRY_DEFAULT_FILTERS,
FLDNG_HSTRY_DOWNLOAD_BATCH_SIZE,
FLDNG_HSTRY_DOWNLOAD_HEADERS,
FLDNG_HSTRY_LIST_HEADLIST,
FLDNG_HSTRY_PAGE_SIZE,
FLDNG_HSTRY_ROW_SELECT_MESSAGE,
@@ -58,26 +55,6 @@ const resolveFldngHstryMapFeatureFid = (row: FldngHstryTableRow) => {
return "";
};
const buildDownloadRows = (rows: FldngHstryTableRow[]) =>
rows.map((row) => ({
rowNo: row.rowNo,
floodYearDisplay: row.floodYearDisplay,
ctpvNm: row.ctpvNm,
sggNm: row.sggNm,
disasterName: row.disasterName,
rainBgngTmDisplay: row.rainBgngTmDisplay,
rainEndTmDisplay: row.rainEndTmDisplay,
rainTotDisplay: row.rainTotDisplay,
rainStrDisplay: row.rainStrDisplay,
floodHourDisplay: row.floodHourDisplay,
districtName: row.districtName,
causeDetailCode: row.causeDetailCode,
causeDetailName: row.causeDetailName,
avgFloodDepthDisplay: row.avgFloodDepthDisplay,
floodAreaDisplay: row.floodAreaDisplay,
exmnYearDisplay: row.exmnYearDisplay,
damageAmountDisplay: row.damageAmountDisplay,
}));
const resolveRegionNames = (
row: FldngHstryTableRow,
@@ -265,19 +242,13 @@ export default function FldngHstryPopupPage() {
setIsLoading(true);
try {
const response = await fetchAllPages({
batchSize: FLDNG_HSTRY_DOWNLOAD_BATCH_SIZE,
fetchPage: (page, size) => fetchRows(page + 1, appliedFilters, size),
getItems: (page) => page.rows,
getTotal: (page) => page.total,
});
await downloadExcelWithKoreanHeaders({
data: buildDownloadRows(response.items) as unknown as JSON[],
fileName: `침수발생이력_${new Date().toISOString().slice(0, 10)}.xlsx`,
sheetNameArray: ["침수발생이력"],
headerRange: "A1:Q1",
headerMapping: FLDNG_HSTRY_DOWNLOAD_HEADERS,
});
const params = new URLSearchParams(buildFldngHstryListQueryString(appliedFilters, 1, FLDNG_HSTRY_PAGE_SIZE));
params.delete("page"); params.delete("size");
await requestGridExcel("v1/spatialGridExports/floodHistories", {
history: Object.fromEntries(params),
ctpvNames: Object.fromEntries(ctpv.map((item) => [String(item.val).trim(), item.text])),
sggNames: Object.fromEntries(sgg.map((item) => [String(item.val).trim(), item.text])),
}, `침수발생이력_${new Date().toISOString().slice(0, 10)}.xlsx`);
} catch (error) {
console.error("침수발생이력 다운로드 중 오류:", error);
alert(
@@ -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 },
@@ -11,8 +11,7 @@ import {
type UiHeadProps,
type UiTableSort,
} from "@common/business_components";
import { downloadExcelWithKoreanHeaders } from "@/shared/lib/download-excel";
import { fetchAllPages } from "@/shared/lib/fetch-all-pages";
import { downloadAnalysisExcel } from "@/shared/lib/download-analysis-excel";
import { useAlertStore } from "@/shared/lib";
import { Button } from "@common/components";
@@ -160,7 +159,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 ?? "-"),
@@ -241,29 +240,7 @@ export default function InftwtResultPopupPage() {
try {
setIsLoading(true);
const response = await fetchAllPages<any, Record<string, unknown>>({
fetchPage: (page, size) =>
getData(
`v1/inftwtAnlsRslt/ppl/${selectedRow.inftwtAnlsSn}?page=${page}&size=${size}`,
),
getItems: (page) =>
Array.isArray(page?.content) ? page.content : [],
getTotal: (page) => Number(page?.totalElements ?? 0),
});
const excelRows = response.items;
await downloadExcelWithKoreanHeaders({
data: excelRows as unknown as JSON[],
fileName: `침입수분석결과_${selectedRow.swtrmtFcltNm}_${selectedRow.aplcnYm}_${selectedRow.inftwtAnlsSn}`,
sheetNameArray: ["침입수 분석결과"],
headerRange: "A1:D1",
headerMapping: {
inftwtAnlsSn: "침입수분석\n분석번호",
pplMngNo: "관로\n관리번호",
anlsTm: "분석시각",
predcOilq: "예측유량\n(m³/h)",
},
});
await downloadAnalysisExcel("inftwt", selectedRow.inftwtAnlsSn, `침입수분석결과_${selectedRow.swtrmtFcltNm}_${selectedRow.aplcnYm}_${selectedRow.inftwtAnlsSn}`);
} catch (error) {
console.error("침입수 분석결과 다운로드 중 오류:", error);
alert("침입수 분석결과 다운로드 중 오류가 발생했습니다.");
@@ -8,20 +8,18 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useSearchParams } from "next/navigation";
import { Button, Input } from "@common/components";
import { StandardPopupLayout } from "@common/business_components";
import { downloadExcelWithKoreanHeaders } from "@/shared/lib/download-excel";
import { requestGridExcel } from "@/shared/api/grid-export";
import { getData } from "@/components/lib/api";
import { appendApiSearchParams } from "@/shared/util/util";
import LoadingOverlay from "@/components/ui/LoadingBar";
import useStore from "@/store/useStore";
import { useAlertStore } from "@/shared/lib";
import { fetchAllPages } from "@/shared/lib/fetch-all-pages";
import { useUserStore } from "@/shared/lib/use-user-store";
import MapPopupDataTable from "@/features/windowPopup/ui/MapPopupDataTable";
import PopupSearchPanel from "@/features/windowPopup/ui/PopupSearchPanel";
import { MS_SWAGE_PPL_CODES, MS_SWAGE_PPL_USE_CODES } from "../common/table";
import {
PIPE_ANALYSIS_DETAIL_DEFAULT_FILTERS,
PIPE_ANALYSIS_DETAIL_EXCEL_HEADERS,
PIPE_ANALYSIS_DETAIL_HEADLIST,
PIPE_ANALYSIS_DETAIL_ROW_SELECT_MESSAGE,
applyScopeToDetailFilters,
@@ -41,19 +39,6 @@ import {
type DetailViewRow = ReturnType<typeof toPipeAnalysisDetailViewRows>[number];
const PIPE_ANALYSIS_DETAIL_PAGE_SIZE = 10;
const PIPE_ANALYSIS_MAP_WINDOW_NAME = "sn-sewerage-map-root";
const PIPE_ANALYSIS_DETAIL_EXPORT_KEYS = [
"rowNo",
"ctpvNm",
"sggNm",
"fcltMngNo",
"instMngNo",
"swtrmtZoneNm",
"instlYmd",
"useNm",
"pdmtVl",
"prlgVl",
"materialName",
] as const;
const PIPE_ANALYSIS_DETAIL_STANDARD_HEADLIST = PIPE_ANALYSIS_DETAIL_HEADLIST;
const resolveErrorMessage = (error: unknown, fallbackMessage: string) =>
@@ -240,33 +225,19 @@ export default function PipeAnalysisDetailPopupPage() {
try {
setIsLoading(true);
const response = await fetchAllPages({
fetchPage: (page, size) => fetchRows(page + 1, appliedFilters, size),
getItems: (page) => page.rows,
getTotal: (page) => page.total,
});
const exportRows = response.items.map((row) => ({
rowNo: row.rowNo,
ctpvNm: row.ctpvNm,
sggNm: row.sggNm,
fcltMngNo: row.fcltMngNo,
instMngNo: row.instMngNo,
swtrmtZoneNm: row.swtrmtZoneNm,
instlYmd: row.instlYmd,
useNm: row.useNm,
pdmtVl: row.pdmtVl,
prlgVl: row.prlgVl,
materialName:
resolveMaterialKoreanName(row.materialCode) || row.materialName,
}));
const detailHeaderRange = `A1:${String.fromCharCode(64 + PIPE_ANALYSIS_DETAIL_EXPORT_KEYS.length)}1`;
await downloadExcelWithKoreanHeaders({
data: exportRows as unknown as JSON[],
fileName: `관로분석_관로현황_${buildPipeAnalysisFileDate()}`,
sheetNameArray: ["관로현황"],
headerRange: detailHeaderRange,
headerMapping: PIPE_ANALYSIS_DETAIL_EXCEL_HEADERS,
const params = new URLSearchParams({ analysisType: baseAnalysisType });
appendApiSearchParams(params, {
ctpvCd: appliedFilters.ctpvCd, sggCd: appliedFilters.sggCd,
swtrmtZoneNm: appliedFilters.swtrmtZoneNm, elapsedYearBucket: baseElapsedYearBucket,
materialCode: appliedFilters.materialCode, useCd: appliedFilters.useCd,
diameterFrom: appliedFilters.diameterFrom, diameterTo: appliedFilters.diameterTo,
});
if (scope.regionCode) params.set("scopeRegionCode", scope.regionCode);
await requestGridExcel("v1/spatialGridExports/pipes", {
pipe: Object.fromEntries(params),
ctpvNames: Object.fromEntries(ctpv.map((item) => [String(item.val).trim(), item.text])),
sggNames: Object.fromEntries(sgg.map((item) => [String(item.val).trim(), item.text])),
}, `관로분석_관로현황_${buildPipeAnalysisFileDate()}.xlsx`);
} catch (error) {
console.error("관로현황 다운로드 중 오류:", error);
alert(
@@ -83,8 +83,12 @@ export default function PipeAnalysisPopupPage() {
Array<{ value: string; label: string }>
>([]);
const [summaryCurrentPage, setSummaryCurrentPage] = useState(1);
// 복합표는 물리 행 수가 아니라 서버가 반환하는 처리구역 그룹 수를 사용한다.
const [summaryTotal, setSummaryTotal] = useState(0);
const [isLoading, setIsLoading] = useState(false);
const initializedRef = useRef(false);
// 늦게 완료된 이전 페이지 응답이 최신 조회조건의 표를 덮어쓰지 않게 한다.
const summaryRequestId = useRef(0);
useEffect(() => {
if (ctpv.length === 0) fetchCtpv();
@@ -124,9 +128,13 @@ export default function PipeAnalysisPopupPage() {
}, [draftFilters.ctpvCd, draftFilters.sggCd, scope.regionCode, sgg]);
const fetchSummary = useCallback(
async (nextFilters: PipeAnalysisSummaryFilters) => {
async (nextFilters: PipeAnalysisSummaryFilters, page = 1) => {
const requestId = ++summaryRequestId.current;
setIsLoading(true);
const params = new URLSearchParams({
analysisType: nextFilters.analysisType,
page: String(page - 1),
size: String(PIPE_ANALYSIS_SUMMARY_PAGE_SIZE),
});
appendApiSearchParams(params, {
@@ -137,22 +145,27 @@ export default function PipeAnalysisPopupPage() {
if (nextFilters.scopeRegionCode)
params.set("scopeRegionCode", nextFilters.scopeRegionCode);
const response = (await getData(
`v1/fclts/pplAnalysis/summaries?${params.toString()}`,
)) as PipeAnalysisSummaryResponse;
const nextMaterialColumns = Array.isArray(response?.materialColumns)
? response.materialColumns
: [];
setMaterialColumns(nextMaterialColumns);
setRows(buildPipeAnalysisSummaryViewRows(response, ctpv, sgg));
try {
const response = (await getData(
`v1/fclts/pplAnalysis/summaryPage?${params.toString()}`,
)) as PipeAnalysisSummaryResponse & { count: number };
if (requestId !== summaryRequestId.current) return;
const nextMaterialColumns = Array.isArray(response?.materialColumns) ? response.materialColumns : [];
setMaterialColumns(nextMaterialColumns);
setRows(buildPipeAnalysisSummaryViewRows(response, ctpv, sgg));
setSummaryTotal(response.count);
setSummaryCurrentPage(page);
} catch (error) {
if (requestId === summaryRequestId.current) throw error;
} finally {
if (requestId === summaryRequestId.current) setIsLoading(false);
}
},
[ctpv, sgg],
);
const handleSearch = useCallback(
async (nextFilters = draftFilters) => {
setIsLoading(true);
try {
const scopedFilters = applyScopeToSummaryFilters(nextFilters, scope);
setDraftFilters(scopedFilters);
@@ -168,9 +181,8 @@ export default function PipeAnalysisPopupPage() {
),
);
setRows([]);
setSummaryTotal(0);
setMaterialColumns([]);
} finally {
setIsLoading(false);
}
},
[alert, draftFilters, fetchSummary, scope],
@@ -191,6 +203,18 @@ export default function PipeAnalysisPopupPage() {
}
try {
// 화면은 한 페이지뿐이므로 Excel만 별도 전체 집계 계약으로 조회한다.
const params = new URLSearchParams({ analysisType: appliedFilters.analysisType });
appendApiSearchParams(params, {
ctpvCd: appliedFilters.ctpvCd, sggCd: appliedFilters.sggCd,
swtrmtZoneNm: appliedFilters.swtrmtZoneNm,
});
if (appliedFilters.scopeRegionCode) params.set("scopeRegionCode", appliedFilters.scopeRegionCode);
const response = await getData(`v1/fclts/pplAnalysis/summaries?${params}`) as PipeAnalysisSummaryResponse;
const materialColumns = response.materialColumns ?? [];
const rows = buildPipeAnalysisSummaryViewRows(response, ctpv, sgg);
const orderedYearMaterialGroups = buildPipeAnalysisYearMaterialGroups(rows, materialColumns)
.map((group, index) => ({ ...group, rowNo: index + 1 }));
const exportHeadList = buildPipeAnalysisSummaryHeadList(
appliedFilters.analysisType,
materialColumns,
@@ -334,31 +358,11 @@ export default function PipeAnalysisPopupPage() {
() =>
yearMaterialGroups.map((group, index) => ({
...group,
rowNo: yearMaterialGroups.length - index,
rowNo: (summaryCurrentPage - 1) * PIPE_ANALYSIS_SUMMARY_PAGE_SIZE + index + 1,
})),
[yearMaterialGroups],
[yearMaterialGroups, summaryCurrentPage],
);
const summaryPageOffset =
(summaryCurrentPage - 1) * PIPE_ANALYSIS_SUMMARY_PAGE_SIZE;
const pagedRows = useMemo(
() =>
appliedFilters.analysisType === "YEAR_MATERIAL"
? rows
: rows.slice(
summaryPageOffset,
summaryPageOffset + PIPE_ANALYSIS_SUMMARY_PAGE_SIZE,
),
[appliedFilters.analysisType, rows, summaryPageOffset],
);
const visibleYearMaterialGroups = useMemo(
() =>
orderedYearMaterialGroups.slice(
summaryPageOffset,
summaryPageOffset + PIPE_ANALYSIS_SUMMARY_PAGE_SIZE,
),
[orderedYearMaterialGroups, summaryPageOffset],
);
const sggOptions = useMemo(
() =>
draftFilters.ctpvCd === "0"
@@ -498,19 +502,18 @@ export default function PipeAnalysisPopupPage() {
</PopupSearchPanel>
<PipeAnalysisSummaryTable
rows={pagedRows}
groups={visibleYearMaterialGroups}
rows={rows}
groups={orderedYearMaterialGroups}
grouped={appliedFilters.analysisType === "YEAR_MATERIAL"}
heads={headList}
title={`${resolvePipeAnalysisTypeLabel(appliedFilters.analysisType)} 목록`}
total={
appliedFilters.analysisType === "YEAR_MATERIAL"
? orderedYearMaterialGroups.length
: rows.length
}
total={summaryTotal}
page={summaryCurrentPage}
pageSize={PIPE_ANALYSIS_SUMMARY_PAGE_SIZE}
onPageChange={setSummaryCurrentPage}
onPageChange={(page) => {
void fetchSummary(appliedFilters, page)
.catch(() => alert("관로분석 페이지를 불러오지 못했습니다."));
}}
onCellClick={handleCellClick}
onGroupedCellClick={handleYearMaterialCellClick}
actions={
@@ -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
@@ -34,7 +34,7 @@ import {
getSwtrmtPrcsZoneId,
} from "./swtrmtPrcsZoneAnalysisApi";
import type {
RepairHistoryRow,
RepairHistoryMapFeature,
SwtrmtPrcsZoneAnalysisMetric,
SwtrmtPrcsZoneAnalysisResponse,
} from "./swtrmtPrcsZoneAnalysisApi";
@@ -140,6 +140,8 @@ const SwtrmtPrcsZoneAnalysis = ({
// 보수이력 지도 레이어는 React 렌더링 대상이 아니므로 ref로 보관해 팝업 종료·구역 전환 때 제거한다.
const repairLayerRef = useRef<VectorLayer<VectorSource> | null>(null);
const repairPopupRef = useRef<Window | null>(null);
// 목록이 먼저 표시되어도 지도 도형 준비 후 마지막으로 클릭한 관로로 이동한다.
const pendingRepairFidRef = useRef("");
const requestKey = useMemo(() => {
const rawValues = rawFeatureData?.values_ ?? rawFeatureData ?? {};
@@ -173,7 +175,7 @@ const SwtrmtPrcsZoneAnalysis = ({
/** 윈도우 팝업 조회 결과 전체를 청록색 관로 레이어로 교체한다. */
const replaceRepairLayer = useCallback(
(rows: RepairHistoryRow[]) => {
(rows: RepairHistoryMapFeature[]) => {
if (!gisMap) return;
clearRepairLayer();
@@ -285,16 +287,19 @@ const SwtrmtPrcsZoneAnalysis = ({
if (
data?.type === WINDOW_POPUP_MESSAGE_TYPE.REPAIR_HISTORY_SHOW_RESULTS
) {
replaceRepairLayer(
Array.isArray(data.payload?.rows) ? data.payload.rows : [],
);
const features = Array.isArray(data.payload?.rows) ? data.payload.rows : [];
if (features.length === 0) pendingRepairFidRef.current = "";
replaceRepairLayer(features);
if (pendingRepairFidRef.current) focusRepairFeature(pendingRepairFidRef.current);
return;
}
if (data?.type === WINDOW_POPUP_MESSAGE_TYPE.REPAIR_HISTORY_SELECT_PIPE) {
focusRepairFeature(String(data.payload?.fid ?? ""));
pendingRepairFidRef.current = String(data.payload?.fid ?? "");
focusRepairFeature(pendingRepairFidRef.current);
return;
}
if (data?.type === WINDOW_POPUP_MESSAGE_TYPE.REPAIR_HISTORY_CLEAR) {
pendingRepairFidRef.current = "";
clearRepairLayer();
}
};
@@ -69,7 +69,8 @@ export type RepairHistoryResponse = {
};
const ANALYSIS_ENDPOINT_PREFIX = "v1/swagePrcsZones";
const REPAIR_HISTORY_PAGE_SIZE = 200;
/** 지도 전체 강조에 필요한 필드만 제공하며 표의 행 수와 독립적이다. */
export type RepairHistoryMapFeature = Pick<RepairHistoryRow, "fid" | "geometryJson">;
const firstValue = (...values: unknown[]) => {
const value = values.find((item) => {
@@ -158,41 +159,26 @@ const getRepairHistoryPath = (
return `${ANALYSIS_ENDPOINT_PREFIX}/${encodeURIComponent(zoneId)}/repairHistories${suffix}${query ? `?${query}` : ""}`;
};
/** 윈도우 팝업과 부모 지도가 같은 전체 결과를 사용하도록 서버 페이지를 끝까지 합친다. */
export const fetchAllRepairHistoriesByZoneId = async (
/** 목록은 현재 페이지 하나만 조회한다. 전체 지도/Excel과 호출 계약을 분리한다. */
export const fetchRepairHistoryPage = async (
zoneId: string,
filters: RepairHistoryFilters,
page: number,
size: number,
): Promise<RepairHistoryResponse> => {
if (!zoneId.trim()) throw new Error("처리구역 식별값이 없습니다.");
const firstParams = new URLSearchParams();
appendRepairHistoryFilters(firstParams, filters);
firstParams.set("page", "0");
firstParams.set("size", String(REPAIR_HISTORY_PAGE_SIZE));
const first = (await getData(
`${ANALYSIS_ENDPOINT_PREFIX}/${encodeURIComponent(zoneId)}/repairHistories?${firstParams.toString()}`,
)) as RepairHistoryResponse;
const pages: RepairHistoryResponse[] = [first];
for (let page = 1; page < (first.totalPages ?? 0); page += 1) {
const params = new URLSearchParams(firstParams);
params.set("page", String(page));
pages.push((await getData(
`${ANALYSIS_ENDPOINT_PREFIX}/${encodeURIComponent(zoneId)}/repairHistories?${params.toString()}`,
)) as RepairHistoryResponse);
}
const data = pages.flatMap((page) => page?.data ?? []);
return {
...first,
totalElements: data.length,
totalPages: 1,
page: 0,
size: data.length,
data,
};
return getData(getRepairHistoryPath(zoneId, filters, "", {
page: String(page), size: String(size),
})) as Promise<RepairHistoryResponse>;
};
/** 검색조건에 맞는 전체 도형을 한 번 조회하며 목록 페이지 이동 시에는 호출하지 않는다. */
export const fetchRepairHistoryMapFeatures = (
zoneId: string,
filters: RepairHistoryFilters,
): Promise<RepairHistoryMapFeature[]> =>
getData(getRepairHistoryPath(zoneId, filters, "/mapFeatures"));
/** 현재 조회 조건의 화면 목록 항목 파일을 내려받는다. */
export const downloadRepairHistoriesByZoneId = async (
zoneId: string,
@@ -15,8 +15,7 @@ import { Button } from "@common/components";
import { getData } from "@/components/lib/api";
import { appendApiSearchParams } from "@/shared/util/util";
import { downloadExcelWithKoreanHeaders } from "@/shared/lib/download-excel";
import { fetchAllPages } from "@/shared/lib/fetch-all-pages";
import { downloadAnalysisExcel } from "@/shared/lib/download-analysis-excel";
import { useAlertStore } from "@/shared/lib";
import { CtpvSggPrcsFcltSelectBox } from "@/components/ui/ctpvSggPrcsFcltSelectBox";
import { formatDisplayData } from "@/components/lib/utils/formatDisplayValue";
@@ -162,7 +161,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 ?? "-"),
@@ -243,29 +242,7 @@ export default function RdiiResultPopupPage() {
try {
setIsLoading(true);
const response = await fetchAllPages<any, Record<string, unknown>>({
fetchPage: (page, size) =>
getData(
`v1/rdiiAnlsRslt/ppl/${selectedRow.rainInfwtAnlsSn}?page=${page}&size=${size}`,
),
getItems: (page) =>
Array.isArray(page?.content) ? page.content : [],
getTotal: (page) => Number(page?.totalElements ?? 0),
});
const excelRows = response.items;
await downloadExcelWithKoreanHeaders({
data: excelRows as unknown as JSON[],
fileName: `RDII분석결과_${selectedRow.swtrmtFcltNm}_${selectedRow.aplcnYm}_${selectedRow.rainInfwtAnlsSn}`,
sheetNameArray: ["RDII 분석결과"],
headerRange: "A1:D1",
headerMapping: {
rainInfwtAnlsSn: "RDII분석\n분석번호",
pplMngNo: "관로\n관리번호",
anlsTm: "분석시각",
predcOilq: "예측유량\n(m³/h)",
},
});
await downloadAnalysisExcel("rdii", selectedRow.rainInfwtAnlsSn, `RDII분석결과_${selectedRow.swtrmtFcltNm}_${selectedRow.aplcnYm}_${selectedRow.rainInfwtAnlsSn}`);
} catch (error) {
console.error("RDII 분석결과 다운로드 중 오류:", error);
alert("RDII 분석결과 다운로드 중 오류가 발생했습니다.");
@@ -4,7 +4,7 @@
*/
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Button, Input } from "@common/components";
import {
StandardPopupLayout,
@@ -23,7 +23,8 @@ import {
import { MS_SWAGE_PPL_CODES } from "../common/table";
import {
downloadRepairHistoriesByZoneId,
fetchAllRepairHistoriesByZoneId,
fetchRepairHistoryPage,
fetchRepairHistoryMapFeatures,
type RepairHistoryFilters,
type RepairHistoryRow,
} from "../popupComponent/swtrmtPrcsZoneAnalysisApi";
@@ -175,17 +176,21 @@ export default function RepairHistoryPopupPage({
useState<RepairHistoryFilters>(buildEmptyFilters);
const [appliedFilters, setAppliedFilters] =
useState<RepairHistoryFilters>(buildEmptyFilters);
// 전체 조회 결과는 부모 지도 강조와 팝업 내 10건 페이지 표가 함께 사용한다.
// 목록에는 현재 서버 페이지의 행만 저장하고 지도 전체 도형은 별도로 전달한다.
const [rows, setRows] = useState<RepairHistoryRow[]>([]);
const [total, setTotal] = useState(0);
// 페이지 요청과 지도 요청을 분리해 페이지 이동이 진행 중인 전체 지도 조회를 취소하지 않는다.
const pageRequestId = useRef(0);
const mapRequestId = useRef(0);
// 용도 선택지는 별도 하드코딩하지 않고 관망도 트리 설정 API의 sortOrdr를 따른다.
const [pipeUseOptions, setPipeUseOptions] = useState<PipeUseOption[]>([]);
const [currentPage, setCurrentPage] = useState(1);
const [isLoading, setIsLoading] = useState(false);
const [errorMessage, setErrorMessage] = useState("");
/** 조회 결과 전체를 부모 지도 레이어와 팝업 표에 동시에 반영한다. */
/** 목록은 한 페이지만 조회하고 검색조건 변경 시에만 지도 전체 도형을 갱신한다. */
const searchRepairHistories = useCallback(
async (nextFilters: RepairHistoryFilters) => {
async (nextFilters: RepairHistoryFilters, page = 1, refreshMap = true) => {
if (!zoneId) {
setErrorMessage("처리구역 식별값이 없습니다.");
return;
@@ -193,38 +198,45 @@ export default function RepairHistoryPopupPage({
setIsLoading(true);
setErrorMessage("");
const requestId = ++pageRequestId.current;
if (refreshMap) {
const mapId = ++mapRequestId.current;
postRepairMessage(WINDOW_POPUP_MESSAGE_TYPE.REPAIR_HISTORY_SHOW_RESULTS, { zoneId, rows: [] });
void fetchRepairHistoryMapFeatures(zoneId, nextFilters).then((features) => {
if (mapId !== mapRequestId.current) return;
postRepairMessage(WINDOW_POPUP_MESSAGE_TYPE.REPAIR_HISTORY_SHOW_RESULTS, { zoneId, rows: features });
}).catch(() => {
if (mapId === mapRequestId.current) setErrorMessage("보수이력 지도 도형을 불러오지 못했습니다. 조회를 다시 실행해주세요.");
});
}
try {
const response = await fetchAllRepairHistoriesByZoneId(
const response = await fetchRepairHistoryPage(
zoneId,
nextFilters,
page - 1,
REPAIR_HISTORY_PAGE_SIZE,
);
if (requestId !== pageRequestId.current) return;
const nextRows = response.data ?? [];
setRows(nextRows);
setTotal(response.totalElements);
setAppliedFilters({ ...nextFilters });
setCurrentPage(1);
postRepairMessage(
WINDOW_POPUP_MESSAGE_TYPE.REPAIR_HISTORY_SHOW_RESULTS,
{
zoneId,
rows: nextRows,
},
);
setCurrentPage(page);
} catch (error) {
if (requestId !== pageRequestId.current) return;
setRows([]);
postRepairMessage(
WINDOW_POPUP_MESSAGE_TYPE.REPAIR_HISTORY_SHOW_RESULTS,
{
zoneId,
rows: [],
},
);
setTotal(0);
if (refreshMap) {
mapRequestId.current += 1;
postRepairMessage(WINDOW_POPUP_MESSAGE_TYPE.REPAIR_HISTORY_SHOW_RESULTS, { zoneId, rows: [] });
}
setErrorMessage(
error instanceof Error
? error.message
: "보수이력을 불러오지 못했습니다.",
);
} finally {
setIsLoading(false);
if (requestId === pageRequestId.current) setIsLoading(false);
}
},
[zoneId],
@@ -270,6 +282,8 @@ export default function RepairHistoryPopupPage({
};
window.addEventListener("beforeunload", clearParentLayer);
return () => {
pageRequestId.current += 1;
mapRequestId.current += 1;
window.removeEventListener("beforeunload", clearParentLayer);
clearParentLayer();
};
@@ -277,7 +291,7 @@ export default function RepairHistoryPopupPage({
/** 화면에서 적용한 조건과 동일한 9개 목록 항목의 XLSX를 내려받는다. */
const handleDownload = async () => {
if (!zoneId || rows.length === 0) return;
if (!zoneId || total === 0) return;
setIsLoading(true);
setErrorMessage("");
try {
@@ -306,18 +320,10 @@ export default function RepairHistoryPopupPage({
});
};
const visibleRows = useMemo(
() =>
rows.slice(
(currentPage - 1) * REPAIR_HISTORY_PAGE_SIZE,
currentPage * REPAIR_HISTORY_PAGE_SIZE,
),
[currentPage, rows],
);
// 공통 표에 전달할 표시값은 원본 API 행과 분리해 지도 연계 식별값을 보존한다.
const tableRows = useMemo<RepairHistoryTableRow[]>(
() =>
visibleRows.map((row) => ({
rows.map((row) => ({
fid: row.fid,
fcltMngNo: formatCellValue(row.fcltMngNo),
useName: formatCellValue(row.useNm),
@@ -329,7 +335,7 @@ export default function RepairHistoryPopupPage({
installationYear: formatYear(row.installationYear),
burialDepth: formatCellValue(row.burialDepth),
})),
[visibleRows],
[rows],
);
return (
<StandardPopupLayout title="보수이력 목록">
@@ -459,12 +465,12 @@ export default function RepairHistoryPopupPage({
}
headlist={REPAIR_HISTORY_HEADLIST}
title={`${zoneName || "처리구역"} 보수이력 목록`}
total={rows.length}
total={total}
page={currentPage}
pageSize={REPAIR_HISTORY_PAGE_SIZE}
onPageChange={setCurrentPage}
onPageChange={(page) => void searchRepairHistories(appliedFilters, page, false)}
onRowClick={(_, index) => {
const row = visibleRows[index];
const row = rows[index];
if (row) handleRowClick(row);
}}
rowId={(row, index) => String(row.fid ?? index)}
@@ -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),
+143 -52
View File
@@ -194,6 +194,7 @@ import {
isSbmrgDngrMapRootNode,
} from "./components/sbmrgDngrMap/sbmrgDngrMapTypes";
import {
FLOOD_DNGR_MAP_LAYER_NAME,
FLOOD_DNGR_MAP_LAYER_TYPE,
FLOOD_DNGR_MAP_POPUP_KEY,
isFloodDngrMapRootNode,
@@ -1176,11 +1177,24 @@ const Map = () => {
const sinkholeScope = useMemo(() => buildSinkholeScope(userInfo), [userInfo]);
const [treeData, setTreeData] = useState<any[]>([]); // 초기값을 객체 또는 null로 설정
// 통수위험 결과 행을 선택할 때 기존 체크를 유지하면서 강우 격자 레이어를 켜기 위한 요청이다.
// 지도 연계 팝업의 행을 선택할 때 기존 체크를 유지하면서 필요한 업무 레이어를 켜기 위한 요청이다.
const [checkedLayerRequest, setCheckedLayerRequest] = useState<{
requestKey: number;
layerNames: string[];
} | null>(null);
// 침수예측현황에서 선택한 행은 도시침수지도 레이어가 켜진 뒤 지도 이동에 사용한다.
const [pendingFloodPredictionSelection, setPendingFloodPredictionSelection] =
useState<{
extent: [number, number, number, number];
sggCd: string;
frequency: string;
analysisDateTime: string;
} | null>(null);
// 팝업 Window 객체는 렌더링 대상이 아니므로 자동 레이어 선택 실패 안내용 ref로 보관한다.
const pendingFloodPredictionPopupRef = useRef<{
source: Window | null;
origin: string;
} | null>(null);
const [layerLoadError, setLayerLoadError] = useState<string | null>(null);
const gisMaps = useRef<any | null>(null);
const [isOpen, setIsOpen] = useState(false); // 팝업 열기/닫기 상태
@@ -1856,6 +1870,74 @@ const Map = () => {
[removeFloodPredictionGridLayer],
);
/** 활성화된 도시침수지도에서 선택 행의 시군구·빈도 피처를 선택하고 지도와 오버레이를 이동한다. */
const applyFloodPredictionSelection = useCallback(
async (selection: {
extent: [number, number, number, number];
sggCd: string;
frequency: string;
analysisDateTime: string;
}) => {
const map = gisMaps.current;
const select = GisApp.Module.select as any;
if (!map?.getView || !select?.selectFocusedBoundaryFeature) return false;
const floodLayers = map
.getLayers()
.getArray()
.filter(
(layer: any) =>
String(layer.get?.("lyrTyNm") ?? "").trim() ===
FLOOD_DNGR_MAP_LAYER_TYPE && layer.getVisible?.() !== false,
);
if (floodLayers.length === 0) return false;
await select.resetSelectedFeature?.();
const selectedFeatureIds = new Set<string>();
const selectLoadedGroupFeatures = (source: any) => {
const features = source?.getFeatures?.() ?? [];
features.forEach((feature: any) => {
const featureSggCd = String(feature.get?.("sgg_cd") ?? "").trim();
const featureFrequency = String(
feature.get?.("sbmrg_frq_expln") ?? "",
).trim();
if (
featureSggCd !== selection.sggCd ||
featureFrequency !== selection.frequency
)
return;
const featureId = String(
feature.getId?.() ?? feature.get?.("fid") ?? "",
);
if (featureId && selectedFeatureIds.has(featureId)) return;
if (featureId) selectedFeatureIds.add(featureId);
select.selectFocusedBoundaryFeature(feature, true);
});
};
floodLayers.forEach((layer: any) => {
const source = layer.getSource?.();
if (!source?.getFeatures) return;
selectLoadedGroupFeatures(source);
source.once?.("featuresloadend", () =>
selectLoadedGroupFeatures(source),
);
});
map.getView().fit(selection.extent, {
duration: 300,
padding: [50, 50, 50, 50],
maxZoom: 16,
});
void loadFloodPredictionGridLayer(
selection.sggCd,
selection.analysisDateTime,
selection.extent,
);
return true;
},
[loadFloodPredictionGridLayer],
);
useEffect(() => {
fetchData();
}, []);
@@ -2008,58 +2090,23 @@ const Map = () => {
) {
return;
}
await select.resetSelectedFeature?.();
const selectedFeatureIds = new Set<string>();
let floodLayerVisible = false;
const selectLoadedGroupFeatures = (source: any) => {
const features = source?.getFeatures?.() ?? [];
features.forEach((feature: any) => {
const featureSggCd = String(feature.get?.("sgg_cd") ?? "").trim();
const featureFrequency = String(
feature.get?.("sbmrg_frq_expln") ?? "",
).trim();
if (featureSggCd !== sggCd || featureFrequency !== frequency)
return;
const featureId = String(
feature.getId?.() ?? feature.get?.("fid") ?? "",
);
if (featureId && selectedFeatureIds.has(featureId)) return;
if (featureId) selectedFeatureIds.add(featureId);
select.selectFocusedBoundaryFeature(feature, true);
});
const selection = {
extent: extent as [number, number, number, number],
sggCd,
frequency,
analysisDateTime,
};
if (await applyFloodPredictionSelection(selection)) return;
map
.getLayers()
.getArray()
.forEach((layer: any) => {
if (
String(layer.get?.("lyrTyNm") ?? "").trim() !==
FLOOD_DNGR_MAP_LAYER_TYPE
)
return;
if (layer.getVisible?.() !== false) floodLayerVisible = true;
const source = layer.getSource?.();
if (!source?.getFeatures) return;
selectLoadedGroupFeatures(source);
source.once?.("featuresloadend", () =>
selectLoadedGroupFeatures(source),
);
});
if (!floodLayerVisible) {
(event.source as Window | null)?.postMessage?.(
{ type: WINDOW_POPUP_MESSAGE_TYPE.MAP_SEARCH_FLOOD_LAYER_REQUIRED },
event.origin,
);
return;
}
map.getView().fit(extent, {
duration: 300,
padding: [50, 50, 50, 50],
maxZoom: 16,
});
void loadFloodPredictionGridLayer(sggCd, analysisDateTime, extent);
pendingFloodPredictionPopupRef.current = {
source: event.source as Window | null,
origin: event.origin,
};
setPendingFloodPredictionSelection(selection);
setCheckedLayerRequest((current) => ({
requestKey: (current?.requestKey ?? 0) + 1,
layerNames: [FLOOD_DNGR_MAP_LAYER_NAME],
}));
return;
}
@@ -2098,7 +2145,50 @@ const Map = () => {
window.clearInterval(timer);
window.removeEventListener("message", handlePopupMessage);
};
}, [loadFloodPredictionGridLayer]);
}, [applyFloodPredictionSelection]);
useEffect(() => {
if (!pendingFloodPredictionSelection) return;
let retryTimer: number | null = null;
let retryCount = 0;
const maxRetryCount = 20;
/** 자동 체크 요청으로 도시침수지도 레이어가 생성될 때까지 선택 행 적용을 제한적으로 재시도한다. */
const applyPendingSelection = async () => {
if (
await applyFloodPredictionSelection(pendingFloodPredictionSelection)
) {
setPendingFloodPredictionSelection((current) =>
current === pendingFloodPredictionSelection ? null : current,
);
pendingFloodPredictionPopupRef.current = null;
return;
}
retryCount += 1;
if (retryCount >= maxRetryCount) {
const popup = pendingFloodPredictionPopupRef.current;
popup?.source?.postMessage(
{ type: WINDOW_POPUP_MESSAGE_TYPE.MAP_SEARCH_FLOOD_LAYER_REQUIRED },
popup.origin,
);
pendingFloodPredictionPopupRef.current = null;
setPendingFloodPredictionSelection((current) =>
current === pendingFloodPredictionSelection ? null : current,
);
return;
}
retryTimer = window.setTimeout(() => {
void applyPendingSelection();
}, 200);
};
void applyPendingSelection();
return () => {
if (retryTimer !== null) window.clearTimeout(retryTimer);
};
}, [applyFloodPredictionSelection, pendingFloodPredictionSelection]);
useEffect(() => {
if (shouldShowGisEditSession) return;
@@ -7692,6 +7782,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);
@@ -23,6 +23,7 @@ import { windowPopupDefinitions } from "@/features/windowPopup/model/popupDefini
import {
ExclnSewagePumpCol2,
FldngWtlvMapCol2,
FldngDpthMapCol2,
MsSwageMhlCol2,
MsSwagePplCol2,
stofcbrCol2,
@@ -103,6 +104,8 @@ const SearchModal: React.FC<SearchModalProps> = ({
? SwtrmtPrcsZoneCol2
: basicType === "tb_ms_fldng_wtlv_map"
? FldngWtlvMapCol2
: basicType === "tb_ms_fldng_dpth_map"
? FldngDpthMapCol2
: basicType === "tb_ms_swage_mhl"
? MsSwageMhlCol2
: basicType === "tb_ms_swage_pmstn"
@@ -12,21 +12,13 @@
* 🔗 사용 API
* - 관망도 시설물 목록 조회: GET /api/v1/fclts (sewerage-service)
*/
import { downloadExcelWithKoreanHeaders } from "@/shared/lib/download-excel";
import { fetchAllPages } from "@/shared/lib/fetch-all-pages";
import { requestGridExcel } from "@/shared/api/grid-export";
import {
MS_SWAGE_PPL_USE_CODES,
MS_SWAGE_PPL_CODES,
MS_SWAGE_PPL_SHAPE,
MS_SWAGE_MHL_CODES,
MS_SWAGE_MHL_USE_CODES,
FldngWtlvMapExcelCol,
MsSwagePplExcelCol,
SwtrmtFcltExcelCol,
SwtrmtPrcsZoneExcelCol,
MsSwageMhlExcelCol,
ExclnSewagePumpExcelCol,
stofcbrExcelCol,
} from "../components/common/table";
import { getData } from "@/components/lib/api";
import { BASIC_TYPES } from "./basicTab";
@@ -51,9 +43,6 @@ const isPopupWindow = () =>
!!window.opener &&
window.location.pathname.startsWith("/popup/");
// Excel 열에 포함되지 않는 화면용 유하방향 보강은 제외하고 서버가 일괄 병합할 수 있는 크기로 순회한다.
const BASIC_EXCEL_BATCH_SIZE = 2_000;
export default function useBasicTab() {
const alert = useAlertStore((state) => state.setMessage);
const { confirm } = useConfirm();
@@ -152,7 +141,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,
@@ -232,284 +221,17 @@ export default function useBasicTab() {
try {
const appliedQuery = useSearchModalStore.getState().appliedBasicQuery;
if (!appliedQuery) {
throw new Error(
"화면 조회조건을 확인할 수 없습니다. 다시 조회해주세요.",
);
}
const appliedBasicType = appliedQuery.basicType;
const queryParams = new URLSearchParams(appliedQuery.query);
if (appliedBasicType === "tb_ms_swage_ppl") {
queryParams.delete("includeFlowDirectionBasis");
}
let headerMapping: { [key: string]: string } = {};
let headerRange: string = "A1:F1";
switch (appliedBasicType) {
case "tb_ms_swage_ppl": {
// 하수관로
headerMapping = MsSwagePplExcelCol;
headerRange = "A1:Q1";
break;
}
case "tb_il_swage_prcs_fclt": {
// 하수처리시설
headerMapping = SwtrmtFcltExcelCol;
headerRange = "A1:J1";
break;
}
case "tb_ms_swage_prcs_zone": {
// 하수처리구역
headerMapping = SwtrmtPrcsZoneExcelCol;
headerRange = "A1:D1";
break;
}
case "tb_ms_fldng_wtlv_map": {
// 침수흔적도
headerMapping = FldngWtlvMapExcelCol;
headerRange = "A1:O1";
break;
}
case "tb_ms_swage_mhl": {
// 하수맨홀
headerMapping = MsSwageMhlExcelCol;
headerRange = "A1:J1";
break;
}
case "tb_ms_swage_pmstn": {
// 하수펌프장
headerMapping = ExclnSewagePumpExcelCol;
headerRange = "A1:J1";
break;
}
case "tb_ms_stofcbr": {
// 우수토실
headerMapping = stofcbrExcelCol;
headerRange = "A1:H1";
break;
}
}
const res = await fetchAllPages<any, any>({
batchSize: BASIC_EXCEL_BATCH_SIZE,
fetchPage: (page, size) => {
const pageParams = new URLSearchParams(queryParams);
pageParams.set("page", String(page));
pageParams.set("size", String(size));
return getData(`v1/fclts?${pageParams.toString()}`);
},
getItems: (page) => (Array.isArray(page?.data) ? page.data : []),
getTotal: (page) => Number(page?.count ?? 0),
});
const mhlUsgCdMap = new Map(
MS_SWAGE_MHL_USE_CODES.map((code) => [code.val?.trim(), code.text]),
);
const pplUsgCdMap = new Map(
MS_SWAGE_PPL_USE_CODES.map((code) => [code.val?.trim(), code.text]),
);
const pplMqltCdMap = new Map(
MS_SWAGE_PPL_CODES.map((code) => [code.val?.trim(), code.text]),
);
const pplShpCdMap = new Map(
MS_SWAGE_PPL_SHAPE.map((code) => [code.val?.trim(), code.text]),
); // 관로 시설물형태코드
const ctpvMap = new Map(
ctpv.map((code) => [code.val?.trim(), code.text]),
);
const sggMap = new Map(
sgg.map((code) => [code.val?.trim(), code.text]),
);
let mappedContent = res.items.map((item: any) => {
const formattedItem = formatDisplayData(item);
let ctpvCd = item?.ctpvCd ?? formattedItem?.ctpvCd ?? "";
let sggCd = item?.sggCd ?? formattedItem?.sggCd ?? "";
if (!ctpvCd || !sggCd) {
const code = String(
item?.pbadmsEmdCd ??
formattedItem?.pbadmsEmdCd ??
item?.stdgCd ??
formattedItem?.stdgCd ??
"",
)?.trim();
if (!ctpvCd && code) ctpvCd = code.slice(0, 2);
if (!sggCd && code) sggCd = code.slice(0, 5);
}
// trim 한 번만 수행
const trimmedUsgCd = item?.usgCd?.trim();
// const trimmedMqltCd = item?.mqltCd?.trim();
switch (appliedBasicType) {
case "tb_ms_swage_ppl": {
// 하수관로
return {
fcltMngNo: formattedItem.fcltMngNo ?? item.fcltMngNo ?? "-",
instMngNo: formattedItem.instMngNo ?? item.instMngNo ?? "-",
ctpvNm: ctpvMap.get(ctpvCd) ?? item.ctpvNm ?? "-",
sggNm: sggMap.get(sggCd) ?? item.sggNm ?? "-",
swtrmt_zone_nm:
formattedItem.swtrmt_zone_nm ?? item.swtrmt_zone_nm ?? "-",
instlYmd: formattedItem.instlYmd ?? item.instlYmd ?? "-",
usgCd:
pplUsgCdMap.get(trimmedUsgCd) ||
item.usgCd ||
formattedItem.usgCd ||
"-",
pdmtVl: formattedItem.pdmtVl ?? item.pdmtVl ?? "-",
prlgVl: formattedItem.prlgVl ?? item.prlgVl ?? "-",
tmptDpth: formattedItem.tmptDpth ?? item.tmptDpth ?? "-",
tmptMhlMngNo:
formattedItem.tmptMhlMngNo ?? item.tmptMhlMngNo ?? "-",
scendDpth: formattedItem.scendDpth ?? item.scendDpth ?? "-",
scendMhlMngNo:
formattedItem.scendMhlMngNo ?? item.scendMhlMngNo ?? "-",
vrtcLen: formattedItem.vrtcLen ?? item.vrtcLen ?? "-",
wdthLen: formattedItem.wdthLen ?? item.wdthLen ?? "-",
fcltyShpCd:
pplShpCdMap.get(item?.fcltyShpCd?.trim()) ||
item.fcltyShpCd ||
formattedItem.fcltyShpCd ||
"-",
mqltCd:
pplMqltCdMap.get(item?.mqltCd?.trim()) ||
item.mqltCd ||
formattedItem.mqltCd ||
"-",
};
}
case "tb_il_swage_prcs_fclt": {
// 하수처리시설
return {
swtrmtFcltNm:
formattedItem.swtrmtFcltNm ?? item.swtrmtFcltNm ?? "-",
fcltCpct: formattedItem.fcltCpct ?? item.fcltCpct ?? "-",
rgnExpln: formattedItem.rgnExpln ?? item.rgnExpln ?? "-",
oprtngStrtYmd:
formattedItem.oprtngStrtYmd ?? item.oprtngStrtYmd ?? "-",
laeNm: formattedItem.laeNm ?? item.laeNm ?? "-",
sareaNm: formattedItem.sareaNm ?? item.sareaNm ?? "-",
rvrNm: formattedItem.rvrNm ?? item.rvrNm ?? "-",
dschgWtstNm:
formattedItem.dschgWtstNm ?? item.dschgWtstNm ?? "-",
swtrmtPpltnCnt:
formattedItem.swtrmtPpltnCnt ?? item.swtrmtPpltnCnt ?? "-",
rprsMsrcCn: formattedItem.rprsMsrcCn ?? item.rprsMsrcCn ?? "-",
};
}
case "tb_ms_swage_prcs_zone": {
// 하수처리구역
return {
swtrmtZoneNm:
formattedItem.swtrmtZoneNm ?? item.swtrmtZoneNm ?? "-",
ctpvNm: ctpvMap.get(ctpvCd) ?? formattedItem.ctpvNm ?? "-",
sggNm: sggMap.get(sggCd) ?? formattedItem.sggNm ?? "-",
swtrmtFcltNm:
formattedItem.swtrmtFcltNm ?? item.swtrmtFcltNm ?? "-",
};
}
case "tb_ms_fldng_wtlv_map": {
// 침수흔적도
return {
fldngYr: formattedItem.fldngYr ?? item.fldngYr ?? "-",
fldngDstrNm:
formattedItem.fldngDstrNm ?? item.fldngDstrNm ?? "-",
ctpvNm: ctpvMap.get(ctpvCd) ?? formattedItem.ctpvNm ?? "-",
sggNm: sggMap.get(sggCd) ?? formattedItem.sggNm ?? "-",
bgngYmd: formattedItem.bgngYmd ?? item.bgngYmd ?? "-",
fldngBgngHm:
formattedItem.fldngBgngHm ?? item.fldngBgngHm ?? "-",
endYmd: formattedItem.endYmd ?? item.endYmd ?? "-",
fldngEndHm: formattedItem.fldngEndHm ?? item.fldngEndHm ?? "-",
fldngHr: formattedItem.fldngHr ?? item.fldngHr ?? "-",
fldngDstrctNm:
formattedItem.fldngDstrctNm ?? item.fldngDstrctNm ?? "-",
fldngCsDtlCd:
formattedItem.fldngCsDtlCd ?? item.fldngCsDtlCd ?? "-",
avgFldngWtlv:
formattedItem.avgFldngWtlv ?? item.avgFldngWtlv ?? "-",
fldngArea: formattedItem.fldngArea ?? item.fldngArea ?? "-",
exmnYr: formattedItem.exmnYr ?? item.exmnYr ?? "-",
damAmt: formattedItem.damAmt ?? item.damAmt ?? "-",
};
}
case "tb_ms_swage_mhl": {
// 하수맨홀
return {
fcltMngNo: formattedItem.fcltMngNo ?? item.fcltMngNo ?? "-",
ctpvNm: ctpvMap.get(ctpvCd) ?? formattedItem.ctpvNm ?? "-",
sggNm: sggMap.get(sggCd) ?? formattedItem.sggNm ?? "-",
instlYmd: formattedItem.instlYmd ?? item.instlYmd ?? "-",
usgCd:
mhlUsgCdMap.get(trimmedUsgCd) ||
item.usgCd ||
formattedItem.usgCd ||
"-",
swageMhlDiamVl:
formattedItem.swageMhlDiamVl ?? item.swageMhlDiamVl ?? "-",
wdthLen: formattedItem.wdthLen ?? item.wdthLen ?? "-",
vrtcLen: formattedItem.vrtcLen ?? item.vrtcLen ?? "-",
altdVl: formattedItem.altdVl ?? item.altdVl ?? "-",
dpthlVl: formattedItem.dpthlVl ?? item.dpthlVl ?? "-",
};
}
case "tb_ms_swage_pmstn": {
// 하수펌프장
return {
fcltMngNo: formattedItem.fcltMngNo ?? item.fcltMngNo ?? "-",
ctpvNm: ctpvMap.get(ctpvCd) ?? formattedItem.ctpvNm ?? "-",
sggNm: sggMap.get(sggCd) ?? formattedItem.sggNm ?? "-",
swagePmstnNm:
formattedItem.swagePmstnNm ?? item.swagePmstnNm ?? "-",
instlYmd: formattedItem.instlYmd ?? item.instlYmd ?? "-",
dailyPrcsCpct:
formattedItem.dailyPrcsCpct ?? item.dailyPrcsCpct ?? "-",
maxImpndCpct:
formattedItem.maxImpndCpct ?? item.maxImpndCpct ?? "-",
altdVl: formattedItem.altdVl ?? item.altdVl ?? "-",
wtlvVl: formattedItem.wtlvVl ?? item.wtlvVl ?? "-",
exclnWpmpAbltVl:
formattedItem.exclnWpmpAbltVl ?? item.exclnWpmpAbltVl ?? "-",
};
}
case "tb_ms_stofcbr": {
// 우수토실
return {
fcltMngNo: formattedItem.fcltMngNo ?? item.fcltMngNo ?? "-",
mapshtNo: formattedItem.mapshtNo ?? item.mapshtNo ?? "-",
ctpvNm: ctpvMap.get(ctpvCd) ?? formattedItem.ctpvNm ?? "-",
sggNm: sggMap.get(sggCd) ?? formattedItem.sggNm ?? "-",
instlYmd: formattedItem.instlYmd ?? item.instlYmd ?? "-",
worflwWallHgt:
formattedItem.worflwWallHgt ?? item.worflwWallHgt ?? "-",
swageMhlMngNo:
formattedItem.swageMhlMngNo ?? item.swageMhlMngNo ?? "-",
drtnAngl: formattedItem.drtnAngl ?? item.drtnAngl ?? "-",
};
}
}
});
// 선택된 행 데이터를 Excel 다운로드 형식으로 변환
const excelData = mappedContent; // 배열로 감싸서 전달
// 파일명 생성 (분석번호 기반)
const label = BASIC_TYPES.find(
(type) => type.val === appliedBasicType,
)?.text;
const fileName = `${label} 조회 결과_${todayYear}${todayMonth}${todayDate}`;
await downloadExcelWithKoreanHeaders({
data: excelData as unknown as JSON[],
fileName: fileName,
sheetNameArray: [fileName],
headerRange: headerRange,
headerMapping: headerMapping,
});
if (!appliedQuery) throw new Error("화면 조회조건을 확인할 수 없습니다. 다시 조회해주세요.");
const params = new URLSearchParams(appliedQuery.query);
// 현재 페이지나 목록 배열을 보내지 않고 전체 조건과 표시명만 서버 작업으로 전달한다.
const label = BASIC_TYPES.find((type) => type.val === appliedQuery.basicType)?.text ?? "시설";
await requestGridExcel("v1/facilityExports", {
lyrTypeNm: appliedQuery.basicType,
cqlFilter: params.get("cqlFiter") ?? "",
sort: params.get("sort"),
ctpvNames: Object.fromEntries(ctpv.map((item) => [String(item.val).trim(), item.text])),
sggNames: Object.fromEntries(sgg.map((item) => [String(item.val).trim(), item.text])),
}, `${label} 조회 결과_${todayYear}${todayMonth}${todayDate}.xlsx`);
setIsLoading(false);
} catch (error) {
setIsLoading(false);
@@ -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,
};
},
@@ -19,8 +19,7 @@
*/
"use client";
import { deleteData, getData } from "@/components/lib/api";
import { downloadExcelWithKoreanHeaders } from "@/shared/lib/download-excel";
import { fetchAllPages } from "@/shared/lib/fetch-all-pages";
import { downloadAnalysisExcel } from "@/shared/lib/download-analysis-excel";
import { zodResolver } from "@hookform/resolvers/zod";
import { useState, useEffect } from "react";
import { useForm } from "react-hook-form";
@@ -170,7 +169,7 @@ const Result = () => {
const totalElements = Number(data?.totalElements ?? 0);
// 전체 건수를 기준으로 현재 페이지의 순번을 내림차순 계산한다.
// 현재 페이지의 시작 위치를 기준으로 오름차순 표시 순번을 계산한다.
const dataWithRowNumber = (data.content || [])
.map((item: any, index: number) => {
// 🎯 먼저 각 아이템을 포맷팅
@@ -179,11 +178,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:
@@ -240,37 +239,7 @@ const Result = () => {
}
try {
const data = await fetchAllPages<any, Record<string, unknown>>({
fetchPage: (page, size) =>
getData(
`v1/rdiiAnlsRslt/ppl/${selectedRowData.rainInfwtAnlsSn}?page=${page}&size=${size}`,
),
getItems: (page) =>
Array.isArray(page?.content) ? page.content : [],
getTotal: (page) => Number(page?.totalElements ?? 0),
});
// 선택된 분석의 전체 관로 결과를 Excel 다운로드 형식으로 변환한다.
const excelData = data.items;
// 헤더 매핑 정의
const headerMapping: { [key: string]: string } = {
rainInfwtAnlsSn: "RDII분석\n분석번호",
pplMngNo: "관로\n관리번호",
anlsTm: "분석시각",
predcOilq: "예측유량\n(m³/h)",
};
// 파일명 생성 (분석번호 기반)
const fileName = `RDII분석결과_${selectedRowData.swtrmtFcltNm}_${selectedRowData.aplcnYm}_${selectedRowData.rainInfwtAnlsSn}`;
await downloadExcelWithKoreanHeaders({
data: excelData as unknown as JSON[],
fileName: fileName,
sheetNameArray: ["RDII 분석결과"],
headerRange: "A1:D1",
headerMapping: headerMapping,
});
await downloadAnalysisExcel("rdii", selectedRowData.rainInfwtAnlsSn, `RDII분석결과_${selectedRowData.swtrmtFcltNm}_${selectedRowData.aplcnYm}_${selectedRowData.rainInfwtAnlsSn}`);
setIsLoadingResult(false);
} catch (error) {
setIsLoadingResult(false);
@@ -19,8 +19,7 @@
*/
"use client";
import { deleteData, getData } from "@/components/lib/api";
import { downloadExcelWithKoreanHeaders } from "@/shared/lib/download-excel";
import { fetchAllPages } from "@/shared/lib/fetch-all-pages";
import { downloadAnalysisExcel } from "@/shared/lib/download-analysis-excel";
import { zodResolver } from "@hookform/resolvers/zod";
import { useState, useEffect } from "react";
import { useForm } from "react-hook-form";
@@ -170,7 +169,7 @@ const Result = () => {
const totalElements = Number(data?.totalElements ?? 0);
// 전체 건수를 기준으로 현재 페이지의 순번을 내림차순 계산한다.
// 현재 페이지의 시작 위치를 기준으로 오름차순 표시 순번을 계산한다.
const dataWithRowNumber = (data.content || [])
.map((item: any, index: number) => {
// 🎯 먼저 각 아이템을 포맷팅
@@ -179,11 +178,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:
@@ -240,37 +239,7 @@ const Result = () => {
}
try {
const data = await fetchAllPages<any, Record<string, unknown>>({
fetchPage: (page, size) =>
getData(
`v1/inftwtAnlsRslt/ppl/${selectedRowData.inftwtAnlsSn}?page=${page}&size=${size}`,
),
getItems: (page) =>
Array.isArray(page?.content) ? page.content : [],
getTotal: (page) => Number(page?.totalElements ?? 0),
});
// 선택된 분석의 전체 관로 결과를 Excel 다운로드 형식으로 변환한다.
const excelData = data.items;
// 헤더 매핑 정의
const headerMapping: { [key: string]: string } = {
inftwtAnlsSn: "침입수분석\n분석번호",
pplMngNo: "관로\n관리번호",
anlsTm: "분석시각",
predcOilq: "예측유량\n(m³/h)",
};
// 파일명 생성 (분석번호 기반)
const fileName = `침입수분석결과_${selectedRowData.swtrmtFcltNm}_${selectedRowData.aplcnYm}_${selectedRowData.inftwtAnlsSn}`;
await downloadExcelWithKoreanHeaders({
data: excelData as unknown as JSON[],
fileName: fileName,
sheetNameArray: ["침입수 분석결과"],
headerRange: "A1:D1",
headerMapping: headerMapping,
});
await downloadAnalysisExcel("inftwt", selectedRowData.inftwtAnlsSn, `침입수분석결과_${selectedRowData.swtrmtFcltNm}_${selectedRowData.aplcnYm}_${selectedRowData.inftwtAnlsSn}`);
setIsLoadingResult(false);
} catch (error) {
setIsLoadingResult(false);
@@ -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>
);
}
@@ -40,7 +40,8 @@ import { useConfirm } from "@frontend-opensource/use-react-hooks";
import {
deleteDredgingRecord,
fetchAllDredgingRecords,
fetchDredgingGridPage,
downloadDredgingGridExcel,
isDredgingBackendEnabled,
} from "./lib/client";
import { createDredgingClientUuid } from "./lib/uuid";
@@ -343,6 +344,9 @@ const Page = () => {
const [records, setRecords] = useState<DredgingRecord[]>(
isDredgingBackendEnabled ? [] : initialRecords,
);
// 서버 페이지와 전체 건수는 별도 보관해 목록 10건이 전체 결과로 오인되지 않게 한다.
const [serverRows, setServerRows] = useState<TableRow[]>([]);
const [serverTotal, setServerTotal] = useState(0);
const [isLoading, setIsLoading] = useState(isDredgingBackendEnabled);
const [loadError, setLoadError] = useState<string | null>(null);
const [selectedRecordId, setSelectedRecordId] = useState<string | null>(null);
@@ -376,16 +380,26 @@ const Page = () => {
setIsLoading(true);
setLoadError(null);
try {
const response = await fetchAllDredgingRecords({
const response = await fetchDredgingGridPage({
viewType,
fromDate: appliedFromDate,
toDate: appliedToDate,
ctpv: appliedCtpvName,
sgg: appliedSggName,
page: currentPage - 1,
size: PAGE_SIZE,
sort: sort ? `${sort.key},${sort.direction}` : undefined,
});
if (!cancelled) setRecords(response.items ?? []);
if (!cancelled) {
setServerRows(response.content);
setServerTotal(response.totalElements);
setRecords(viewType === "MUNICIPALITY" ? response.content as unknown as DredgingRecord[] : []);
}
} catch {
if (!cancelled) {
setRecords([]);
setServerRows([]);
setServerTotal(0);
setLoadError(DREDGING_LOAD_ERROR_MESSAGE);
}
} finally {
@@ -398,7 +412,7 @@ const Page = () => {
};
// 조회 버튼과 등록 완료 이벤트에서 searchVersion을 증가시켜 명시적으로 재조회한다.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchVersion]);
}, [searchVersion, currentPage, viewType, sort, appliedFromDate, appliedToDate, appliedCtpvName, appliedSggName]);
const filteredRecords = useMemo(
() =>
@@ -420,12 +434,20 @@ const Page = () => {
);
const tableRows = useMemo<TableRow[]>(() => {
if (isDredgingBackendEnabled) {
return serverRows.map((row) => viewType === "MUNICIPALITY" ? {
...row,
pipeMaterial: resolvePipeMaterialName(String(row.pipeMaterial ?? "")),
installationYear: row.installationYear ?? "확인불가",
elapsedYears: row.elapsedYears ?? "확인불가",
} : row);
}
if (viewType === "DIAMETER") return buildDiameterRows(filteredRecords);
if (viewType === "ELAPSED_YEAR")
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),
@@ -436,17 +458,17 @@ const Page = () => {
: currentYear - record.installationYear,
recordIds: [record.id],
}));
}, [filteredRecords, viewType]);
}, [filteredRecords, viewType, serverRows]);
// 준설 API의 모든 페이지를 수집한 뒤 표시명·집계값 기준으로 정렬한다.
// 운영 모드는 서버가 정렬·페이징하며 로컬 시연 모드만 메모리 처리를 유지한다.
const sortedTableRows = useMemo(
() => sortStandardTableRows(tableRows, sort),
() => isDredgingBackendEnabled ? tableRows : sortStandardTableRows(tableRows, sort),
[sort, tableRows],
);
const pageRows = useMemo(
() =>
sortedTableRows.slice(
isDredgingBackendEnabled ? sortedTableRows : sortedTableRows.slice(
(currentPage - 1) * PAGE_SIZE,
currentPage * PAGE_SIZE,
),
@@ -455,11 +477,6 @@ const Page = () => {
const selectedRecord =
records.find((record) => record.id === selectedRecordId) ?? null;
const selectedJobRecords = selectedRecord
? records.filter(
(record) => record.dredgingJobSn === selectedRecord.dredgingJobSn,
)
: [];
const canManageSelectedRecord =
viewType === "MUNICIPALITY" && selectedRecord !== null && !isMutating;
@@ -477,6 +494,7 @@ const Page = () => {
headlist.map((head) => ({
id: head.id,
accessorFn: (row) => row[head.id],
// 표시명 컬럼까지 서버 전체 정렬을 사용한다.
header: () => (
<StandardTableSortHeader
label={head.title}
@@ -494,7 +512,7 @@ const Page = () => {
align: head.align ?? "center",
},
})),
[headlist, sort],
[headlist, sort, viewType],
);
const pagination: PaginationState = {
pageIndex: currentPage - 1,
@@ -513,7 +531,7 @@ const Page = () => {
pagination,
rowSelection: selectedTableRowId ? { [selectedTableRowId]: true } : {},
},
rowCount: sortedTableRows.length,
rowCount: isDredgingBackendEnabled ? serverTotal : sortedTableRows.length,
manualPagination: true,
getCoreRowModel: getCoreRowModel(),
getRowId: (row) => row.recordIds.join("|"),
@@ -559,6 +577,18 @@ const Page = () => {
const handleDownload = async () => {
try {
if (isDredgingBackendEnabled) {
setIsLoading(true);
await downloadDredgingGridExcel({
viewType,
fromDate: appliedFromDate,
toDate: appliedToDate,
ctpv: appliedCtpvName,
sgg: appliedSggName,
sort: sort ? `${sort.key},${sort.direction}` : undefined,
});
return;
}
const visibleHeads = headlist.filter((head) => head.id !== "recordIds");
const excelRows = [
visibleHeads.map((head) => head.title),
@@ -581,6 +611,8 @@ const Page = () => {
} catch (error) {
console.error("준설현황 엑셀 다운로드 실패", error);
alert("엑셀 파일을 생성하지 못했습니다.");
} finally {
setIsLoading(false);
}
};
@@ -645,7 +677,7 @@ const Page = () => {
const handleDeleteRecord = async () => {
if (!canManageSelectedRecord || !selectedRecord) return;
const confirmed = await confirm(
`선택한 준설작업 전체를 삭제하시겠습니까?\n준설일자: ${selectedRecord.dredgingDate}\n연결 관로: ${selectedJobRecords.length}건 · 등록사진: ${selectedRecord.photoCount}건`,
`선택한 준설작업 전체를 삭제하시겠습니까?\n준설일자: ${selectedRecord.dredgingDate}\n현재 페이지에 보이지 않는 연결 관로와 사진도 모두 삭제됩니다.`,
);
if (!confirmed) return;
@@ -877,7 +909,7 @@ const Page = () => {
}}
actions={
<>
<Button type="button" onClick={() => void handleDownload()}>
<Button type="button" disabled={isLoading || (isDredgingBackendEnabled && serverTotal === 0)} onClick={() => void handleDownload()}>
엑셀다운로드
</Button>
<Button
@@ -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,
}));
}
@@ -0,0 +1,56 @@
import { NextRequest, NextResponse } from "next/server";
import { cookies } from "next/headers";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
/** 완성된 Excel을 버퍼/Blob에 모으지 않고 기존 인증 쿠키로 서버에서 브라우저에 스트리밍한다. */
export async function GET(request: NextRequest, context: { params: Promise<{ jobId: string }> }) {
const { jobId } = await context.params;
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(jobId)) {
return NextResponse.json({ message: "다운로드 작업 식별자가 올바르지 않습니다." }, { status: 400 });
}
const token = (await cookies()).get(process.env.NEXT_PUBLIC_CLAIM_NAME || "Authorization")?.value;
if (!token) return NextResponse.json({ message: "로그인이 필요합니다." }, { status: 401 });
const base = process.env.SEWERAGE_API_URL;
if (!base) return NextResponse.json({ message: "다운로드 서버 설정을 확인해주세요." }, { status: 503 });
const url = `${base.replace(/\/$/, "")}/sewerage-service/api/v1/gridExportJobs/${jobId}`;
const headers = { Authorization: token, "X-Site-Id": process.env.NEXT_PUBLIC_SITE_ID || "SN" };
let upstream: Response;
try {
upstream = await fetch(`${url}/download`, { headers, cache: "no-store", signal: request.signal });
} catch {
return NextResponse.json({ message: "다운로드 서버에 연결하지 못했습니다." }, { status: 502 });
}
if (!upstream.ok || !upstream.body || !upstream.headers.get("content-type")?.includes("spreadsheetml.sheet")) {
return NextResponse.json({ message: "Excel 파일을 받을 수 없습니다. 다시 다운로드해주세요." }, { status: upstream.ok ? 502 : upstream.status });
}
const reader = upstream.body.getReader();
let cleaned = false;
const cleanup = async () => {
if (cleaned) return;
cleaned = true;
// 수신 완료/취소 후 서버 임시 파일을 정리한다. 연결 실패 시 서버 TTL 정리가 대신 수행한다.
await fetch(url, { method: "DELETE", headers, cache: "no-store", signal: AbortSignal.timeout(10_000) }).catch(() => undefined);
};
const stream = new ReadableStream<Uint8Array>({
async pull(controller) {
try {
const { done, value } = await reader.read();
if (done) { await cleanup(); controller.close(); }
else controller.enqueue(value);
} catch (error) { await cleanup(); controller.error(error); }
},
async cancel(reason) { await reader.cancel(reason); await cleanup(); },
});
const requestedName = (request.nextUrl.searchParams.get("fileName") || "grid.xlsx")
.replace(/[\\/:*?"<>|\r\n]/g, "_").replace(/\.xlsx$/i, "");
const fileName = `${Array.from(requestedName).slice(0, 180).join("") || "grid"}.xlsx`;
const encodedName = encodeURIComponent(fileName).replace(/['()*]/g, (value) => `%${value.charCodeAt(0).toString(16).toUpperCase()}`);
return new Response(stream, { headers: {
"Content-Type": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"Content-Disposition": `attachment; filename*=UTF-8''${encodedName}`,
"Cache-Control": "no-store",
"X-Content-Type-Options": "nosniff",
} });
}
+2 -1
View File
@@ -17,6 +17,7 @@ import { cookies } from "next/headers";
import { AppProvider } from "./providers";
import "../src/app/styles/base.css";
import "../src/app/styles/components.css";
import "../src/app/styles/grid-ellipsis.css";
export const metadata: Metadata = {
title: "디지털 하수관로",
@@ -35,7 +36,7 @@ export default async function RootLayout({
return (
<html lang="ko" data-theme={theme?.value}>
<body>
<body data-sn-grid-ellipsis="true">
<AppProvider>{children}</AppProvider>
</body>
</html>
@@ -0,0 +1,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 변경만 역적용한다.
@@ -0,0 +1,232 @@
# SN 전체 그리드 페이징 점검
기준일: 2026-09-23. 분석만 수행했으며 서비스 코드·API·DB·환경·배포 설정은 변경하지 않았다.
## 1. 결론과 범위
**모든 그리드가 전체 조회 후 화면에서 페이징하는 것은 아니다.** DB/GeoServer에서 필요한 페이지를 조회하는 화면, 전체 결과를 받은 후 브라우저에서 자르는 화면, 서버가 전체를 조회한 뒤 메모리에서 자르는 화면, 페이지 없이 전체를 표시하는 표가 혼용된다.
- SN `app`, `src`의 TS/TSX 소스 **339개**를 탐색했다.
- TypeScript 구문 트리로 표 렌더링 **56개 파일, 71개 지점**을 식별했다. Table, table, *Table, DataGrid 계열과 관련 래퍼·라우트를 추적했다.
- 71은 화면 수가 아니다. 공통 래퍼 내부 표, 하나의 표를 감싸는 컴포넌트, 조건부 분기와 입력·상세 표가 포함된다. 엑셀 6개 탭처럼 하나의 렌더 지점이 여러 목록을 표시하는 경우도 있다.
- SN이 참조하는 business_components의 StandardDataTable/UiTable와 서버 sewerage-service의 Controller·Service·Repository·SQL을 대조했다.
- 다른 서비스 앱 자체의 그리드 및 외부 CMS/DC 서버 내부는 전수 범위가 아니다. SN의 해당 호출은 확인하되 외부 서버 내부 조회 방식은 미확인으로 구분했다.
- 코드 전수검사와 브라우저 전수회귀는 다르다. 본 문서는 코드 전수검사 결과이며 실제 모든 메뉴를 데이터·권한·팝업 조합별로 조작한 보고서는 아니다.
### 요구사항 근거
원본 `IIPBS/docs/기준문서/요구사항정의서.xlsx`의 `3. (기능)요구사항정의 및 추적표`, SFR-010/011/012 목록·조사·준설·GIS 기능을 확인했다. 화면설계서의 노후관로(슬라이드 8, SN_MN010102), 통수위험(19), 침수예측(24), 침수발생이력(29·31) 등 목록·상세 구조를 대조했다. 화면 표시 요구와 데이터 조회 방식은 별도 문제로 구분하며 아래 통일안은 기존 요구사항 확정이 아닌 **개선 제안**이다.
## 2. 판정 기준
| 분류 | 데이터 흐름 | 페이지 이동 때 |
| --- | --- | --- |
| S: 서버/DB 페이징 | 화면 page,size → DB LIMIT/OFFSET 또는 JPA Pageable → 해당 페이지 반환 | 해당 페이지만 다시 요청 |
| G: GeoServer 페이징 | 화면 page,size → 백엔드 → WFS maxFeatures/startIndex | 해당 공간정보 페이지만 다시 요청 |
| C: 화면 페이징 | API 전체 목록 또는 파일 전체 → 브라우저 메모리 → slice/TanStack pagination | 보관한 배열을 나눔 |
| C+: 서버 페이지 전량 수집 | API 0~마지막 페이지를 반복 호출 → 브라우저에 합침 → 화면 페이징 | 표시 페이지 이동은 배열 처리 |
| M: 서버 메모리 페이징 | 서버가 전체 후보를 읽고 가공·정렬 → 일부만 응답 | 페이지 이동마다 전체 가공이 반복될 수 있음 |
| N: 페이징 없음 | 조건 내 전체 상세/집계/입력 데이터를 그대로 표시 | 스크롤 또는 고정 행 |
DB가 count·정렬·집계를 위해 조건 전체를 스캔하는 것과, 서버/브라우저가 전체 행을 메모리에 가져오는 것은 다르다. S/G도 DB 실행계획에 따라 비용은 발생한다. 실제 성능 수치는 실행계획·요청 측정 없이 단정하지 않았다.
## 3. 메뉴·화면별 목록 전수 분류
페이지 크기는 실제 표시 기준이다. 같은 화면을 여는 라우트/팝업은 함께 적었다.
| 번호 | 메뉴/화면 | 화면 단위 | 판정 및 백엔드 처리 |
| --- | --- | --- | --- |
| 1 | 운영정보 > 노후 하수관로 정비사업 기존 목록 /oper/oldPp | 10건 | **S**. v1/dcrpPpl, DcrpPplRepositoryImpl → PageUtil.fetchPage |
| 2 | 운영정보 > 도시침수예방사업 기존 목록 /oper/floodPre | 10건 | **S**. v1/sbmrgPrevnt, SbmrgPrevntRepositoryImpl → PageUtil |
| 3 | 운영정보 > 노후 하수관로 정비사업 /oper/obsltPplBiz | 10건 | **S**. v1/obsltPplBiz, ObsltPplBizRepositoryImpl → PageUtil |
| 4 | 운영정보 > 도시침수예방사업 /oper/ctyFldngPrvtnBiz | 10건 | **S**. v1/ctyFldngPrvtnBiz, CtyFldngPrvtnBizRepositoryImpl → PageUtil |
| 5 | 최종확정예산사업 선택 팝업 | 10건 | **S**. v1/onsemiro/cfmtnBgt, CfmtnBgtRepositoryImpl의 setFirstResult/setMaxResults와 count |
| 6 | 조사사업 등록/조사현황 사업목록 /oper/exmn/register, /oper/exmn/status | 10건 | **S**. 두 라우트가 ExmnRegistrationPage 재사용. v1/exmnBusinesses, ExmnBusinessRepositoryImpl offset/limit |
| 7 | 처리분구관리 /oper/trtdst | 10건 | **C**. v1/trtdstSettings에서 조건 내 전체 활성 목록 반환. TrtdstSettingRepository.findSettings는 List이며 Pageable 없음. FE getPaginationRowModel |
| 8 | 준설관리 > 지자체별/관경별/경과년도별 /oper/dredging | 10건 | **C+**. fetchAllDredgingRecords가 기본 2,000건 단위로 모든 페이지 수집. API 자체는 DredgingRepository의 offset/limit. FE가 필터·집계·정렬 후 slice |
| 9 | I/I 분석 > 침입수 분석결과 /ii/result | 10건 | **S**. v1/inftwtAnls, InftwtAnlsRepositoryImpl → PageUtil |
| 10 | I/I 분석 > RDII 분석결과 /ii/rdiiResult | 10건 | **S**. v1/rdiiAnls, RdiiAnlsRepositoryImpl → PageUtil |
| 11 | 하수관망도 > 침입수 분석결과 팝업 | 10건 | **S**. 9와 같은 목록 API, 팝업 전용 필터/표 구현 |
| 12 | 하수관망도 > RDII 분석결과 팝업 | 10건 | **S**. 10과 같은 목록 API |
| 13 | 하수관망도 > 정보검색 > 기본정보(시설유형 공통) | 10건 | **G**. v1/fclts, FcltService가 Pageable을 WFS maxFeatures/startIndex로 전달 |
| 14 | 하수관망도 > 정보검색 > 운영정보 사업목록 | 5건 | **S**. v1/bizInfo, BizInfoRepositoryImpl → PageUtil |
| 15 | 하수관망도 > 정보검색 > 운영정보 연결 시설목록 | 5건 | **S + 페이지 내 외부 병합**. v1/bizInfo/fclts에서 DB Page를 읽고 그 시설만 GeoServer 속성과 병합 |
| 16 | 하수도정비 기본계획보고서 목록(정보검색 보고서 포함) | 10건 | **C**. DC /api/v1/clsf 전체 트리를 펼쳐 조건 필터 후 slice. 현재 페이지의 파일 메타정보는 별도 보강 |
| 17 | 보고서 파일 목록 팝업 | 10건 | **C**. DC /api/v1/file?clsfSystmSn=… 배열을 보관하고 slice. DC 내부 쿼리는 미확인 |
| 18 | 노후 하수관로 정비사업 첨부 상세 6개 탭 | 각 10건 | **C(파일)**. Excel 전체 다운로드·파싱 후 각 탭에 getPaginationRowModel. DB 목록 페이징 대상과 구별 |
| 19 | 도시침수예방사업 첨부 상세 2개 탭 | 각 10건 | **C(파일)**. 관리SHEET/운영관리현황 점검결과 전체 파싱 후 화면 페이징 |
| 20 | 하수관망도 > 노후관로 목록 | 10건 | **G**. v1/fclts에 설치일자/행정구역 CQL과 page,size 전달. 전체 관로를 브라우저에서 잘라 표시하는 방식 아님 |
| 21 | 하수관망도 > 시도별 노후관로현황 | 10개 시도 행 | **C(집계)**. v1/swagePpl/agedStatusByCtpv의 시도·연도 집계 결과 전체를 받아 화면 페이징. 원시 관로 전체를 FE로 전송하지 않음 |
| 22 | 하수관망도 > 관로분석 요약(경과년도/관재질/복합) | 10행 또는 10그룹 | **C(집계)**. v1/fclts/pplAnalysis/summaries는 전체 집계+캐시. FE rows.slice, 복합형은 처리구역 그룹 단위 slice |
| 23 | 관로분석 > 관로현황 상세 | 10건 | **S**. v1/fclts/pplAnalysis/details. PplAnalysisSqlBuilder가 count와 LIMIT/OFFSET 페이지 SQL 생성 |
| 24 | 하수처리구역 > 보수이력 | 10건 | **C+와 M 중첩**. FE가 200건씩 끝까지 수집 후 slice. BE RepairHistoryService는 요청마다 두 DB 결과 전체 결합·정렬 후 범위 행만 응답 |
| 25 | 하수관망도 > 지반침하발생현황 | 10건 | 일반 조건 **S**. 좌표상태 필터 또는 좌표상태 정렬은 **M**으로 전환. 후보 전체를 배치 조회·좌표 계산한 뒤 subList |
| 26 | 하수관망도 > 침수위험지도(중점관리지역) 목록 | 10건 | **S**. SbmrgDngrMapListRepository의 집계 목록 count 및 limit/offset |
| 27 | 하수관망도 > 도시침수지도 목록 | 10건 | **S**. FloodDngrMapListRepository의 집계 목록 count 및 limit/offset |
| 28 | 하수관망도 > 침수발생이력 목록 | 10건 | **S**. FldngHstryListRepository QueryDSL offset/limit |
| 29 | 하수관망도 > 침수예측현황 | 10건 | **S**. FldngPredcPrstRepository limit/offset 및 별도 count |
| 30 | 하수관망도 > 통수 위험 분석결과 | 10건 | **S**. v1/waterRisk/results, WaterRiskResultQueryRepository limit :size offset :offset |
| 31 | GIS 관리 > 업로드 이력 | 10건 | **S**. v1/map/gisUploads, GisUploadHistoryRepository의 SQL limit/offset |
| 32 | GIS 관리 > 검증 오류 목록 | 10건 | **S**. validationErrors?page,size, 별도 count 및 limit/offset. 업로드 직후 첫 오류 페이지는 응답 일부를 표시하는 예외 있음 |
| 33 | 중점우수토실 > 강우정보·측정정보·수량/수질측정 | 3건 | **S**. 앞 두 표는 같은 rainMeasureData/curPage 공유, 측정 상세는 curDetailPage 별도. SwgRain/SwgRainMntrngRepositoryImpl → PageUtil |
### 페이지 없는 표와 공통·미사용 경로
| 대상 | 판정 | 설명 |
| --- | --- | --- |
| 조사현황 하단 처리분구별 집계 | N(전체 집계) | ExmnStatusDetailPanel → /exmnBusinesses/{id}/sections. 전체 집계 List 렌더 |
| 조사 상세현황 관로/맨홀 결함 집계 | N(전체 상세) | ExmnSurveyDetailPopupPage → /exmnFacilities. ExmnFacilityQueryService.findFacilityDetails 전체 List. ExmnSurveyStandardDataTable은 viewPagination=false |
| 조사 본단위구간 표 | N(전체 상세) | /exmnFacilities/pplUnitSections, 서버가 시설 JSON 상세를 펼친 전체 결과. 병합 셀 전체 렌더 |
| 지도 시설의 조사결과 표 | N | 선택 시설에 연결된 조사 요약을 ExmnSurveyResultSection에서 그대로 표시 |
| 지반침하 인근 관로 표 | N(제한 목록) | nearbyPipes 배열 전체 렌더. 서버 인근관로 조회의 limit은 있으나 다음 페이지 구조는 없음 |
| 시설 속성 상세·수정 | N | featureClickEventPopup, MsSwagePpl, smh, sewagePump, stormWaterPump, SwtrmtFclt, Ssc 상세. 단건 속성 VerticalTable이며 total=0 등으로 페이저 숨김 |
| GIS 편집 세션의 기존/수정 속성 2개 표 | N | GisEditPipeSessionPopup. 한 시설의 속성표, onPageChange 빈 함수 |
| 하수처리구역 분석 지표 표 | N | SwtrmtPrcsZoneAnalysis의 지표별 표. 요청한 처리구역 집계 표시 |
| 침수발생 상세의 속성/강우 표 | N | FldngHstryDetailPanel. 선택 객체의 속성 목록 |
| 준설 등록/수정의 등록정보·사진·관로정보 표 | N | DredgingRegistrationBody의 3개 렌더 지점. 사용자 편집 상태와 관로별 최대 5장 사진 |
| 준설 상세 표 | N | DredgingDetailPopupPage. 선택 준설/관로 속성 |
| RDII 입력의 단위도계수·초기차단깊이 2개 표 | N | EditableFormTable. 폼 입력 행이며 서버 목록 아님 |
| 지도 입력 항목 표 | N | InputSection. 전달된 입력 행을 표시 |
| 지도 통수위험 요약 표 | N | map/page.tsx의 3개 지표 표시 |
| 메뉴 권한관리 다이얼로그 | 현재 직접 사용처 미발견 | CMS 응답 content를 getCoreRowModel로 표시. 서버 page,size 요청 및 FE getPaginationRowModel 없음. export만 있고 SN 내 실제 렌더 호출 미발견. 외부 CMS 기본 응답 크기는 미확인 |
| ExmnStandardDataTable | 공통/현재 직접 렌더 사용처 미발견 | manualPagination 래퍼. 현재는 다른 파일에서 헤더 상수만 import. 독립 조회 화면으로 집계하지 않음 |
| MapPopupDataTable | 공통 래퍼 | manualPagination=true이나 데이터를 자르거나 조회하지 않음. 부모가 전달한 rows/total/onPageChange가 실제 방식을 결정 |
| PipeAnalysisSummaryTable | 공통 표시 래퍼 | manualPagination=true라도 부모가 이미 집계를 slice하므로 실제 화면 페이징 |
| StandardDataTable / UiTable | 공통 표시 컴포넌트 | 그 자체가 서버 페이징을 보장하지 않음. TanStack 설정 또는 부모가 제공한 배열을 렌더 |
| VerticalTable / EditableFormTable | 공통 속성/입력 표 | 업무 목록과 별도 분류. pageSize prop이 있어도 데이터 목록 페이징으로 단정하면 안 됨 |
| getPplSummaries / getPplFacilities | 미사용 API 헬퍼 | 전자는 전체 sections 결과 slice, 후자는 page,size를 전달하지만 현재 SN 호출 미발견. 실제 사용 화면으로 잘못 집계하지 않음 |
## 4. 우선순위별 문제점과 개선 제안
### P1. 보수이력의 중복 전체 조회
- FE: `RepairHistoryPopupPage.tsx:196` 부근에서 전체 수집 함수를 호출하고 `:311`에서 10건씩 slice한다.
- FE 수집기: `swtrmtPrcsZoneAnalysisApi.ts:162`에서 200건씩 마지막 페이지까지 요청한다.
- BE: `RepairHistoryService.java:63`에서 매번 findItems 전체를 만든 뒤 현재 범위만 응답한다. `:140` 부근에서 준설 집계와 관로 속성을 두 DB에서 합친다.
- 결과 N건을 모으는 데 약 ceil(N/200)번의 API 요청이 발생하고 각 요청이 같은 전체 후보 결합을 재수행할 수 있다. 이는 코드 경로에 따른 위험 분석이며 실제 소요시간을 측정한 값은 아니다.
- 제안: 표 목록은 한 페이지씩, 지도 강조는 별도 최소 FID/geometry 응답으로 분리한다. 서로 다른 DB의 결합은 집계 테이블·캐시·배치 구성 중 실제 환경에 맞는 방법을 선택하고, 가능한 필터·정렬을 DB로 내린다.
### P1. 준설관리의 전체 원시 자료 수집
- FE: `oper/dredging/page.tsx:379`에서 fetchAllDredgingRecords, `:449`에서 slice.
- API 헬퍼: `dredging-api.ts:76` 부근, 공통 수집기 기본 2,000건으로 전체 페이지 순회.
- BE 자체는 `DredgingRepository.java:203`에서 offset/limit을 적용한다. 서버 페이징 API가 있어도 현재 화면이 모두 요청하므로 FE 메모리/초기 로딩 절감 효과를 잃는다.
- 이유: 관경별·경과년도별 집계와 표시명 정렬을 FE에서 수행한다.
- 제안: 일반 목록은 서버 페이지 조회, 관경·경과년도 집계는 별도 서버 집계 API, 전체 Excel은 다운로드 작업으로 분리한다.
### P1~P2. 조사 상세 대량 렌더
- 시설 상세와 본단위구간은 현재 페이징 없이 전체 응답·전체 DOM 렌더다.
- 큰 사업/처리분구에서 응답 크기와 행 수가 커질 위험이 있다.
- 제안: 시설 또는 관로를 페이지 단위로 나누고, 같은 관로의 병합 셀·결함 행이 페이지 경계에서 깨지지 않도록 그룹 기준을 먼저 정한다. 총합은 페이지 합계와 전체 합계를 명시적으로 구별한다.
### P2. 지반침하 좌표상태 조건의 숨은 전체 순회
- `SinkholeQueryService.java:78` 이후 coordStatus 필터/정렬 분기는 모든 후보를 배치 조회·좌표 해석한 뒤 목록을 자른다.
- includeCoordStatus=true만으로 항상 전체 조회하는 것은 아니다. **좌표상태 필터 또는 해당 정렬**이 전환 조건이다.
- 제안: 좌표 판정 결과 사전 계산·캐시/저장 후 검색 가능하게 하거나, 해당 조건의 기간 제한과 비용 안내를 적용한다. 단순히 프론트 page,size만 통일해도 해결되지 않는다.
### P2. 처리분구·보고서·관로분석 집계의 범위 관리
- 처리분구는 전체 활성 목록을 가져오며 일괄 편집 상태와 연결된다. 서버 페이징 도입 시 미저장 행 보존·중복 검증 흐름을 함께 설계해야 한다.
- 보고서 트리는 전체 분류 데이터를 받은 뒤 FE 필터·페이징한다. 현재 페이지 파일 메타정보 추가 요청도 있다. 자료관 서버의 검색·페이지 계약을 확인해야 한다.
- 관로분석 요약은 원시 관로가 아니라 집계 결과 전체를 받는다. 시도별 집계처럼 작고 고정된 결과는 화면 페이징을 유지할 수 있으나 전국 처리구역별 결과는 건수 상한과 응답량을 확인해야 한다.
- 화면 페이징을 모두 결함으로 볼 수 없다. Excel 미리보기, 작은 시도 집계, 단건 속성표는 목적에 맞는 예외다.
## 5. 통일 권장안
추가 확인: 기본정보/노후관로의 WFS 정렬은 `FcltWfsSortPolicy.resolve`에서 정렬 미지정 시 null을 반환해 GeoServer 기본 순서에 의존한다. FE 기본 조회에도 sort가 없는 경로가 있다. 페이지 안정성을 위해 유일 식별자를 포함한 기본 정렬을 검토해야 한다. 실제 중복·누락 발생을 관찰한 것은 아니므로 잠재 위험으로 분류한다.
1. 데이터가 누적되는 업무 목록은 **서버 페이지 조회**를 기본으로 한다.
2. 화면 page는 1부터, 서버 page는 0부터라는 변환을 공통화하고 size 기본값을 명시한다. 3·5·10건 표시는 공간 차이로 허용하되 조회 방식과 혼동하지 않는다.
3. 응답은 content/totalElements/page/size 등의 공통 형태를 검토한다. 현재 content+totalElements, data+count, items+total, data+totalElements가 섞여 있다.
4. 서버 페이지 목록은 필터·정렬도 서버에서 전체 결과 기준으로 처리하고 마지막 정렬에 유일키를 넣어 페이지 중복·누락을 방지한다.
5. 집계, 지도 전체 표시, Excel 전체 추출은 목록 페이지와 별도 책임으로 분리한다.
6. 클라이언트 페이징 허용 대상은 작은 고정 집계/파일 미리보기/로컬 입력 목록처럼 기준을 문서화하고 최대 건수를 정한다.
7. 페이징 props와 API 유무만으로 판단하지 않고 **부모 rows 생성 → API → Service → Repository**까지 확인한다.
## 6. 엑셀·지도 전체 조회와 구분
- I/I 분석, 사업 목록, 관로현황, 정보검색 등에서 fetchAllPages가 보여도 다운로드 클릭 핸들러에만 있다면 일반 화면은 서버 페이징이다.
- 노후관로 Excel은 별도 다운로드 작업을 사용하며 목록 페이징과 다르다.
- 지도 레이어도 전체/영역 내 도형이 필요하므로 별도의 대량 요청이 있을 수 있다. 이를 목록 표가 전체 조회한다고 오인하면 안 된다.
- 반대로 준설과 보수이력은 **목록 로딩 자체**에서 전량 수집하기 때문에 개선 대상으로 분류했다.
## 7. 검증·제약·변경 내역
- AST 탐색, 전체 소스의 page/size/slice/getPaginationRowModel/manualPagination 검색, 호출부·서버 구현 추적으로 판정했다.
- 변경 파일은 이 분석보고서만이다. 실행 코드 변경·서버 기동/종료·DB 변경·배포는 하지 않았다. 롤백할 실행 변경 없음.
- 코드상 판정은 위 표에 기재했고, 브라우저 검증 결과는 아래에 별도로 기록한다.
- 재현용 테스트 파일·스크립트·원시 결과 파일은 만들지 않았다. 분석 명령은 메모리에서 실행했고 최종 보고서만 보존한다.
- 외부 DC/CMS 내부 쿼리, 모든 화면의 실데이터 마지막 페이지/동시변경/성능 수치는 미검증이다.
### 실제 브라우저 표본 검증
- 로컬 3002에 지정된 검증 계정으로 로그인했다. 최초 준설 페이지 진입은 인증 화면으로 이동했으며 로그인 후 재진입했다.
- 준설관리: 1페이지에서 2페이지로 직접 이동해 순번 11인 마지막 1행과 다음/마지막 버튼 비활성화를 확인했다. 코드상 C+ 분류와 별도로 실제 페이지 이동 동작을 확인한 것이다. 네트워크 전체 요청 횟수 측정은 하지 않았다.
- 시도별 노후관로현황: 2페이지로 이동해 순번 11~18 범위와 마지막 페이지 상태를 확인했다. 전체 시도 집계 결과를 받은 뒤 화면에서 나누는 코드 경로를 함께 대조했다.
- 노후관로 목록: 2페이지를 클릭한 후 새로운 시설관리번호와 순번 11~20을 확인했다. 서버/WFS 페이지 조회라는 판정은 FcltService/GeoJsonService 호출 코드에 근거하며 브라우저에서는 목록 갱신 동작을 확인했다.
- 그 외 모든 메뉴를 직접 조작한 것은 아니므로 위 목록 전체를 브라우저 PASS로 표시하지 않는다.
## 8. 표 렌더링 지점 전체 인벤토리
아래는 검색에서 확인한 56개 파일의 71개 표 렌더 지점이다. 동적 래퍼인 OldPpTablePopupContent/FloodPreTablePopupContent는 실제 tableModal로 연결해 위 표에서 별도 추적했다. 목록이 없는 로그인·파일입력·사업 입력 등 라우트도 탐색 범위에는 포함됐다.
| 파일(SN 기준 상대경로) | 표 컴포넌트와 줄 |
| --- | --- |
| `app/(map-layout)/map/components/agedPipe/AgedPipePopupPage.tsx` | MapPopupDataTable:367 |
| `app/(map-layout)/map/components/agedPipeStatusByCtpv/AgedPipeStatusByCtpvPopupPage.tsx` | StandardDataTable:300 |
| `app/(map-layout)/map/components/exmn/ExmnSurveyResultSection.tsx` | Table:131 |
| `app/(map-layout)/map/components/fldngHstry/FldngHstryDetailPanel.tsx` | table:23 |
| `app/(map-layout)/map/components/fldngHstry/FldngHstryPopupPage.tsx` | MapPopupDataTable:449 |
| `app/(map-layout)/map/components/floodDngrMap/FloodDngrMapPopupPage.tsx` | MapPopupDataTable:387 |
| `app/(map-layout)/map/components/gisEdit/GisEditPipeSessionPopup.tsx` | VerticalTable:447, VerticalTable:458 |
| `app/(map-layout)/map/components/gisManagement/GisManagementPopupPage.tsx` | MapPopupDataTable:764, MapPopupDataTable:793 |
| `app/(map-layout)/map/components/inftwtResult/InftwtResultPopupPage.tsx` | MapPopupDataTable:381 |
| `app/(map-layout)/map/components/InputSection.tsx` | Table:40 |
| `app/(map-layout)/map/components/pipeAnalysis/PipeAnalysisDetailPopupPage.tsx` | MapPopupDataTable:468 |
| `app/(map-layout)/map/components/pipeAnalysis/PipeAnalysisPopupPage.tsx` | PipeAnalysisSummaryTable:500 |
| `app/(map-layout)/map/components/pipeAnalysis/PipeAnalysisSummaryTable.tsx` | StandardDataTable:219 |
| `app/(map-layout)/map/components/popupComponent/featureClickEventPopup.tsx` | VerticalTable:1026 |
| `app/(map-layout)/map/components/popupComponent/MsSwagePpl.tsx` | VerticalTable:153 |
| `app/(map-layout)/map/components/popupComponent/sewagePump.tsx` | VerticalTable:51 |
| `app/(map-layout)/map/components/popupComponent/smh.tsx` | VerticalTable:121 |
| `app/(map-layout)/map/components/popupComponent/Ssc.tsx` | MapPopupDataTable:276, MapPopupDataTable:291, MapPopupDataTable:307, VerticalTable:323 |
| `app/(map-layout)/map/components/popupComponent/stormWaterPump.tsx` | VerticalTable:22 |
| `app/(map-layout)/map/components/popupComponent/SwtrmtFclt.tsx` | VerticalTable:43 |
| `app/(map-layout)/map/components/popupComponent/SwtrmtPrcsZoneAnalysis.tsx` | Table:105 |
| `app/(map-layout)/map/components/rdiiResult/RdiiResultPopupPage.tsx` | MapPopupDataTable:383 |
| `app/(map-layout)/map/components/repairHistory/RepairHistoryPopupPage.tsx` | MapPopupDataTable:454 |
| `app/(map-layout)/map/components/sbmrgDngrMap/SbmrgDngrMapPopupPage.tsx` | MapPopupDataTable:293 |
| `app/(map-layout)/map/components/sinkhole/SinkholeDetailPanel.tsx` | VerticalTable:187, table:236 |
| `app/(map-layout)/map/components/sinkhole/SinkholePopupPage.tsx` | MapPopupDataTable:489 |
| `app/(map-layout)/map/components/waterRisk/WaterRiskPopupPage.tsx` | MapPopupDataTable:673 |
| `app/(map-layout)/map/page.tsx` | table:7538 |
| `app/(map-layout)/map/searchModal/reportTab.tsx` | MapPopupDataTable:491 |
| `app/(map-layout)/map/searchModal/searchModal.tsx` | MapPopupDataTable:231, MapPopupDataTable:262, MapPopupDataTable:287 |
| `app/(with-base-layout)/ii/rdii/page.tsx` | EditableFormTable:531, EditableFormTable:542 |
| `app/(with-base-layout)/ii/rdiiResult/page.tsx` | MapPopupDataTable:368 |
| `app/(with-base-layout)/ii/result/page.tsx` | MapPopupDataTable:367 |
| `app/(with-base-layout)/oper/dredging/components/DredgingDetailPopupPage.tsx` | Table:523 |
| `app/(with-base-layout)/oper/dredging/page.tsx` | StandardDataTable:872 |
| `app/(with-base-layout)/oper/floodPre/page.tsx` | StandardDataTable:323 |
| `app/(with-base-layout)/oper/floodPre/tableModal.tsx` | StandardDataTable:272, StandardDataTable:280 |
| `app/(with-base-layout)/oper/oldPp/page.tsx` | StandardDataTable:302 |
| `app/(with-base-layout)/oper/oldPp/tableModal.tsx` | StandardDataTable:354 |
| `app/components/ui/EditableFormTable.tsx` | Table:61 |
| `app/components/ui/verticalTable.tsx` | Table:182, Table:307 |
| `src/entities/menu-authority/ui/management-authority-dialog.tsx` | DataTable:86 |
| `src/features/cfmtnBgt/ui/CfmtnBgtPopupContent.tsx` | StandardDataTable:654 |
| `src/features/ctyFldngPrvtnBiz/ui/CtyFldngPrvtnBizListPage.tsx` | StandardDataTable:751 |
| `src/features/dredging/ui/DredgingRegistrationBody.tsx` | Table:121, Table:284, Table:411 |
| `src/features/exmn/ui/ExmnPplUnitDetailTable.tsx` | Table:73 |
| `src/features/exmn/ui/ExmnRegistrationPage.tsx` | StandardDataTable:661 |
| `src/features/exmn/ui/ExmnStandardDataTable.tsx` | StandardDataTable:127 |
| `src/features/exmn/ui/ExmnStatusDetailPanel.tsx` | Table:413 |
| `src/features/exmn/ui/ExmnSurveyDetailPopupPage.tsx` | ExmnSurveyStandardDataTable:672, ExmnPplUnitDetailTable:722, Table:731 |
| `src/features/exmn/ui/ExmnSurveyStandardDataTable.tsx` | StandardDataTable:54 |
| `src/features/exmn/ui/TrtdstManagementPage.tsx` | StandardDataTable:638 |
| `src/features/fldngPredcPrst/ui/FldngPredcPrstPopupContent.tsx` | MapPopupDataTable:568 |
| `src/features/obsltPpl/ui/ObsltPplListPage.tsx` | StandardDataTable:702 |
| `src/features/windowPopup/ui/MapPopupDataTable.tsx` | StandardDataTable:184 |
| `src/features/windowPopup/ui/ReportFilesPopupContent.tsx` | MapPopupDataTable:107 |
@@ -0,0 +1,217 @@
# SN 서버 페이징·지도·Excel 처리 결과
작업일: 2026-09-23. 상태: 구현 및 아래 범위의 로컬 검증 완료. 미검증 사용자 흐름과 구조적 예외는 별도 표시.
2026-09-28 후속 변경: 아래의 `facility-export-profiles.json` 설명은 9월 23일 구현 이력이다. 사용자 승인에 따라 JSON을 제거하고 `FacilityExcelProfile.java`의 컬럼 정의와 `FacilityCodeNames`의 CMS 조회로 분리했다. 기본정보·준설·관로 상세 Excel 및 준설 관재질 정렬에 적용한다. 상세 변경/검증은 [시설 Excel 정의 정리 결과](D:/workspace/IIPBS/sewerage-service/docs/작업계획/SN-시설Excel정의정리/001-be-SN-시설Excel정의정리-작업결과.md)를 참조한다.
## 1. 요청과 적용 기준
- 앞선 전수검사(339개 TS/TSX, 56개 파일·71개 표 렌더 지점)를 기준으로 일반 화면과 팝업을 함께 확인했다.
- 업무 목록은 서버 페이지를 기본으로 하고, 지도 전체 도형과 Excel 전체 자료는 목록 페이지 배열과 분리한다.
- API 업무 경로는 lowerCamelCase를 유지했다. DB 스키마·데이터, Java 버전, Docker, 배포 환경, 공유 프론트 패키지는 변경하지 않았다.
- 원본 요구사항정의서 SFR-010-012/013, SFR-011-001~009, SFR-012-001~005 및 화면설계서의 노후관로(슬라이드 8), 통수위험(19), 침수예측(24), 침수이력(29·31), 준설 관련 화면을 대조했다.
- 데이터표준 `IIPBS표준용어`의 FCLT_MNG_NO(번호V50/VARCHAR 50), REG_DT(연월일시분초D/DATETIME)를 확인했다. 식별자를 숫자로 변환하거나 DB 컬럼을 변경하지 않았다.
## 2. 주요 수정과 이유
### 준설관리
기존에는 모든 서버 페이지를 브라우저에 모아 집계·정렬·slice했다. 이제 `dredging/grid`에서 해당 페이지만 받는다. 일반 목록은 DB OFFSET/LIMIT, 관경·경과년도는 DB 집계 결과의 서버 페이지를 사용한다.
- 처리구역명은 필요한 구역 식별자만 조회해 MS 명칭을 결합하고, 관재질은 SN과 같은 명칭 사전을 사용한다. 이름은 SQL CASE의 바인딩 값으로 전달하여 **정렬 기능을 제거하지 않고 DB 페이지 정렬**을 유지했다.
- 페이지 안의 행 수를 해당 준설작업 전체 관로 수로 오인하지 않도록 삭제 확인 문구를 보완했다. 실제 삭제 API와 범위는 기존 그대로다.
- Excel은 검색조건 전체를 500건씩 서버에서 읽어 만든다. 현재 페이지 배열을 사용하지 않는다.
### 보수이력
기존 200건 단위 전량 수집을 제거했다. 목록은 `repairHistories?page&size`, 지도는 `repairHistories/mapFeatures`, Excel은 기존 전체 다운로드 API로 분리했다.
- 지도 응답은 조건 전체의 FID와 EPSG:3857 geometryJson만 반환한다.
- 페이지를 이동해도 지도 전체 레이어를 재생성하거나 현재 페이지 도형만 남기지 않는다.
- 목록이 지도보다 먼저 준비된 상태에서 클릭하면 도형 준비 후 마지막 선택 관로로 이동한다.
- 서버 내부 SN/MS 교차 DB 결합은 기존 로직을 유지한다. 이는 DB LIMIT만 사용하는 방식과 다른 예외이며 5절에 명시했다.
### 조사 상세·본단위구간 팝업
- 시설 집계는 `/exmnFacilities/page`로 서버 페이지를 받는다. 전체 합계는 별도 summary로 받아 현재 페이지 합계로 바뀌지 않게 했다.
- 본단위구간은 `/exmnFacilities/pplUnitSections/page`에서 **관로번호+단위구간 그룹**을 기준으로 나눈다. 구간 내부의 병합 행은 잘리지 않는다.
- 가변 결함 열·CMS 명칭·소계·합계·병합을 포함한 Excel을 서버 생성으로 전환했다. 데이터 행은 클라이언트에서 보내지 않고 조회조건과 표시명만 전달한다.
- 이전 프론트 전용 상세 Excel 생성 모듈은 사용처 제거 후 삭제했다.
### 처리분구관리
- `/trtdstSettings/page`를 추가하고 QueryDSL count/OFFSET/LIMIT으로 조회한다.
- 사업등록의 처리분구 선택지용 기존 전체 API는 그대로 유지한다.
- 미저장 추가·수정·삭제대기 또는 편집 중이면 페이지 이동·정렬을 차단하고 저장/취소를 안내한다. 페이지 전환으로 편집 내용이 조용히 사라지지 않는다.
- 저장 후 현재 서버 페이지를 다시 조회하고, 마지막 페이지가 비면 남은 마지막 페이지로 보정한다.
### 관로분석 요약
- `/fclts/pplAnalysis/summaryPage`를 사용한다. 경과년도·관재질 복합표는 처리구역 그룹 단위로 나눈다.
- 전체 동적 컬럼 메타데이터는 유지하고 캐시 객체를 직접 수정하지 않는다.
- Excel은 현재 페이지가 아닌 기존 전체 summaries 계약을 별도로 호출한다. 집계 결과·병합 양식의 프론트 생성 예외다.
### I/I 분석결과 및 기본정보 Excel
- 침입수·RDII 일반 화면과 지도 팝업 4개가 동일한 서버 Excel 작업을 사용한다. 2,000건 단위로 전체 관로·시각 결과를 읽는다.
- 기본정보 8개 시설 유형은 GeoServer 결과를 서버에서 2,000건씩 읽어 XLSX에 기록한다. 기존 컬럼 순서·코드명·지역명·날짜 표시를 프로파일로 보존했다.
- 기본정보의 침수흔적심선은 선택지는 있었지만 목록 컬럼과 Excel 변환 분기가 빠져 있었다. 기존 속성표 정의를 바탕으로 camelCase 목록 컬럼과 서버 Excel 프로파일을 연결했다.
- 기본정보 Excel은 기존과 같은 50만 데이터 행 단위 다중 시트로 나눈다. 브라우저에서 원시 수백만 행을 모으지 않는다.
### 관로현황 상세·침수발생이력 Excel
- `spatialGridExports/pipes`, `spatialGridExports/floodHistories`로 적용조건 전체를 서버에서 1,000건씩 읽어 생성한다. 기존 목록·지도 API는 변경하지 않는다.
- 관로 상세 11개 열, 침수이력 17개 열의 순서·지역명·날짜·수치 표시를 유지한다. 다운로드를 위해 불필요한 지도 geometry를 브라우저에서 만들지 않는다.
## 3. 대량 다운로드 흐름
```text
적용조건으로 작업 접수 → 서버 생성 → 상태 조회 → 인증 스트리밍 수신 → 임시 파일 정리
```
- 공통 `gridExportJobs`는 기존 다운로드 executor를 재사용한다. 실행 환경이나 executor 설정 파일은 변경하지 않았다.
- 파일 생성은 HTTP 요청과 분리하여 생성 단계의 10초 요청 제한 위험을 줄였다.
- 새 SN `api/gridDownload/{jobId}` 경로는 기존 인증 쿠키로 백엔드 파일을 읽어 브라우저에 스트리밍한다. 프론트 JS에서 완성 파일을 Blob으로 통째로 적재하지 않는다.
- 검증된 인증 주체로 작업 소유권을 확인하여 토큰이 갱신돼도 동일 사용자의 다운로드가 유지된다. 인증값 원문은 작업 저장소에 보관하지 않는다.
- 파일 수신 완료/취소 후 정리하며, 정리 실패·미수신 완료 파일은 30분 TTL로 처리한다. 서버 재기동 중 생성 작업은 재요청해야 한다.
- 전체 건수가 도중에 달라지거나 예상보다 일찍 빈 페이지가 오면 생성 실패로 처리한다. 부분 파일을 정상 전체 결과로 제공하지 않는다.
## 4. 지도와 목록의 호출 관계
| 기능 | 목록 | 지도 | 이번 영향 |
| --- | --- | --- | --- |
| 기본정보·노후관로 | fclts의 WFS 페이지 조회 | 기존 GeoServer 도형/영역 요청 | 지도 호출·페이지 제한 변경 없음. 기본정보 Excel만 별도 작업으로 분리 |
| 지반침하 | sinkholes 페이지 | 기존 레이어 조회 조건/크기 | 기존 공용 조건 유지, 전역 size 제한 변경 없음 |
| 침수지도·침수이력 | 그룹/이력 페이지 API | 기존 WFS·extent·그룹 FID | 전체 그룹 FID/도형 전달 유지 |
| 통수위험·침수예측 | 결과 페이지 API | 기존 mapView/extent | 변경 없음 |
| 조사결과 | 새 상세 페이지 API | 기존 조사 지도 레이어 API | 신규 그리드 경로로 분리, 지도 경로 변경 없음 |
| 보수이력 | 10건 서버 페이지 | 별도 전체 mapFeatures | 페이지 이동 시 전체 레이어 유지 및 지연 도형 선택 보완 |
| 준설 | 새 grid 페이지 | 기존 위치·최근접관로·지도 연계 | records/nearestPipes 등 지도·등록 계약 유지 |
GeoServer DescribeFeatureType에서 하수관로의 `fid`가 일반 속성으로 노출되지 않는 것을 확인했다. 이를 확인하지 않고 공용 WFS에 `sortBy=fid`를 강제하지 않았다. 외부 WFS의 동시 변경까지 DB 스냅샷으로 고정하는 보장은 없다.
## 5. 예외와 사유
| 대상 | 남긴 방식 | 사유/제약 |
| --- | --- | --- |
| 보수이력 서버 내부 | SN/MS 전체 후보 결합 후 서버 페이지 | 서로 다른 DB의 실적·공간조건 결합. DB 통합/사전 집계 없이 의미를 바꾸지 않기 위해 유지. 브라우저 전량 수집은 제거 |
| 조사 상세·본단위 서버 내부 | JSONB 판정·전체 합계/그룹 계산 후 서버 페이지 | 선택 결함 판정·표시명 정렬·병합·전체 합계 보존. DB 원시 읽기 비용까지 제거한 것은 아님 |
| 관로분석 요약·준설 시도별 통계 | DB 집계 후 서버 가공 페이지 | 동적 컬럼/그룹 메타데이터와 작은 시도별 결과 처리. 원시 관로 전량 전송 아님 |
| 시도별 노후관로현황 | 작은 시도별 집계 결과의 화면 페이징 | 확인된 결과 18행. 원시 관로 전체가 아닌 고정 범주의 집계 |
| 첨부 Excel 미리보기 6개/2개 탭 | 파일 전체 파싱 후 화면 페이징 | 첨부 파일 내부 시트 미리보기이며 업무 목록 API가 아님 |
| 보고서 트리·보고서 파일 목록 | 외부 DC 전체 응답 후 화면 페이징 | 외부 DC 계약과 수정 권한 범위 제한. DC 내부 페이징은 본 저장소에서 변경하지 않음 |
| 입력표·단건 속성표·소규모 조사 집계 | 페이징 없음 | 입력 중인 값·병합·단건 속성/정해진 지표 표시. 목록 원시 자료와 구분 |
| 지반침하 좌표상태 필터/정렬 | 서버 후보 순회 후 페이지 | 기존 좌표 판정 결과에 의존하는 필터. 기본 목록은 DB 페이징 유지 |
| 사업 업로드 호환 Excel·최종확정예산·소규모 집계 Excel | 프론트 양식 생성 일부 유지 | 업로드 호환 셀 위치·병합 등 양식을 유지. 현재 페이지가 아닌 전체 적용조건 순회 및 건수 검사 계약 확인 |
## 6. 검증 기록
- Java compileJava, SN TypeScript 검사 통과. 사용자 프론트 개발서버가 실행 중이므로 next build는 실행하지 않았다.
- 일회성 자동 검증 7개 모두 통과(실패 0, 오류 0): 서버 요청 페이지 제한, 정렬 허용목록, 조사 전체 합계, 구간 그룹 보존, 준설 501행 전체 XLSX, 작업 소유권/토큰 갱신/파일 정리, 시설 2,001행 전체 XLSX와 표시값 보존.
- 실제 API: 준설 총 11건을 10건/1건으로 확인. 관경·경과년도는 각각 5개 집계 행 확인.
- 실제 파일: 준설 일반 11행·13열, 관경 5행·9열, 경과년도 5행·7열. 조사 PPL/MNHL 각각 3개 데이터 행과 소계·합계, 본단위 19개 상세 행 확인. 조사 전체 연장 합계 82.85가 유지됐다.
- 새 작업 API의 인증 없는 조회는 401. 실제 파일 수신 후 DELETE 정리 성공을 확인했다.
- 기존 목록 API 17종에 page=0,size=1을 요청해 응답 범위를 확인했다. 빈 결과인 조건은 빈 결과 검증으로만 분류한다.
- 실제 스트리밍 XLSX를 다시 열어 침입수 116,088행/4열(약 6.72초), RDII 145,110행/4열(약 8.62초), 관로현황 상세 7,906행/11열, 침수이력 82행/17열을 확인했다. 순번은 1부터 마지막 행까지 유지된다. 시간은 해당 로컬 조건에서 생성·수신한 관측값이지 운영 SLA가 아니다.
- 기본정보 실제 하수관로 1행/17열에서 식별자·날짜를 확인했고, 다중 배치는 자동 검증의 2,001행으로 확인했다. 전국 4,132,699행의 전체 부하는 실행하지 않았다.
- 실제 브라우저: 준설 2페이지의 11번 행·관재질 정렬·관경별 집계·Excel 시작/버튼 복귀, 처리분구 미저장 정렬 방지/초기화, 조사 상세 전체 합계·본단위 전환·1060 검색, 기본정보 하수관로/침수흔적심선 및 심선 2페이지 11~20번 행, 보수이력 실제 3행을 확인했다. 처리분구 편집은 저장하지 않고 폐기했다.
- 지도 베이스맵과 시설 레이어 표출은 확인했다. 보수이력 API는 목록 size=1에 1행, 전체 mapFeatures에 3개 도형이 반환되고 전체 목록 FID와 일치했다. 단, 검증 브라우저에서 지도 버튼으로 연 자식 팝업이 노출되지 않아 부모 지도와 자식 창을 함께 조작하는 강조 이동 시나리오는 미검증이다. 직접 팝업 주소를 연 검증과 혼동하지 않는다.
## 7. 메뉴 > 화면명별 결과
`API`와 `실파일`은 해당 검증 통과, `화면`은 명시한 시나리오 통과를 뜻한다. `코드 확인`은 정적 전수검사이며 실제 화면 회귀 통과를 뜻하지 않는다. 빈 결과는 데이터가 있는 경우의 검증을 대체하지 않는다. 전체 71개 표 렌더 지점의 파일별 근거는 같은 폴더의 001 분석결과를 참조한다.
| 메뉴 > 화면명 | 목록/팝업 처리 | Excel | 확인 상태 |
| --- | --- | --- | --- |
| 운영정보 > 노후정비사업(oldPp) | 기존 DB 페이지 유지 | 원본 첨부 유지 | API 1/총2 |
| 운영정보 > 도시침수예방(floodPre) | 기존 DB 페이지 유지 | 원본 첨부 유지 | API 1/총7 |
| 운영정보 > 노후정비사업(obsltPplBiz) | 기존 DB 페이지 유지 | 전체 적용조건·업로드 호환 양식 | API 1/총518 |
| 운영정보 > 도시침수예방(ctyFldngPrvtnBiz) | 기존 DB 페이지 유지 | 전체 적용조건·업로드 호환 양식 | API 1/총257 |
| 최종확정예산사업 팝업 | 기존 DB 페이지 유지 | 전체 순회·지정 양식 | 2026 조건 API 빈 결과 |
| 노후정비사업 첨부 상세 6탭 | 파일 미리보기 예외 | 원본 파일 | 코드 확인 |
| 도시침수예방 첨부 상세 2탭 | 파일 미리보기 예외 | 원본 파일 | 코드 확인 |
| 유지관리 > 조사사업 등록/조사현황 | 기존 DB 페이지 유지 | 기존 서버 종합 Excel 유지 | API 1/총14 |
| 조사 상세 팝업 > 관로/맨홀 | 서버 페이지·전체 합계 분리 | 서버 작업형으로 전환 | API/실파일 통과, 관로 화면 합계 확인 |
| 조사 상세 팝업 > 본단위구간 | 서버 구간 그룹 페이지 | 서버 작업형으로 전환 | API/실파일/화면 전환·검색 통과 |
| 유지관리 > 처리분구관리 | QueryDSL DB 페이지로 전환 | 기존 없음 | API 2/총6, 화면 미저장 보호 통과 |
| 유지관리 > 준설관리 3구분 | 서버 페이지·DB 집계 | 서버 작업형으로 전환 | API/실파일 통과, 화면 페이지·정렬·관경 통과 |
| 준설 등록/수정/상세 팝업 | 입력·사진·단건 표 예외 | 기존 기능 유지 | 코드 확인 |
| I/I 분석 > 침입수 분석결과 | 기존 DB 페이지 유지 | 서버 작업형으로 전환 | 목록 API 1/총51, XLSX 116,088행 통과 |
| I/I 분석 > RDII 분석결과 | 기존 DB 페이지 유지 | 서버 작업형으로 전환 | 목록 API 1/총25, XLSX 145,110행 통과 |
| 하수관망도 > 침입수/RDII 결과 팝업 | 기존 DB 페이지 유지 | 일반 화면과 같은 서버 다운로드 | 코드 확인 |
| I/I 분석 > RDII 입력표 2개 | 입력표 예외 | 해당 없음 | 코드 확인 |
| 하수관망도 > 정보검색 기본정보 8유형 | 기존 GeoServer 페이지 유지 | 서버 작업·스트리밍으로 전환 | 하수관로 API/실파일, 관로·심선 화면/심선 페이지 통과. 나머지 유형 코드 확인 |
| 정보검색 > 유지관리 사업목록 | 기존 DB 5건 페이지 유지 | 적용조건 전체 순회 | API 1/총155 |
| 정보검색 > 연결 시설목록 | 기존 DB 페이지+해당 시설 보강 | 적용조건 전체 순회 | 코드 확인 |
| 정보검색/보고서 팝업 > 기본계획보고서 | 외부 DC 예외 | 원본 파일 다운로드 | 코드 확인 |
| 보고서 파일 목록 팝업 | 외부 DC 예외 | 원본 파일 다운로드 | 코드 확인 |
| 하수관망도 > 노후관로 목록 | 기존 GeoServer 페이지 유지 | 기존 서버 전체 Excel | 코드 확인 |
| 하수관망도 > 시도별 노후관로현황 | 18행 집계 화면 페이징 예외 | 전체 시도 집계 양식 | 코드 확인 |
| 하수관망도 > 관로분석 요약 3유형 | 서버 그룹 페이지로 전환 | 별도 전체 summaries 조회 | API 3유형 통과, 동적 26열·그룹 보존 확인 |
| 관로분석 > 관로현황 상세 | 기존 DB 페이지 유지 | 서버 작업형으로 전환 | API/실파일 7,906행 통과 |
| 처리구역 > 보수이력 팝업 | 서버 페이지/지도 전체 분리 | 기존 서버 전체 Excel | API 목록1/지도3 일치, 팝업 3행 확인. 부모 지도 강조 미검증 |
| 하수관망도 > 지반침하발생현황 | DB 페이지, 좌표상태는 서버 가공 예외 | 적용조건 전체 순회 | API 1/총213 |
| 지반침하 > 상세/인근관로 | 단건·반경 제한 목록 예외 | 해당 없음 | 코드 확인 |
| 하수관망도 > 침수위험지도 목록 | 기존 DB 페이지 유지 | 기존 없음 | API 1/총50 |
| 하수관망도 > 도시침수지도 목록 | 기존 DB 페이지 유지 | 기존 없음 | API 1/총894 |
| 하수관망도 > 침수발생이력 | 기존 DB 페이지 유지 | 서버 작업형으로 전환 | API 1/총139,121, 조건부 실파일82행 통과 |
| 침수발생이력 상세 | 단건 속성/강우 표 예외 | 해당 없음 | 코드 확인 |
| 하수관망도 > 침수예측현황 | 기존 DB 페이지 유지 | 적용조건 전체 순회 | API 1/총208 |
| 하수관망도 > 통수 위험 | 기존 DB 페이지 유지 | 기존 없음 | API 빈 결과 |
| GIS 관리 > 업로드 이력/검증 오류 | 기존 DB 페이지 유지 | 기존 서버 파일 작업 유지 | 코드 확인 |
| GIS 편집 > 기존/수정 속성 팝업 | 단건 편집표 예외 | 해당 없음 | 코드 확인 |
| 시설 상세 > 중점우수토실 3표 | 기존 DB 3건 페이지 유지 | 기존 기능 유지 | 코드 확인 |
| 시설 상세 > 관로/맨홀/펌프장/처리시설 | 단건 속성표 예외 | 기존 기능 유지 | 코드 확인 |
| 처리구역 분석/지도 위험 지표/조사결과 요약 | 지표·선택 객체 표 예외 | 기존 기능 유지 | 코드 확인 |
| 메뉴 권한관리 | SN 직접 사용처 미발견, 외부 CMS | 해당 없음 | 코드 확인 |
## 8. 운영·롤백·정리
- 새 프론트 경로를 쓰려면 백엔드 새 API가 먼저 배포돼야 한다. 이번 작업에서 개발서버 배포·Git push는 하지 않는다.
- 롤백은 이번 BE/FE 변경을 함께 되돌린다. DB 마이그레이션은 없으며 지도 원래 경로는 유지했다.
- 대량 WFS 원천의 동시 변경, 수백만 건의 전체 부하, 외부 DC/CMS 내부 계약은 별도 제약이다. 소량 실파일과 다중 배치 자동 검증을 전체 부하 통과로 확대 해석하지 않는다.
- 검증용 백엔드 PID 60700과 실행 세션을 종료했고 8883 포트 해제를 확인했다. 사용자가 실행한 프론트 PID 43172/3002는 유지했다. 검증 브라우저 탭도 닫았다.
- 일회성 Java 테스트 소스 1개는 삭제했다. 사용자 원본과 기존 테스트 리소스는 보존했다. SQL 로그는 줄바꿈을 정규화하여 기존 HEAD 내용이 그대로 접두부에 남아 있음을 확인한 뒤, 이번 검증 추가분 134행만 제거해 원래 상태로 복원했다.
- **정리 미완료:** 임시 XLSX 11개·요청 JSON 3개와 생성된 테스트 class/XML/HTML/binary 8개의 파일 삭제 명령이 실행 정책에 의해 거부됐다. 다른 방식으로 우회 삭제하지 않았다. 파일은 아직 존재하며 아래 위치에 남았다. 테스트 원본 소스는 삭제됐으므로 재실행할 영구 테스트를 남긴 것은 아니다.
- `C:/Users/mindone/AppData/Local/Temp/sn-grid-paging-qa-20260923/`: 검증용 파일 14개.
- `D:/workspace/IIPBS/sewerage-service/build/classes/java/test/iipbs/sewerage/paging/GridPagingVerificationTest.class`
- `build/test-results/test/`: 해당 테스트 XML 1개와 binary 3개.
- `build/reports/tests/test/`: index.html, 해당 테스트 classes HTML, paging packages HTML 3개. 공용 CSS/JS와 빌드 캐시는 정리 대상에서 제외했다.
- 삭제한 테스트 소스와 로그 추가분은 별도 복구 사본을 남기지 않았다. 제거된 프론트 구형 Excel 모듈은 Git 이력에서 복구할 수 있다. 최종 보고서 2개는 보존한다.
## 9. 수정 파일과 코드 구조
경로는 백엔드 `D:/workspace/IIPBS/sewerage-service`, 프론트 `D:/workspace/IIPBS-FRONTEND/frontend/apps/sn` 기준이다.
| 구분 | 주요 파일/모듈 | 책임 |
| --- | --- | --- |
| 공통 페이지 | BE `api/common/dto/GridPageDto.java` | content/totalElements/number/size/totalPages와 범위 검증 |
| 준설 | BE DredgingController/Repository/Service, 신규 DredgingGridService/ExcelService/SortNameCatalog | DB 페이지·허용 정렬·명칭 정렬·전체 Excel |
| 조사 | BE ExmnFacilityController, ExmnGridService/ExcelService/ExportRequest | 서버 필터·전체 합계·그룹 단위 페이지·가변 Excel |
| 처리분구 | BE TrtdstSettingPageRepository/Service/Controller | QueryDSL count와 페이지, 선택지 API 분리 |
| 보수이력 | BE RepairHistoryService/MapFeatureDto, SwagePrcsZoneController | 전체 도형만 반환하는 독립 계약 |
| 관로분석 | BE PplAnalysisService/Controller | 요약 그룹 페이지와 전체 메타데이터 |
| 전체 Excel | BE GridExportJobService/Controller, AnalysisExcelService, FacilityGridExcelService, SpatialGridExcelService 및 각 ExportController/Request | 비동기 생성·사용자 소유권·전체 쿼리 순회·파일 정리 |
| Excel 표시 프로파일 | BE `src/main/resources/grid/facility-export-profiles.json` | 시설 8유형 열/표시 계약과 기존 코드명 |
| 다운로드 클라이언트 | FE `src/shared/api/grid-export.ts`, `src/shared/lib/download-analysis-excel.ts`, `app/api/gridDownload/[jobId]/route.ts` | 작업 접수/폴링·4개 분석 화면 공통화·인증 스트리밍 |
| 화면/팝업 | FE 준설 page, ExmnSurveyDetailPopupPage/StandardDataTable, TrtdstManagementPage, PipeAnalysisPopupPage, RepairHistoryPopupPage 및 연관 api 모듈 | 페이지·정렬·합계·그룹·편집 보호 |
| 지도 연결 | FE SwtrmtPrcsZoneAnalysis 및 swtrmtPrcsZoneAnalysisApi | 전체 도형 전달과 지연 선택 |
| Excel 호출 전환 | FE I/I 일반/팝업 4개, useBasicTab, PipeAnalysisDetailPopupPage, FldngHstryPopupPage | 화면 페이지와 다운로드 조건 분리 |
| 심선 누락 보완 | FE common/table.ts, searchModal.tsx | 침수흔적심선 camelCase 목록 컬럼 연결 |
| 기준 | 공통 AGENTS.md, SN AGENTS.md | 서버 페이지 기본·지도 분리·전체 다운로드·예외 보고 기준 |
핵심 변경 패턴은 다음과 같다. 모든 화면을 같은 응답 이름으로 일괄 변경하지 않고 기존 계약은 보존했다.
```ts
// 이전: 브라우저에 모든 페이지를 모은 후 현재 화면을 잘라냄
const allRows = await fetchAllPages(...);
const visibleRows = allRows.slice(page * size, (page + 1) * size);
// 변경: 서버 페이지와 전체 건수를 함께 사용, Excel에는 page/size를 보내지 않음
const result = await fetchPage({ ...appliedFilters, page, size, sort });
setRows(result.content);
setTotal(result.totalElements);
await requestGridExcel(exportUrl, appliedFilters, fileName);
```
위 코드는 전환 원리를 보여주는 요약 예시이며 그대로 실행하는 테스트 소스가 아니다. 실제 함수·응답 계약은 화면별 모듈을 따른다.
@@ -0,0 +1,39 @@
# 침수예측현황 분석시각 초기화 구현기록
## 작업 목적
- 침수예측현황 팝업을 처음 열거나 검색조건을 초기화할 때 분석시각 범위를 자동 입력하지 않는다.
- 분석시각 제한 없이 조회한 결과는 최근 분석 데이터부터 표시한다.
## 원본 및 변경 근거
- 요구사항정의서 `SFR-011-008 위험도 산정`
- 화면설계서 슬라이드 24, `SN_MN010205 침수예측현황`
- 사용자 승인사항: 분석시각 기본 검색조건 제거 및 최근 데이터 우선 표시
## 변경 내용
- 최초·초기화 검색조건의 `analysisFrom`, `analysisTo`를 빈 값으로 변경했다.
- 목록과 Excel 전체 조회에 `analysisDateTime,desc` 정렬을 명시했다.
- 사용자가 필요할 때 직접 분석시각 범위를 입력할 수 있도록 검색 입력란은 유지했다.
## 영향 범위
- `apps/sn/src/features/fldngPredcPrst/ui/FldngPredcPrstPopupContent.tsx`
- 백엔드 API와 응답 구조는 변경하지 않는다.
- 시도, 시군구, 강우시각, 초과 빈도 검색조건은 기존 동작을 유지한다.
## 검증
- `pnpm --filter sn run typecheck` 통과
- `git diff --check` 통과
- 실행 중인 개발 서버를 유지하기 위해 별도 빌드는 수행하지 않는다.
## 롤백
- `buildInitialFilters`에서 분석시각의 금일 기본 범위를 다시 생성하고 명시적 최신순 정렬 파라미터를 제거한다.
## 남은 확인
- 팝업 최초 진입 시 분석시각 입력란이 비어 있는지 확인한다.
- 초기화 버튼을 누르면 분석시각이 비워지고 첫 페이지에 최신 분석 데이터가 표시되는지 확인한다.
@@ -0,0 +1,42 @@
# 침수예측현황 도시침수지도 자동 선택 구현기록
## 작업 목적
- 침수예측현황 팝업에서 행을 선택하면 사용자가 레이어 트리에서 직접 조작하지 않아도 도시침수지도 레이어를 자동으로 체크한다.
- 레이어 활성화 후 선택한 시군구와 빈도에 맞는 위치 이동, 피처 선택, 침수예측 오버레이 표시를 이어서 실행한다.
## 원본 근거
- 요구사항정의서 `3. (기능)요구사항정의 및 추적표` 94행, `SFR-011-008 위험도 산정`
- 화면설계서 슬라이드 24, `SN_MN010205 침수예측현황`
- 화면설계서 슬라이드 28, `SN_MN010206 침수예측지도`
- 자동 레이어 선택 비교 화면: 슬라이드 47, `SN_MN040106 조사결과 지도보기`
## 변경 내용
- 하수관망도에 이미 연결된 `checkedLayerRequest`를 사용하여 `도시침수지도(정보제공포털)` 레이어를 기존 체크 상태에 추가한다.
- 도시침수지도 레이어가 활성화되기 전에 도착한 행 선택 정보는 임시 상태에 보관한다.
- 레이어가 준비되면 기존 시군구·빈도 피처 선택, 지도 범위 이동, 침수예측 오버레이 표시 로직을 실행한다.
- 제한 시간 안에 레이어가 준비되지 않으면 기존 수동 선택 안내 메시지를 팝업에 전달한다.
## 영향 범위
- `apps/sn/app/(map-layout)/map/page.tsx`
- 백엔드 API와 메시지 payload 계약은 변경하지 않는다.
- 기존에 사용자가 체크한 다른 지도 레이어는 유지한다.
- 도시침수지도 미제공 빈도에 대한 기존 안내는 유지한다.
## 검증
- `pnpm --filter sn exec tsc --noEmit` 통과
- `git diff --check` 통과
- 개발 서버가 3002 포트에서 실행 중이므로 별도 빌드는 수행하지 않는다.
## 롤백
- `map/page.tsx`의 도시침수지도 자동 체크 요청, 보류 상태, 재시도 effect를 제거하고 기존 레이어 가시성 검사와 수동 선택 안내 흐름으로 복원한다.
## 남은 확인
- 로그인된 브라우저에서 도시침수지도가 꺼진 상태와 이미 켜진 상태를 각각 확인한다.
- 행을 연속 선택했을 때 마지막 선택 행 기준으로 지도와 오버레이가 갱신되는지 확인한다.
@@ -148,3 +148,21 @@
- 목록은 CMS 코드값 또는 코드명과 정확히 일치하는 값만 코드명으로 표시하고 미등록 기존 값은 그대로 표시한다.
- 영문 `lwpoCd` 저장 전환과 기존 데이터 이관은 이번 범위에서 수행하지 않았다.
- 백엔드 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",
"start:sn": "next start -p 3002",
"lint": "next lint",
"test:refactor": "node scripts/run-refactor-tests.cjs",
"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"
},
"dependencies": {
@@ -13,6 +13,7 @@ import { AlertDialog } from "@/shared/ui/alert-dialog";
import { PromptDialog } from "@/shared/ui/prompt-dialog";
import { ConfirmDialog } from "@/shared/ui/confirm-dialog";
import { BusinessUserMessageBridge } from "@/shared/ui/business-user-message-bridge";
import { GridCellTooltip } from "@/shared/ui/grid-cell-tooltip";
// 개발 도구는 실제 로컬 개발 환경에서만 별도 청크로 불러와 최초 페이지 컴파일 범위를 줄인다.
const LazyReactQueryDevtools = dynamic(
@@ -30,6 +31,7 @@ const AppProvider = ({ children }: Readonly<{ children: React.ReactNode }>) => {
<QueryClientProvider client={queryClient}>
<StoreProvider>
<BusinessUserMessageBridge />
<GridCellTooltip />
<PromptProvider>
<ConfirmProvider>
<AuthWrapper>
+19
View File
@@ -0,0 +1,19 @@
/* SN 목록 그리드의 데이터 텍스트만 한 줄로 제한한다. 폼·병합·액션 셀은 보존한다. */
body[data-sn-grid-ellipsis="true"] table:has(> thead) > tbody > tr > td:not([colspan]):not([rowspan]):not([data-sn-cell-wrap]):not(:has(input, textarea, select, button, [role="checkbox"], [role="switch"], [contenteditable="true"], table, img, svg)) {
max-width: 0;
overflow: hidden;
white-space: nowrap !important;
text-overflow: ellipsis;
}
/* 셀 안의 block/whitespace-normal·pre-line 텍스트도 실제 열 너비 안에서 생략한다. */
body[data-sn-grid-ellipsis="true"] table:has(> thead) > tbody > tr > td:not([colspan]):not([rowspan]):not([data-sn-cell-wrap]):not(:has(input, textarea, select, button, [role="checkbox"], [role="switch"], [contenteditable="true"], table, img, svg)) :is(div, span, a, p) {
display: block;
min-width: 0;
max-width: 100%;
overflow: hidden;
white-space: nowrap !important;
overflow-wrap: normal;
word-break: normal;
text-overflow: ellipsis;
}
@@ -3,6 +3,7 @@
*/
"use client";
import { getSnRowNumber } from "@/shared/lib/row-number";
import { useEffect, useMemo, useState } from "react";
import { useConfirm } from "@frontend-opensource/use-react-hooks";
@@ -10,7 +11,6 @@ import useOnsemiroAddrStore from "@/app/store/useOnsemiroAddrStore";
import {
StandardDataTable,
StandardPopupLayout,
getStandardRowNumber,
} from "@common/business_components";
import {
Button,
@@ -273,7 +273,7 @@ export default function CfmtnBgtPopupContent({
() =>
(Array.isArray(data.content) ? data.content : []).map((row, index) => ({
...row,
rowNumber: getStandardRowNumber({
rowNumber: getSnRowNumber({
total: data.totalElements,
rowIndex: index,
pageIndex: page,
@@ -1,12 +1,12 @@
"use client";
import { getSnRowNumber } from "@/shared/lib/row-number";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import {
StandardDataTable,
StandardTableSortHeader,
getStandardRowNumber,
type UiTableSort,
} from "@common/business_components";
import {
@@ -463,7 +463,7 @@ export function CtyFldngPrvtnBizListPage() {
result.content.map((row, i) =>
flattenRow(
row,
getStandardRowNumber({
getSnRowNumber({
total: result.totalElements,
rowIndex: i,
pageIndex: target - 1,
@@ -6,7 +6,7 @@ import {
postRawJson,
putData,
} from "@/components/lib/api";
import { fetchAllPages } from "@/shared/lib/fetch-all-pages";
import { requestGridExcel } from "@/shared/api/grid-export";
import { isDredgingBackendEnabled } from "./backend-mode";
import { toDredgingApiError } from "./dredging-error";
import {
@@ -28,6 +28,41 @@ import type {
} from "../model/registration-request";
export type { AttachmentReference } from "../model/registration-request";
/** 그리드는 0 기반 서버 페이지 하나만 요청한다. 지도·전체 다운로드는 별도 계약이다. */
export type DredgingGridQuery = {
viewType: string;
fromDate: string;
toDate: string;
ctpv?: string;
sgg?: string;
page: number;
size: number;
sort?: string;
};
export const fetchDredgingGridPage = async (query: DredgingGridQuery): Promise<{
content: Array<Record<string, unknown> & { recordIds: string[] }>;
totalElements: number;
number: number;
size: number;
}> => {
const params = new URLSearchParams();
Object.entries(query).forEach(([key, value]) => {
if (value !== undefined && value !== "") params.set(key, String(value));
});
const body = await getRawJson(`v1/dredging/grid?${params}`);
return body?.data ?? body;
};
/** 적용 조건 전체의 서버 XLSX를 받는다. page/size는 다운로드에 전달하지 않는다. */
export const downloadDredgingGridExcel = (query: Omit<DredgingGridQuery, "page" | "size">): Promise<void> => {
const params = new URLSearchParams();
Object.entries(query).forEach(([key, value]) => {
if (value !== undefined && value !== "") params.set(key, String(value));
});
return requestGridExcel(`v1/dredging/grid/exportJobs?${params}`, {}, `준설관리_${query.viewType}_${query.fromDate}_${query.toDate}.xlsx`);
};
type LocationRequest = GeoPoint & {
locationKey: string;
};
@@ -64,18 +99,6 @@ export const fetchDredgingRecords = async (params: {
}
};
/** 조회조건에 맞는 준설기록 전체 페이지를 순회해 목록·Excel 건수를 일치시킨다. */
export const fetchAllDredgingRecords = async (params: {
fromDate: string;
toDate: string;
ctpv?: string;
sgg?: string;
}) =>
fetchAllPages({
fetchPage: (page, size) => fetchDredgingRecords({ ...params, page, size }),
getItems: (response) => response.items ?? [],
getTotal: (response) => Number(response.total ?? 0),
});
/** 등록된 준설사진의 공통 첨부파일 참조를 조회한다. */
export const fetchDredgingPhotos = async (
@@ -12,9 +12,6 @@ import type {
ExmnPipeReportAvailability,
ExmnSectionSummaryRow,
ExmnTemplateResult,
PplFacility,
PplFacilitySearchParams,
PplSummaryRow,
} from "../model/exmn-business-types";
import {
type ExmnDefectGroup,
@@ -216,7 +213,34 @@ interface ExmnPplUnitDetailPayload {
remark: string | null;
}
const PPL_STATUS_LABELS: Record<string, string> = {
/** 조사 상세 표의 0 기반 서버 페이지와 조건 전체 합계를 반환한다. */
export const getExmnFacilityPage = async (params: {
exmnBizId: string; jobType: ExmnJobType; trtdstId: string;
gisManagementNo?: string; defectType: string; defectCodes: string;
page: number; size: number; sort?: string;
}): Promise<{ content: ExmnSurveyDetailRow[]; totalElements: number; summary: ExmnSurveyDetailRow }> => {
const query = new URLSearchParams();
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== "") query.set(key, String(value));
});
return unwrapData(await getData(`v1/exmnFacilities/page?${query}`));
};
/** 관로·단위구간 병합을 보존한 서버 그룹 페이지를 화면 표시명으로 변환한다. */
export const getExmnPplUnitPage = async (params: {
exmnBizId: string; trtdstId: string; gisManagementNo?: string; page: number; size: number;
}): Promise<{ content: ExmnPplUnitDetailRow[]; totalElements: number }> => {
const query = new URLSearchParams();
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== "") query.set(key, String(value));
});
const result = unwrapData<{ content: ExmnPplUnitDetailPayload[]; totalElements: number }>(
await getData(`v1/exmnFacilities/pplUnitSections/page?${query}`),
);
return { ...result, content: result.content.map((row) => ({ ...row, defectName: getPplUnitDetailName(row) })) };
};
export const PPL_STATUS_LABELS: Record<string, string> = {
IS: "조사시작(순주행)",
ISR: "조사시작(역주행)",
IE: "조사완료(순주행)",
@@ -366,47 +390,3 @@ export const commitExmnImport = async (
`${EXMN_BUSINESS_API_PATH}/${encodeURIComponent(exmnBizId)}/imports/commit`,
formData,
) as Promise<ExmnImportCommitResult>;
/** 선택 사업의 처리분구별 PPL 집계를 조회한다. */
export const getPplSummaries = async (
exmnBizId: string,
params: { trtdstId?: string; page: number; size: number; sort?: string },
): Promise<ExmnPage<PplSummaryRow>> => {
const summaries = await getExmnSectionSummaries(exmnBizId, "PPL");
const filtered = params.trtdstId
? summaries.filter((row) => row.trtdstId === params.trtdstId)
: summaries;
const start = Math.max(params.page, 0) * Math.max(params.size, 1);
const size = Math.max(params.size, 1);
return {
content: filtered.slice(start, start + size),
totalElements: filtered.length,
number: Math.max(params.page, 0),
size,
totalPages: Math.ceil(filtered.length / size),
};
};
/** 선택 사업의 PPL 시설을 처리분구·구간·GIS번호·등급으로 조회한다. */
export const getPplFacilities = async (
params: PplFacilitySearchParams,
): Promise<ExmnPage<PplFacility>> => {
const query = new URLSearchParams({
exmnBizId: params.exmnBizId,
jobType: "PPL",
page: String(params.page),
size: String(params.size),
});
if (params.trtdstId) query.set("trtdstId", params.trtdstId);
if (params.sectionType) query.set("sectionType", params.sectionType);
if (params.gisManagementNo?.trim())
query.set("gisManagementNo", params.gisManagementNo.trim());
if (params.structuralGrade)
query.set("structuralGrade", params.structuralGrade);
if (params.operationalGrade)
query.set("operationalGrade", params.operationalGrade);
if (params.defectType && params.defectType !== "ALL")
query.set("defectType", params.defectType);
if (params.sort) query.set("sort", params.sort);
return toPage<PplFacility>(await getData(`v1/exmnFacilities?${query}`));
};
@@ -10,6 +10,17 @@ import type {
/** 조사사업 공통 선행 처리분구 API의 기준 경로다. */
const TRTDST_API_PATH = "v1/trtdstSettings";
/** 관리 그리드는 활성 처리분구 한 페이지만 조회한다. 등록 화면의 전체 선택지와 구분한다. */
export const getTrtdstPage = async (params: {
ctpvNm?: string; sggNm?: string; page: number; size: number; sort?: string;
}): Promise<{ content: TrtdstSettingDto[]; totalElements: number }> => {
const query = new URLSearchParams();
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== "") query.set(key, String(value));
});
return getData(`${TRTDST_API_PATH}/page?${query}`);
};
/** 선택된 행정구역 조건으로 활성 처리분구 목록을 조회한다. */
export const getTrtdstSettings = async (
params: TrtdstSearchParams,
@@ -1,291 +0,0 @@
import { downloadExcelWithKoreanHeaders } from "@/shared/lib/download-excel";
import {
EXMN_DETAIL_SEVERITIES,
type ExmnDetailSeverity,
type ExmnDetailDefectColumn,
type ExmnPplUnitDetailRow,
type ExmnSurveyDetailRow,
type ExmnSeverityCounts,
} from "../model/exmn-detail-table";
import { getVisibleAnomalyTotals } from "./exmn-detail-totals";
type ExmnDetailDownloadOptions = {
selectedTrtdstNm: string;
defectGroupTitle: string;
isUnitMode: boolean;
defectTypeLabel: string;
unitRows: ExmnPplUnitDetailRow[];
rows: ExmnSurveyDetailRow[];
recordTypeLabels: Record<string, string>;
pipeItemLabels: Record<string, string>;
severityLabels: Record<ExmnDetailSeverity, string>;
visibleDefectColumns: ExmnDetailDefectColumn[];
totals: {
extensionM: number;
drivingDistanceM: number;
structuralGrade: number;
operationalGrade: number;
};
defectSummary: {
anomalySubtotals: ExmnSeverityCounts;
anomalyTotal: number;
defectSubtotals: Record<string, ExmnSeverityCounts>;
defectTotals: Record<string, number>;
};
summaryLabelColumnCount: number;
fixedColumnCount: number;
setNotice: (notice: { type: "info"; text: string } | null) => void;
};
/** 가변 결함 열 수에 맞춰 엑셀의 마지막 열 이름(A, Z, AA 등)을 계산한다. */
const getExcelColumnName = (columnCount: number) => {
let remaining = columnCount;
let result = "";
while (remaining > 0) {
remaining -= 1;
result = String.fromCharCode(65 + (remaining % 26)) + result;
remaining = Math.floor(remaining / 26);
}
return result || "A";
};
/** 현재 조회·필터·표 순서의 조사 결과로 Excel 열·상세·소계·합계·병합을 구성한다. */
export const downloadExmnSurveyDetails = async ({
selectedTrtdstNm,
defectGroupTitle,
isUnitMode,
defectTypeLabel,
unitRows,
rows,
recordTypeLabels,
pipeItemLabels,
severityLabels,
visibleDefectColumns,
totals,
defectSummary,
summaryLabelColumnCount,
fixedColumnCount,
setNotice,
}: ExmnDetailDownloadOptions) => {
const downloadDate = new Date().toISOString().slice(0, 10);
const safeFilePrefix = `${selectedTrtdstNm}_${defectGroupTitle}조사_${
isUnitMode ? "본단위등급표" : defectTypeLabel
}`.replace(/[\\/:*?"<>|]/g, "_");
try {
if (isUnitMode) {
if (unitRows.length === 0) {
setNotice({
type: "info",
text: "다운로드할 본단위구간 데이터가 없습니다.",
});
return;
}
const unitHeaderMapping: Record<string, string> = {
pipeNo: "관로번호",
unitSeq: "단위구간",
unitLengthM: "단위길이(m)",
defectPositionM: "결함위치(m)",
defectName: "결함 종류",
code: "결함 코드",
severity: "결함 등급",
occurrencePosition: "발생위치",
infiltrationBonus: "침입수 가점",
continuous: "연속결함",
structuralScore: "구조적 등급점수",
operationalScore: "운영적 등급점수",
surveyStopReason: "조사중단사유",
remark: "비고",
};
const unitDownloadRows = unitRows.map((row) => ({
pipeNo: row.pipeNo,
unitSeq: row.unitSeq,
unitLengthM: row.unitLengthM,
defectPositionM: row.defectPositionM,
defectName: row.recordType
? `(${recordTypeLabels[row.recordType] || row.recordType})${pipeItemLabels[row.code] || row.defectName.replace(/^\([^)]*\)/, "")}`
: row.defectName,
code: row.code,
severity: row.severity ? severityLabels[row.severity] : "-",
occurrencePosition: row.occurrencePosition,
infiltrationBonus: row.infiltrationBonus,
continuous: row.continuous == null ? "-" : row.continuous ? "Y" : "N",
structuralScore: row.structuralScore ?? "-",
operationalScore: row.operationalScore ?? "-",
surveyStopReason: row.surveyStopReason ?? "-",
remark: row.remark ?? "-",
}));
await downloadExcelWithKoreanHeaders({
data: unitDownloadRows as unknown as JSON[],
fileName: `${safeFilePrefix}_${downloadDate}.xlsx`,
sheetNameArray: ["본단위등급표"],
headerRange: "A1:N1",
headerMapping: unitHeaderMapping,
});
return;
}
if (rows.length === 0) {
setNotice({
type: "info",
text: "다운로드할 상세현황 데이터가 없습니다.",
});
return;
}
const detailHeaderMapping: Record<string, string> = {
reportNo: "보고서번호",
pipeNo: "관로번호",
upstreamManhole: "상류맨홀",
downstreamManhole: "하류맨홀",
drainageType: "배수방식",
pipeType: "관종",
specification: "규격",
extensionM: "연장(m)",
drivingDistanceM: "주행거리(m)",
};
Object.assign(detailHeaderMapping, {
structuralGrade: "구조적 내부결함",
operationalGrade: "운영적 내부결함",
});
Object.assign(detailHeaderMapping, {
anomalyL: "이상항목 합계-대",
anomalyM: "이상항목 합계-중",
anomalyS: "이상항목 합계-소",
});
visibleDefectColumns.forEach((column) => {
EXMN_DETAIL_SEVERITIES.forEach((severity) => {
detailHeaderMapping[`${column.code}_${severity}`] =
`${column.label} (${column.code})-${severityLabels[severity]}`;
});
});
const detailDownloadRows: Record<string, string | number>[] = rows.map(
(row) => {
const anomalyTotals = getVisibleAnomalyTotals(
row,
visibleDefectColumns,
);
const result: Record<string, string | number> = {
reportNo: row.reportNo,
pipeNo: row.pipeNo,
upstreamManhole: row.upstreamManhole,
downstreamManhole: row.downstreamManhole,
drainageType: row.drainageTypeName?.trim() || row.drainageType,
pipeType: row.pipeTypeName?.trim() || row.pipeType,
specification: row.specification,
extensionM: row.extensionM,
drivingDistanceM: row.drivingDistanceM,
};
Object.assign(result, {
structuralGrade: row.structuralGrade,
operationalGrade: row.operationalGrade,
});
Object.assign(result, {
anomalyL: anomalyTotals.L,
anomalyM: anomalyTotals.M,
anomalyS: anomalyTotals.S,
});
visibleDefectColumns.forEach((column) => {
EXMN_DETAIL_SEVERITIES.forEach((severity) => {
result[`${column.code}_${severity}`] =
row.defectCounts[column.code]?.[severity] ?? 0;
});
});
return result;
},
);
const subtotalRow: Record<string, string | number> = {
reportNo: "소계",
pipeNo: "",
upstreamManhole: "",
downstreamManhole: "",
drainageType: "",
pipeType: "",
specification: "",
extensionM: totals.extensionM,
drivingDistanceM: totals.drivingDistanceM,
};
const totalRow: Record<string, string | number> = {
reportNo: "합계",
pipeNo: "",
upstreamManhole: "",
downstreamManhole: "",
drainageType: "",
pipeType: "",
specification: "",
extensionM: totals.extensionM,
drivingDistanceM: totals.drivingDistanceM,
};
Object.assign(subtotalRow, {
structuralGrade: totals.structuralGrade,
operationalGrade: totals.operationalGrade,
});
Object.assign(totalRow, {
structuralGrade: totals.structuralGrade,
operationalGrade: totals.operationalGrade,
});
Object.assign(subtotalRow, {
anomalyL: defectSummary.anomalySubtotals.L,
anomalyM: defectSummary.anomalySubtotals.M,
anomalyS: defectSummary.anomalySubtotals.S,
});
Object.assign(totalRow, {
anomalyL: defectSummary.anomalyTotal,
anomalyM: "",
anomalyS: "",
});
visibleDefectColumns.forEach((column) => {
EXMN_DETAIL_SEVERITIES.forEach((severity) => {
subtotalRow[`${column.code}_${severity}`] =
defectSummary.defectSubtotals[column.code]?.[severity] ?? 0;
});
totalRow[`${column.code}_L`] =
defectSummary.defectTotals[column.code] ?? 0;
totalRow[`${column.code}_M`] = "";
totalRow[`${column.code}_S`] = "";
});
detailDownloadRows.push(subtotalRow, totalRow);
const subtotalExcelRow = rows.length + 2;
const totalExcelRow = rows.length + 3;
const summaryLabelLastColumn = getExcelColumnName(summaryLabelColumnCount);
const mergeRanges = [
`A${subtotalExcelRow}:${summaryLabelLastColumn}${subtotalExcelRow}`,
`A${totalExcelRow}:${summaryLabelLastColumn}${totalExcelRow}`,
];
for (
let groupStartColumn = fixedColumnCount + 1;
groupStartColumn < Object.keys(detailHeaderMapping).length;
groupStartColumn += EXMN_DETAIL_SEVERITIES.length
) {
mergeRanges.push(
`${getExcelColumnName(groupStartColumn)}${totalExcelRow}:${getExcelColumnName(
groupStartColumn + EXMN_DETAIL_SEVERITIES.length - 1,
)}${totalExcelRow}`,
);
}
await downloadExcelWithKoreanHeaders({
data: detailDownloadRows as unknown as JSON[],
fileName: `${safeFilePrefix}_${downloadDate}.xlsx`,
sheetNameArray: [
`${defectGroupTitle}조사 ${defectTypeLabel}`.slice(0, 31),
],
headerRange: `A1:${getExcelColumnName(Object.keys(detailHeaderMapping).length)}1`,
headerMapping: detailHeaderMapping,
mergeRanges,
});
} catch (error) {
console.error("조사 상세현황 엑셀 다운로드 오류:", error);
setNotice({
type: "info",
text:
error instanceof Error
? error.message
: "엑셀 다운로드 중 오류가 발생했습니다.",
});
}
};
@@ -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),
@@ -6,7 +6,6 @@ import { useEffect, useMemo, useState } from "react";
import {
StandardPopupLayout,
StandardTableSortHeader,
sortStandardTableRows,
type UiTableSort,
} from "@common/business_components";
import {
@@ -20,15 +19,16 @@ import {
SelectValue,
Table,
} from "@common/components";
import { downloadExmnSurveyDetails } from "../lib/download-exmn-details";
import { requestGridExcel } from "@/shared/api/grid-export";
import { getVisibleAnomalyTotals } from "../lib/exmn-detail-totals";
import PopupSearchPanel, {
PopupSearchActions,
} from "@/features/windowPopup/ui/PopupSearchPanel";
import {
getExmnFacilityDetails,
getExmnPplUnitDetails,
PPL_STATUS_LABELS,
getExmnFacilityPage,
getExmnPplUnitPage,
getExmnSectionSummaries,
} from "../api/exmn-business-api";
import type {
@@ -161,10 +161,16 @@ export function ExmnSurveyDetailPopupPage({
const [notice, setNotice] = useState<Notice>(null);
/** 실제 DB 상세조회 결과와 요청 상태다. */
const [actualRows, setActualRows] = useState<ExmnSurveyDetailRow[]>([]);
// 표 행은 현재 페이지만 보관하고 합계는 서버의 전체 조건 합계를 별도로 사용한다.
const [page, setPage] = useState(1);
const [total, setTotal] = useState(0);
const [serverSummary, setServerSummary] = useState<ExmnSurveyDetailRow | null>(null);
const summaryRows = useMemo(() => serverSummary ? [serverSummary] : [], [serverSummary]);
const [actualUnitRows, setActualUnitRows] = useState<ExmnPplUnitDetailRow[]>(
[],
);
const [isLoading, setIsLoading] = useState(false);
const [isDownloading, setIsDownloading] = useState(false);
const [loadError, setLoadError] = useState<string | null>(null);
const severityLabels: Record<ExmnDetailSeverity, string> = {
...DEFAULT_SEVERITY_LABELS,
@@ -257,13 +263,10 @@ export function ExmnSurveyDetailPopupPage({
row.pipeNo.toLocaleLowerCase("ko-KR").includes(normalizedKeyword),
);
}, [actualUnitRows, appliedDetailNumberKeyword, isUnitMode]);
const rows = useMemo(
() =>
sortStandardTableRows(filteredRows, sort, (row, key) =>
getFixedCellValue(row, key as ExmnDetailFixedColumnId),
),
[filteredRows, sort],
);
// 현재 페이지를 다시 정렬하지 않는다. 정렬 조건을 서버에 보내 전체 결과 순서를 적용한다.
const rows = filteredRows;
useEffect(() => { setPage(1); }, [selectedTrtdstId, appliedDetailNumberKeyword, defectType, detailMode, jobType]);
/** 관로·맨홀 조사유형 또는 결함 범위가 바뀌면 이전 검색 조건과 안내를 초기화한다. */
useEffect(() => {
@@ -300,7 +303,7 @@ export function ExmnSurveyDetailPopupPage({
/** 전체 또는 결함 분류별 모드에서 선택 처리분구의 활성 시설과 JSONB 결함 집계를 조회한다. */
useEffect(() => {
if (isUnitMode) {
if (isUnitMode || !selectedTrtdstId) {
setActualRows([]);
setIsLoading(false);
setLoadError(null);
@@ -310,19 +313,29 @@ export function ExmnSurveyDetailPopupPage({
setActualRows([]);
setIsLoading(true);
setLoadError(null);
void getExmnFacilityDetails({
void getExmnFacilityPage({
exmnBizId: business.exmnBizId,
jobType,
trtdstId: selectedTrtdstId,
gisManagementNo: appliedDetailNumberKeyword,
defectType,
defectCodes: visibleDefectColumns.map((column) => column.code).join(","),
page: page - 1,
size: 10,
sort: sort ? `${sort.key},${sort.direction}` : undefined,
})
.then((result) => {
if (active) setActualRows(result);
if (active) {
setActualRows(result.content);
setTotal(result.totalElements);
setServerSummary(result.summary);
}
})
.catch((error) => {
if (!active) return;
setActualRows([]);
setTotal(0);
setServerSummary(null);
setLoadError(
error instanceof Error
? error.message
@@ -343,11 +356,13 @@ export function ExmnSurveyDetailPopupPage({
jobType,
searchRequestSeq,
selectedTrtdstId,
page,
sort,
]);
/** 본단위구간 모드에서 선택 처리분구의 PPL_UNIT_V1을 실제 상세행으로 조회한다. */
useEffect(() => {
if (!isUnitMode || isDefectTypeView) {
if (!isUnitMode || isDefectTypeView || !selectedTrtdstId) {
setActualUnitRows([]);
return;
}
@@ -355,17 +370,23 @@ export function ExmnSurveyDetailPopupPage({
setActualUnitRows([]);
setIsLoading(true);
setLoadError(null);
void getExmnPplUnitDetails({
void getExmnPplUnitPage({
exmnBizId: business.exmnBizId,
trtdstId: selectedTrtdstId,
gisManagementNo: appliedDetailNumberKeyword,
page: page - 1,
size: 10,
})
.then((result) => {
if (active) setActualUnitRows(result);
if (active) {
setActualUnitRows(result.content);
setTotal(result.totalElements);
}
})
.catch((error) => {
if (!active) return;
setActualUnitRows([]);
setTotal(0);
setLoadError(
error instanceof Error
? error.message
@@ -385,10 +406,12 @@ export function ExmnSurveyDetailPopupPage({
isUnitMode,
searchRequestSeq,
selectedTrtdstId,
page,
]);
/** 화면 조회 버튼으로 입력 번호를 확정해 상세 조회를 다시 실행한다. */
const handleSearch = () => {
setPage(1);
setAppliedDetailNumberKeyword(detailNumberKeyword.trim());
setSearchRequestSeq((current) => current + 1);
setNotice(null);
@@ -420,7 +443,7 @@ export function ExmnSurveyDetailPopupPage({
const totals = useMemo(
() =>
rows.reduce(
summaryRows.reduce(
(sum, row) => ({
extensionM: sum.extensionM + row.extensionM,
drivingDistanceM: sum.drivingDistanceM + row.drivingDistanceM,
@@ -436,7 +459,7 @@ export function ExmnSurveyDetailPopupPage({
operationalGrade: 0,
},
),
[rows],
[summaryRows],
);
/** 고정 열의 합산값이며 관로·맨홀 모두 구조·운영 등급 합계를 표시한다. */
@@ -454,7 +477,7 @@ export function ExmnSurveyDetailPopupPage({
visibleDefectColumns.map((column) => [column.code, { L: 0, M: 0, S: 0 }]),
) as Record<string, Record<ExmnDetailSeverity, number>>;
rows.forEach((row) => {
summaryRows.forEach((row) => {
visibleDefectColumns.forEach((column) => {
const columnCounts = defectSubtotals[column.code];
if (!columnCounts) return;
@@ -483,19 +506,37 @@ export function ExmnSurveyDetailPopupPage({
]),
) as Record<string, number>,
};
}, [rows, visibleDefectColumns]);
}, [summaryRows, visibleDefectColumns]);
/** 현재 화면에 표시된 관로·맨홀 상세 결과를 한글 헤더 엑셀로 내려받는다. */
/** 화면은 확정 조회/표 상태만 전달하고 Excel 조립·저장은 기능 모듈이 담당한다. */
const handleDownload = () => downloadExmnSurveyDetails({
selectedTrtdstNm,
defectGroupTitle: definition.defectGroupTitle,
isUnitMode, defectTypeLabel, unitRows, rows,
recordTypeLabels, pipeItemLabels, severityLabels, visibleDefectColumns,
totals, defectSummary, summaryLabelColumnCount,
fixedColumnCount: fixedColumns.length,
setNotice,
});
const handleDownload = async () => {
// 현재 페이지의 행은 전송하지 않고 적용조건·CMS 표시명으로 서버 전체 Excel을 생성한다.
if (isDownloading) return;
if (total === 0) {
setNotice({ type: "info", text: "다운로드할 조사 상세현황 데이터가 없습니다." });
return;
}
setIsDownloading(true);
setNotice({ type: "info", text: "검색조건 전체의 Excel 자료를 준비하고 있습니다." });
try {
const filePrefix = `${selectedTrtdstNm}_${definition.defectGroupTitle}조사_${isUnitMode ? "본단위등급표" : defectTypeLabel}`
.replace(/[\\/:*?"<>|]/g, "_");
await requestGridExcel("v1/exmnFacilities/exportJobs", {
exmnBizId: business.exmnBizId, jobType, trtdstId: selectedTrtdstId,
gisManagementNo: appliedDetailNumberKeyword, defectType,
mode: isUnitMode ? "UNIT" : "PIPE",
sort: sort ? `${sort.key},${sort.direction}` : undefined,
defectColumns: visibleDefectColumns.map((column) => ({ code: column.code, label: column.label })),
recordTypeLabels, pipeItemLabels: { ...PPL_STATUS_LABELS, ...pipeItemLabels }, severityLabels,
}, `${filePrefix}.xlsx`);
setNotice({ type: "info", text: "검색조건 전체의 Excel 파일 다운로드를 시작했습니다." });
} catch (error) {
setNotice({ type: "info", text: error instanceof Error ? error.message : "전체 Excel 자료를 불러오지 못했습니다." });
} finally {
setIsDownloading(false);
}
};
const tableWidth = useMemo(
() =>
@@ -507,8 +548,8 @@ export function ExmnSurveyDetailPopupPage({
);
/** 본단위구간 요약은 조회된 한 관로의 단위 수와 상태·결함 기록 수를 사용한다. */
const unitSectionCount = useMemo(
() => new Set(unitRows.map((row) => `${row.pipeNo}::${row.unitSeq}`)).size,
[unitRows],
() => total,
[total],
);
/** 관로 집계와 본단위구간 행을 같은 StandardDataTable 데이터 유무 계약으로 전달한다. */
const standardTableRows: Array<ExmnSurveyDetailRow | ExmnPplUnitDetailRow> =
@@ -532,6 +573,7 @@ export function ExmnSurveyDetailPopupPage({
<Button
type="button"
color="primary"
disabled={isLoading || isDownloading}
onClick={() => void handleDownload()}
>
엑셀다운로드
@@ -670,6 +712,7 @@ export function ExmnSurveyDetailPopupPage({
)}
<ExmnSurveyStandardDataTable
pagination={{ page, size: 10, total, onPageChange: setPage }}
rows={standardTableRows}
title={tableTitle}
loading={isLoading}
@@ -792,7 +835,7 @@ export function ExmnSurveyDetailPopupPage({
}
sortKey={column.id}
sort={sort}
onSortChange={setSort}
onSortChange={(nextSort) => { setSort(nextSort); setPage(1); }}
/>
) : (
<span className="whitespace-pre-line">
@@ -18,6 +18,8 @@ interface ExmnSurveyStandardDataTableProps<T extends object> {
minTableWidth?: string;
onScroll?: UIEventHandler<HTMLDivElement>;
renderTable: () => ReactNode;
/** 목록은 서버 페이지를 사용하며 본단위 표는 물리 행이 아닌 구간 수를 전달한다. */
pagination?: { page: number; size: number; total: number; onPageChange: (page: number) => void };
}
/**
@@ -33,6 +35,7 @@ export function ExmnSurveyStandardDataTable<T extends object>({
minTableWidth = "100%",
onScroll,
renderTable,
pagination,
}: ExmnSurveyStandardDataTableProps<T>) {
/** 공통 표가 데이터 유무와 로딩 상태를 판단할 수 있도록 복합 행을 최소 열로 연결한다. */
const columns = useMemo<ColumnDef<T>[]>(
@@ -48,6 +51,15 @@ export function ExmnSurveyStandardDataTable<T extends object>({
data: rows,
columns,
getCoreRowModel: getCoreRowModel(),
manualPagination: Boolean(pagination),
rowCount: pagination?.total,
state: pagination ? { pagination: { pageIndex: pagination.page - 1, pageSize: pagination.size } } : {},
onPaginationChange: (updater) => {
if (!pagination) return;
const previous = { pageIndex: pagination.page - 1, pageSize: pagination.size };
const next = typeof updater === "function" ? updater(previous) : updater;
pagination.onPageChange(next.pageIndex + 1);
},
});
return (
@@ -60,7 +72,7 @@ export function ExmnSurveyStandardDataTable<T extends object>({
className="!w-full !max-w-full"
scrollMode="horizontal"
minTableWidth={minTableWidth}
viewPagination={false}
viewPagination={Boolean(pagination)}
flushOuterMargin
onScroll={onScroll}
renderTable={renderTable}
@@ -1,13 +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";
import {
@@ -22,7 +21,6 @@ import {
} from "@common/components";
import {
getCoreRowModel,
getPaginationRowModel,
useReactTable,
type ColumnDef,
type PaginationState,
@@ -31,7 +29,7 @@ import { useConfirm } from "@frontend-opensource/use-react-hooks";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
getTrtdstReferences,
getTrtdstSettings,
getTrtdstPage,
saveTrtdstSettings,
} from "../api/trtdst-api";
import {
@@ -73,16 +71,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 +101,7 @@ const toSavedTableRows = (
rows: TrtdstTableRow[],
savedItems: TrtdstSettingDto[] = [],
): TrtdstTableRow[] =>
withDescendingRowNumbers(
withAscendingRowNumbers(
rows
.filter((row) => row.rowStatus !== "deletePending")
.map((row) => {
@@ -142,6 +140,7 @@ export function TrtdstManagementPage() {
const [sggCd, setSggCd] = useState("0");
// 서버 조회 결과와 사용자의 미저장 변경을 함께 보관한다.
const [rows, setRows] = useState<TrtdstTableRow[]>([]);
const [total, setTotal] = useState(0);
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 10,
@@ -190,21 +189,33 @@ export function TrtdstManagementPage() {
() => rows.some((row) => row.rowStatus !== "clean"),
[rows],
);
// 타이핑마다 열 정의를 재생성해 입력 포커스를 잃지 않도록 정렬 가드는 최신 상태를 ref로 읽는다.
const editGuardRef = useRef({ hasChanges, editingRowKey, isLoading, appliedRegion });
editGuardRef.current = { hasChanges, editingRowKey, isLoading, appliedRegion };
/** 확정된 조회조건으로 서버 목록을 읽고 화면 변경 상태를 초기화한다. */
const fetchRows = useCallback(async (region: AppliedRegion) => {
const fetchRows = useCallback(async (region: AppliedRegion, pageIndex = 0, nextSort?: UiTableSort) => {
setIsLoading(true);
setNotice(null);
try {
const items = await getTrtdstSettings({
let result = await getTrtdstPage({
ctpvNm: region.ctpvNm,
sggNm: region.sggNm,
useYn: "Y",
page: pageIndex, size: 10,
sort: nextSort ? `${nextSort.key},${nextSort.direction}` : undefined,
});
setRows(toTableRows(items));
// 마지막 페이지의 행이 삭제된 경우 남은 마지막 페이지를 다시 조회한다.
if (pageIndex > 0 && result.content.length === 0) {
pageIndex = Math.max(0, Math.ceil(result.totalElements / 10) - 1);
result = await getTrtdstPage({ ...region, page: pageIndex, size: 10,
sort: nextSort ? `${nextSort.key},${nextSort.direction}` : undefined });
}
setRows(toTableRows(result.content));
setTotal(result.totalElements);
setSort(nextSort);
setSelectedRowKey(null);
setEditingRowKey(null);
setPagination((current) => ({ ...current, pageIndex: 0 }));
setPagination((current) => ({ ...current, pageIndex }));
return true;
} catch (error) {
setSelectedRowKey(null);
@@ -281,8 +292,6 @@ export function TrtdstManagementPage() {
return;
}
setEditingRowKey(selectedRow.localKey);
setSort(undefined);
setPagination((current) => ({ ...current, pageIndex: 0 }));
};
/** 선택한 행정구역으로 빈 신규 행을 만들고 처리분구명은 그리드에서 직접 입력받는다. */
@@ -297,10 +306,8 @@ export function TrtdstManagementPage() {
}
const localKey = createLocalKey();
setSort(undefined);
setPagination((current) => ({ ...current, pageIndex: 0 }));
setRows((current) =>
withDescendingRowNumbers([
withAscendingRowNumbers([
...current,
{
trtdstId: "",
@@ -353,7 +360,7 @@ export function TrtdstManagementPage() {
if (selectedRow.rowStatus === "added") {
if (!(await confirm("선택한 처리분구를 삭제하시겠습니까?"))) return;
setRows((current) =>
withDescendingRowNumbers(
withAscendingRowNumbers(
current.filter((row) => row.localKey !== selectedRow.localKey),
),
);
@@ -446,13 +453,8 @@ export function TrtdstManagementPage() {
setSelectedRowKey(null);
setEditingRowKey(null);
try {
const refreshed = await getTrtdstSettings({
ctpvNm: appliedRegion.ctpvNm,
sggNm: appliedRegion.sggNm,
useYn: "Y",
});
if (refreshed.length > 0 || savedRows.length === 0) {
setRows(toTableRows(refreshed));
if (!(await fetchRows(appliedRegion, pagination.pageIndex, sort))) {
throw new Error("서버 페이지를 다시 불러오지 못했습니다.");
}
setNotice({ type: "success", text: summary });
} catch (refreshError) {
@@ -475,8 +477,8 @@ export function TrtdstManagementPage() {
};
const sortedRows = useMemo(
() => sortStandardTableRows(rows, sort),
[rows, sort],
() => rows.map((row, index) => ({ ...row, rowNumber: pagination.pageIndex * pagination.pageSize + index + 1 })),
[rows, pagination.pageIndex, pagination.pageSize],
);
/** 인라인 편집과 변경 상태 표시를 유지한 채 공통 DataTable 열로 변환한다. */
@@ -491,10 +493,13 @@ export function TrtdstManagementPage() {
sortKey={head.id}
sort={sort}
onSortChange={(nextSort) => {
setSort(nextSort);
setPagination((current) => ({ ...current, pageIndex: 0 }));
setSelectedRowKey(null);
setEditingRowKey(null);
const guard = editGuardRef.current;
if (guard.isLoading) return;
if (guard.hasChanges || guard.editingRowKey) {
setNotice({ type: "error", text: "변경사항을 저장하거나 취소한 뒤 정렬해주세요." });
return;
}
void fetchRows(guard.appliedRegion, 0, nextSort);
}}
/>
),
@@ -532,7 +537,7 @@ export function TrtdstManagementPage() {
},
meta: { width: head.width, align: "center" as const },
})),
[editingRowKey, sort],
[editingRowKey, sort, fetchRows],
);
const table = useReactTable({
@@ -543,9 +548,18 @@ export function TrtdstManagementPage() {
rowSelection: selectedRowKey ? { [selectedRowKey]: true } : {},
},
getRowId: (row) => row.localKey,
onPaginationChange: setPagination,
manualPagination: true,
rowCount: total + rows.filter((row) => row.rowStatus === "added").length,
onPaginationChange: (updater) => {
if (isLoading) return;
if (hasChanges || editingRowKey) {
setNotice({ type: "error", text: "변경사항을 저장하거나 취소한 뒤 페이지를 이동해주세요." });
return;
}
const next = typeof updater === "function" ? updater(pagination) : updater;
void fetchRows(appliedRegion, next.pageIndex, sort);
},
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
});
return (
@@ -17,7 +17,6 @@ import MapPopupDataTable from "@/features/windowPopup/ui/MapPopupDataTable";
import PopupSearchPanel from "@/features/windowPopup/ui/PopupSearchPanel";
import { useAlertStore } from "@/shared/lib";
import useStore from "@/store/useStore";
import { createPredictionAnalysisTimeRange } from "../model/analysisTimeRange";
import { fetchAllPages } from "@/shared/lib/fetch-all-pages";
import { downloadFloodPredictionStatusExcel } from "../lib/fldng-predc-prst-excel";
@@ -111,21 +110,16 @@ const HEADS: UiHeadProps[] = [
{ id: "floodRiskGrade", title: "침수위험도", widthPercent: 10 },
];
/** 최초·초기화 조회조건은 금일 분석결과를 기준으로 생성한다. */
const buildInitialFilters = (): Filters => {
const range = createPredictionAnalysisTimeRange();
const toDateTimeLocal = (value: string) =>
`${value.slice(0, 4)}-${value.slice(4, 6)}-${value.slice(6, 8)}T${value.slice(8, 10)}:${value.slice(10, 12)}`;
return {
ctpvCd: "0",
sggCd: "0",
rainBgngFrom: "",
rainBgngTo: "",
analysisFrom: toDateTimeLocal(range.analysisFrom),
analysisTo: toDateTimeLocal(range.analysisTo),
frequencyYear: "0",
};
};
/** 최초·초기화 시 시각 범위를 제한하지 않고 최신 침수예측 데이터부터 조회한다. */
const buildInitialFilters = (): Filters => ({
ctpvCd: "0",
sggCd: "0",
rainBgngFrom: "",
rainBgngTo: "",
analysisFrom: "",
analysisTo: "",
frequencyYear: "0",
});
const apiDate = (value: string) => value.replace(/[^0-9]/g, "").slice(0, 12);
@@ -151,6 +145,7 @@ const buildQuery = (filters: Filters, page?: number, size = PAGE_SIZE) => {
if (page !== undefined) {
params.set("page", String(page - 1));
params.set("size", String(size));
params.set("sort", "analysisDateTime,desc");
}
appendApiSearchParams(params, {
ctpvCd: filters.ctpvCd === "0" ? "0" : filters.ctpvCd.slice(0, 2),
@@ -33,7 +33,7 @@ const SHEET_CONTRACTS: SheetContract[] = [
const DATA_START_ROW_INDEX = 7;
const FIRST_DATA_COLUMN_INDEX = 1;
const DATA_COLUMN_COUNT = 21;
const DATA_COLUMN_COUNT = 20;
const DOWNLOAD_FILENAME = "노후하수관로 정밀조사 및 정비사업 현황.xlsx";
/** API 문자열에 남은 Excel 개행 이스케이프를 실제 줄바꿈으로 정리한다. */
@@ -63,7 +63,7 @@ const validateHeaders = (sheet: XLSX.WorkSheet, contract: SheetContract) => {
[6, 14, "증빙서류제출여부"], [5, 15, contract.progressHeader],
[6, 15, contract.startDateHeader], [6, 16, "준공(예정)일자(yyyy.mm.dd)"],
[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) {
@@ -116,7 +116,7 @@ const dateText = (value: string): string | null => {
return (digits || normalized).slice(0, 8);
};
/** B~V가 모두 비어 있는 행은 데이터가 아닌 양식 여백으로 판단한다. */
/** B~U가 모두 비어 있는 행은 데이터가 아닌 양식 여백으로 판단한다. */
const isEmptyDataRow = (sheet: XLSX.WorkSheet, rowIndex: number) =>
Array.from({ length: DATA_COLUMN_COUNT }, (_, 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)),
bizPicCnpl: optionalText(valueAt(18)),
rmrkCn: optionalText(valueAt(19)),
pbcpOpnnCn: optionalText(valueAt(20)),
});
}
return result;
@@ -183,27 +182,27 @@ export const parseObsltPplExcel = async (file: File): Promise<ObsltPplBusinessSa
return parseObsltPplWorkbook(await file.arrayBuffer());
};
/** 업로드 검증과 정확히 같은 B6:V7 위치에 시트별 헤더를 배치한다. */
/** 업로드 검증과 정확히 같은 B6:U7 위치에 시트별 헤더를 배치한다. */
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 = [
"순번", "정밀\n조사\n차수", contract.businessCodeHeader.replace("사업코드", "\n사업코드"),
"시도", "시군", "환경청", "사업명", "추진현황", "착수\n년도", "준공(예정)\n년도",
contract.extensionHeader, "", "", "", contract.progressHeader.replace("추진현황", " 추진현황"),
"", "사업추진 부진 사유", "사업 담당자(지자체)", "", "비고", "",
"", "사업추진 부진 사유", "사업 담당자(지자체)", "", "비고",
];
const bottomHeaders = [
"", "", "", "", "", "", "", "", "", "", "계획연장(km)", "상반기(예상)연장(km)",
"잔여연장(km)", "증빙서류\n제출여부", contract.startDateHeader.replace("(yyyy.mm.dd)", "\n(yyyy.mm.dd)"),
"준공(예정)일자\n(yyyy.mm.dd)", "", "이름", "연락처",
"(필요시 사업 담당자 추가 작성)", "공단의견",
"(필요시 사업 담당자 추가 작성)",
];
topHeaders.forEach((value, index) => { rows[5]![index + FIRST_DATA_COLUMN_INDEX] = value; });
bottomHeaders.forEach((value, index) => { rows[6]![index + FIRST_DATA_COLUMN_INDEX] = value; });
return rows;
};
/** DB에 저장하지 않는 B열 순번을 시트 출력 순서로 만들고 C~V에 업무값을 배치한다. */
/** DB에 저장하지 않는 B열 순번을 시트 출력 순서로 만들고 C~U에 업무값을 배치한다. */
const downloadDataRow = (row: ObsltPplBusiness, index: number): Array<string | number> => [
"",
index + 1,
@@ -226,7 +225,6 @@ const downloadDataRow = (row: ObsltPplBusiness, index: number): Array<string | n
excelTextValue(row.bizPicNm),
excelTextValue(row.bizPicCnpl),
excelTextValue(row.rmrkCn),
excelTextValue(row.pbcpOpnnCn),
];
/** 조회 결과를 정밀조사·정비사업 두 시트의 재업로드 가능한 위치 구조로 생성한다. */
@@ -266,7 +264,7 @@ export const buildObsltPplWorkbook = (businesses: ObsltPplBusiness[]): XLSX.Work
];
const widths = [
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 }));
XLSX.utils.book_append_sheet(workbook, worksheet, contract.sheetName);
@@ -304,7 +302,7 @@ export const downloadObsltPplExcel = async (
fill: "FFF2CC",
border: true,
});
styledSheet.range("B6:V7").style({
styledSheet.range("B6:U7").style({
bold: true,
fontFamily: "맑은 고딕",
fontSize: 10,
@@ -315,7 +313,7 @@ export const downloadObsltPplExcel = async (
});
styledSheet.range("B6:H7").style("fill", "B4C6E7");
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(3).height(26);
styledSheet.row(5).height(22);
@@ -325,7 +323,7 @@ export const downloadObsltPplExcel = async (
if (rowCount > 0) {
const dataStartRow = DATA_START_ROW_INDEX + 1;
const dataEndRow = DATA_START_ROW_INDEX + rowCount;
styledSheet.range(`B${dataStartRow}:V${dataEndRow}`).style({
styledSheet.range(`B${dataStartRow}:U${dataEndRow}`).style({
fontFamily: "맑은 고딕",
fontSize: 10,
horizontalAlignment: "center",
@@ -336,7 +334,7 @@ export const downloadObsltPplExcel = async (
styledSheet
.range(`L${dataStartRow}:N${dataEndRow}`)
.style("numberFormat", "#,##0.###");
["H", "R", "U", "V"].forEach((column) =>
["H", "R", "U"].forEach((column) =>
styledSheet
.range(`${column}${dataStartRow}:${column}${dataEndRow}`)
.style({ horizontalAlignment: "left", wrapText: true }),
@@ -22,7 +22,6 @@ export interface ObsltPplBusiness {
bizPicNm?: string | null;
bizPicCnpl?: string | null;
rmrkCn?: string | null;
pbcpOpnnCn?: string | null;
regDt?: string | null;
mdfcnDt?: string | null;
}
@@ -79,7 +79,6 @@ const FIELDS: Array<{
{ key: "bizPicNm", label: "담당자 이름" },
{ key: "bizPicCnpl", label: "담당자 연락처" },
{ key: "rmrkCn", label: "비고", type: "textarea" },
{ key: "pbcpOpnnCn", label: "공단 의견", type: "textarea" },
];
const NUMBER_KEYS = new Set<FormKey>([
@@ -530,7 +529,6 @@ export function ObsltPplFormPage({ id }: { id?: string }) {
{renderField("bizPicCnpl", 2)}
{renderField("bizPrplDlyRsnCn", 3)}
{renderField("rmrkCn", 3)}
{renderField("pbcpOpnnCn", 3)}
</StandardFormSection>
<div className="flex justify-center gap-4 pb-8 pt-6">
@@ -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 {
@@ -65,8 +65,10 @@ type ObsltPplTableColumn = {
};
type ObsltPplSearch = {
bizSe: string;
bizCd: string;
ctpvNm: string;
sigunNm: string;
prplPrst: string;
bizNm: string;
};
@@ -120,7 +122,6 @@ const TABLE_COLUMNS: ObsltPplTableColumn[] = [
{ id: "bizPicNm", title: "담당자 이름", width: 90 },
{ id: "bizPicCnpl", title: "담당자 연락처", width: 120 },
{ id: "rmrkCn", title: "비고", width: 160, align: "left" },
{ id: "pbcpOpnnCn", title: "공단\n의견", width: 90, align: "left" },
];
const TABLE_MIN_WIDTH = "280rem";
@@ -154,6 +155,7 @@ export function ObsltPplListPage() {
// 검색값·원본 행은 유지하고 사업구분·추진·증빙 표시명만 CMS 기준으로 변환한다.
const {
obsltBusinessTypeOptions,
businessProgressOptions,
evidenceStatusLabels,
businessProgressLabels,
} = useOperBusinessCommonCodes();
@@ -162,8 +164,10 @@ export function ObsltPplListPage() {
const initialSearch = useMemo<ObsltPplSearch>(
() => ({
bizSe: searchParams.get("bizSe") ?? "정밀조사",
bizCd: searchParams.get("bizCd") ?? "",
ctpvNm: searchParams.get("ctpvNm") ?? "",
sigunNm: searchParams.get("sigunNm") ?? "",
prplPrst: searchParams.get("prplPrst") ?? "",
bizNm: searchParams.get("bizNm") ?? "",
}),
[searchParams],
@@ -331,7 +335,7 @@ export function ObsltPplListPage() {
setRows(
result.content.map((row, index) => ({
...formatBusinessForDisplay(row),
rowNumber: getStandardRowNumber({
rowNumber: getSnRowNumber({
total: result.totalElements,
rowIndex: index,
pageIndex: targetPage - 1,
@@ -454,8 +458,10 @@ export function ObsltPplListPage() {
const handleReset = () => {
const nextSearch: ObsltPplSearch = {
bizSe: "정밀조사",
bizCd: "",
ctpvNm: "",
sigunNm: "",
prplPrst: "",
bizNm: "",
};
setSearch(nextSearch);
@@ -599,6 +605,54 @@ export function ObsltPplListPage() {
</Select>
</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">
<Label
htmlFor="obslt-ppl-biz-name"
@@ -140,10 +140,7 @@ export const windowPopupDefinitions = {
title: "노후 하수관로 목록",
width: 1160,
height: 820,
placement: "browser-left",
alignTop: true,
offsetLeft: 0,
offsetTop: 70,
// 다른 목록 팝업과 동일한 공통 중앙 배치를 사용한다.
},
sinkholePopup: {
key: "sinkholePopup",
@@ -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,
+29
View File
@@ -0,0 +1,29 @@
import { deleteData, getData, postRawJson } from "@/components/lib/api";
/** 대량 Excel은 서버 작업으로 생성한다. 목록의 현재 page/size를 전달하지 않는다. */
export async function requestGridExcel(url: string, body: object = {}, fileName = "grid.xlsx"): Promise<void> {
const submitted = await postRawJson(url, body);
const job = submitted?.data ?? submitted;
if (!job?.jobId) throw new Error("Excel 생성 작업을 접수하지 못했습니다.");
const jobPath = `v1/gridExportJobs/${encodeURIComponent(job.jobId)}`;
const started = Date.now();
while (Date.now() - started < 30 * 60 * 1000) {
const status = await getData(jobPath);
if (status?.status === "READY") {
// 브라우저 JS 메모리에 완성 파일까지 적재하지 않도록 동일 출처 스트리밍 경로를 사용한다.
const anchor = document.createElement("a");
anchor.href = `/api/gridDownload/${encodeURIComponent(job.jobId)}?fileName=${encodeURIComponent(fileName)}`;
anchor.download = fileName;
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
return;
}
if (status?.status === "FAILED") {
await deleteData(jobPath).catch(() => undefined);
throw new Error(status.message || "Excel 생성에 실패했습니다.");
}
await new Promise((resolve) => window.setTimeout(resolve, 1000));
}
throw new Error("Excel 생성 대기시간이 초과되었습니다. 검색조건을 좁혀 다시 시도해주세요.");
}
@@ -0,0 +1,6 @@
import { requestGridExcel } from "@/shared/api/grid-export";
/** I/I 일반 화면과 지도 팝업에서 선택 분석의 전체 결과를 동일한 서버 작업으로 내려받는다. */
export const downloadAnalysisExcel = async (kind: "inftwt" | "rdii", id: string | number, fileName: string) => {
await requestGridExcel(`v1/analysisExports/${kind}/${encodeURIComponent(id)}`, {}, `${fileName}.xlsx`);
};
+17
View File
@@ -0,0 +1,17 @@
/** SN 표시 순번 계산용 인자. pageIndex와 rowIndex는 0부터 시작한다. */
type SnRowNumberParams = {
/** 기존 호출부의 전체 건수는 페이지 계산에 사용하지 않는다. */
total?: number;
rowIndex: number;
pageIndex?: number;
pageSize?: number;
};
/** 데이터 정렬·식별자는 유지하고 현재 조회 순서에 1부터 연속 순번을 붙인다. */
export function getSnRowNumber({
rowIndex,
pageIndex = 0,
pageSize = 0,
}: SnRowNumberParams): number {
return pageIndex * pageSize + rowIndex + 1;
}
@@ -0,0 +1,38 @@
"use client";
import { useEffect } from "react";
/** CSS 말줄임표 대상과 같은 범위를 사용하며 입력/액션/병합 셀의 툴팁은 건드리지 않는다. */
const CELL_SELECTOR = 'body[data-sn-grid-ellipsis="true"] table:has(> thead) > tbody > tr > td:not([colspan]):not([rowspan]):not([data-sn-cell-wrap]):not(:has(input, textarea, select, button, [role="checkbox"], [role="switch"], [contenteditable="true"], table, img, svg))';
/** 동적 목록·포털에도 공통 적용하며 기존 title과 실제 데이터는 변경하지 않는다. */
export function GridCellTooltip() {
useEffect(() => {
const generatedTitles = new WeakMap<HTMLTableCellElement, string>();
const showFullText = (event: Event) => {
if (!(event.target instanceof Element)) return;
const cell = event.target.closest<HTMLTableCellElement>(CELL_SELECTOR);
if (!cell) return;
const previous = generatedTitles.get(cell);
// 화면에서 이미 지정한 상세 툴팁을 우선한다.
if ((cell.hasAttribute("title") && cell.title !== previous) || cell.querySelector("[title]")) return;
const overflowed = [cell, ...cell.querySelectorAll<HTMLElement>("div, span, a, p")]
.some((element) => element.scrollWidth > element.clientWidth + 1);
if (overflowed) {
const fullText = cell.textContent?.trim() ?? "";
cell.title = fullText;
generatedTitles.set(cell, fullText);
} else if (previous !== undefined) {
cell.removeAttribute("title");
generatedTitles.delete(cell);
}
};
document.addEventListener("pointerover", showFullText);
document.addEventListener("focusin", showFullText);
return () => {
document.removeEventListener("pointerover", showFullText);
document.removeEventListener("focusin", showFullText);
};
}, []);
return null;
}
@@ -93,6 +93,8 @@ function Gis({
showLayerPanelTitle = true,
// sn 관망도처럼 트리와 선택 설정을 카드 두 개로 구분해야 하는 화면에서만 활성화한다.
sectionedTreePanel = false,
// 전체 체크 조작은 필요한 화면에서만 명시적으로 활성화한다.
showBulkLayerToggle = false,
showZoomScaleInfo = false,
}) {
const mapRef = useRef();
@@ -345,6 +347,7 @@ function Gis({
detachedToggleButton={detachedTreeToggleButton}
showLayerPanelTitle={showLayerPanelTitle}
sectionedPanel={sectionedTreePanel}
showBulkLayerToggle={showBulkLayerToggle}
/>
{typeof WaterDepth === "function" && (
@@ -10,6 +10,7 @@ import React, { useEffect, useLayoutEffect, useState, useRef, useCallback } from
import { showBusinessUserMessage } from "../../lib/user-message";
import { useGisContext } from "./Gis";
import GisApp from "./lib/gis.core";
import { getBulkLayerSelection, getBulkLayerState } from "./layerBulkSelection";
/**
* GisTree (React memoized component)
@@ -63,6 +64,7 @@ export default React.memo(function GisTree({
showLayerPanelTitle = true,
detachedToggleButton = false,
sectionedPanel = false,
showBulkLayerToggle = false,
}) {
const { gisCore, treeData } = useGisContext();
@@ -71,6 +73,8 @@ export default React.memo(function GisTree({
const [expanded, setExpanded] = useState([]);
const [nodes, setNodes] = useState([]);
const [activeTab, setActiveTab] = useState("layers");
// 전체 선택이면 ON, 부분 선택이면 OFF 위치와 별도 상태 문구로 구분한다.
const bulkLayerState = getBulkLayerState(nodes, checked);
// 자동 애니메이션 관련 ref (실행 인덱스, 대상 노드 목록, 일시정지, 타이머)
const currentAnimationIndexRef = useRef(0);
@@ -470,6 +474,19 @@ export default React.memo(function GisTree({
isPausedRef.current = true;
}, []);
/** 전체 조작은 자동재생을 멈추고 트리·지도·업무 콜백에 같은 선택을 한 번 반영한다. */
const handleBulkLayerToggle = useCallback((enabled) => {
if (!gisCore || nodes.length === 0) return;
pauseAnimation();
setIsPlaying(false);
const { checkedIds, uncheckedIds, selectedNodes } = getBulkLayerSelection(nodes, enabled);
// 배경지도나 편집 오버레이가 아닌 현재 트리에 등록된 레이어만 변경한다.
gisCore.setLayerOnMap(uncheckedIds, false);
gisCore.setLayerOnMap(checkedIds, true);
setChecked(checkedIds);
onCheckedChange?.(checkedIds, selectedNodes);
}, [gisCore, nodes, onCheckedChange, pauseAnimation, setIsPlaying]);
/**
* 재생/정지/대상/간격 변경에 반응하여 애니메이션을 관리합니다.
*/
@@ -626,6 +643,20 @@ export default React.memo(function GisTree({
<Layers size={14} strokeWidth={2} />
</span>
<h3 className="text-[1.4rem] font-bold text-foreground">레이어 관리</h3>
{showBulkLayerToggle && (
<button type="button" role="switch" aria-label="전체 레이어"
aria-checked={bulkLayerState.allChecked}
aria-description={bulkLayerState.partiallyChecked ? "일부 레이어 켜짐. 누르면 전체를 켭니다." : undefined}
title={bulkLayerState.allChecked ? "레이어 전체 끄기" : "레이어 전체 켜기"}
disabled={!gisCore || bulkLayerState.disabled}
className="ml-auto flex shrink-0 items-center gap-1 rounded-sm text-[1.1rem] focus-visible:outline focus-visible:outline-2 focus-visible:outline-primary disabled:cursor-not-allowed disabled:opacity-40"
onClick={() => handleBulkLayerToggle(!bulkLayerState.allChecked)}>
<span>{bulkLayerState.partiallyChecked ? "일부 켜짐" : "전체"}</span>
<span aria-hidden="true" className={`relative inline-flex h-[18px] w-[32px] items-center rounded-full transition-colors ${bulkLayerState.allChecked ? "bg-[#168CFF]" : "bg-gray-300"}`}>
<span className={`h-[14px] w-[14px] rounded-full bg-white shadow-sm transition-transform ${bulkLayerState.allChecked ? "translate-x-[16px]" : "translate-x-[2px]"}`} />
</span>
</button>
)}
</div>
)}
<div
@@ -0,0 +1,25 @@
/** 화면에 표시하도록 허용된 트리 노드만 전체 켜기 대상으로 선택한다. */
export function getBulkLayerSelection(nodes, enabled) {
const selectedNodes = enabled
? nodes.filter((node) => node.data?.exprsYn === "Y")
: [];
const checkedIds = selectedNodes.map((node) => node.id);
const selected = new Set(checkedIds);
return {
checkedIds,
selectedNodes,
uncheckedIds: nodes.filter((node) => !selected.has(node.id)).map((node) => node.id),
};
}
/** 개별 체크 변경도 전체 토글에 반영하며 숨김 노드는 집계에서 제외한다. */
export function getBulkLayerState(nodes, checkedIds) {
const visibleIds = nodes.filter((node) => node.data?.exprsYn === "Y").map((node) => node.id);
const checked = new Set(checkedIds);
const count = visibleIds.filter((id) => checked.has(id)).length;
return {
disabled: visibleIds.length === 0,
allChecked: visibleIds.length > 0 && count === visibleIds.length,
partiallyChecked: count > 0 && count < visibleIds.length,
};
}