서버페이징 변경
This commit is contained in:
@@ -134,6 +134,8 @@ Codex 작업 루트는 가능하면 `../../../../../`로 잡고, 공통 시작
|
||||
|
||||
## 작업 원칙
|
||||
|
||||
- 일반 목록·모든 팝업은 공통 AGENTS.md의 서버 페이징·지도·Excel 계약을 따른다. 지도 전체 표출에 현재 그리드 페이지 배열을 재사용하지 않는다. Excel은 적용조건 전체를 사용하고 대량 원시 자료는 서버 생성으로 분리한다. 화면 페이징/페이징 없음 예외는 사유와 검증 범위를 메뉴 > 화면명별로 기록한다.
|
||||
|
||||
1. 모든 변경은 원본 요구사항정의서와 화면설계서, 2차 요구사항, 기능 명세 또는 승인된 작업 문서를 근거로 한다.
|
||||
2. `apps/sn`과 `packages/common/business_components` 범위를 넘는 수정은 하지 않는다.
|
||||
3. 공통 패키지 수정은 `sn` 요구사항을 충족하기 위한 최소 범위로 제한한다.
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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("침입수 분석결과 다운로드 중 오류가 발생했습니다.");
|
||||
|
||||
+13
-42
@@ -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: index + 1,
|
||||
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={
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
};
|
||||
|
||||
+16
-30
@@ -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";
|
||||
@@ -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)}
|
||||
|
||||
@@ -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();
|
||||
@@ -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);
|
||||
|
||||
@@ -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";
|
||||
@@ -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";
|
||||
@@ -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);
|
||||
|
||||
@@ -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,6 +434,14 @@ 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);
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
} });
|
||||
}
|
||||
@@ -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);
|
||||
```
|
||||
|
||||
위 코드는 전환 원리를 보여주는 요약 예시이며 그대로 실행하는 테스트 소스가 아니다. 실제 함수·응답 계약은 화면별 모듈을 따른다.
|
||||
@@ -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
|
||||
: "엑셀 다운로드 중 오류가 발생했습니다.",
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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}
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
STANDARD_ROW_NUMBER_HEADER,
|
||||
StandardDataTable,
|
||||
StandardTableSortHeader,
|
||||
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 {
|
||||
@@ -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,8 +306,6 @@ export function TrtdstManagementPage() {
|
||||
}
|
||||
|
||||
const localKey = createLocalKey();
|
||||
setSort(undefined);
|
||||
setPagination((current) => ({ ...current, pageIndex: 0 }));
|
||||
setRows((current) =>
|
||||
withAscendingRowNumbers([
|
||||
...current,
|
||||
@@ -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 (
|
||||
|
||||
@@ -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`);
|
||||
};
|
||||
Reference in New Issue
Block a user