Merge branch 'iipbs/sn/dev' of https://www.onasys.co.kr:3000/ONAANDALL/frontend into iipbs/sn/dev

This commit is contained in:
2026-08-27 22:34:15 +09:00
67 changed files with 2470 additions and 1118 deletions
@@ -20,7 +20,7 @@ import {
type AgedPipeFilters,
type AgedPipeRow,
} from "./agedPipeUtils";
import { downloadAgedPipeCsv } from "./agedPipeExport";
import { downloadAgedPipeExcel } from "./agedPipeExport";
import MapPopupDataTable from "@/features/windowPopup/ui/MapPopupDataTable";
import PopupSearchPanel from "@/features/windowPopup/ui/PopupSearchPanel";
@@ -32,16 +32,22 @@ export default function AgedPipePopupPage() {
const alert = useAlertStore((state) => state.setMessage);
const searchParams = useSearchParams();
const { ctpv, sgg, fetchCtpv, fetchSgg } = useStore();
const [filters, setFilters] = useState<AgedPipeFilters>(() =>
normalizeAgedPipeFilters({
elapsedYears: Number(
searchParams.get("elapsedYears") ??
AGED_PIPE_DEFAULT_FILTERS.elapsedYears,
),
ctpvCd: searchParams.get("ctpvCd") ?? AGED_PIPE_DEFAULT_FILTERS.ctpvCd,
sggCd: searchParams.get("sggCd") ?? AGED_PIPE_DEFAULT_FILTERS.sggCd,
}),
const initialFilters = useMemo(
() =>
normalizeAgedPipeFilters({
elapsedYears: Number(
searchParams.get("elapsedYears") ??
AGED_PIPE_DEFAULT_FILTERS.elapsedYears,
),
ctpvCd: searchParams.get("ctpvCd") ?? AGED_PIPE_DEFAULT_FILTERS.ctpvCd,
sggCd: searchParams.get("sggCd") ?? AGED_PIPE_DEFAULT_FILTERS.sggCd,
}),
[searchParams],
);
const [filters, setFilters] = useState<AgedPipeFilters>(initialFilters);
// 화면 목록을 마지막으로 조회한 조건이며 페이지 이동·Excel이 동일하게 사용한다.
const [appliedFilters, setAppliedFilters] =
useState<AgedPipeFilters>(initialFilters);
const [rows, setRows] = useState<AgedPipeRow[]>([]);
const [total, setTotal] = useState(0);
const [currentPage, setCurrentPage] = useState(1);
@@ -102,6 +108,7 @@ export default function AgedPipePopupPage() {
setRows(response.rows);
setTotal(response.total);
setCurrentPage(page);
setAppliedFilters(normalizeAgedPipeFilters(nextFilters));
} catch (error) {
console.error("노후관로 목록 조회 중 오류:", error);
setRows([]);
@@ -162,22 +169,36 @@ export default function AgedPipePopupPage() {
alert("다운로드할 데이터가 없습니다.");
return;
}
if (!validateElapsedYears(Number(filters.elapsedYears))) {
alert("경과년도는 1년~100년 범위 내에서 입력해주세요.");
return;
}
const normalizedDraft = normalizeAgedPipeFilters(filters);
if (JSON.stringify(normalizedDraft) !== JSON.stringify(appliedFilters)) {
alert("조회조건이 변경되었습니다. 조회 후 Excel을 다운로드해주세요.");
return;
}
try {
setIsLoading(true);
setDownloadProgressText("CSV export 작업을 준비하고 있습니다.");
await downloadAgedPipeCsv(filters, (job) => {
if (job.status === "QUEUED") {
setDownloadProgressText("CSV export 작업이 대기 중입니다.");
return;
}
setDownloadProgressText("Excel 파일을 준비하고 있습니다.");
await downloadAgedPipeExcel(
appliedFilters,
(job) => {
if (job.status === "QUEUED") {
setDownloadProgressText("Excel 다운로드 작업이 대기 중입니다.");
return;
}
if (job.status === "RUNNING") {
setDownloadProgressText(
`CSV export 생성 중입니다. (${job.progressPercent ?? 0}%)`,
);
}
});
if (job.status === "RUNNING") {
setDownloadProgressText(
`Excel 파일 생성 중입니다. (${job.progressPercent ?? 0}%)`,
);
}
},
total,
);
setDownloadProgressText("");
} catch (error) {
console.error("노후관로 목록 다운로드 중 오류:", error);
@@ -185,7 +206,7 @@ export default function AgedPipePopupPage() {
alert(
error instanceof Error
? error.message
: "노후관로 CSV 다운로드 중 오류가 발생했습니다.",
: "노후관로 Excel 다운로드 중 오류가 발생했습니다.",
);
} finally {
setIsLoading(false);
@@ -322,7 +343,7 @@ export default function AgedPipePopupPage() {
total={total}
page={currentPage}
pageSize={AGED_PIPE_PAGE_SIZE}
onPageChange={(page) => void handleSearch(page)}
onPageChange={(page) => void loadRows(page, appliedFilters)}
onRowClick={handleRowClick}
rowId={(row, index) => String(row.fid ?? row.fcltMngNo ?? index)}
actions={
@@ -14,26 +14,35 @@ type AgedPipeExportProgress = {
status: "QUEUED" | "RUNNING" | "COMPLETED";
};
/** 노후관로 CSV 다운로드를 요청한다. */
export const downloadAgedPipeCsv = async (
/** 노후관로 XLSX 다운로드를 요청한다. */
export const downloadAgedPipeExcel = async (
filters: AgedPipeFilters,
onProgress?: (progress: AgedPipeExportProgress) => void,
expectedTotal?: number,
) => {
const normalizedFilters = normalizeAgedPipeFilters(filters);
onProgress?.({ status: "RUNNING", progressPercent: 0 });
const { blob, fileName } = await postBlobData("v1/fclts/agedPipes/exportJobs/download", {
elapsedYears: normalizedFilters.elapsedYears,
ctpvCd: normalizedFilters.ctpvCd,
sggCd: normalizedFilters.sggCd,
format: "csv",
columns: [...AGED_PIPE_EXCEL_FIELDS],
});
const { blob, fileName } = await postBlobData(
"v1/fclts/agedPipes/exportJobs/download",
{
elapsedYears: normalizedFilters.elapsedYears,
ctpvCd: normalizedFilters.ctpvCd,
sggCd: normalizedFilters.sggCd,
format: "xlsx",
columns: [...AGED_PIPE_EXCEL_FIELDS],
expectedTotal,
},
);
const resolvedFileName = fileName || "노후관로_현황.csv";
const resolvedFileName = fileName || "노후관로_현황.xlsx";
fileDownload(blob, resolvedFileName);
onProgress?.({ status: "COMPLETED", fileName: resolvedFileName, progressPercent: 100 });
onProgress?.({
status: "COMPLETED",
fileName: resolvedFileName,
progressPercent: 100,
});
return {
status: "COMPLETED" as const,
@@ -11,15 +11,15 @@ import {
AGED_PIPE_STATUS_BY_CTPV_DEFAULT_ELAPSED_YEARS,
buildAgedPipeStatusByCtpvHeadList,
buildAgedPipeStatusYearOptions,
getAgedPipeStatusByCtpvValidationMessage,
normalizeAgedPipeStatusByCtpvResponse,
normalizeAgedPipeStatusByCtpvFilters,
toAgedPipeStatusByCtpvRows,
validateAgedPipeStatusByCtpvFilters,
type AgedPipeStatusByCtpvFilters,
type AgedPipeStatusByCtpvResponse,
type AgedPipeStatusByCtpvRow,
} from "./agedPipeStatusByCtpvUtils";
import { downloadAgedPipeStatusByCtpvCsv } from "./agedPipeStatusByCtpvExport";
import { downloadAgedPipeStatusByCtpvExcel } from "./agedPipeStatusByCtpvExport";
import MapPopupDataTable from "@/features/windowPopup/ui/MapPopupDataTable";
import PopupSearchPanel from "@/features/windowPopup/ui/PopupSearchPanel";
@@ -34,6 +34,13 @@ export default function AgedPipeStatusByCtpvPopupPage() {
elapsedYears: AGED_PIPE_STATUS_BY_CTPV_DEFAULT_ELAPSED_YEARS,
}),
);
// 화면 목록을 마지막으로 조회한 조건이며 페이지 이동·Excel이 동일하게 사용한다.
const [appliedFilters, setAppliedFilters] =
useState<AgedPipeStatusByCtpvFilters>(() =>
normalizeAgedPipeStatusByCtpvFilters({
elapsedYears: AGED_PIPE_STATUS_BY_CTPV_DEFAULT_ELAPSED_YEARS,
}),
);
// 서버 응답을 화면 그리드에 맞게 평탄화한 row 목록이다.
const [rows, setRows] = useState<AgedPipeStatusByCtpvRow[]>([]);
// 동적 컬럼 생성을 위한 기준년도 목록이다.
@@ -80,6 +87,7 @@ export default function AgedPipeStatusByCtpvPopupPage() {
setYears(Array.isArray(response?.years) ? response.years : []);
setRows(toAgedPipeStatusByCtpvRows(response));
setCurrentPage(1);
setAppliedFilters(normalized);
} catch (error) {
console.error("시도별 노후관로현황 조회 중 오류:", error);
setYears([]);
@@ -92,26 +100,43 @@ export default function AgedPipeStatusByCtpvPopupPage() {
);
useEffect(() => {
void loadRows(filters);
}, [filters, loadRows]);
const initialFilters = normalizeAgedPipeStatusByCtpvFilters({
elapsedYears: AGED_PIPE_STATUS_BY_CTPV_DEFAULT_ELAPSED_YEARS,
});
void loadRows(initialFilters);
}, [loadRows]);
const handleSearch = async () => {
const normalized = normalizeAgedPipeStatusByCtpvFilters(filters);
if (!validateAgedPipeStatusByCtpvFilters(normalized)) {
alert(
"기준년도는 최대 3년 범위 내에서 선택하고, 경과년도는 1~100 사이로 입력해주세요.",
);
const validationMessage = getAgedPipeStatusByCtpvValidationMessage(filters);
if (validationMessage) {
alert(validationMessage);
return;
}
const normalized = normalizeAgedPipeStatusByCtpvFilters(filters);
setFilters(normalized);
await loadRows(normalized);
};
const handleDownload = async () => {
const validationMessage = getAgedPipeStatusByCtpvValidationMessage(filters);
if (validationMessage) {
alert(validationMessage);
return;
}
const normalizedDraft = normalizeAgedPipeStatusByCtpvFilters(filters);
if (JSON.stringify(normalizedDraft) !== JSON.stringify(appliedFilters)) {
alert("조회조건이 변경되었습니다. 조회 후 Excel을 다운로드해주세요.");
return;
}
try {
setIsLoading(true);
setDownloadProgressText("시도별 노후관로현황 CSV를 준비하고 있습니다.");
await downloadAgedPipeStatusByCtpvCsv(filters);
setDownloadProgressText(
"시도별 노후관로현황 Excel 파일을 준비하고 있습니다.",
);
await downloadAgedPipeStatusByCtpvExcel(appliedFilters);
setDownloadProgressText("");
} catch (error) {
console.error("시도별 노후관로현황 다운로드 중 오류:", error);
@@ -119,7 +144,7 @@ export default function AgedPipeStatusByCtpvPopupPage() {
alert(
error instanceof Error
? error.message
: "시도별 노후관로현황 CSV 다운로드 중 오류가 발생했습니다.",
: "시도별 노후관로현황 Excel 다운로드 중 오류가 발생했습니다.",
);
} finally {
setIsLoading(false);
@@ -148,12 +173,10 @@ export default function AgedPipeStatusByCtpvPopupPage() {
className="h-[3.2rem] w-[14rem] rounded-md border border-input bg-field px-3 text-xl"
value={filters.startYear}
onChange={(event) =>
setFilters((current) =>
normalizeAgedPipeStatusByCtpvFilters({
...current,
startYear: Number(event.target.value),
}),
)
setFilters((current) => ({
...current,
startYear: Number(event.target.value),
}))
}
>
{yearOptions.map((year) => (
@@ -3,23 +3,30 @@
import { postBlobData } from "@/components/lib/api";
import { fileDownload } from "@/components/lib/utils/fileDownload";
import {
getAgedPipeStatusByCtpvValidationMessage,
normalizeAgedPipeStatusByCtpvFilters,
type AgedPipeStatusByCtpvFilters,
} from "./agedPipeStatusByCtpvUtils";
/** 시도별 노후관로현황 CSV 다운로드를 요청한다. */
export const downloadAgedPipeStatusByCtpvCsv = async (
/** 시도별 노후관로현황 XLSX 다운로드를 요청한다. */
export const downloadAgedPipeStatusByCtpvExcel = async (
filters: AgedPipeStatusByCtpvFilters,
) => {
const normalizedFilters = normalizeAgedPipeStatusByCtpvFilters(filters);
const { blob, fileName } = await postBlobData("v1/swagePpl/aged-status-by-ctpv/exportJobs/download", {
startYear: normalizedFilters.startYear,
endYear: normalizedFilters.endYear,
elapsedYears: normalizedFilters.elapsedYears,
format: "csv",
});
const validationMessage = getAgedPipeStatusByCtpvValidationMessage(filters);
if (validationMessage) throw new Error(validationMessage);
const resolvedFileName = fileName || "시도별_노후관로현황.csv";
const normalizedFilters = normalizeAgedPipeStatusByCtpvFilters(filters);
const { blob, fileName } = await postBlobData(
"v1/swagePpl/aged-status-by-ctpv/exportJobs/download",
{
startYear: normalizedFilters.startYear,
endYear: normalizedFilters.endYear,
elapsedYears: normalizedFilters.elapsedYears,
format: "xlsx",
},
);
const resolvedFileName = fileName || "시도별_노후관로현황.xlsx";
fileDownload(blob, resolvedFileName);
return resolvedFileName;
};
@@ -87,9 +87,6 @@ export const normalizeAgedPipeStatusByCtpvFilters = (
startYear = endYear;
endYear = swap;
}
if (endYear - startYear + 1 > AGED_PIPE_STATUS_BY_CTPV_MAX_YEAR_SPAN) {
endYear = startYear + (AGED_PIPE_STATUS_BY_CTPV_MAX_YEAR_SPAN - 1);
}
return {
startYear,
@@ -98,18 +95,29 @@ export const normalizeAgedPipeStatusByCtpvFilters = (
};
};
export const validateAgedPipeStatusByCtpvFilters = (
/** 입력한 기준년도·경과년도가 조회 가능한 범위인지 사용자 안내 문구로 반환한다. */
export const getAgedPipeStatusByCtpvValidationMessage = (
filters: AgedPipeStatusByCtpvFilters,
) => {
if (filters.startYear > filters.endYear) return false;
if (filters.startYear > filters.endYear) {
return "기준년도 시작은 종료보다 늦을 수 없습니다.";
}
if (
filters.endYear - filters.startYear + 1 >
AGED_PIPE_STATUS_BY_CTPV_MAX_YEAR_SPAN
)
return false;
return filters.elapsedYears >= 1 && filters.elapsedYears <= 100;
) {
return `기준년도 조회 범위는 최대 ${AGED_PIPE_STATUS_BY_CTPV_MAX_YEAR_SPAN}년까지 가능합니다.`;
}
if (filters.elapsedYears < 1 || filters.elapsedYears > 100) {
return "노후관로 기준은 1년부터 100년까지 입력할 수 있습니다.";
}
return null;
};
export const validateAgedPipeStatusByCtpvFilters = (
filters: AgedPipeStatusByCtpvFilters,
) => getAgedPipeStatusByCtpvValidationMessage(filters) === null;
const formatMetric = (value: number) =>
Number(value ?? 0).toLocaleString("ko-KR", {
minimumFractionDigits: 2,
@@ -10,6 +10,7 @@ import { getRawJson } from "@/components/lib/api";
import { downloadExcelWithKoreanHeaders } from "@/shared/lib/download-excel";
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";
@@ -258,13 +259,15 @@ export default function FldngHstryPopupPage() {
setIsLoading(true);
try {
const response = await fetchRows(
1,
appliedFilters,
Math.max(total, FLDNG_HSTRY_DOWNLOAD_BATCH_SIZE),
);
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.rows) as unknown as JSON[],
data: buildDownloadRows(response.items) as unknown as JSON[],
fileName: `침수발생이력_${new Date().toISOString().slice(0, 10)}.xlsx`,
sheetNameArray: ["침수발생이력"],
headerRange: "A1:Q1",
@@ -5,7 +5,7 @@ import type { UiHeadProps } from "@common/business_components/ui";
export const FLOOD_DNGR_MAP_POPUP_KEY = "floodDngrMapPopup";
export const FLOOD_DNGR_MAP_LAYER_TYPE = "tb_ms_flood_dngr_map";
export const FLOOD_DNGR_MAP_LAYER_NAME = "도시침수지도(정보제공포털)";
export const FLOOD_DNGR_MAP_PAGE_SIZE = 20;
export const FLOOD_DNGR_MAP_PAGE_SIZE = 10;
export type FloodDngrMapFilters = {
ctpvCd: string;
@@ -5,8 +5,10 @@ import { DateMonthPicker } from "@common/business_components/ui/date_month_picke
import {
StandardPopupLayout,
type UiHeadProps,
type UiTableSort,
} from "@common/business_components";
import { downloadExcelWithKoreanHeaders } from "@/shared/lib/download-excel";
import { fetchAllPages } from "@/shared/lib/fetch-all-pages";
import { useAlertStore } from "@/shared/lib";
import { Button } from "@common/components";
@@ -28,16 +30,21 @@ import {
const INFTWT_RESULT_PAGE_SIZE = 10;
const INFTWT_RESULT_HEADLIST: UiHeadProps[] = [
{ id: "rowNumber", title: "순번" },
{
id: "rowNumber",
title: "순번",
sortable: true,
sortKey: "inftwtAnlsSn",
},
{ id: "ctpvNm", title: "시도" },
{ id: "sggNm", title: "시군구" },
{ id: "swtrmtFcltNm", title: "하수처리시설" },
{ id: "aplcnYm", title: "적용연월" },
{ id: "drtwSwtcRt", title: "오수 전환율" },
{ id: "anlsInfo", title: "침입수 분석정보" },
{ id: "regDt", title: "분석일자" },
{ id: "swtrmtFcltNm", title: "하수처리시설", sortable: true },
{ id: "aplcnYm", title: "적용연월", sortable: true },
{ id: "drtwSwtcRt", title: "오수 전환율", sortable: true },
{ id: "anlsInfo", title: "침입수 분석정보", sortable: true },
{ id: "regDt", title: "분석일자", sortable: true },
{ id: "rgtrNm", title: "분석자" },
{ id: "scsYn", title: "분석성공여부" },
{ id: "scsYn", title: "분석성공여부", sortable: true },
];
type InftwtResultFilters = {
@@ -99,6 +106,7 @@ export default function InftwtResultPopupPage() {
const [rows, setRows] = useState<InftwtResultRow[]>([]);
const [total, setTotal] = useState(0);
const [currentPage, setCurrentPage] = useState(1);
const [sort, setSort] = useState<UiTableSort>();
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
@@ -107,13 +115,22 @@ export default function InftwtResultPopupPage() {
}, [ctpv.length, fetchCtpv, fetchSgg, sgg.length]);
const fetchRows = useCallback(
async (page: number, nextFilters: InftwtResultFilters) => {
async (
page: number,
nextFilters: InftwtResultFilters,
nextSort: UiTableSort | undefined = sort,
) => {
const queryParams = new URLSearchParams({
page: String(page - 1),
size: String(INFTWT_RESULT_PAGE_SIZE),
scsYn: "Y",
});
queryParams.append("sort", "inftwtAnlsSn,desc");
queryParams.append(
"sort",
nextSort
? `${nextSort.key},${nextSort.direction}`
: "inftwtAnlsSn,desc",
);
if (nextFilters.aplcnYmFrom)
queryParams.set("aplcnYmFrom", nextFilters.aplcnYmFrom);
@@ -155,14 +172,18 @@ export default function InftwtResultPopupPage() {
setTotal(totalElements);
setCurrentPage(page);
},
[ctpv, sgg],
[ctpv, sgg, sort],
);
const loadRows = useCallback(
async (page = 1, nextFilters = filters) => {
async (
page = 1,
nextFilters = filters,
nextSort: UiTableSort | undefined = sort,
) => {
setIsLoading(true);
try {
await fetchRows(page, nextFilters);
await fetchRows(page, nextFilters, nextSort);
} catch (error) {
console.error("침입수 분석결과 목록 조회 중 오류:", error);
alert("침입수 분석결과 목록을 불러오지 못했습니다.");
@@ -172,11 +193,11 @@ export default function InftwtResultPopupPage() {
setIsLoading(false);
}
},
[alert, fetchRows, filters],
[alert, fetchRows, filters, sort],
);
useEffect(() => {
void loadRows(1, DEFAULT_FILTERS);
void loadRows(1, DEFAULT_FILTERS, undefined);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
@@ -190,7 +211,15 @@ export default function InftwtResultPopupPage() {
setFilters(DEFAULT_FILTERS);
setSelectedSwagePrcsFclt("0");
setSelectedRow(null);
await loadRows(1, DEFAULT_FILTERS);
setSort(undefined);
await loadRows(1, DEFAULT_FILTERS, undefined);
};
/** 정렬 변경 시 선택 결과를 해제하고 서버 첫 페이지를 조회한다. */
const handleSortChange = (nextSort?: UiTableSort) => {
setSort(nextSort);
setSelectedRow(null);
void loadRows(1, filters, nextSort);
};
const handleRowClick = (index?: number) => {
@@ -210,15 +239,19 @@ export default function InftwtResultPopupPage() {
try {
setIsLoading(true);
const response = await getData(
`v1/inftwtAnlsRslt/ppl/${selectedRow.inftwtAnlsSn}`,
);
const excelRows = Array.isArray(response?.content)
? response.content
: [];
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 JSON[],
data: excelRows as unknown as JSON[],
fileName: `침입수분석결과_${selectedRow.swtrmtFcltNm}_${selectedRow.aplcnYm}_${selectedRow.inftwtAnlsSn}`,
sheetNameArray: ["침입수 분석결과"],
headerRange: "A1:D1",
@@ -355,6 +388,8 @@ export default function InftwtResultPopupPage() {
rowId={(row) => String(row.inftwtAnlsSn)}
scrollMode="horizontal"
minTableWidth="110rem"
sort={sort}
onSortChange={handleSortChange}
actions={
<Button type="button" onClick={() => void handleDownload()}>
엑셀다운로드
@@ -9,9 +9,11 @@ import { getData } from "@/components/lib/api";
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,
@@ -33,6 +35,7 @@ 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",
@@ -205,9 +208,20 @@ export default function PipeAnalysisDetailPopupPage() {
void loadRows(page, appliedFilters);
};
/** 선택 관로를 지도에 표시하고 상세창의 최상위 하수관망도 부모창을 앞으로 가져온다. */
const handleRowClick = (row: DetailViewRow) => {
const targetWindow = window.opener?.opener ?? window.opener;
targetWindow?.postMessage(
if (!targetWindow || targetWindow.closed) return;
// Chrome가 다른 탭/창의 focus()만 무시하는 경우를 대비해 사용자 클릭 안에서
// 기존 최상위 지도창을 고정 이름으로 다시 선택한 뒤 포커스를 요청한다.
targetWindow.name = targetWindow.name || PIPE_ANALYSIS_MAP_WINDOW_NAME;
const namedTargetWindow =
window.open("", targetWindow.name) ?? targetWindow;
window.opener?.blur();
window.blur();
namedTargetWindow.focus();
targetWindow.postMessage(
{
type: PIPE_ANALYSIS_DETAIL_ROW_SELECT_MESSAGE,
row,
@@ -224,12 +238,12 @@ export default function PipeAnalysisDetailPopupPage() {
try {
setIsLoading(true);
const response = await fetchRows(
1,
appliedFilters,
Math.max(total, 1000),
);
const exportRows = response.rows.map((row) => ({
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,
@@ -264,15 +278,9 @@ export default function PipeAnalysisDetailPopupPage() {
const materialOptions = useMemo(() => {
const options = [
{ value: "0", label: "전체" },
...Array.from(
new Set(
rows
.map((row) => String(row.materialCode ?? "").trim())
.filter((value) => value && value !== "0"),
),
).map((value) => ({
value,
label: resolveMaterialName(value),
...MS_SWAGE_PPL_CODES.map((item) => ({
value: item.val.trim(),
label: item.text,
})),
];
@@ -288,7 +296,7 @@ export default function PipeAnalysisDetailPopupPage() {
}
return options;
}, [baseMaterialCode, rows]);
}, [baseMaterialCode]);
const titleSuffix = useMemo(() => {
const locationParts: string[] = [];
@@ -345,7 +353,7 @@ export default function PipeAnalysisDetailPopupPage() {
용도
</div>
<select
className="sn-popup-input w-[14rem] !h-[3.2rem] !text-xl"
className="sn-popup-input !h-[3.2rem] w-[14rem] !text-xl"
value={filters.useCd}
onChange={(event) =>
setFilters((current) => ({
@@ -355,10 +363,11 @@ export default function PipeAnalysisDetailPopupPage() {
}
>
<option value="0">전체</option>
<option value="SBA001">합류관</option>
<option value="SBA002">차집관</option>
<option value="SBA003">우수관</option>
<option value="SBA004">오수관</option>
{MS_SWAGE_PPL_USE_CODES.map((item) => (
<option key={item.val} value={item.val}>
{item.text}
</option>
))}
</select>
</label>
@@ -367,7 +376,7 @@ export default function PipeAnalysisDetailPopupPage() {
관재질
</div>
<select
className="sn-popup-input w-[14rem] !h-[3.2rem] !text-xl"
className="sn-popup-input !h-[3.2rem] w-[14rem] !text-xl"
value={filters.materialCode}
onChange={(event) =>
setFilters((current) => ({
@@ -389,7 +398,7 @@ export default function PipeAnalysisDetailPopupPage() {
관경 시작
</div>
<Input
className="sn-popup-input w-[12rem] !h-[3.2rem] !text-xl"
className="sn-popup-input !h-[3.2rem] w-[12rem] !text-xl"
value={filters.diameterFrom}
onChange={(event) =>
setFilters((current) => ({
@@ -408,7 +417,7 @@ export default function PipeAnalysisDetailPopupPage() {
관경 종료
</div>
<Input
className="sn-popup-input w-[12rem] !h-[3.2rem] !text-xl"
className="sn-popup-input !h-[3.2rem] w-[12rem] !text-xl"
value={filters.diameterTo}
onChange={(event) =>
setFilters((current) => ({
@@ -119,6 +119,9 @@ export default function PipeAnalysisPopupPage() {
const zones = await fetchPipeAnalysisZoneOptions({
ctpvCd: draftFilters.ctpvCd,
sggCd: draftFilters.sggCd,
sggNm: sgg.find(
(option) => String(option.val).trim() === draftFilters.sggCd,
)?.text,
scopeRegionCode: scope.regionCode,
});
if (!mounted) return;
@@ -133,7 +136,7 @@ export default function PipeAnalysisPopupPage() {
return () => {
mounted = false;
};
}, [draftFilters.ctpvCd, draftFilters.sggCd, scope.regionCode]);
}, [draftFilters.ctpvCd, draftFilters.sggCd, scope.regionCode, sgg]);
const fetchSummary = useCallback(
async (nextFilters: PipeAnalysisSummaryFilters) => {
@@ -654,15 +654,23 @@ export const buildPipeAnalysisFileDate = () =>
export const fetchPipeAnalysisZoneOptions = async ({
ctpvCd,
sggCd,
sggNm,
scopeRegionCode,
}: {
ctpvCd: string;
sggCd: string;
sggNm?: string;
scopeRegionCode?: string;
}): Promise<PipeAnalysisZoneOption[]> => {
const filters: string[] = [];
const selectedSggCd = String(sggCd ?? "").trim();
const selectedSggNm = String(sggNm ?? "").trim();
if (sggCd && sggCd !== "0") {
filters.push(`sgg_cd='${sggCd}'`);
// 처리구역 원천의 시군구코드가 잘못된 경우도 있어 시도 범위로 받은 뒤
// 기준코드 또는 시군구명 중 하나가 일치하는 처리구역만 남긴다.
const selectedCtpvCd =
ctpvCd && ctpvCd !== "0" ? ctpvCd : selectedSggCd.slice(0, 2);
filters.push(`ctpv_cd='${selectedCtpvCd}'`);
} else if (ctpvCd && ctpvCd !== "0") {
filters.push(`ctpv_cd='${ctpvCd}'`);
} else if (scopeRegionCode) {
@@ -679,7 +687,7 @@ export const fetchPipeAnalysisZoneOptions = async ({
request: "GetFeature",
typeName: "hasudo:tb_ms_swage_prcs_zone",
outputFormat: "application/json",
propertyName: "ctpv_cd,sgg_cd,swtrmt_zone_nm",
propertyName: "ctpv_cd,sgg_cd,sgg_nm,swtrmt_zone_nm",
});
if (filters.length > 0) {
@@ -698,12 +706,22 @@ export const fetchPipeAnalysisZoneOptions = async ({
features.forEach((feature: any) => {
const properties = feature?.properties ?? {};
const zoneName = String(properties.swtrmt_zone_nm ?? "").trim();
const featureSggCd = String(properties.sgg_cd ?? "").trim();
const featureSggNm = String(properties.sgg_nm ?? "").trim();
if (!zoneName) return;
if (
selectedSggCd &&
selectedSggCd !== "0" &&
featureSggCd !== selectedSggCd &&
(!selectedSggNm || featureSggNm !== selectedSggNm)
) {
return;
}
dedupMap.set(zoneName, {
value: zoneName,
label: zoneName,
ctpvCd: String(properties.ctpv_cd ?? "").trim(),
sggCd: String(properties.sgg_cd ?? "").trim(),
sggCd: selectedSggCd !== "0" ? selectedSggCd : featureSggCd,
});
});
@@ -928,7 +928,7 @@ const FeatureClickEventPopup = ({
className="absolute right-0 top-0 z-[50] flex h-[calc(100vh-5rem)] w-[350px] flex-col overflow-hidden border-r border-border bg-background transition-all duration-300"
>
<LoadingOverlay isLoading={isLoading} />
<div className="card-header relative flex items-center justify-between bg-primary p-4">
<div className="card-header relative flex items-center justify-between bg-primary p-4 pr-12">
<div className="flex flex-1 items-center space-x-4">
{surveyMode && (
<h3 className="whitespace-nowrap text-2xl font-medium text-primary-foreground">
@@ -948,11 +948,14 @@ const FeatureClickEventPopup = ({
관리번호 : {layerSpecificNm}
</h3>
</div>
<div className="flex items-center space-x-3">
<div
className={`flex shrink-0 items-center space-x-3 ${layerNm === "하수관로" ? "mr-8" : ""}`}
>
{/* 하수관로일 때만 Infil/RDII 버튼 표시 */}
{layerNm === "하수관로" && (
<>
<Button
className="px-2"
onClick={() => {
const popupWindow = openWindowPopup("infilChart", {
pplMngNo,
@@ -968,6 +971,7 @@ const FeatureClickEventPopup = ({
침입수분석
</Button>
<Button
className="px-2"
onClick={() => {
const popupWindow = openWindowPopup("rdiiChart", {
pplMngNo,
@@ -1002,10 +1006,12 @@ const FeatureClickEventPopup = ({
</div>
<Button
onClick={onClose}
className="text-primary-foreground/80 flex items-center space-x-4 hover:text-primary-foreground focus:outline-none"
className="text-primary-foreground/80 absolute right-2 top-1/2 z-10 h-8 w-8 -translate-y-1/2 p-0 hover:text-primary-foreground focus:outline-none"
size="xs"
aria-label="시설 상세정보 닫기"
title="닫기"
>
✕
<span aria-hidden="true">✕</span>
</Button>
</div>
<div
@@ -5,11 +5,13 @@ import { DateMonthPicker } from "@common/business_components/ui/date_month_picke
import {
StandardPopupLayout,
type UiHeadProps,
type UiTableSort,
} from "@common/business_components";
import { Button } from "@common/components";
import { getData } from "@/components/lib/api";
import { downloadExcelWithKoreanHeaders } from "@/shared/lib/download-excel";
import { fetchAllPages } from "@/shared/lib/fetch-all-pages";
import { useAlertStore } from "@/shared/lib";
import { CtpvSggPrcsFcltSelectBox } from "@/components/ui/ctpvSggPrcsFcltSelectBox";
import { formatDisplayData } from "@/components/lib/utils/formatDisplayValue";
@@ -28,18 +30,23 @@ import {
const RDII_RESULT_PAGE_SIZE = 10;
const RDII_RESULT_HEADLIST: UiHeadProps[] = [
{ id: "rowNumber", title: "순번" },
{
id: "rowNumber",
title: "순번",
sortable: true,
sortKey: "rainInfwtAnlsSn",
},
{ id: "ctpvNm", title: "시도" },
{ id: "sggNm", title: "시군구" },
{ id: "swtrmtFcltNm", title: "하수처리시설" },
{ id: "aplcnYm", title: "상수사용연월" },
{ id: "drtwSwtcRt", title: "오수 전환율" },
{ id: "srcNm", title: "RTK 계수 출처" },
{ id: "cntbArea", title: "RDII 기여면적" },
{ id: "anlsInfo", title: "RDII\n분석정보" },
{ id: "regDt", title: "분석일자" },
{ id: "swtrmtFcltNm", title: "하수처리시설", sortable: true },
{ id: "aplcnYm", title: "상수사용연월", sortable: true },
{ id: "drtwSwtcRt", title: "오수 전환율", sortable: true },
{ id: "srcNm", title: "RTK 계수 출처", sortable: true },
{ id: "cntbArea", title: "RDII 기여면적", sortable: true },
{ id: "anlsInfo", title: "RDII\n분석정보", sortable: true },
{ id: "regDt", title: "분석일자", sortable: true },
{ id: "rgtrNm", title: "분석자" },
{ id: "scsYn", title: "분석성공여부" },
{ id: "scsYn", title: "분석성공여부", sortable: true },
];
type RdiiResultFilters = {
@@ -101,6 +108,7 @@ export default function RdiiResultPopupPage() {
const [rows, setRows] = useState<RdiiResultRow[]>([]);
const [total, setTotal] = useState(0);
const [currentPage, setCurrentPage] = useState(1);
const [sort, setSort] = useState<UiTableSort>();
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
@@ -109,13 +117,22 @@ export default function RdiiResultPopupPage() {
}, [ctpv.length, fetchCtpv, fetchSgg, sgg.length]);
const fetchRows = useCallback(
async (page: number, nextFilters: RdiiResultFilters) => {
async (
page: number,
nextFilters: RdiiResultFilters,
nextSort: UiTableSort | undefined = sort,
) => {
const queryParams = new URLSearchParams({
page: String(page - 1),
size: String(RDII_RESULT_PAGE_SIZE),
scsYn: "Y",
});
queryParams.append("sort", "rainInfwtAnlsSn,desc");
queryParams.append(
"sort",
nextSort
? `${nextSort.key},${nextSort.direction}`
: "rainInfwtAnlsSn,desc",
);
if (nextFilters.aplcnYmFrom)
queryParams.set("aplcnYmFrom", nextFilters.aplcnYmFrom);
@@ -157,14 +174,18 @@ export default function RdiiResultPopupPage() {
setTotal(totalElements);
setCurrentPage(page);
},
[ctpv, sgg],
[ctpv, sgg, sort],
);
const loadRows = useCallback(
async (page = 1, nextFilters = filters) => {
async (
page = 1,
nextFilters = filters,
nextSort: UiTableSort | undefined = sort,
) => {
setIsLoading(true);
try {
await fetchRows(page, nextFilters);
await fetchRows(page, nextFilters, nextSort);
} catch (error) {
console.error("RDII 분석결과 목록 조회 중 오류:", error);
alert("RDII 분석결과 목록을 불러오지 못했습니다.");
@@ -174,11 +195,11 @@ export default function RdiiResultPopupPage() {
setIsLoading(false);
}
},
[alert, fetchRows, filters],
[alert, fetchRows, filters, sort],
);
useEffect(() => {
void loadRows(1, DEFAULT_FILTERS);
void loadRows(1, DEFAULT_FILTERS, undefined);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
@@ -192,7 +213,15 @@ export default function RdiiResultPopupPage() {
setFilters(DEFAULT_FILTERS);
setSelectedSwagePrcsFclt("0");
setSelectedRow(null);
await loadRows(1, DEFAULT_FILTERS);
setSort(undefined);
await loadRows(1, DEFAULT_FILTERS, undefined);
};
/** 정렬 변경 시 선택 결과를 해제하고 서버 첫 페이지를 조회한다. */
const handleSortChange = (nextSort?: UiTableSort) => {
setSort(nextSort);
setSelectedRow(null);
void loadRows(1, filters, nextSort);
};
const handleRowClick = (index?: number) => {
@@ -212,15 +241,19 @@ export default function RdiiResultPopupPage() {
try {
setIsLoading(true);
const response = await getData(
`v1/rdiiAnlsRslt/ppl/${selectedRow.rainInfwtAnlsSn}`,
);
const excelRows = Array.isArray(response?.content)
? response.content
: [];
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 JSON[],
data: excelRows as unknown as JSON[],
fileName: `RDII분석결과_${selectedRow.swtrmtFcltNm}_${selectedRow.aplcnYm}_${selectedRow.rainInfwtAnlsSn}`,
sheetNameArray: ["RDII 분석결과"],
headerRange: "A1:D1",
@@ -357,6 +390,8 @@ export default function RdiiResultPopupPage() {
rowId={(row) => String(row.rainInfwtAnlsSn)}
scrollMode="horizontal"
minTableWidth="125rem"
sort={sort}
onSortChange={handleSortChange}
actions={
<Button type="button" onClick={() => void handleDownload()}>
엑셀다운로드
@@ -295,7 +295,15 @@ export default function SbmrgDngrMapPopupPage() {
pageSize={SBMRG_DNGR_MAP_PAGE_SIZE}
onPageChange={(page) => void loadRows(page, appliedFilters)}
onRowClick={handleRowClick}
rowId={(row, index) => String(row.emphsMngRgnMngNo ?? index)}
rowId={(row, index) =>
[
row.emphsMngRgnMngNo ?? "unknown",
row.floodWtlvFrqVl ?? "unknown",
row.ctpvCd ?? "unknown",
row.sggCd ?? "unknown",
index,
].join(":")
}
/>
</StandardPopupLayout>
);
@@ -5,7 +5,7 @@ import type { UiHeadProps } from "@common/business_components/ui";
export const SBMRG_DNGR_MAP_POPUP_KEY = "sbmrgDngrMapPopup";
export const SBMRG_DNGR_MAP_LAYER_TYPE = "tb_ms_sbmrg_dngr_map";
export const SBMRG_DNGR_MAP_LAYER_NAME = "침수위험지도(중점관리지역)";
export const SBMRG_DNGR_MAP_PAGE_SIZE = 20;
export const SBMRG_DNGR_MAP_PAGE_SIZE = 10;
export const SBMRG_DNGR_MAP_SCOPE_SIZE = 2000;
export type SbmrgDngrMapFilters = {
@@ -10,10 +10,14 @@ import {
import { getRawJson } from "@/components/lib/api";
import { downloadExcelWithKoreanHeaders } from "@/shared/lib/download-excel";
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";
import PopupSearchPanel from "@/features/windowPopup/ui/PopupSearchPanel";
import PopupSearchPanel, {
PopupSearchActions,
PopupSearchField,
} from "@/features/windowPopup/ui/PopupSearchPanel";
import type { SelectDataType } from "@common/business_components";
import {
buildSinkholeDefaultFilters,
@@ -274,20 +278,16 @@ export default function SinkholePopupPage() {
setIsLoading(true);
try {
const normalized = normalizeSinkholeFilters(appliedFilters);
const downloadItems: SinkholeListItem[] = [];
const totalPages = Math.max(1, Math.ceil(total / DOWNLOAD_BATCH_SIZE));
for (let page = 1; page <= totalPages; page += 1) {
const response = await fetchRows(
page,
normalized,
DOWNLOAD_BATCH_SIZE,
{
const response = await fetchAllPages({
batchSize: DOWNLOAD_BATCH_SIZE,
fetchPage: (page, size) =>
fetchRows(page + 1, normalized, size, {
includeCoordStatus: false,
},
);
downloadItems.push(...response.items);
}
}),
getItems: (page) => page.items,
getTotal: (page) => page.total,
});
const downloadItems: SinkholeListItem[] = response.items;
if (downloadItems.length === 0) {
alert("다운로드할 데이터가 없습니다.");
@@ -318,13 +318,10 @@ export default function SinkholePopupPage() {
<StandardPopupLayout title="지반침하 목록">
<LoadingOverlay isLoading={isLoading} />
<PopupSearchPanel contentClassName="flex flex-wrap items-end gap-x-5 gap-y-3">
<label className="block md:max-w-[170px]">
<div className="mb-1 text-[1.05rem] font-medium text-label">
발생기간(시작)
</div>
<PopupSearchPanel>
<PopupSearchField label="발생기간(시작)">
<Input
className="h-[3.2rem] !text-[1.3rem]"
className="h-[3.2rem] w-[12rem] !text-[1.3rem]"
type="date"
min={formatYmdToDateInput(SINKHOLE_MIN_SEARCH_YMD)}
value={formatYmdToDateInput(filters.startYmd)}
@@ -335,14 +332,11 @@ export default function SinkholePopupPage() {
})
}
/>
</label>
</PopupSearchField>
<label className="block md:max-w-[170px]">
<div className="mb-1 text-[1.05rem] font-medium text-label">
발생기간(종료)
</div>
<PopupSearchField label="발생기간(종료)">
<Input
className="h-[3.2rem] !text-[1.3rem]"
className="h-[3.2rem] w-[12rem] !text-[1.3rem]"
type="date"
min={formatYmdToDateInput(SINKHOLE_MIN_SEARCH_YMD)}
value={formatYmdToDateInput(filters.endYmd)}
@@ -353,12 +347,11 @@ export default function SinkholePopupPage() {
})
}
/>
</label>
</PopupSearchField>
<label className="block md:max-w-[220px]">
<div className="mb-1 text-[1.05rem] font-medium text-label">시도</div>
<PopupSearchField label="시도">
<select
className="h-[3.2rem] w-full rounded-md border border-border px-3 text-[1.3rem]"
className="h-[3.2rem] w-[12rem] rounded-md border border-input bg-field px-3 text-[1.3rem] text-foreground"
value={filters.ctpvCd}
onChange={(event) =>
setFilters({
@@ -375,14 +368,11 @@ export default function SinkholePopupPage() {
</option>
))}
</select>
</label>
</PopupSearchField>
<label className="block md:max-w-[220px]">
<div className="mb-1 text-[1.05rem] font-medium text-label">
시군구
</div>
<PopupSearchField label="시군구">
<select
className="h-[3.2rem] w-full rounded-md border border-border px-3 text-[1.3rem]"
className="h-[3.2rem] w-[12rem] rounded-md border border-input bg-field px-3 text-[1.3rem] text-foreground"
value={filters.sggCd}
onChange={(event) =>
setFilters({ ...filters, sggCd: event.target.value })
@@ -395,14 +385,11 @@ export default function SinkholePopupPage() {
</option>
))}
</select>
</label>
</PopupSearchField>
<label className="block md:max-w-[180px]">
<div className="mb-1 text-[1.05rem] font-medium text-label">
복구상태
</div>
<PopupSearchField label="복구상태">
<select
className="h-[3.2rem] w-full rounded-md border border-border px-3 text-[1.3rem]"
className="h-[3.2rem] w-[10rem] rounded-md border border-input bg-field px-3 text-[1.3rem] text-foreground"
value={filters.restoreStatus}
onChange={(event) =>
setFilters({ ...filters, restoreStatus: event.target.value })
@@ -414,14 +401,11 @@ export default function SinkholePopupPage() {
</option>
))}
</select>
</label>
</PopupSearchField>
<label className="block md:max-w-[180px]">
<div className="mb-1 text-[1.05rem] font-medium text-label">
좌표보정상태
</div>
<PopupSearchField label="좌표보정상태">
<select
className="h-[3.2rem] w-full rounded-md border border-border px-3 text-[1.3rem]"
className="h-[3.2rem] w-[10rem] rounded-md border border-input bg-field px-3 text-[1.3rem] text-foreground"
value={filters.coordStatus}
onChange={(event) =>
setFilters({ ...filters, coordStatus: event.target.value })
@@ -433,9 +417,9 @@ export default function SinkholePopupPage() {
</option>
))}
</select>
</label>
</PopupSearchField>
<div className="ml-auto flex flex-wrap justify-end gap-2">
<PopupSearchActions>
<Button
type="button"
color="primary"
@@ -457,7 +441,7 @@ export default function SinkholePopupPage() {
>
초기화
</Button>
</div>
</PopupSearchActions>
</PopupSearchPanel>
<MapPopupDataTable
@@ -470,6 +454,16 @@ export default function SinkholePopupPage() {
onPageChange={(page) => void handlePageChange(page)}
onRowClick={handleRowClick}
rowId={(row, index) => String(row.fid ?? row.acdntNo ?? index)}
renderCell={(row, headId) =>
headId === "fullAddress" ? (
<span
className="block whitespace-nowrap"
title={toDisplay(row.fullAddress)}
>
{toDisplay(row.fullAddress)}
</span>
) : undefined
}
scrollMode="horizontal"
minTableWidth="150rem"
actions={
@@ -440,6 +440,7 @@ export default function WaterRiskPopupPage(_props: WindowPopupContentProps) {
const handleChangeResultMode = (mode: WaterRiskResultMode) => {
setResultMode(mode);
setResultCurrentPage(1);
setResultSort(undefined);
setResultPage(EMPTY_RESULT_PAGE);
setSelectedResultKey("");
};
+15 -11
View File
@@ -96,7 +96,7 @@ import {
toDisplayPipeRow,
validateElapsedYears,
} from "./components/agedPipe/agedPipeUtils";
import { downloadAgedPipeCsv } from "./components/agedPipe/agedPipeExport";
import { downloadAgedPipeExcel } from "./components/agedPipe/agedPipeExport";
import { useInftwtResultMap } from "./components/inftwtResult/useInftwtResultMap";
import { useRdiiResultMap } from "./components/rdiiResult/useRdiiResultMap";
import SinkholeDetailPanel from "./components/sinkhole/SinkholeDetailPanel";
@@ -6064,6 +6064,8 @@ const Map = () => {
const handlePipeAnalysisRowSelect = useCallback(
async (row: Pick<PipeAnalysisDetailRow, "fid">) => {
// 관로현황 팝업에서 선택한 경우 메시지를 받은 최상위 지도창을 먼저 앞으로 가져온다.
window.focus();
try {
shouldFocusAgedPipeOnNextSelectRef.current = true;
const core = GisApp.Module.layer as any;
@@ -6076,6 +6078,8 @@ const Map = () => {
setFeatureData(row as any);
setIsOpen(true);
setIsSearchModalOpen(false);
// 지도 이동과 상세 패널 렌더링 이후에도 최상위 창 포커스를 한 번 더 요청한다.
window.focus();
},
[setIsSearchModalOpen],
);
@@ -6088,19 +6092,19 @@ const Map = () => {
try {
setIsAgedPipeListLoading(true);
setAgedPipeDownloadProgressText("CSV export 작업을 준비하고 있습니다.");
await downloadAgedPipeCsv(appliedAgedPipeFilters, (job) => {
setAgedPipeDownloadProgressText("Excel 파일을 준비하고 있습니다.");
await downloadAgedPipeExcel(appliedAgedPipeFilters, (job) => {
if (job.status === "QUEUED") {
setAgedPipeDownloadProgressText("CSV export 작업이 대기 중입니다.");
setAgedPipeDownloadProgressText("Excel 다운로드 작업이 대기 중입니다.");
return;
}
if (job.status === "RUNNING") {
setAgedPipeDownloadProgressText(
`CSV export 생성 중입니다. (${job.progressPercent ?? 0}%)`,
`Excel 파일 생성 중입니다. (${job.progressPercent ?? 0}%)`,
);
}
});
}, agedPipeTotalCount);
setAgedPipeDownloadProgressText("");
} catch (error) {
console.error("노후관로 목록 다운로드 중 오류:", error);
@@ -6108,7 +6112,7 @@ const Map = () => {
alert(
error instanceof Error
? error.message
: "노후관로 CSV 다운로드 중 오류가 발생했습니다.",
: "노후관로 Excel 다운로드 중 오류가 발생했습니다.",
);
} finally {
setIsAgedPipeListLoading(false);
@@ -8885,13 +8889,12 @@ const Map = () => {
</div>
<div className="bg-background px-[9px] py-[6px] text-[9px] text-muted-foreground">
<div>
GIS 매핑 관로 중 Capacity 0.8 이상이 1개라도 있으면{" "}
GIS 매핑 관로 중 Capacity 0.8 이상이 1개라도 있으면
</div>
<div>
<span className="font-bold text-[#ff1c15]">위험</span>, 없으면{" "}
<span className="font-bold text-[#1677ff]">정상</span>
</div>
<div className="mt-[2px]">
맨홀 침수유량은 지역 위험 판정에서 제외
</div>
</div>
<table className="w-full table-fixed border-collapse border-t border-border">
<colgroup>
@@ -9061,6 +9064,7 @@ const Map = () => {
isFloodDngrMapRootNode(node) ||
isFldngHstryNode(node)
}
layerIconRequiresChecked={false}
defaultZoom={7.5}
defaultLonLat={[129, 36.3]}
viewExtent={SOUTH_KOREA_VIEW_EXTENT}
@@ -13,6 +13,7 @@
* - 관망도 시설물 목록 조회: GET /api/v1/fclts (sewerage-service)
*/
import { downloadExcelWithKoreanHeaders } from "@/shared/lib/download-excel";
import { fetchAllPages } from "@/shared/lib/fetch-all-pages";
import {
MS_SWAGE_PPL_USE_CODES,
MS_SWAGE_PPL_CODES,
@@ -50,6 +51,9 @@ 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();
@@ -64,6 +68,7 @@ export default function useBasicTab() {
setTableData,
setTableCount,
setIsLoading,
setAppliedBasicQuery,
} = useSearchModalStore();
const { ctpv, sgg } = useStore();
@@ -168,6 +173,12 @@ export default function useBasicTab() {
setTableData(dataWithRowNumber);
setTableCount(totalCount);
queryParams.delete("page");
queryParams.delete("size");
setAppliedBasicQuery({
basicType,
query: queryParams.toString(),
});
};
// 기본정보 테이블 cellClick 핸들러
@@ -220,73 +231,77 @@ export default function useBasicTab() {
setIsLoading(true);
try {
const queryParams = new URLSearchParams({
lyrTypeNm: basicType,
size: "10000",
});
if (basicType === "tb_ms_swage_ppl")
queryParams.set("includeFlowDirectionBasis", "true");
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 cqlFilters: string[] = [];
let headerMapping: { [key: string]: string } = {};
let headerRange: string = "A1:F1";
switch (basicType) {
switch (appliedBasicType) {
case "tb_ms_swage_ppl": {
// 하수관로
cqlFilters = swagePplCqlFilters();
headerMapping = MsSwagePplExcelCol;
headerRange = "A1:Q1";
break;
}
case "tb_il_swage_prcs_fclt": {
// 하수처리시설
cqlFilters = swagePrcsFcltCqlFilters();
headerMapping = SwtrmtFcltExcelCol;
headerRange = "A1:J1";
break;
}
case "tb_ms_swage_prcs_zone": {
// 하수처리구역
cqlFilters = swagePrcsZoneCqlFilters();
headerMapping = SwtrmtPrcsZoneExcelCol;
headerRange = "A1:D1";
break;
}
case "tb_ms_fldng_wtlv_map": {
// 침수흔적도
cqlFilters = fldngWtlvMapCqlFilters();
headerMapping = FldngWtlvMapExcelCol;
headerRange = "A1:O1";
break;
}
case "tb_ms_swage_mhl": {
// 하수맨홀
cqlFilters = swageMhlCqlFilters();
headerMapping = MsSwageMhlExcelCol;
headerRange = "A1:J1";
break;
}
case "tb_ms_swage_pmstn": {
// 하수펌프장
cqlFilters = swagePmstnCqlFilters();
headerMapping = ExclnSewagePumpExcelCol;
headerRange = "A1:J1";
break;
}
case "tb_ms_stofcbr": {
// 우수토실
cqlFilters = stofcbrCqlFilters();
headerMapping = stofcbrExcelCol;
headerRange = "A1:H1";
break;
}
}
if (cqlFilters.length > 0)
queryParams.append("cqlFiter", cqlFilters.join(" and "));
const res = await getData(`v1/fclts?${queryParams.toString()}`);
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]),
@@ -307,7 +322,7 @@ export default function useBasicTab() {
sgg.map((code) => [code.val?.trim(), code.text]),
);
let mappedContent = (res.data || []).map((item: any) => {
let mappedContent = res.items.map((item: any) => {
const formattedItem = formatDisplayData(item);
let ctpvCd = item?.ctpvCd ?? formattedItem?.ctpvCd ?? "";
@@ -329,7 +344,7 @@ export default function useBasicTab() {
const trimmedUsgCd = item?.usgCd?.trim();
// const trimmedMqltCd = item?.mqltCd?.trim();
switch (basicType) {
switch (appliedBasicType) {
case "tb_ms_swage_ppl": {
// 하수관로
return {
@@ -483,11 +498,13 @@ export default function useBasicTab() {
const excelData = mappedContent; // 배열로 감싸서 전달
// 파일명 생성 (분석번호 기반)
const label = BASIC_TYPES.find((type) => type.val === basicType)?.text;
const label = BASIC_TYPES.find(
(type) => type.val === appliedBasicType,
)?.text;
const fileName = `${label} 조회 결과_${todayYear}${todayMonth}${todayDate}`;
await downloadExcelWithKoreanHeaders({
data: excelData as JSON[],
data: excelData as unknown as JSON[],
fileName: fileName,
sheetNameArray: [fileName],
headerRange: headerRange,
@@ -15,6 +15,7 @@
* - 유지관리 정보(사업정보) 관련 시설 목록 조회: GET /api/v1/bizInfo/fclts (sewerage-service)
*/
import { downloadExcelWithKoreanHeaders } from "@/shared/lib/download-excel";
import { fetchAllPages } from "@/shared/lib/fetch-all-pages";
import {
MS_SWAGE_PPL_CODES,
MS_SWAGE_PPL_USE_CODES,
@@ -28,6 +29,7 @@ import useSearchModalStore from "@/store/useSearchModalStore";
import useStore from "@/store/useStore";
import { postPopupMessageAndClose } from "@/features/windowPopup/lib/popupMessage";
import { WINDOW_POPUP_MESSAGE_TYPE } from "@/features/windowPopup/model/types";
import { useRef } from "react";
const isPopupWindow = () =>
typeof window !== "undefined" &&
@@ -73,6 +75,15 @@ export default function useOperationTab() {
{ val: "['100']", text: "점검 및 진단" },
{ val: "['203']", text: "청소 및 준설" },
];
// 화면 사업 목록과 결과 목록을 마지막으로 만든 요청을 보관해 Excel·페이지 이동에 재사용한다.
const appliedOperationQueryRef = useRef<{
query: string;
workType: string;
} | null>(null);
const appliedOperationDetailQueryRef = useRef<{
query: string;
workType: string;
} | null>(null);
const OPERATION_TABLE_HEADLIST = [
// 유지관리 정보 - 사업 현황
{ id: "rowNumber", title: "순번" },
@@ -173,6 +184,14 @@ export default function useOperationTab() {
setTableCount(totalElements);
setDetailTableData([]);
setDetailTableCount(0);
const appliedParams = new URLSearchParams(params);
appliedParams.delete("page");
appliedParams.delete("size");
appliedOperationQueryRef.current = {
query: appliedParams.toString(),
workType,
};
appliedOperationDetailQueryRef.current = null;
};
// 유지관리 정보 테이블 cellClick 핸들러
@@ -196,9 +215,9 @@ export default function useOperationTab() {
};
RequestDTO.workTypes = JSON.parse(workType.replace(/'/g, '"'));
const requestParams = new URLSearchParams(RequestDTO);
const res = await getData(
`v1/bizInfo/fclts?${new URLSearchParams(RequestDTO).toString()}`,
`v1/bizInfo/fclts?${requestParams.toString()}`,
);
const totalDetailElements = Number(res.count ?? 0);
@@ -224,6 +243,12 @@ export default function useOperationTab() {
setDetailTableData(mappedData);
setDetailTableCount(totalDetailElements);
requestParams.delete("page");
requestParams.delete("size");
appliedOperationDetailQueryRef.current = {
query: requestParams.toString(),
workType,
};
} catch (error) {
console.error("유지관리 정보 cellClick 에러:", error);
setDetailTableData([]);
@@ -272,13 +297,18 @@ export default function useOperationTab() {
if (typeof index === "number") {
setIsDetailLoading(true);
try {
const RequestDTO: Record<string, string> = {
page: `${detailCurPage - 1}`,
size: `${OPERATION_TABLE_SIZE}`,
bizId: String(detailBizId),
};
const appliedDetail = appliedOperationDetailQueryRef.current;
const requestParams = new URLSearchParams(
appliedDetail?.query ??
new URLSearchParams({
bizId: String(detailBizId),
workTypes: String(JSON.parse(workType.replace(/'/g, '"'))),
}).toString(),
);
requestParams.set("page", `${detailCurPage - 1}`);
requestParams.set("size", `${OPERATION_TABLE_SIZE}`);
const res = await getData(
`v1/bizInfo/fclts?${new URLSearchParams(RequestDTO).toString()}`,
`v1/bizInfo/fclts?${requestParams.toString()}`,
);
const totalElements = Number(res.count ?? 0);
@@ -335,27 +365,24 @@ export default function useOperationTab() {
setIsLoading(true);
try {
const RequestDTO: Record<string, string> = {
size: "10000",
};
if (var1) RequestDTO.bizInfoNm = var1;
if (var2 != "0" && var2) RequestDTO.bizTypeCd = var2;
// 수행기간 범위
const startDate = getDateString(yearFilter.start, "", "", "19000101");
const endDate = getDateString(
yearFilter.end,
"",
"",
`${todayYear}${todayMonth}${todayDate}`,
);
RequestDTO.fromflfmtEndYmd = startDate;
RequestDTO.toflfmtEndYmd = endDate;
const appliedQuery = appliedOperationQueryRef.current;
if (!appliedQuery) {
throw new Error(
"화면 조회조건을 확인할 수 없습니다. 다시 조회해주세요.",
);
}
// JSON.parse 사용으로 문자열 -> 배열 변환
RequestDTO.workTypes = JSON.parse(workType.replace(/'/g, '"'));
const params = new URLSearchParams(RequestDTO).toString();
const res = await getData(`v1/bizInfo?${params}`);
const res = await fetchAllPages<any, any>({
fetchPage: (page, size) => {
const params = new URLSearchParams(appliedQuery.query);
params.set("page", String(page));
params.set("size", String(size));
return getData(`v1/bizInfo?${params.toString()}`);
},
getItems: (page) =>
Array.isArray(page?.content) ? page.content : [],
getTotal: (page) => Number(page?.totalElements ?? 0),
});
// 헤더 매핑 정의
const headerMapping: { [key: string]: string } = {
@@ -371,7 +398,7 @@ export default function useOperationTab() {
BIZ_TY_CD.map((code) => [code.lwpoCd, code.lwpoCdNm]),
);
const mappedContent = (res.content || []).map((item: any) => {
const mappedContent = res.items.map((item: any) => {
const formattedItem = formatDisplayData(item);
return {
@@ -394,11 +421,13 @@ export default function useOperationTab() {
const excelData = mappedContent; // 배열로 감싸서 전달
// 파일명 생성 (분석번호 기반)
const label = WORK_TYPES.find((type) => type.val === workType)?.text;
const label = WORK_TYPES.find(
(type) => type.val === appliedQuery.workType,
)?.text;
const fileName = `${label} 사업 현황_${todayYear}${todayMonth}${todayDate}`;
await downloadExcelWithKoreanHeaders({
data: excelData as JSON[],
data: excelData as unknown as JSON[],
fileName: fileName,
sheetNameArray: [fileName],
headerRange: "A1:F1",
@@ -433,14 +462,23 @@ export default function useOperationTab() {
setIsLoading(true);
try {
const RequestDTO: Record<string, string> = {
size: "100",
bizId: String(detailBizId),
};
const appliedQuery = appliedOperationDetailQueryRef.current;
if (!appliedQuery) {
throw new Error(
"화면 결과 조회조건을 확인할 수 없습니다. 다시 조회해주세요.",
);
}
const res = await getData(
`v1/bizInfo/fclts?${new URLSearchParams(RequestDTO).toString()}`,
);
const res = await fetchAllPages<any, any>({
fetchPage: (page, size) => {
const params = new URLSearchParams(appliedQuery.query);
params.set("page", String(page));
params.set("size", String(size));
return getData(`v1/bizInfo/fclts?${params.toString()}`);
},
getItems: (page) => (Array.isArray(page?.data) ? page.data : []),
getTotal: (page) => Number(page?.count ?? 0),
});
// 헤더 매핑 정의
const headerMapping: { [key: string]: string } = {
@@ -463,7 +501,7 @@ export default function useOperationTab() {
);
// 🎯 단일 반복문으로 최적화 - 각 아이템마다 필요한 필드만 직접 매핑
const mappedData = (res.data || []).map((item: any) => {
const mappedData = res.items.map((item: any) => {
const formattedItem = formatDisplayData(item);
// trim 한 번만 수행
@@ -507,11 +545,13 @@ export default function useOperationTab() {
const excelData = mappedData; // 배열로 감싸서 전달
// 파일명 생성 (분석번호 기반)
const label = WORK_TYPES.find((type) => type.val === workType)?.text;
const label = WORK_TYPES.find(
(type) => type.val === appliedQuery.workType,
)?.text;
const fileName = `${label} 결과표_${todayYear}${todayMonth}${todayDate}`;
await downloadExcelWithKoreanHeaders({
data: excelData as JSON[],
data: excelData as unknown as JSON[],
fileName: fileName,
sheetNameArray: [fileName],
headerRange: "A1:I1",
@@ -319,7 +319,11 @@ const BaseLayout = ({
{titleName?.secondCardTitle}
</h3>
<div className="ml-auto flex items-center gap-2">
{graphBtn && <Button type="button">엑셀다운로드</Button>}
{graphBtn && (
<Button type="button" onClick={isChckFile}>
엑셀다운로드
</Button>
)}
{/* 그래프 있을 시 */}
{graphBtn && (
<Button
@@ -20,6 +20,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 { zodResolver } from "@hookform/resolvers/zod";
import { useState, useEffect } from "react";
import { useForm } from "react-hook-form";
@@ -239,12 +240,18 @@ const Result = () => {
}
try {
const data = await getData(
`v1/rdiiAnlsRslt/ppl/${selectedRowData.rainInfwtAnlsSn}`,
);
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.content; // 배열로 감싸서 전달
// 선택된 분석의 전체 관로 결과를 Excel 다운로드 형식으로 변환한다.
const excelData = data.items;
// 헤더 매핑 정의
const headerMapping: { [key: string]: string } = {
@@ -258,7 +265,7 @@ const Result = () => {
const fileName = `RDII분석결과_${selectedRowData.swtrmtFcltNm}_${selectedRowData.aplcnYm}_${selectedRowData.rainInfwtAnlsSn}`;
await downloadExcelWithKoreanHeaders({
data: excelData as JSON[],
data: excelData as unknown as JSON[],
fileName: fileName,
sheetNameArray: ["RDII 분석결과"],
headerRange: "A1:D1",
@@ -20,6 +20,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 { zodResolver } from "@hookform/resolvers/zod";
import { useState, useEffect } from "react";
import { useForm } from "react-hook-form";
@@ -239,12 +240,18 @@ const Result = () => {
}
try {
const data = await getData(
`v1/inftwtAnlsRslt/ppl/${selectedRowData.inftwtAnlsSn}`,
);
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.content; // 배열로 감싸서 전달
// 선택된 분석의 전체 관로 결과를 Excel 다운로드 형식으로 변환한다.
const excelData = data.items;
// 헤더 매핑 정의
const headerMapping: { [key: string]: string } = {
@@ -258,7 +265,7 @@ const Result = () => {
const fileName = `침입수분석결과_${selectedRowData.swtrmtFcltNm}_${selectedRowData.aplcnYm}_${selectedRowData.inftwtAnlsSn}`;
await downloadExcelWithKoreanHeaders({
data: excelData as JSON[],
data: excelData as unknown as JSON[],
fileName: fileName,
sheetNameArray: ["침입수 분석결과"],
headerRange: "A1:D1",
@@ -6,6 +6,7 @@ import {
putData,
} from "@/components/lib/api";
import { handleUpload } from "@/components/lib/utils";
import { fetchAllPages } from "@/shared/lib/fetch-all-pages";
import type { HttpResponse } from "tus-js-client";
import type {
@@ -121,6 +122,20 @@ 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 (
dredgingJobSn: string | number,
@@ -3,13 +3,18 @@
*
* - SFR-010-012 화면설계서 50~55쪽을 기준으로 한 FE 선행 구현
* - 사진 EXIF 촬영일/GPS, 지도 수동 위치, 최근접 관로 자동 매핑 흐름 제공
* - 지자체별/관경별/경과년도별 조회와 CSV 다운로드 제공
* - 지자체별/관경별/경과년도별 조회와 XLSX 다운로드 제공
*/
"use client";
import LoadingOverlay from "@/components/ui/LoadingBar";
import { useAlertStore } from "@/shared/lib";
import { StandardDataTable } from "@common/business_components";
import {
StandardDataTable,
StandardTableSortHeader,
sortStandardTableRows,
type UiTableSort,
} from "@common/business_components";
import {
Button,
InputDatePicker,
@@ -35,7 +40,7 @@ import { useConfirm } from "@frontend-opensource/use-react-hooks";
import {
deleteDredgingRecord,
fetchDredgingRecords,
fetchAllDredgingRecords,
isDredgingBackendEnabled,
} from "./lib/client";
import type {
@@ -343,6 +348,8 @@ const Page = () => {
// 삭제 중 중복 요청과 선택 변경을 막는다.
const [isMutating, setIsMutating] = useState(false);
const [currentPage, setCurrentPage] = useState(1);
// 조회구분별 전체 결과를 페이지 절단 전에 정렬하는 단일 상태다.
const [sort, setSort] = useState<UiTableSort>();
const [searchVersion, setSearchVersion] = useState(0);
/** 공통 레이아웃에서 아직 지역 기준정보를 받지 못한 경우 조회를 보완한다. */
@@ -368,12 +375,11 @@ const Page = () => {
setIsLoading(true);
setLoadError(null);
try {
const response = await fetchDredgingRecords({
const response = await fetchAllDredgingRecords({
fromDate: appliedFromDate,
toDate: appliedToDate,
ctpv: appliedCtpvName,
sgg: appliedSggName,
size: 2000,
});
if (!cancelled) setRecords(response.items ?? []);
} catch {
@@ -431,10 +437,19 @@ const Page = () => {
}));
}, [filteredRecords, viewType]);
// 준설 API의 모든 페이지를 수집한 뒤 표시명·집계값 기준으로 정렬한다.
const sortedTableRows = useMemo(
() => sortStandardTableRows(tableRows, sort),
[sort, tableRows],
);
const pageRows = useMemo(
() =>
tableRows.slice((currentPage - 1) * PAGE_SIZE, currentPage * PAGE_SIZE),
[tableRows, currentPage],
sortedTableRows.slice(
(currentPage - 1) * PAGE_SIZE,
currentPage * PAGE_SIZE,
),
[currentPage, sortedTableRows],
);
const selectedRecord =
@@ -461,13 +476,24 @@ const Page = () => {
headlist.map((head) => ({
id: head.id,
accessorFn: (row) => row[head.id],
header: head.title,
header: () => (
<StandardTableSortHeader
label={head.title}
sortKey={head.id}
sort={sort}
onSortChange={(nextSort) => {
setSort(nextSort);
setCurrentPage(1);
setSelectedRecordId(null);
}}
/>
),
cell: ({ getValue }) => formatTableValue(getValue()),
meta: {
align: head.align ?? "center",
},
})),
[headlist],
[headlist, sort],
);
const pagination: PaginationState = {
pageIndex: currentPage - 1,
@@ -486,7 +512,7 @@ const Page = () => {
pagination,
rowSelection: selectedTableRowId ? { [selectedTableRowId]: true } : {},
},
rowCount: tableRows.length,
rowCount: sortedTableRows.length,
manualPagination: true,
getCoreRowModel: getCoreRowModel(),
getRowId: (row) => row.recordIds.join("|"),
@@ -524,6 +550,7 @@ const Page = () => {
setSggName("");
setAppliedCtpvName("");
setAppliedSggName("");
setSort(undefined);
setCurrentPage(1);
setSelectedRecordId(null);
setSearchVersion((version) => version + 1);
@@ -534,7 +561,7 @@ const Page = () => {
const visibleHeads = headlist.filter((head) => head.id !== "recordIds");
const excelRows = [
visibleHeads.map((head) => head.title),
...tableRows.map((row) =>
...sortedTableRows.map((row) =>
visibleHeads.map((head) => row[head.id] ?? ""),
),
];
@@ -727,6 +754,7 @@ const Page = () => {
value={viewType}
onValueChange={(value) => {
setViewType(value as DredgingViewType);
setSort(undefined);
setCurrentPage(1);
setSelectedRecordId(null);
}}
+11
View File
@@ -15,6 +15,11 @@ import { create } from "zustand";
export const SEARCH_MODAL_DEFAULT_BASIC_TYPE = "tb_ms_swage_ppl";
export const SEARCH_MODAL_DEFAULT_WORK_TYPE = "['200', '201', '204']";
export type AppliedBasicQuery = {
basicType: string;
query: string;
};
// 날짜 조합 함수
const getDateString = (
year: string,
@@ -49,6 +54,8 @@ export interface SearchModalState {
detailTableData: any[];
tableCount: number;
detailTableCount: number;
// 기본정보 화면을 마지막으로 만든 조회조건. 조회 훅과 다운로드 훅이 같은 값을 공유한다.
appliedBasicQuery: AppliedBasicQuery | null;
// 페이지네이션
curPage: number;
detailCurPage: number;
@@ -93,6 +100,7 @@ export interface SearchModalState {
setDetailTableData: (data: any[]) => void;
setTableCount: (count: number) => void;
setDetailTableCount: (count: number) => void;
setAppliedBasicQuery: (query: AppliedBasicQuery | null) => void;
setCurPage: (page: number) => void;
setDetailCurPage: (page: number) => void;
setIsLoading: (loading: boolean) => void;
@@ -133,6 +141,7 @@ const useSearchModalStore = create<SearchModalState>((set) => ({
detailTableData: [],
tableCount: 0,
detailTableCount: 0,
appliedBasicQuery: null,
curPage: 1,
detailCurPage: 1,
isLoading: false,
@@ -166,6 +175,7 @@ const useSearchModalStore = create<SearchModalState>((set) => ({
setDetailTableData: (data) => set({ detailTableData: data }),
setTableCount: (count) => set({ tableCount: count }),
setDetailTableCount: (count) => set({ detailTableCount: count }),
setAppliedBasicQuery: (appliedBasicQuery) => set({ appliedBasicQuery }),
setCurPage: (page) => set({ curPage: page }),
setDetailCurPage: (page) => set({ detailCurPage: page }),
setIsLoading: (loading) => set({ isLoading: loading }),
@@ -207,6 +217,7 @@ const useSearchModalStore = create<SearchModalState>((set) => ({
detailTableData: [],
tableCount: 0,
detailTableCount: 0,
appliedBasicQuery: null,
curPage: 1,
detailCurPage: 1,
isResetPage: true,
@@ -32,3 +32,34 @@
- 입력 중인 필터를 바로 읽던 기존 흐름 대신 `조회` 시점에만 적용 조건을 갱신하므로, 미적용 초안 필터 때문에 상세 팝업 결과 수나 페이지 상태가 흔들리던 회귀를 방지한다.
- 상세 팝업은 페이지당 10건과 별도 테이블 스크롤 영역으로 조정되어 현재 팝업 높이에서 페이지네이션이 화면 하단에 노출되는 구조로 바뀌었다.
- 브라우저 실동작 최종 확인은 사용자 수동 검증 기준으로 진행한다.
## 8. 2026-08-26 대구광역시 서구 브라우저 검증
- 브라우저에서 `관로분석 > 대구광역시 > 서구 > 조회`를 직접 실행했다.
- 처리구역 선택지에 `달서천처리구역`, `북부처리구역`이 표시됐다.
- 결과표에 전체 656.701km, 달서천처리구역 413.045km, 북부처리구역 243.656km와 경과년도 구간별 값이 표시됐다.
- `재질별`은 처리구역 2행, `경과년도&재질`은 8행이 렌더링됐고 두 화면 모두 브라우저 오류 로그가 없었다.
- 검증 화면: `COMMON_DOCS_ROOT/작업내용/SFR-011-002-관로분석/검증/20260826-대구광역시-서구-조회결과.jpg`
- 변경 파일 TypeScript 구문검사 0건, Prettier와 `git diff --check` 통과.
- SN 전체 타입검사는 동시 작업 파일 `cty-fldng-prvtn-biz-excel.ts`의 선행 타입 오류 3건으로 차단됐으며, 이번 관로분석 변경 파일에서는 오류가 확인되지 않았다.
- 판정: 화면 표출 PASS.
## 9. 2026-08-26 관로현황 용도·관재질 검색조건 검증
- 용도 선택지: 전체, 미분류, 합류관, 차집관, 우수관, 오수관, 기타의 7개를 브라우저에서 확인했다.
- 관재질 선택지: 공통 코드 193개와 전체를 합한 194개를 확인했다.
- 전국 API 실데이터: 미분류 8,577건, 기타 5,921건.
- 대구광역시 서구 브라우저 조회: 미분류 51건 중 10건 표출, 관재질 `MOP012` 127건 중 10건 표출.
- 브라우저 오류 로그 없이 필터가 API 요청과 결과표에 반영됐다.
- 검증 화면: `COMMON_DOCS_ROOT/작업내용/SFR-011-002-관로분석/검증/20260826-관로현황-용도미분류-조회.jpg`
- SN 전체 TypeScript, Prettier, `git diff --check` 통과.
- 판정: PASS.
## 10. 2026-08-26 관로현황 최상위 지도창 포커스 검증
- 실제 Chrome에서 `하수관망도 → 관로분석 → 재질별 결과 → 관로현황`의 3단계 창 계층을 구성했다.
- 관로 `118529`, `118530` 행 클릭 시 최상위 지도창에서 해당 관로 이동·강조와 우측 상세정보 갱신을 확인했다.
- 상세창 직접 `focus()`만 사용하면 Chrome에서 상세창이 활성 상태로 남았고, 상세창 `blur()`만 추가하면 중간 관로분석창이 활성화되는 차이를 확인했다.
- 최종 구현은 상세창·중간창 `blur()`와 이름 기반 지도창 `focus()`, 지도창 메시지 처리 전후 self-focus를 함께 적용했다.
- 자동화 제어는 창 전환 순간 기존 상세창 제어가 종료되어 OS 전경창 최종 상태의 재확인이 제한됐다. 실제 사용자 클릭 기준 수동 포커스 확인은 남은 검증 항목이다.
- SN 전체 TypeScript와 `git diff --check` 통과. `map/page.tsx` 전체 Prettier는 기존 파일의 선행 포맷 차이로 통과하지 않았고 이번 추가 구문은 기존 스타일에 맞췄다.
@@ -145,7 +145,7 @@
- 분석기준시간 라벨·값이 현재 탭 행 오른쪽 끝에 표시되고 검색패널에서 제거된 것을 정적 확인했다.
- 행 클릭 성공 안내문 제거와 오류 메시지 조건부 렌더링을 확인했다.
- 보령 동대궁촌의 목록 최대 침수유량과 지도카드 값이 모두 `121.48m³/s`로 일치함을 실 API로 확인했다.
- 지도카드 안내는 GIS 매핑 관로의 `Capacity >= 0.8`을 지역 위험 기준으로 표시하고, 맨홀 침수유량은 지역 위험 판정에서 제외됨을 명시한다.
- 지도카드 안내는 GIS 매핑 관로의 `Capacity >= 0.8` 지역 위험 기준만 표시한다.
- 페이지별 데이터 길이·스크롤바에 따른 전체표 확대축소를 제거하고 `1320px` 고정폭·10개 열 비율·긴 행정명 말줄임 처리를 확인했다.
- 팝업 컴포넌트에 `window.resizeTo` 호출이 없고 창 크기는 `popupDefinitions`의 `1380×900`으로만 결정되는 것을 정적 확인했다. 검색·결과영역의 overflow 숨김과 결과영역 좌우 여백 보정은 유지한다.
@@ -192,3 +192,8 @@
- 과거 종료결과에서 실제 `2026-07-01 01:00`, `03:00`, `05:00` 등의 분석기준시간이 반환됐다.
- 과거 종료결과 전체의 상단 최신 분석기준시간은 `2026-07-06 08:00`으로 반환됐다.
- Java Service 테스트와 FE 대상 ESLint가 통과했다.
## 14. 2026-08-26 과거분석이력 최신순·중복 키 검증
- 정렬 파라미터가 없는 기본 요청도 백엔드 `RAIN_BGNG_DT DESC`에 따라 2026-08-26 07:00, 2026-08-25 13:00, 10:00, 06:00 순으로 반환된다.
- 2026-08-25 경기 가평 지역 2건은 기존 키가 1개로 충돌했지만 강우시작시간·분석기준시간을 포함한 신규 키는 2개 모두 고유하다.
@@ -91,3 +91,41 @@
- 노후관로 요청에는 `includeFlowDirectionBasis`가 없고, 정보검색 기본정보의 하수관로 요청에만 `true`가 전달되는 것을 정적으로 확인했다.
- 따라서 노후관로 정렬·페이지 이동마다 유하방향 근거 CTE 배치 조회가 추가 실행되지 않는다.
- 실제 응답시간 비교는 변경된 백엔드 재기동 후 사용자 통합 환경에서 확인한다.
## 11. 2026-08-26 유지관리 대메뉴 검증
- `@common/business_components` tsup ESM/DTS 빌드: 성공.
- SN TypeScript `tsc --noEmit`: 성공.
- sewerage-service `compileJava`: 성공.
- 사용자 실행 SN 개발 서버를 보호하기 위해 Next build는 수행하지 않았다.
- 조사 목록의 사업명 서버 정렬은 11건을 10건·1건의 2페이지로 조회했고 asc/desc 변경 시 첫 페이지 복귀를 확인했다.
- 조사 상세현황은 합계 행을 고정한 채 처리분구명 `덕풍↔초이` 순서 전환을 확인했다.
- 관로조사 상세 팝업은 9개 고정 열 정렬 버튼과 보고서번호 asc/desc, 소계·합계 고정을 확인했다.
- 준설 지자체별 13개·관경별 9개 정렬 헤더와 관재질 표시명 asc/desc를 확인했다.
- 처리분구 목록은 4개 정렬 헤더와 처리분구명 asc/desc/default 순환을 확인했다.
- 정렬 아이콘은 미선택 흰색, 선택 노란색이며 한 단계 확대된 상태를 실제 화면에서 확인했다.
- 팝업 콘솔 신규 오류는 0건이다. 메인 화면의 Radix `aria-controls` hydration mismatch는 기존 공통 경고로 분리한다.
- FO·WR 화면은 수정·브라우저 적용하지 않았다.
## 12. 2026-08-26 운영정보·I/I 분석 검증
- SN TypeScript `tsc --noEmit`: 성공.
- 도시침수예방사업 23개 직접 필드 정렬 헤더와 시군구 asc/desc를 확인했다.
- 노후 하수관로 정비사업 22개 정렬 헤더와 시군구 `가평군...` asc, `화순군...` desc를 확인했다.
- 침입수 분석결과는 2025-01~2026-08 범위 48건·5페이지, 7개 헤더와 분석정보 asc/desc를 확인했다.
- RDII 분석결과는 같은 기간 20건·2페이지, 9개 헤더와 기여면적 sort 요청을 확인했다.
- I/I 시도·시군구·분석자는 정렬 버튼이 노출되지 않는 것을 확인했다.
- MapPopup 정렬 아이콘이 유지관리와 같은 미선택 흰색·선택 노란색 스타일을 사용하는 것을 확인했다.
- 운영정보 등록·수정과 I/I 분석 입력 편집표는 정렬 대상에서 제외했다.
- FO·WR는 수정·검증 범위에서 제외했다.
## 13. 2026-08-26 운영정보 Excel 생성 수정 검증
- 사용자 첨부 도시침수·노후관로 생성본과 확정 원본 양식을 값·시트·렌더링 이미지로 비교했다.
- SN TypeScript `tsc --noEmit --ignoreDeprecations 5.0`: 성공.
- 브라우저에서 도시침수 19건과 노후관로 정밀조사 128건을 실제 재다운로드했고 콘솔 오류는 0건이었다.
- 도시침수 첫 데이터 순번이 `1, 2, 3`이고 제목·요약·5단 헤더·본문 테두리·고정 행 높이가 렌더링되는 것을 확인했다.
- 노후관로 두 시트의 제목·요약·다단 병합 헤더·구분색과 22pt 본문 높이를 확인했다.
- 계획연장 합계는 `30,381.602`로 반올림됐고 긴 텍스트의 `_x000d_` 표기는 실제 줄바꿈으로 변환됐다.
- 두 최종 파일의 수식 오류 토큰 검색 결과는 0건이었다.
- 사용자 실행 개발 서버를 보호하기 위해 Next 전체 빌드는 수행하지 않았다.
@@ -46,3 +46,24 @@
- 엑셀 다운로드는 화면에 마지막으로 적용된 필터·정렬과 같은 조건으로 전체 상세 데이터를 다시 조회한다.
- `관로현황`은 페이지당 10건으로 조정하고, 팝업 본문은 필터 영역과 테이블 영역을 분리해 페이지네이션이 현재 창 높이 안에서 바로 보이도록 정리했다.
- 이번 수정으로 `관로현황` 팝업의 기존 페이징 기능을 유지하면서, 사용자가 필터 입력만 바꿔 둔 상태에서 페이지 이동이나 정렬을 눌러도 미적용 초안 값이 섞이지 않고 페이지네이션도 다시 노출되도록 정리했다.
## 8. 2026-08-26 대구광역시 서구 처리구역 선택 보완
- 처리구역 WFS 원천의 시군구코드가 기준코드와 다를 수 있으므로, 시군구 선택 시 시도 범위의 처리구역을 조회한 뒤 `기준 시군구코드 또는 시군구명`이 일치하는 행만 남긴다.
- 선택된 정상 시군구코드를 처리구역 옵션에도 유지해 이후 화면/API 조건이 잘못된 원천코드로 되돌아가지 않게 했다.
- 대구광역시 서구 선택 시 `달서천처리구역`, `북부처리구역` 옵션이 노출되는 것을 확인했다.
## 9. 2026-08-26 관로현황 용도·관재질 검색조건 전체 코드 보완
- 용도 선택지는 화면에 하드코딩된 합류관·차집관·우수관·오수관 4개 대신 공통 `MS_SWAGE_PPL_USE_CODES` 전체를 사용한다.
- 이에 따라 `미분류(SBA000)`와 `기타(SBA999)`가 검색조건에 추가됐다.
- 관재질 선택지는 현재 페이지의 최대 10개 결과에서 추출하던 방식을 제거하고 공통 `MS_SWAGE_PPL_CODES` 전체를 사용한다.
- 상위 집계에서 전달된 관재질 코드가 공통 목록에 없는 경우에는 기존 fallback 옵션 추가를 유지한다.
## 10. 2026-08-26 관로현황 선택 시 최상위 지도창 포커스 보완
- 기존 상세창은 `window.opener?.opener`인 최상위 지도창에 관로 선택 메시지만 전달했다.
- 관로행 클릭 시 최상위 지도창에 고정 window name을 부여하고 같은 이름의 기존 창을 다시 선택해 `focus()`를 요청한다.
- 상세창과 중간 관로분석창을 `blur()`한 뒤 지도창을 활성화해 중간 팝업에 포커스가 머무는 경우를 방지한다.
- 최상위 지도창도 메시지 처리 시작과 지도 이동·상세 패널 렌더링 완료 시점에 각각 `window.focus()`를 요청한다.
- 기존 관로 지도 이동, 강조와 우측 상세정보 표출은 그대로 유지한다.
@@ -216,7 +216,7 @@
- 분석기준시간은 DB 결과와 독립된 현재 정시이므로 진행중 데이터가 없어도 사라지지 않는다. 입력창으로 오해하지 않도록 테두리·배경 없이 글씨만 표시한다.
- `분석기준시간` 라벨과 시각은 현재 탭 행의 오른쪽 끝으로 이동하고 검색패널에는 중점관리지역 선택과 우측 조회 버튼만 유지한다.
- 행 선택 성공 후 왼쪽 아래 안내문은 제거하고 오류 메시지만 조건부 표시한다.
- 지도 요약카드에는 GIS 매핑 관로 중 Capacity 0.8 이상이 1개라도 있으면 위험이며, 맨홀 침수유량은 지역 위험 판정에서 제외된다는 설명을 표시한다.
- 지도 요약카드에는 GIS 매핑 관로 중 Capacity 0.8 이상이 1개라도 있으면 위험이라는 기준만 간결하게 표시한다.
- 결과표 `No.`를 `No`로 바꾸고 표 전체를 `1320px` 고정폭으로 지정한 뒤 `table-fixed`·`colgroup` 비율을 적용해 1·2페이지의 절대 열 너비를 동일하게 고정했다.
- 공통 그리드 교체 후 검색영역과 결과영역의 가로·세로 overflow를 숨기고 결과영역 좌우 여백을 보정해 팝업 내부 스크롤바가 나타나지 않게 했다.
@@ -245,3 +245,9 @@
- 중점관리지역 아래 capacity 설명 자체에는 구분선을 두지 않고 문장 안의 `위험`은 빨간색, `정상`은 파란색 텍스트로 강조한다. 설명과 수치표의 경계인 표 상단선은 유지한다.
- 기존 위험상태 배지와 카드 `290px` 폭을 유지한다.
- 팝업은 `pagehide`·`beforeunload`에서 `WATER_RISK_CLOSED` 메시지를 부모로 전달한다. 부모는 메시지 수신 즉시 중점관리지역·관로·맨홀 레이어, capacity/Flooding 범례, 정보카드와 선택상태를 모두 초기화하며 300ms close watcher는 예비 감시로 유지한다.
## 17. 2026-08-26 과거분석이력 최신순 기본 정렬·행 키 보완
- 팝업 최초 조회와 현재·과거 탭 전환 시 정렬 파라미터를 비워 백엔드 기본 강우시작시간 최신순을 적용한다.
- 사용자가 다른 정렬 열을 선택하면 기존 서버 정렬을 유지하고, 탭을 전환하면 다시 강우시작시간 최신순으로 돌아간다.
- 동일 날짜·지역에 여러 강우이벤트가 존재해도 React 행 키가 겹치지 않도록 분석일·강우시작시간·분석기준시간·행정코드·지역번호를 조합한다.
@@ -129,3 +129,33 @@
- 정보검색 기본정보의 하수관로 목록만 `includeFlowDirectionBasis=true`를 전달해 현재 페이지의 유하방향 근거를 배치 보강한다.
- 노후관로 목록은 해당 파라미터를 전달하지 않아 페이징·CQL 필터·WFS 서버 정렬과 기존 시설 정보 병합만 수행한다.
- 정렬 정확도를 위해 전체 필터 결과의 서버 정렬은 유지하며, 현재 페이지 배열만 정렬하는 임시 우회는 사용하지 않는다.
## 16. 2026-08-26 유지관리 대메뉴 적용
- 사용자 최신 지시에 따라 FO·WR는 적용하지 않고 SN `유지관리` 대메뉴만 구현했다.
- 공통 `StandardTableSortHeader`, `sortStandardTableRows`를 추가해 StandardDataTable 계열이 기존 UiTable과 같은 3상태 정렬을 선택 사용하도록 했다.
- 미선택 아이콘은 흰색, 선택 아이콘은 노란색이며 헤더 높이를 유지한 채 아이콘을 한 단계 확대했다.
- 조사 목록은 `page/size=10` 서버 페이징과 기존 `ExmnBusinessRepositoryImpl` 정렬을 연결했다.
- 조사 상세현황과 관로·맨홀 상세 팝업은 전체 결과 로컬 정렬을 적용하고 합계·소계·가변 결함·병합 본단위표는 제외했다.
- 준설 지자체별·관경별·경과년도별은 전체 페이지 수집 결과를 정렬한 뒤 10건 페이지를 자르고 Excel에도 정렬 순서를 유지한다.
- 처리분구관리는 전체 활성 목록을 정렬한 뒤 페이지를 자르며 추가·수정 시작 시 기본순서로 복귀한다.
- 상세 화면·정렬 컬럼·제외 사유는 `COMMON_DOCS_ROOT/작업내용/공통-UiTable-컬럼정렬/003-ai-공통-UiTable-컬럼정렬-20260826-유지관리대메뉴-구현기록.md`에 통합했다.
## 17. 2026-08-26 운영정보·I/I 분석 적용
- 도시침수예방사업 직접 필드 23개와 노후 하수관로 정비사업 22개를 기존 서버 Pageable 정렬에 연결했다.
- 두 운영정보 Excel 전체 페이지 수집은 현재 화면 sort를 유지한다.
- 결산연도·단계별 자식 상세와 등록·수정 입력표는 제외했다.
- 침입수 결과 7개, RDII 결과 9개 원천 필드를 서버 정렬에 연결했다.
- I/I 코드명·외부 사용자명 컬럼은 잘못된 코드·UUID 정렬 대신 비활성화했다.
- `MapPopupDataTable`의 정렬 헤더도 공통 Standard 스타일로 통일했다.
- 상세 결과는 `COMMON_DOCS_ROOT/작업내용/공통-UiTable-컬럼정렬/004-ai-공통-UiTable-컬럼정렬-20260826-운영정보-I-I분석-구현기록.md`를 따른다.
## 18. 2026-08-26 운영정보 Excel 생성 보완
- 사용자 첨부 생성본 2개를 `COMMON_DOCS_ROOT/작업지시`의 확정 원본 양식 2개와 직접 비교했다.
- 도시침수 Excel은 제목·요약·본문 서식을 복원하고 순번을 출력 순서 `1..N`으로 변경했으며 결산 총계를 연도별 합으로 채운다.
- 노후관로 Excel은 두 시트의 제목·조회 건수·계획연장 합계, B6:V7 병합 헤더·구분색과 고정 본문 높이를 적용했다.
- 두 생성기 모두 API 문자열의 `_x000d_`를 실제 줄바꿈으로 정리한다.
- 노후관로 다운로드는 비동기 스타일 생성 완료를 `await`한 뒤 로딩 상태를 해제한다.
- 시트명·업로드 열 위치, 조회조건·정렬순서, 백엔드·DB 계약은 변경하지 않았다.
@@ -11,6 +11,7 @@ import { PromptProvider, StoreProvider } from "@/shared/providers";
import { AlertDialog } from "@/shared/ui/alert-dialog";
import { PromptDialog } from "@/shared/ui/prompt-dialog";
import { ConfirmDialog } from "@/shared/ui/confirm-dialog";
import { BusinessUserMessageBridge } from "@/shared/ui/business-user-message-bridge";
// 개발 도구는 실제 로컬 개발 환경에서만 별도 청크로 불러와 최초 페이지 컴파일 범위를 줄인다.
const LazyReactQueryDevtools = dynamic(
@@ -26,6 +27,7 @@ const AppProvider = ({ children }: Readonly<{ children: React.ReactNode }>) => {
return (
<QueryClientProvider client={queryClient}>
<StoreProvider>
<BusinessUserMessageBridge />
<PromptProvider>
<ConfirmProvider>
<AuthWrapper>
@@ -46,6 +46,7 @@ import type {
import LoadingOverlay from "@/components/ui/LoadingBar";
import PopupSearchPanel from "@/features/windowPopup/ui/PopupSearchPanel";
import { fetchAllPages } from "@/shared/lib/fetch-all-pages";
type SelectOption = { text: string; val: string };
type CfmtnBgtTableRow = CfmtnBgtRow;
@@ -159,6 +160,8 @@ export default function CfmtnBgtPopupContent({
});
const [search, setSearch] = useState(initial);
// 화면 목록을 마지막으로 조회한 조건이며 페이지 이동·Excel이 동일하게 사용한다.
const [appliedSearch, setAppliedSearch] = useState(initial);
const [codes, setCodes] = useState<CfmtnBgtCode[]>([]);
const [page, setPage] = useState(0);
const [data, setData] = useState(empty);
@@ -414,6 +417,7 @@ export default function CfmtnBgtPopupContent({
: empty,
);
setPage(targetPage);
setAppliedSearch({ ...targetSearch });
setNotice("");
} catch (error) {
console.error(
@@ -449,26 +453,21 @@ export default function CfmtnBgtPopupContent({
};
const download = async () => {
if (JSON.stringify(search) !== JSON.stringify(appliedSearch)) {
setNotice("조회조건이 변경되었습니다. 조회 후 Excel을 다운로드해주세요.");
return;
}
setLoading(true);
try {
let exportPage = 0;
let totalPages = 1;
const exportRows: CfmtnBgtRow[] = [];
while (exportPage < totalPages) {
const result = await getCfmtnBgtPage(params(exportPage, EXPORT_SIZE));
const safeResult =
result &&
typeof result === "object" &&
"content" in result &&
Array.isArray((result as CfmtnBgtPage).content) &&
"totalPages" in result
? (result as CfmtnBgtPage)
: empty;
exportRows.push(...safeResult.content);
totalPages = safeResult.totalPages;
exportPage += 1;
}
downloadCfmtnBgtExcel(exportRows, search.year);
const result = await fetchAllPages<CfmtnBgtPage, CfmtnBgtRow>({
batchSize: EXPORT_SIZE,
fetchPage: (targetPage, size) =>
getCfmtnBgtPage(params(targetPage, size, appliedSearch)),
getItems: (page) => (Array.isArray(page?.content) ? page.content : []),
getTotal: (page) => Number(page?.totalElements ?? 0),
});
downloadCfmtnBgtExcel(result.items, appliedSearch.year);
setNotice("");
} catch (error) {
console.error(
@@ -522,7 +521,7 @@ export default function CfmtnBgtPopupContent({
const next =
typeof updater === "function" ? updater(pagination) : updater;
if (next.pageIndex === page) return;
void load(next.pageIndex);
void load(next.pageIndex, appliedSearch);
},
});
@@ -11,6 +11,7 @@ import {
postRawJson,
putData,
} from "@/components/lib/api";
import { fetchAllPages } from "@/shared/lib/fetch-all-pages";
const API_PATH = "v1/ctyFldngPrvtnBiz";
@@ -27,21 +28,21 @@ export const getCtyFldngPrvtnBusinesses = (params: URLSearchParams) =>
/** 모든 페이지를 순회하여 Excel 대상 전체를 모은다. */
export const getAllCtyFldngPrvtnBusinesses = async (
search: URLSearchParams,
sort = "ctyFldngPrvtnBizSn,desc",
) => {
const rows: CtyFldngPrvtnBiz[] = [];
let page = 0;
let totalPages = 1;
while (page < totalPages) {
const params = new URLSearchParams(search);
params.set("page", String(page));
params.set("size", "500");
params.set("sort", "ctyFldngPrvtnBizSn,asc");
const result = await getCtyFldngPrvtnBusinesses(params);
rows.push(...result.content);
totalPages = result.totalPages;
page += 1;
}
return rows;
const result = await fetchAllPages<CtyFldngPrvtnBizPage, CtyFldngPrvtnBiz>({
batchSize: 500,
fetchPage: (page, size) => {
const params = new URLSearchParams(search);
params.set("page", String(page));
params.set("size", String(size));
params.set("sort", sort);
return getCtyFldngPrvtnBusinesses(params);
},
getItems: (page) => page.content ?? [],
getTotal: (page) => Number(page.totalElements ?? 0),
});
return result.items;
};
/** 수정 화면의 사업 한 건을 조회한다. */
@@ -41,6 +41,17 @@ const textValue = (value: unknown): string | null => {
return !valueText || valueText === "-" ? null : valueText;
};
/** API 문자열에 남은 Excel 개행 이스케이프를 실제 줄바꿈으로 정리한다. */
const excelTextValue = (value: unknown): string | number => {
if (value === null || value === undefined) return "";
if (typeof value === "number") return value;
if (typeof value !== "string") return String(value);
return value
.replace(/_x000d_/gi, "")
.replace(/\r\n?/g, "\n")
.trim();
};
/** 콤마와 단위를 제거한 유한 숫자를 반환한다. */
const numberValue = (
value: unknown,
@@ -333,7 +344,7 @@ const writeDetail = (
detail.exlnPplImprvmLen,
detail.rnwtrPumpstSumCn,
].forEach((v) => {
row[start + o++] = v ?? "";
row[start + o++] = excelTextValue(v);
});
for (let i = 0; i < PUMP_COUNTS[code]; i += 1)
row[start + o++] =
@@ -346,7 +357,7 @@ const writeDetail = (
row[start + o++] = detail.impdFclt?.find((v) => v.no === i + 1)?.cpct ?? "";
[detail.tnlDmtr, detail.tnlLen, detail.tnlRscpt, detail.tnlExcvDpth].forEach(
(v) => {
row[start + o++] = v ?? "";
row[start + o++] = excelTextValue(v);
},
);
if (code !== "EM") {
@@ -363,10 +374,10 @@ const writeDetail = (
detail.frstAprvCn,
detail.frstChgAprvCn,
].forEach((v) => {
row[start + o++] = v ?? "";
row[start + o++] = excelTextValue(v);
});
if (code === "MP") row[start + o++] = detail.scndChgAprvCn ?? "";
if (code === "CW") row[start + o++] = detail.rmrkSrcCn ?? "";
if (code === "MP") row[start + o++] = excelTextValue(detail.scndChgAprvCn);
if (code === "CW") row[start + o++] = excelTextValue(detail.rmrkSrcCn);
}
};
@@ -557,6 +568,7 @@ const saveStyledWorkbook = async (
filename: string,
starts: Record<(typeof DETAIL_CODES)[number], number>,
last: number,
rowCount: number,
) => {
const data = XLSX.write(workbook, { type: "array", bookType: "xlsx" });
const styledWorkbook = await XlsxPopulate.fromDataAsync(data);
@@ -571,6 +583,23 @@ const saveStyledWorkbook = async (
wrapText: true,
border: true,
};
styledSheet.range("B2:Q3").style({
bold: true,
fontFamily: "맑은 고딕",
fontSize: 16,
horizontalAlignment: "center",
verticalAlignment: "center",
border: true,
});
styledSheet.range("B5:N5").style({
bold: true,
fontFamily: "맑은 고딕",
fontSize: 10,
horizontalAlignment: "center",
verticalAlignment: "center",
fill: "D9EAF7",
border: true,
});
styledSheet.range(`B8:${lastColumn}12`).style(commonHeaderStyle);
styledSheet
.range(`B8:${XLSX.utils.encode_col(starts.EM - 1)}12`)
@@ -591,6 +620,32 @@ const saveStyledWorkbook = async (
[8, 9, 10, 11, 12].forEach((row) =>
styledSheet.row(row).height(row === 8 ? 30 : 24),
);
styledSheet.row(2).height(26);
styledSheet.row(3).height(26);
styledSheet.row(5).height(22);
if (rowCount > 0) {
const dataStartRow = DATA_START_ROW + 1;
const dataEndRow = DATA_START_ROW + rowCount;
styledSheet.range(`B${dataStartRow}:${lastColumn}${dataEndRow}`).style({
fontFamily: "맑은 고딕",
fontSize: 10,
horizontalAlignment: "center",
verticalAlignment: "center",
wrapText: false,
border: true,
});
styledSheet
.range(`R${dataStartRow}:${lastColumn}${dataEndRow}`)
.style("numberFormat", "#,##0.###");
styledSheet
.range(`M${dataStartRow}:Q${dataEndRow}`)
.style("horizontalAlignment", "left");
styledSheet
.range(`${lastColumn}${dataStartRow}:${lastColumn}${dataEndRow}`)
.style({ horizontalAlignment: "left", wrapText: true });
for (let row = dataStartRow; row <= dataEndRow; row += 1)
styledSheet.row(row).height(22);
}
const blob = await styledWorkbook.outputAsync();
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
@@ -625,7 +680,30 @@ export const downloadCtyFldngPrvtnExcel = async (
Array(last + 1).fill(""),
);
const merges: XLSX.Range[] = [];
aoa[0]![1] = "하수도정비중점관리지역 도시침수대응사업 현황";
aoa[1]![1] = "하수도정비중점관리지역 도시침수 대응사업 추진 현황";
addMerge(merges, 1, 1, 2, 16);
if (templateOnly) {
putHeader(aoa, 4, 1, "업로드 양식 · 13행부터 사업자료를 입력해 주세요.");
addMerge(merges, 4, 1, 4, 13);
} else {
const totalBudget = businesses.reduce(
(sum, business) => sum + (business.tpco ?? 0),
0,
);
putHeader(aoa, 4, 1, "조회 건수");
addMerge(merges, 4, 1, 4, 3);
putHeader(aoa, 4, 4, `${businesses.length.toLocaleString("ko-KR")}건`);
addMerge(merges, 4, 4, 4, 6);
putHeader(aoa, 4, 7, "총사업비 합계(백만원)");
addMerge(merges, 4, 7, 4, 9);
putHeader(
aoa,
4,
10,
totalBudget ? totalBudget.toLocaleString("ko-KR") : "-",
);
addMerge(merges, 4, 10, 4, 13);
}
const verticalBasicHeaders: Array<[number, string]> = [
[1, "순번"],
[2, "단계"],
@@ -680,7 +758,7 @@ export const downloadCtyFldngPrvtnExcel = async (
businesses.forEach((b, index) => {
const row = Array(last + 1).fill("");
[
b.seqNo,
index + 1,
b.stp,
b.ctpvNm,
b.sggNm,
@@ -700,11 +778,22 @@ export const downloadCtyFldngPrvtnExcel = async (
b.gvexp,
b.lcexp,
].forEach((v, i) => {
row[i + 1] = v ?? "";
row[i + 1] = excelTextValue(v);
});
years.forEach((year, i) => {
row[21 + i] =
b.settlements?.find((s) => s.soaYr === year)?.soaAmt ?? "";
const settlementAmounts = years.map(
(year) => b.settlements?.find((s) => s.soaYr === year)?.soaAmt,
);
const hasSettlement = settlementAmounts.some(
(amount) => amount !== null && amount !== undefined,
);
row[20] = hasSettlement
? settlementAmounts.reduce<number>(
(sum, amount) => sum + (amount ?? 0),
0,
)
: "";
settlementAmounts.forEach((amount, i) => {
row[21 + i] = amount ?? "";
});
DETAIL_CODES.forEach((code) =>
writeDetail(
@@ -714,14 +803,24 @@ export const downloadCtyFldngPrvtnExcel = async (
code,
),
);
row[last - 2] = b.picNm ?? "";
row[last - 1] = b.picCnpl ?? "";
row[last] = b.excptnMttrCn ?? "";
row[last - 2] = excelTextValue(b.picNm);
row[last - 1] = excelTextValue(b.picCnpl);
row[last] = excelTextValue(b.excptnMttrCn);
aoa.push(row);
});
const sheet = XLSX.utils.aoa_to_sheet(aoa);
sheet["!merges"] = merges;
sheet["!cols"] = Array.from({ length: last + 1 }, () => ({ wch: 14 }));
const columnWidths = Array.from({ length: last + 1 }, () => ({ wch: 11 }));
const widthOverrides: Array<[number, number]> = [
[0, 3], [1, 7], [2, 9], [3, 12], [4, 12], [5, 12], [6, 11],
[7, 12], [8, 10], [9, 9], [10, 13], [11, 13], [12, 32], [13, 30],
[14, 16], [15, 20], [16, 15], [17, 14], [18, 14], [19, 14],
[last - 2, 12], [last - 1, 16], [last, 42],
];
widthOverrides.forEach(([column, width]) => {
columnWidths[column] = { wch: width };
});
sheet["!cols"] = columnWidths;
const workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(workbook, sheet, SHEET_NAME);
await saveStyledWorkbook(
@@ -729,5 +828,6 @@ export const downloadCtyFldngPrvtnExcel = async (
templateOnly ? "도시침수예방사업_업로드양식.xlsx" : DOWNLOAD_FILENAME,
starts,
last,
templateOnly ? 0 : businesses.length,
);
};
@@ -5,7 +5,9 @@ import { useRouter, useSearchParams } from "next/navigation";
import {
StandardDataTable,
StandardTableSortHeader,
getStandardRowNumber,
type UiTableSort,
} from "@common/business_components";
import {
Button,
@@ -206,10 +208,24 @@ const createTableColumn = (
id: string,
title: string,
widthPercent = 8,
sortKey?: string,
sort?: UiTableSort,
onSortChange?: (sort: UiTableSort | undefined) => void,
): ColumnDef<TableRow> => ({
id,
accessorFn: (row) => row[id],
header: () => <span className="whitespace-pre-line">{title}</span>,
header: () =>
sortKey && onSortChange ? (
<StandardTableSortHeader
label={title}
title={<span className="whitespace-pre-line">{title}</span>}
sortKey={sortKey}
sort={sort}
onSortChange={onSortChange}
/>
) : (
<span className="whitespace-pre-line">{title}</span>
),
cell: ({ getValue }) => {
const value = getValue();
const displayValue =
@@ -292,6 +308,7 @@ export function CtyFldngPrvtnBizListPage() {
const [sggCd, setSggCd] = useState(params.get("sggCd") ?? "0");
const [appliedCtpvCd, setAppliedCtpvCd] = useState(ctpvCd);
const [appliedSggCd, setAppliedSggCd] = useState(sggCd);
const [sort, setSort] = useState<UiTableSort>();
const availableSgg = useMemo(
() =>
sgg.filter(
@@ -308,10 +325,29 @@ export function CtyFldngPrvtnBizListPage() {
),
[],
);
/** 정렬 변경 시 서버 첫 페이지로 돌아가고 동일 검색조건을 유지한다. */
const handleSortChange = useCallback(
(nextSort?: UiTableSort) => {
setSort(nextSort);
setPage(1);
const q = listQuery(1, applied, appliedCtpvCd, appliedSggCd);
router.replace(`/oper/ctyFldngPrvtnBiz${q ? `?${q}` : ""}`, {
scroll: false,
});
},
[applied, appliedCtpvCd, appliedSggCd, router],
);
const columns = useMemo<ColumnDef<TableRow>[]>(
() => [
...BASIC_HEADLIST.map((head) =>
createTableColumn(head.id, head.title, head.widthPercent),
createTableColumn(
head.id,
head.title,
head.widthPercent,
head.id === "rowNumber" ? "ctyFldngPrvtnBizSn" : head.id,
sort,
handleSortChange,
),
),
{
id: "settlements",
@@ -332,10 +368,17 @@ export function CtyFldngPrvtnBizListPage() {
),
})),
...TAIL_HEADLIST.map((head) =>
createTableColumn(head.id, head.title, head.widthPercent),
createTableColumn(
head.id,
head.title,
head.widthPercent,
head.id,
sort,
handleSortChange,
),
),
],
[years],
[handleSortChange, sort, years],
);
const pagination: PaginationState = {
pageIndex: page - 1,
@@ -368,11 +411,17 @@ export function CtyFldngPrvtnBizListPage() {
}, [ctpv.length, fetchCtpv, fetchSgg, sgg.length]);
/** 확정된 조건으로 서버 목록을 갱신한다. */
const load = useCallback(
async (target = page, condition = applied) => {
async (
target = page,
condition = applied,
nextSort: UiTableSort | undefined = sort,
) => {
const query = new URLSearchParams({
page: String(target - 1),
size: String(PAGE_SIZE),
sort: "ctyFldngPrvtnBizSn,desc",
sort: nextSort
? `${nextSort.key},${nextSort.direction}`
: "ctyFldngPrvtnBizSn,desc",
});
Object.entries(condition).forEach(([k, v]) => {
if (v.trim()) query.set(k, v.trim());
@@ -404,7 +453,7 @@ export function CtyFldngPrvtnBizListPage() {
setLoading(false);
}
},
[applied, page],
[applied, page, sort],
);
useEffect(() => {
void load();
@@ -453,7 +502,10 @@ export function CtyFldngPrvtnBizListPage() {
if (v.trim()) query.set(k, v.trim());
});
await downloadCtyFldngPrvtnExcel(
await getAllCtyFldngPrvtnBusinesses(query),
await getAllCtyFldngPrvtnBusinesses(
query,
sort ? `${sort.key},${sort.direction}` : undefined,
),
);
} catch (e) {
setNotice(e instanceof Error ? e.message : "엑셀을 내려받지 못했습니다.");
@@ -478,6 +530,7 @@ export function CtyFldngPrvtnBizListPage() {
setSggCd("0");
setAppliedCtpvCd("0");
setAppliedSggCd("0");
setSort(undefined);
setNotice("");
setPage(1);
router.replace("/oper/ctyFldngPrvtnBiz", { scroll: false });
@@ -372,17 +372,19 @@ export const getPplSummaries = async (
exmnBizId: string,
params: { trtdstId?: string; page: number; size: number; sort?: string },
): Promise<ExmnPage<PplSummaryRow>> => {
const query = new URLSearchParams({
page: String(params.page),
size: String(params.size),
});
if (params.trtdstId) query.set("trtdstId", params.trtdstId);
if (params.sort) query.set("sort", params.sort);
return toPage<PplSummaryRow>(
await getData(
`${EXMN_BUSINESS_API_PATH}/${encodeURIComponent(exmnBizId)}/summaries/ppl?${query}`,
),
);
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번호·등급으로 조회한다. */
@@ -408,17 +410,3 @@ export const getPplFacilities = async (
if (params.sort) query.set("sort", params.sort);
return toPage<PplFacility>(await getData(`v1/exmnFacilities?${query}`));
};
/** 현재 PPL 조회조건을 서버 엑셀로 내려받는다. */
export const downloadPplExport = async (
exmnBizId: string,
trtdstId?: string,
): Promise<Blob> => {
const query = new URLSearchParams();
if (trtdstId) query.set("trtdstId", trtdstId);
const queryString = query.toString();
const suffix = queryString ? `?${queryString}` : "";
return getBlobData(
`${EXMN_BUSINESS_API_PATH}/${encodeURIComponent(exmnBizId)}/exports/ppl${suffix}`,
);
};
@@ -1,5 +1,7 @@
"use client";
import { Table } from "@common/components";
import {
type ExmnDetailSeverity,
type ExmnPplUnitDetailRow,
@@ -60,196 +62,195 @@ export function ExmnPplUnitDetailTable({
const tableWidth = COLUMN_WIDTHS.reduce((sum, width) => sum + width, 0);
return (
<div className="min-w-0 overflow-x-auto overflow-y-visible bg-background">
<table
className="table-ui default border-separate border-spacing-0 text-center text-foreground"
style={{ width: "100%", minWidth: `${tableWidth}px` }}
>
<colgroup>
{COLUMN_WIDTHS.map((width, index) => (
<col key={`unit-column-${index}`} style={{ width: `${width}px` }} />
))}
</colgroup>
<thead className="sticky top-0 z-30 text-center font-medium leading-[1.2]">
<tr>
<th
rowSpan={2}
className="border-b border-r border-border px-2 py-[1.2rem] text-center"
>
관로번호
</th>
<th
rowSpan={2}
className="border-b border-r border-border px-2 py-[1.2rem] text-center"
>
단위
<br />
구간
</th>
<th
rowSpan={2}
className="border-b border-r border-border px-2 py-[1.2rem] text-center"
>
단위
<br />
길이
<br />
(m)
</th>
<th
rowSpan={2}
className="border-b border-r border-border px-2 py-[1.2rem] text-center"
>
결함
<br />
위치
<br />
(m)
</th>
<th
colSpan={6}
className="border-b border-r border-border px-2 py-[1.2rem] text-center"
>
결함
</th>
<th
colSpan={2}
className="border-b border-r border-border px-2 py-[1.2rem] text-center"
>
등급점수(점)
</th>
<th
rowSpan={2}
className="border-b border-r border-border px-2 py-[1.2rem] text-center"
>
조사중단사유
</th>
<th
rowSpan={2}
className="border-b border-r border-border px-2 py-[1.2rem] text-center"
>
비고
</th>
</tr>
<tr>
<th className="border-b border-r border-border px-2 py-[1.2rem] text-center">
종류
</th>
<th className="border-b border-r border-border px-2 py-[1.2rem] text-center">
코드
</th>
<th className="border-b border-r border-border px-2 py-[1.2rem] text-center">
등급
</th>
<th className="border-b border-r border-border px-2 py-[1.2rem] text-center">
발생
<br />
위치
</th>
<th className="border-b border-r border-border px-2 py-[1.2rem] text-center">
침입수
<br />
가점
</th>
<th className="border-b border-r border-border px-2 py-[1.2rem] text-center">
연속
<br />
결함
</th>
<th className="border-b border-r border-border px-2 py-[1.2rem] text-center">
구조적
</th>
<th className="border-b border-r border-border px-2 py-[1.2rem] text-center">
운영적
</th>
</tr>
</thead>
<tbody>
{rows.length > 0 ? (
rows.map((row, index) => {
const pipeSpan = pipeSpans.get(row.pipeNo);
const unitKey = `${row.pipeNo}::${row.unitSeq}`;
const unitSpan = unitSpans.get(unitKey);
const showPipeCell = pipeSpan?.firstIndex === index;
const showUnitCells = unitSpan?.firstIndex === index;
<Table
variant="default"
className="border-separate border-spacing-0 text-center text-foreground"
style={{ width: "100%", minWidth: `${tableWidth}px` }}
>
<colgroup>
{COLUMN_WIDTHS.map((width, index) => (
<col key={`unit-column-${index}`} style={{ width: `${width}px` }} />
))}
</colgroup>
<thead className="sticky top-0 z-30 text-center font-medium leading-[1.2]">
<tr>
<th
rowSpan={2}
className="border-b border-r border-border px-2 py-[1.2rem] text-center"
>
관로번호
</th>
<th
rowSpan={2}
className="border-b border-r border-border px-2 py-[1.2rem] text-center"
>
단위
<br />
구간
</th>
<th
rowSpan={2}
className="border-b border-r border-border px-2 py-[1.2rem] text-center"
>
단위
<br />
길이
<br />
(m)
</th>
<th
rowSpan={2}
className="border-b border-r border-border px-2 py-[1.2rem] text-center"
>
결함
<br />
위치
<br />
(m)
</th>
<th
colSpan={6}
className="border-b border-r border-border px-2 py-[1.2rem] text-center"
>
결함
</th>
<th
colSpan={2}
className="border-b border-r border-border px-2 py-[1.2rem] text-center"
>
등급점수(점)
</th>
<th
rowSpan={2}
className="border-b border-r border-border px-2 py-[1.2rem] text-center"
>
조사중단사유
</th>
<th
rowSpan={2}
className="border-b border-r border-border px-2 py-[1.2rem] text-center"
>
비고
</th>
</tr>
<tr>
<th className="border-b border-r border-border px-2 py-[1.2rem] text-center">
종류
</th>
<th className="border-b border-r border-border px-2 py-[1.2rem] text-center">
코드
</th>
<th className="border-b border-r border-border px-2 py-[1.2rem] text-center">
등급
</th>
<th className="border-b border-r border-border px-2 py-[1.2rem] text-center">
발생
<br />
위치
</th>
<th className="border-b border-r border-border px-2 py-[1.2rem] text-center">
침입수
<br />
가점
</th>
<th className="border-b border-r border-border px-2 py-[1.2rem] text-center">
연속
<br />
결함
</th>
<th className="border-b border-r border-border px-2 py-[1.2rem] text-center">
구조적
</th>
<th className="border-b border-r border-border px-2 py-[1.2rem] text-center">
운영적
</th>
</tr>
</thead>
<tbody>
{rows.length > 0 ? (
rows.map((row, index) => {
const pipeSpan = pipeSpans.get(row.pipeNo);
const unitKey = `${row.pipeNo}::${row.unitSeq}`;
const unitSpan = unitSpans.get(unitKey);
const showPipeCell = pipeSpan?.firstIndex === index;
const showUnitCells = unitSpan?.firstIndex === index;
return (
<tr
key={`${row.pipeNo}-${row.unitSeq}-${row.defectPositionM}-${row.code}-${index}`}
className="transition-colors hover:bg-container"
>
{showPipeCell && (
<td
rowSpan={pipeSpan?.count}
className="border-b border-r border-border bg-background px-2 py-[1.2rem] align-middle font-medium text-primary"
>
{row.pipeNo}
</td>
)}
{showUnitCells && (
<>
<td
rowSpan={unitSpan?.count}
className="border-b border-r border-border bg-background px-2 py-[1.2rem] align-middle font-medium"
>
{row.unitSeq}
</td>
<td
rowSpan={unitSpan?.count}
className="border-b border-r border-border bg-background px-2 py-[1.2rem] align-middle"
>
{row.unitLengthM}
</td>
</>
)}
<td className="border-b border-r border-border px-2 py-[1.2rem]">
{formatOptionalValue(row.defectPositionM)}
</td>
<td className="border-b border-r border-border px-2 py-[1.2rem] text-left">
{row.defectName}
</td>
<td className="border-b border-r border-border px-2 py-[1.2rem]">
{row.code}
</td>
<td className="border-b border-r border-border px-2 py-[1.2rem]">
{row.severity ? SEVERITY_LABELS[row.severity] : "-"}
</td>
<td className="border-b border-r border-border px-2 py-[1.2rem]">
{formatOptionalValue(row.occurrencePosition)}
</td>
<td className="border-b border-r border-border px-2 py-[1.2rem]">
{formatOptionalValue(row.infiltrationBonus)}
</td>
<td className="border-b border-r border-border px-2 py-[1.2rem]">
{row.continuous == null ? "-" : row.continuous ? "Y" : "N"}
</td>
<td className="border-b border-r border-border px-2 py-[1.2rem]">
{formatOptionalValue(row.structuralScore)}
</td>
<td className="border-b border-r border-border px-2 py-[1.2rem]">
{formatOptionalValue(row.operationalScore)}
</td>
<td className="border-b border-r border-border px-2 py-[1.2rem]">
{formatOptionalValue(row.surveyStopReason)}
</td>
<td className="border-b border-r border-border px-2 py-[1.2rem]">
{formatOptionalValue(row.remark)}
</td>
</tr>
);
})
) : (
<tr>
<td
colSpan={COLUMN_WIDTHS.length}
className="h-28 border-b border-r border-border text-center text-muted-foreground"
return (
<tr
key={`${row.pipeNo}-${row.unitSeq}-${row.defectPositionM}-${row.code}-${index}`}
className="transition-colors hover:bg-container"
>
{emptyMessage}
</td>
</tr>
)}
</tbody>
</table>
</div>
{showPipeCell && (
<td
rowSpan={pipeSpan?.count}
className="border-b border-r border-border bg-background px-2 py-[1.2rem] align-middle font-medium text-primary"
>
{row.pipeNo}
</td>
)}
{showUnitCells && (
<>
<td
rowSpan={unitSpan?.count}
className="border-b border-r border-border bg-background px-2 py-[1.2rem] align-middle font-medium"
>
{row.unitSeq}
</td>
<td
rowSpan={unitSpan?.count}
className="border-b border-r border-border bg-background px-2 py-[1.2rem] align-middle"
>
{row.unitLengthM}
</td>
</>
)}
<td className="border-b border-r border-border px-2 py-[1.2rem]">
{formatOptionalValue(row.defectPositionM)}
</td>
<td className="border-b border-r border-border px-2 py-[1.2rem] text-left">
{row.defectName}
</td>
<td className="border-b border-r border-border px-2 py-[1.2rem]">
{row.code}
</td>
<td className="border-b border-r border-border px-2 py-[1.2rem]">
{row.severity ? SEVERITY_LABELS[row.severity] : "-"}
</td>
<td className="border-b border-r border-border px-2 py-[1.2rem]">
{formatOptionalValue(row.occurrencePosition)}
</td>
<td className="border-b border-r border-border px-2 py-[1.2rem]">
{formatOptionalValue(row.infiltrationBonus)}
</td>
<td className="border-b border-r border-border px-2 py-[1.2rem]">
{row.continuous == null ? "-" : row.continuous ? "Y" : "N"}
</td>
<td className="border-b border-r border-border px-2 py-[1.2rem]">
{formatOptionalValue(row.structuralScore)}
</td>
<td className="border-b border-r border-border px-2 py-[1.2rem]">
{formatOptionalValue(row.operationalScore)}
</td>
<td className="border-b border-r border-border px-2 py-[1.2rem]">
{formatOptionalValue(row.surveyStopReason)}
</td>
<td className="border-b border-r border-border px-2 py-[1.2rem]">
{formatOptionalValue(row.remark)}
</td>
</tr>
);
})
) : (
<tr>
<td
colSpan={COLUMN_WIDTHS.length}
className="h-28 border-b border-r border-border text-center text-muted-foreground"
>
{emptyMessage}
</td>
</tr>
)}
</tbody>
</Table>
);
}
@@ -19,11 +19,12 @@ import {
import {
STANDARD_ROW_NUMBER_HEADER,
StandardDataTable,
StandardTableSortHeader,
getStandardRowNumber,
type UiTableSort,
} from "@common/business_components";
import {
getCoreRowModel,
getPaginationRowModel,
useReactTable,
type ColumnDef,
type PaginationState,
@@ -40,20 +41,23 @@ import type {
import { ExmnStatusDetailPanel } from "./ExmnStatusDetailPanel";
import { EXMN_TABLE_HEADER_CLASS } from "./ExmnStandardDataTable";
// 서버 호환을 위해 전체 후보를 한 번에 받고 공통 표에서 10건씩 페이지 표시한다.
const BUSINESS_LIST_SCROLL_FETCH_SIZE = 1000;
const BUSINESS_LIST_PAGE_SIZE = 10;
const BUSINESS_TABLE_HEAD = [
{ id: "rowNumber", title: STANDARD_ROW_NUMBER_HEADER },
{ id: "bizCyclText", title: "사업차수" },
{ id: "bizYr", title: "사업년도" },
{ id: "ctpvNm", title: "시도" },
{ id: "sggNm", title: "시군구" },
{ id: "bizNm", title: "사업명" },
{ id: "trtdstCntText", title: "처리구역·분구(개소)" },
{ id: "prcisExmnPrlgText", title: "정밀조사 연장(km)" },
{ id: "reportYnText", title: "보고서" },
{ id: "rowNumber", title: STANDARD_ROW_NUMBER_HEADER, sortKey: "exmnBizId" },
{ id: "bizCyclText", title: "사업차수", sortKey: "bizCycl" },
{ id: "bizYr", title: "사업년도", sortKey: "bizYr" },
{ id: "ctpvNm", title: "시도", sortKey: "ctpvNm" },
{ id: "sggNm", title: "시군구", sortKey: "sggNm" },
{ id: "bizNm", title: "사업명", sortKey: "bizNm" },
{ id: "trtdstCntText", title: "처리구역·분구(개소)", sortKey: "trtdstCnt" },
{
id: "prcisExmnPrlgText",
title: "정밀조사 연장(km)",
sortKey: "prcisExmnPrlg",
},
// 첨부서비스에서 현재 페이지 파일명을 보강하므로 전체 결과 파일명 정렬은 지원하지 않는다.
{ id: "reportYnText", title: "보고서", sortKey: null },
];
interface BusinessFilters {
@@ -94,6 +98,8 @@ export function ExmnRegistrationPage() {
pageIndex: 0,
pageSize: BUSINESS_LIST_PAGE_SIZE,
});
// 표시명 컬럼도 같은 이름 필드를 사용하는 서버 단일 정렬 상태다.
const [sort, setSort] = useState<UiTableSort>();
// 선택 사업 ID는 서버 재조회 뒤에도 같은 행을 복원할 수 있도록 객체와 별도로 유지한다.
const [selectedBusinessId, setSelectedBusinessId] = useState<string | null>(
null,
@@ -143,6 +149,8 @@ export function ExmnRegistrationPage() {
rowNumber: getStandardRowNumber({
total: totalElements,
rowIndex: index,
pageIndex: pagination.pageIndex,
pageSize: pagination.pageSize,
}),
bizCyclText: business.bizCycl == null ? "-" : `${business.bizCycl}차`,
trtdstCntText: Number(business.trtdstCnt ?? 0).toLocaleString("ko-KR"),
@@ -156,35 +164,47 @@ export function ExmnRegistrationPage() {
? "있음"
: "없음"),
})),
[businesses, totalElements],
[businesses, pagination.pageIndex, pagination.pageSize, totalElements],
);
/** 화면 조건을 행정구역 이름과 목록 내부 스크롤용 단일 서버 Page 계약으로 변환한다. */
/** 화면 조건과 현재 정렬을 0-base 서버 Page 계약으로 변환한다. */
const buildSearchParams = (
filters: BusinessFilters,
pageIndex: number,
nextSort?: UiTableSort,
): ExmnBusinessSearchParams => ({
bizCycl: filters.bizCycl || undefined,
bizYr: filters.bizYr !== "0" ? filters.bizYr : undefined,
ctpvNm: filters.ctpvNm || undefined,
sggNm: filters.sggNm || undefined,
bizNm: filters.bizNm || undefined,
page: 0,
size: BUSINESS_LIST_SCROLL_FETCH_SIZE,
page: pageIndex,
size: BUSINESS_LIST_PAGE_SIZE,
sort: nextSort ? `${nextSort.key},${nextSort.direction}` : undefined,
});
/** 지정 조회조건으로 사업 목록을 읽고 선택 행을 가능한 경우 유지한다. */
const fetchBusinesses = async (filters: BusinessFilters) => {
/** 지정 조회조건·정렬·페이지로 사업 목록을 읽고 현재 페이지 선택을 복원한다. */
const fetchBusinesses = async (
filters: BusinessFilters,
pageIndex = 0,
nextSort: UiTableSort | undefined = sort,
) => {
setIsLoading(true);
setNotice(null);
try {
const result = await getExmnBusinesses(buildSearchParams(filters));
const result = await getExmnBusinesses(
buildSearchParams(filters, pageIndex, nextSort),
);
const content = result.content;
setBusinesses(content);
setTotalElements(result.totalElements);
setSelectedBusinessId((current) =>
content.some((item) => item.exmnBizId === current) ? current : null,
);
setPagination((current) => ({ ...current, pageIndex: 0 }));
setPagination((current) => ({
...current,
pageIndex: result.number,
}));
return true;
} catch (error) {
setBusinesses([]);
@@ -207,7 +227,7 @@ export function ExmnRegistrationPage() {
useEffect(() => {
if (initialBusinessesLoadedRef.current) return;
initialBusinessesLoadedRef.current = true;
void fetchBusinesses(EMPTY_FILTERS);
void fetchBusinesses(EMPTY_FILTERS, 0, undefined);
// 최초 진입 시점의 빈 조회조건으로 한 번만 실행한다.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
@@ -215,7 +235,7 @@ export function ExmnRegistrationPage() {
/** 입력 중 조건을 확정하고 첫 페이지를 조회한다. */
const handleSearch = async () => {
setAppliedFilters(draftFilters);
await fetchBusinesses(draftFilters);
await fetchBusinesses(draftFilters, 0, sort);
};
/** CS 초기화 버튼으로 모든 검색조건과 페이지를 최초 조회 기준으로 복원한다. */
@@ -223,7 +243,8 @@ export function ExmnRegistrationPage() {
setDraftFilters(EMPTY_FILTERS);
setAppliedFilters(EMPTY_FILTERS);
setSelectedBusinessId(null);
await fetchBusinesses(EMPTY_FILTERS);
setSort(undefined);
await fetchBusinesses(EMPTY_FILTERS, 0, undefined);
};
/** 목록의 보고서 링크에서 첨부서비스 원본 PDF를 내려받는다. */
@@ -332,7 +353,7 @@ export function ExmnRegistrationPage() {
event.data?.type === WINDOW_POPUP_MESSAGE_TYPE.EXMN_BUSINESS_CREATED
) {
setSelectedBusinessId(null);
void fetchBusinesses(appliedFilters).then((refreshed) => {
void fetchBusinesses(appliedFilters, 0, sort).then((refreshed) => {
if (refreshed)
setNotice({ type: "success", text: "조사사업이 등록되었습니다." });
});
@@ -342,7 +363,11 @@ export function ExmnRegistrationPage() {
if (
event.data?.type === WINDOW_POPUP_MESSAGE_TYPE.EXMN_BUSINESS_UPDATED
) {
void fetchBusinesses(appliedFilters).then((refreshed) => {
void fetchBusinesses(
appliedFilters,
pagination.pageIndex,
sort,
).then((refreshed) => {
if (refreshed)
setNotice({ type: "success", text: "조사사업이 수정되었습니다." });
});
@@ -352,7 +377,11 @@ export function ExmnRegistrationPage() {
if (
event.data?.type === WINDOW_POPUP_MESSAGE_TYPE.EXMN_RESULT_COMMITTED
) {
void fetchBusinesses(appliedFilters).then((refreshed) => {
void fetchBusinesses(
appliedFilters,
pagination.pageIndex,
sort,
).then((refreshed) => {
if (refreshed)
setNotice({
type: "success",
@@ -364,7 +393,14 @@ export function ExmnRegistrationPage() {
window.addEventListener("message", handlePopupMessage);
return () => window.removeEventListener("message", handlePopupMessage);
}, [appliedFilters]);
}, [appliedFilters, pagination.pageIndex, sort]);
/** 정렬 변경 시 첫 페이지와 선택 사업을 초기화하고 서버 전체 결과를 다시 조회한다. */
const handleBusinessSortChange = (nextSort?: UiTableSort) => {
setSort(nextSort);
setSelectedBusinessId(null);
void fetchBusinesses(appliedFilters, 0, nextSort);
};
/** 보고서 다운로드와 행 선택을 유지하며 조사사업 목록을 공통 DataTable 열로 변환한다. */
const columns = useMemo<ColumnDef<(typeof businessRows)[number]>[]>(
@@ -372,7 +408,17 @@ export function ExmnRegistrationPage() {
BUSINESS_TABLE_HEAD.map((head) => ({
id: head.id,
accessorFn: (row) => row[head.id as keyof typeof row],
header: head.title,
header: () =>
head.sortKey === null ? (
head.title
) : (
<StandardTableSortHeader
label={head.title}
sortKey={head.sortKey}
sort={sort}
onSortChange={handleBusinessSortChange}
/>
),
cell: ({ row, getValue }) => {
const business = row.original;
const isReportCell = head.id === "reportYnText";
@@ -420,7 +466,7 @@ export function ExmnRegistrationPage() {
align: "center" as const,
},
})),
[],
[appliedFilters, sort],
);
const table = useReactTable({
@@ -431,9 +477,16 @@ export function ExmnRegistrationPage() {
rowSelection: selectedBusinessId ? { [selectedBusinessId]: true } : {},
},
getRowId: (row) => row.exmnBizId,
onPaginationChange: setPagination,
rowCount: totalElements,
manualPagination: true,
onPaginationChange: (updater) => {
const next =
typeof updater === "function" ? updater(pagination) : updater;
if (next.pageIndex === pagination.pageIndex) return;
setSelectedBusinessId(null);
void fetchBusinesses(appliedFilters, next.pageIndex, sort);
},
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
});
return (
@@ -2,6 +2,11 @@
import LoadingOverlay from "@/components/ui/LoadingBar";
import { openWindowPopup } from "@/features/windowPopup/lib/openWindowPopup";
import {
StandardTableSortHeader,
sortStandardTableRows,
type UiTableSort,
} from "@common/business_components";
import {
Button,
Table,
@@ -28,23 +33,23 @@ import { EXMN_TABLE_HEADER_HEIGHT_REM } from "./ExmnStandardDataTable";
const SUMMARY_GRID_ROW_HEIGHT_REM = 3.2;
const PIPE_SUMMARY_TABLE_HEAD = [
{ id: "rowNumber", title: "순번", widthPercent: 6 },
{ id: "trtdstNm", title: "처리분구", widthPercent: 18 },
{ id: "trgtPrlgText", title: "대상연장(m)", widthPercent: 13 },
{ id: "drvngDstncText", title: "주행거리(m)", widthPercent: 13 },
{ id: "wholeCountText", title: "총 이상항목(개소)", widthPercent: 15 },
{ id: "structuralCountText", title: "구조적결함(개소)", widthPercent: 14 },
{ id: "operationalCountText", title: "운영적결함(개소)", widthPercent: 14 },
{ id: "etcCountText", title: "기타", widthPercent: 7 },
{ id: "rowNumber", title: "순번", widthPercent: 6, sortKey: "rowNumber" },
{ id: "trtdstNm", title: "처리분구", widthPercent: 18, sortKey: "trtdstNm" },
{ id: "trgtPrlgText", title: "대상연장(m)", widthPercent: 13, sortKey: "trgtPrlgText" },
{ id: "drvngDstncText", title: "주행거리(m)", widthPercent: 13, sortKey: "drvngDstncText" },
{ id: "wholeCountText", title: "총 이상항목(개소)", widthPercent: 15, sortKey: "wholeCountText" },
{ id: "structuralCountText", title: "구조적결함(개소)", widthPercent: 14, sortKey: "structuralCountText" },
{ id: "operationalCountText", title: "운영적결함(개소)", widthPercent: 14, sortKey: "operationalCountText" },
{ id: "etcCountText", title: "기타", widthPercent: 7, sortKey: "etcCountText" },
];
const MANHOLE_SUMMARY_TABLE_HEAD = [
{ id: "rowNumber", title: "순번", widthPercent: 8 },
{ id: "trtdstNm", title: "처리분구", widthPercent: 28 },
{ id: "wholeCountText", title: "총 이상항목(개소)", widthPercent: 18 },
{ id: "structuralCountText", title: "구조적결함(개소)", widthPercent: 17 },
{ id: "operationalCountText", title: "운영적결함(개소)", widthPercent: 17 },
{ id: "etcCountText", title: "기타", widthPercent: 12 },
{ id: "rowNumber", title: "순번", widthPercent: 8, sortKey: "rowNumber" },
{ id: "trtdstNm", title: "처리분구", widthPercent: 28, sortKey: "trtdstNm" },
{ id: "wholeCountText", title: "총 이상항목(개소)", widthPercent: 18, sortKey: "wholeCountText" },
{ id: "structuralCountText", title: "구조적결함(개소)", widthPercent: 17, sortKey: "structuralCountText" },
{ id: "operationalCountText", title: "운영적결함(개소)", widthPercent: 17, sortKey: "operationalCountText" },
{ id: "etcCountText", title: "기타", widthPercent: 12, sortKey: "etcCountText" },
];
type Notice = { type: "info" | "error"; text: string } | null;
@@ -88,6 +93,8 @@ export function ExmnStatusDetailPanel({
const [summaryRows, setSummaryRows] = useState<ExmnSectionSummaryRow[]>([]);
/** 상세보기 팝업에 전달할 현재 선택 처리분구다. */
const [selectedTrtdstId, setSelectedTrtdstId] = useState<string | null>(null);
// 서버가 반환한 전체 처리분구 집계행에 적용하는 단일 로컬 정렬 상태다.
const [sort, setSort] = useState<UiTableSort>();
const [isLoading, setIsLoading] = useState(false);
const [isDownloading, setIsDownloading] = useState(false);
const [notice, setNotice] = useState<Notice>(null);
@@ -96,11 +103,13 @@ export function ExmnStatusDetailPanel({
useEffect(() => {
setJobType("PPL");
setSelectedTrtdstId(null);
setSort(undefined);
setNotice(null);
}, [business?.exmnBizId]);
useEffect(() => {
setSelectedTrtdstId(null);
setSort(undefined);
}, [jobType]);
useEffect(() => {
@@ -176,7 +185,7 @@ export function ExmnStatusDetailPanel({
etc: 0,
},
);
const rows: DisplaySummaryRow[] = summaryRows.map((row, index) => {
const displayRows: DisplaySummaryRow[] = summaryRows.map((row, index) => {
const counts = getCounts(row);
return {
trtdstId: row.trtdstId,
@@ -191,7 +200,7 @@ export function ExmnStatusDetailPanel({
};
});
return {
rows,
rows: sortStandardTableRows(displayRows, sort),
totals: {
trgtPrlgText: formatNumber(totals.trgtPrlg),
drvngDstncText: formatNumber(totals.drvngDstnc),
@@ -201,7 +210,7 @@ export function ExmnStatusDetailPanel({
etcCountText: formatNumber(totals.etc),
} as Record<string, string>,
};
}, [jobType, summaryRows]);
}, [jobType, sort, summaryRows]);
const summaryColumns =
jobType === "PPL" ? PIPE_SUMMARY_TABLE_HEAD : MANHOLE_SUMMARY_TABLE_HEAD;
@@ -299,6 +308,12 @@ export function ExmnStatusDetailPanel({
setNotice(null);
};
/** 정렬 변경 시 상세 대상 선택을 해제하고 전체 집계행을 다시 배열한다. */
const handleSummarySortChange = (nextSort?: UiTableSort) => {
setSort(nextSort);
setSelectedTrtdstId(null);
};
/** 보이는 버튼 전체에서 조사유형을 즉시 바꾸고, 상세 조회는 별도 effect에서 수행한다. */
const handleJobTypeChange = (nextJobType: ExmnJobType) => {
if (nextJobType === jobType) return;
@@ -417,7 +432,12 @@ export function ExmnStatusDetailPanel({
className="sticky top-0 z-20 !py-[1.2rem] text-center"
style={{ height: `${EXMN_TABLE_HEADER_HEIGHT_REM}rem` }}
>
{column.title}
<StandardTableSortHeader
label={column.title}
sortKey={column.sortKey}
sort={sort}
onSortChange={handleSummarySortChange}
/>
</TableHead>
))}
</TableRow>
@@ -3,7 +3,12 @@
import type { UIEvent } from "react";
import { useEffect, useMemo, useState } from "react";
import { StandardPopupLayout } from "@common/business_components";
import {
StandardPopupLayout,
StandardTableSortHeader,
sortStandardTableRows,
type UiTableSort,
} from "@common/business_components";
import {
Button,
Input,
@@ -13,9 +18,12 @@ import {
SelectItem,
SelectTrigger,
SelectValue,
Table,
} from "@common/components";
import { downloadExcelWithKoreanHeaders } from "@/shared/lib/download-excel";
import LoadingOverlay from "@/components/ui/LoadingBar";
import PopupSearchPanel, {
PopupSearchActions,
} from "@/features/windowPopup/ui/PopupSearchPanel";
import {
getExmnFacilityDetails,
@@ -40,6 +48,7 @@ import {
} from "../model/exmn-detail-table";
import { ExmnPplUnitDetailTable } from "./ExmnPplUnitDetailTable";
import { ExmnSurveyStandardDataTable } from "./ExmnSurveyStandardDataTable";
const SEVERITY_LABELS: Record<ExmnDetailSeverity, string> = {
L: "대",
@@ -163,6 +172,8 @@ export function ExmnSurveyDetailPopupPage({
const [searchRequestSeq, setSearchRequestSeq] = useState(0);
/** 가로 스크롤 위치를 기준으로 요약 라벨과 고정 열 경계선 위치를 함께 보정한다. */
const [tableScrollLeft, setTableScrollLeft] = useState(0);
// 관로·맨홀 고정 정보 열을 전체 조회 결과에서 정렬하는 상태다.
const [sort, setSort] = useState<UiTableSort>();
/** 다운로드 미연결 등 현재 팝업 안내 메시지를 표시한다. */
const [notice, setNotice] = useState<Notice>(null);
/** 실제 DB 상세조회 결과와 요청 상태다. */
@@ -245,13 +256,20 @@ export function ExmnSurveyDetailPopupPage({
row.pipeNo.toLocaleLowerCase("ko-KR").includes(normalizedKeyword),
);
}, [actualUnitRows, appliedDetailNumberKeyword, isUnitMode]);
const rows = filteredRows;
const rows = useMemo(
() =>
sortStandardTableRows(filteredRows, sort, (row, key) =>
getFixedCellValue(row, key as ExmnDetailFixedColumnId),
),
[filteredRows, sort],
);
/** 관로·맨홀 조사유형 또는 결함 범위가 바뀌면 이전 검색 조건과 안내를 초기화한다. */
useEffect(() => {
setDetailMode("PIPE");
setDetailNumberKeyword("");
setAppliedDetailNumberKeyword("");
setSort(undefined);
setNotice(null);
}, [defectType, jobType]);
@@ -705,6 +723,9 @@ export function ExmnSurveyDetailPopupPage({
() => new Set(unitRows.map((row) => `${row.pipeNo}::${row.unitSeq}`)).size,
[unitRows],
);
/** 관로 집계와 본단위구간 행을 같은 StandardDataTable 데이터 유무 계약으로 전달한다. */
const standardTableRows: Array<ExmnSurveyDetailRow | ExmnPplUnitDetailRow> =
isUnitMode ? unitRows : rows;
const popupTitle = isUnitMode
? "관로조사 본단위구간 상세현황"
: defectType === "ALL"
@@ -735,7 +756,6 @@ export function ExmnSurveyDetailPopupPage({
}
bodyClassName="overflow-x-hidden"
>
<LoadingOverlay isLoading={isLoading} />
{notice && (
<div
role="status"
@@ -747,7 +767,7 @@ export function ExmnSurveyDetailPopupPage({
<section className="flex min-w-0 flex-col gap-[1.2rem]">
{!isDefectTypeView && (
<div className="card card-border !m-0 flex flex-wrap items-center gap-3 !p-[1.2rem]">
<PopupSearchPanel contentClassName="gap-3">
<div className="flex items-center gap-2">
<Label
htmlFor="exmn-detail-district"
@@ -832,7 +852,7 @@ export function ExmnSurveyDetailPopupPage({
</div>
)}
<div className="ml-auto flex items-center gap-2">
<PopupSearchActions>
<Button type="button" color="primary" onClick={handleSearch}>
조회
</Button>
@@ -849,8 +869,8 @@ export function ExmnSurveyDetailPopupPage({
>
초기화
</Button>
</div>
</div>
</PopupSearchActions>
</PopupSearchPanel>
)}
{loadError && (
@@ -862,9 +882,20 @@ export function ExmnSurveyDetailPopupPage({
</div>
)}
<div className="card card-border !m-0 min-w-0 overflow-hidden !p-0">
<div className="card-header flex flex-wrap items-center justify-between gap-4">
<h2 className="text-2xl font-medium text-label">{tableTitle}</h2>
<ExmnSurveyStandardDataTable
rows={standardTableRows}
title={tableTitle}
loading={isLoading}
emptyMessage={
isUnitMode
? appliedDetailNumberKeyword
? "입력한 관로번호에 해당하는 본단위구간이 없습니다."
: "조회된 본단위구간이 없습니다."
: "조회된 상세 결과가 없습니다."
}
minTableWidth={isUnitMode ? "142.2rem" : `${tableWidth}px`}
onScroll={isUnitMode ? undefined : handleTableScroll}
actions={
<div className="flex flex-wrap items-center gap-x-7 gap-y-2 whitespace-nowrap text-xl text-muted-foreground">
{isUnitMode ? (
<>
@@ -898,10 +929,9 @@ export function ExmnSurveyDetailPopupPage({
</>
)}
</div>
</div>
<div className="card !m-[1.2rem] min-w-0 overflow-hidden !p-0">
{isUnitMode ? (
}
renderTable={() =>
isUnitMode ? (
<ExmnPplUnitDetailTable
rows={unitRows}
emptyMessage={
@@ -911,320 +941,329 @@ export function ExmnSurveyDetailPopupPage({
}
/>
) : (
<div
className="min-w-0 overflow-x-auto overflow-y-visible bg-background"
onScroll={handleTableScroll}
<Table
variant="default"
className="border-separate border-spacing-0 text-center text-foreground"
style={{
width: `${tableWidth}px`,
minWidth: `${tableWidth}px`,
}}
>
<table
className="table-ui default border-separate border-spacing-0 text-center text-foreground"
style={{
width: `${tableWidth}px`,
minWidth: `${tableWidth}px`,
}}
>
<colgroup>
{fixedColumns.map((column) => (
<col
key={column.id}
style={{ width: `${column.width}px` }}
/>
))}
{Array.from({
length:
(visibleDefectColumns.length + 1) *
EXMN_DETAIL_SEVERITIES.length,
}).map((_, index) => (
<col
key={`severity-col-${index}`}
style={{ width: `${SEVERITY_COLUMN_WIDTH}px` }}
/>
))}
</colgroup>
<thead className="sticky top-0 z-30 text-center font-medium leading-[1.2]">
<tr>
{fixedColumns.map((column, columnIndex) => {
const sticky = columnIndex < STICKY_COLUMN_COUNT;
const columnLabel = column.label;
return (
<th
key={column.id}
rowSpan={3}
className="border-b border-r border-border !py-[0.8rem] px-2 text-center"
style={
sticky
? {
position: "sticky",
left:
columnIndex === 0
? REPORT_COLUMN_LEFT
: PIPE_COLUMN_LEFT,
zIndex: 60,
boxShadow:
columnIndex === STICKY_COLUMN_COUNT - 1 &&
showStickyDivider
? STICKY_DIVIDER_SHADOW
: undefined,
}
: undefined
}
>
<colgroup>
{fixedColumns.map((column) => (
<col
key={column.id}
style={{ width: `${column.width}px` }}
/>
))}
{Array.from({
length:
(visibleDefectColumns.length + 1) *
EXMN_DETAIL_SEVERITIES.length,
}).map((_, index) => (
<col
key={`severity-col-${index}`}
style={{ width: `${SEVERITY_COLUMN_WIDTH}px` }}
/>
))}
</colgroup>
<thead className="sticky top-0 z-30 text-center font-medium leading-[1.2]">
<tr>
{fixedColumns.map((column, columnIndex) => {
const sticky = columnIndex < STICKY_COLUMN_COUNT;
const columnLabel = column.label;
return (
<th
key={column.id}
rowSpan={3}
className="border-b border-r border-border !py-[0.8rem] px-2 text-center"
style={
sticky
? {
position: "sticky",
left:
columnIndex === 0
? REPORT_COLUMN_LEFT
: PIPE_COLUMN_LEFT,
zIndex: 60,
boxShadow:
columnIndex === STICKY_COLUMN_COUNT - 1 &&
showStickyDivider
? STICKY_DIVIDER_SHADOW
: undefined,
}
: undefined
}
>
{columnIndex < 9 ? (
<StandardTableSortHeader
label={columnLabel.replace(/\n/g, " ")}
title={
<span className="whitespace-pre-line">
{columnLabel}
</span>
}
sortKey={column.id}
sort={sort}
onSortChange={setSort}
/>
) : (
<span className="whitespace-pre-line">
{columnLabel}
</span>
</th>
);
})}
<th
colSpan={
(visibleDefectColumns.length + 1) *
EXMN_DETAIL_SEVERITIES.length
}
className="border-b border-r border-border !py-[0.8rem] px-2 text-center"
>
이상항목
</th>
</tr>
<tr>
)}
</th>
);
})}
<th
colSpan={
(visibleDefectColumns.length + 1) *
EXMN_DETAIL_SEVERITIES.length
}
className="border-b border-r border-border !py-[0.8rem] px-2 text-center"
>
이상항목
</th>
</tr>
<tr>
<th
colSpan={EXMN_DETAIL_SEVERITIES.length}
className="border-b border-r border-border !py-[0.6rem] px-2 text-center"
>
합계
</th>
{visibleDefectColumns.map((column) => (
<th
key={column.code}
colSpan={EXMN_DETAIL_SEVERITIES.length}
title={`${column.label} (${column.code})`}
className="whitespace-nowrap border-b border-r border-border !py-[0.6rem] px-2 text-center"
>
{column.label} ({column.code})
</th>
))}
</tr>
<tr>
{EXMN_DETAIL_SEVERITIES.map((severity) => (
<th
key={`total-${severity}`}
className="border-b border-r border-border !py-[0.6rem] px-2 text-center"
>
합계
{SEVERITY_LABELS[severity]}
</th>
{visibleDefectColumns.map((column) => (
))}
{visibleDefectColumns.flatMap((column) =>
EXMN_DETAIL_SEVERITIES.map((severity) => (
<th
key={column.code}
colSpan={EXMN_DETAIL_SEVERITIES.length}
title={`${column.label} (${column.code})`}
className="whitespace-nowrap border-b border-r border-border !py-[0.6rem] px-2 text-center"
>
{column.label} ({column.code})
</th>
))}
</tr>
<tr>
{EXMN_DETAIL_SEVERITIES.map((severity) => (
<th
key={`total-${severity}`}
className="border-b border-r border-border !py-[0.6rem] px-2 text-center"
key={`${column.code}-${severity}`}
className="border-b border-r border-border !py-[0.6rem] px-2 text-center font-medium"
>
{SEVERITY_LABELS[severity]}
</th>
))}
{visibleDefectColumns.flatMap((column) =>
EXMN_DETAIL_SEVERITIES.map((severity) => (
<th
key={`${column.code}-${severity}`}
className="border-b border-r border-border !py-[0.6rem] px-2 text-center font-medium"
)),
)}
</tr>
</thead>
<tbody>
{rows.length > 0 ? (
<>
{rows.map((row) => {
const visibleAnomalyTotals = getVisibleAnomalyTotals(
row,
visibleDefectColumns,
);
return (
<tr
key={row.reportNo}
className="transition-colors hover:bg-container"
>
{SEVERITY_LABELS[severity]}
</th>
)),
)}
</tr>
</thead>
<tbody>
{rows.length > 0 ? (
<>
{rows.map((row) => {
const visibleAnomalyTotals = getVisibleAnomalyTotals(
row,
visibleDefectColumns,
);
return (
<tr
key={row.reportNo}
className="transition-colors hover:bg-container"
>
{fixedColumns.map((column, columnIndex) => {
const sticky =
columnIndex < STICKY_COLUMN_COUNT;
return (
<td
key={`${row.reportNo}-${column.id}`}
className="whitespace-nowrap border-b border-r border-border bg-background px-2 py-[1.2rem]"
style={
sticky
? {
position: "sticky",
left:
columnIndex === 0
? REPORT_COLUMN_LEFT
: PIPE_COLUMN_LEFT,
zIndex: 10,
boxShadow:
columnIndex ===
STICKY_COLUMN_COUNT - 1 &&
showStickyDivider
? STICKY_DIVIDER_SHADOW
: undefined,
}
: undefined
}
>
{getFixedCellValue(row, column.id)}
</td>
);
})}
{EXMN_DETAIL_SEVERITIES.map((severity) => (
{fixedColumns.map((column, columnIndex) => {
const sticky = columnIndex < STICKY_COLUMN_COUNT;
return (
<td
key={`${row.reportNo}-total-${severity}`}
className="border-b border-r border-border bg-background px-2 py-[1.2rem]"
key={`${row.reportNo}-${column.id}`}
className="whitespace-nowrap border-b border-r border-border bg-background px-2 py-[1.2rem]"
style={
sticky
? {
position: "sticky",
left:
columnIndex === 0
? REPORT_COLUMN_LEFT
: PIPE_COLUMN_LEFT,
zIndex: 10,
boxShadow:
columnIndex ===
STICKY_COLUMN_COUNT - 1 &&
showStickyDivider
? STICKY_DIVIDER_SHADOW
: undefined,
}
: undefined
}
>
{formatCount(visibleAnomalyTotals[severity])}
{getFixedCellValue(row, column.id)}
</td>
))}
{visibleDefectColumns.flatMap((column) =>
EXMN_DETAIL_SEVERITIES.map((severity) => (
<td
key={`${row.reportNo}-${column.code}-${severity}`}
className="border-b border-r border-border px-2 py-[1.2rem]"
>
{formatCount(
row.defectCounts[column.code]?.[severity],
)}
</td>
)),
)}
</tr>
);
})}
<tr className="bg-background font-medium">
<td
colSpan={STICKY_COLUMN_COUNT}
className="sticky left-0 z-20 border-b border-border bg-background px-2 py-[1.2rem] text-center align-middle"
);
})}
{EXMN_DETAIL_SEVERITIES.map((severity) => (
<td
key={`${row.reportNo}-total-${severity}`}
className="border-b border-r border-border bg-background px-2 py-[1.2rem]"
>
{formatCount(visibleAnomalyTotals[severity])}
</td>
))}
{visibleDefectColumns.flatMap((column) =>
EXMN_DETAIL_SEVERITIES.map((severity) => (
<td
key={`${row.reportNo}-${column.code}-${severity}`}
className="border-b border-r border-border px-2 py-[1.2rem]"
>
{formatCount(
row.defectCounts[column.code]?.[severity],
)}
</td>
)),
)}
</tr>
);
})}
<tr className="bg-background font-medium">
<td
colSpan={STICKY_COLUMN_COUNT}
className="sticky left-0 z-20 border-b border-border bg-background px-2 py-[1.2rem] text-center align-middle"
style={{
boxShadow: showStickyDivider
? STICKY_DIVIDER_SHADOW
: undefined,
}}
>
<span
className="inline-block whitespace-nowrap"
style={{
boxShadow: showStickyDivider
? STICKY_DIVIDER_SHADOW
: undefined,
transform: `translateX(${summaryLabelOffset}px)`,
}}
>
<span
className="inline-block whitespace-nowrap"
style={{
transform: `translateX(${summaryLabelOffset}px)`,
}}
>
소계
</span>
소계
</span>
</td>
{summaryLabelColumnCount > STICKY_COLUMN_COUNT && (
<td
aria-hidden="true"
colSpan={
summaryLabelColumnCount - STICKY_COLUMN_COUNT
}
className="border-b border-r border-border bg-background px-2 py-[1.2rem]"
/>
)}
{fixedSummaryValues.map((summary) => (
<td
key={`subtotal-fixed-${summary.key}`}
className="border-b border-r border-border bg-background px-2 py-[1.2rem] text-center"
>
{summary.value}
</td>
{summaryLabelColumnCount > STICKY_COLUMN_COUNT && (
))}
{EXMN_DETAIL_SEVERITIES.map((severity) => (
<td
key={`subtotal-anomaly-${severity}`}
className="border-b border-r border-border px-2 py-[1.2rem]"
>
{formatCount(
defectSummary.anomalySubtotals[severity],
)}
</td>
))}
{visibleDefectColumns.flatMap((column) =>
EXMN_DETAIL_SEVERITIES.map((severity) => (
<td
aria-hidden="true"
colSpan={
summaryLabelColumnCount - STICKY_COLUMN_COUNT
}
className="border-b border-r border-border bg-background px-2 py-[1.2rem]"
/>
)}
{fixedSummaryValues.map((summary) => (
<td
key={`subtotal-fixed-${summary.key}`}
className="border-b border-r border-border bg-background px-2 py-[1.2rem] text-center"
>
{summary.value}
</td>
))}
{EXMN_DETAIL_SEVERITIES.map((severity) => (
<td
key={`subtotal-anomaly-${severity}`}
key={`subtotal-${column.code}-${severity}`}
className="border-b border-r border-border px-2 py-[1.2rem]"
>
{formatCount(
defectSummary.anomalySubtotals[severity],
defectSummary.defectSubtotals[column.code]?.[
severity
],
)}
</td>
))}
{visibleDefectColumns.flatMap((column) =>
EXMN_DETAIL_SEVERITIES.map((severity) => (
<td
key={`subtotal-${column.code}-${severity}`}
className="border-b border-r border-border px-2 py-[1.2rem]"
>
{formatCount(
defectSummary.defectSubtotals[column.code]?.[
severity
],
)}
</td>
)),
)}
</tr>
<tr className="bg-secondary font-medium">
<td
colSpan={STICKY_COLUMN_COUNT}
className="sticky left-0 z-20 border-b border-border bg-secondary px-2 py-[1.2rem] text-center align-middle"
)),
)}
</tr>
<tr className="bg-secondary font-medium">
<td
colSpan={STICKY_COLUMN_COUNT}
className="sticky left-0 z-20 border-b border-border bg-secondary px-2 py-[1.2rem] text-center align-middle"
style={{
boxShadow: showStickyDivider
? STICKY_DIVIDER_SHADOW
: undefined,
}}
>
<span
className="inline-block whitespace-nowrap"
style={{
boxShadow: showStickyDivider
? STICKY_DIVIDER_SHADOW
: undefined,
transform: `translateX(${summaryLabelOffset}px)`,
}}
>
<span
className="inline-block whitespace-nowrap"
style={{
transform: `translateX(${summaryLabelOffset}px)`,
}}
>
합계
</span>
</td>
{summaryLabelColumnCount > STICKY_COLUMN_COUNT && (
<td
aria-hidden="true"
colSpan={
summaryLabelColumnCount - STICKY_COLUMN_COUNT
}
className="border-b border-r border-border bg-secondary px-2 py-[1.2rem]"
/>
)}
{fixedSummaryValues.map((summary) => (
<td
key={`total-fixed-${summary.key}`}
className="border-b border-r border-border bg-secondary px-2 py-[1.2rem] text-center"
>
{summary.value}
</td>
))}
합계
</span>
</td>
{summaryLabelColumnCount > STICKY_COLUMN_COUNT && (
<td
aria-hidden="true"
colSpan={
summaryLabelColumnCount - STICKY_COLUMN_COUNT
}
className="border-b border-r border-border bg-secondary px-2 py-[1.2rem]"
/>
)}
{fixedSummaryValues.map((summary) => (
<td
key={`total-fixed-${summary.key}`}
className="border-b border-r border-border bg-secondary px-2 py-[1.2rem] text-center"
>
{summary.value}
</td>
))}
<td
colSpan={EXMN_DETAIL_SEVERITIES.length}
className="border-b border-r border-border px-2 py-[1.2rem] text-center"
>
{formatCount(defectSummary.anomalyTotal)}
</td>
{visibleDefectColumns.map((column) => (
<td
key={`total-${column.code}`}
colSpan={EXMN_DETAIL_SEVERITIES.length}
className="border-b border-r border-border px-2 py-[1.2rem] text-center"
>
{formatCount(defectSummary.anomalyTotal)}
{formatCount(
defectSummary.defectTotals[column.code],
)}
</td>
{visibleDefectColumns.map((column) => (
<td
key={`total-${column.code}`}
colSpan={EXMN_DETAIL_SEVERITIES.length}
className="border-b border-r border-border px-2 py-[1.2rem] text-center"
>
{formatCount(
defectSummary.defectTotals[column.code],
)}
</td>
))}
</tr>
</>
) : (
<tr>
<td
colSpan={
fixedColumns.length +
(visibleDefectColumns.length + 1) *
EXMN_DETAIL_SEVERITIES.length
}
className="h-28 border-b border-r border-border text-center text-muted-foreground"
>
조회된 상세 결과가 없습니다.
</td>
))}
</tr>
)}
</tbody>
</table>
</div>
)}
</div>
</div>
</>
) : (
<tr>
<td
colSpan={
fixedColumns.length +
(visibleDefectColumns.length + 1) *
EXMN_DETAIL_SEVERITIES.length
}
className="h-28 border-b border-r border-border text-center text-muted-foreground"
>
조회된 상세 결과가 없습니다.
</td>
</tr>
)}
</tbody>
</Table>
)
}
/>
</section>
</StandardPopupLayout>
);
@@ -0,0 +1,71 @@
"use client";
import { useMemo, type ReactNode, type UIEventHandler } from "react";
import {
getCoreRowModel,
useReactTable,
type ColumnDef,
} from "@tanstack/react-table";
import { StandardDataTable } from "@common/business_components";
interface ExmnSurveyStandardDataTableProps<T extends object> {
rows: T[];
title: ReactNode;
actions?: ReactNode;
loading?: boolean;
emptyMessage: string;
minTableWidth?: string;
onScroll?: UIEventHandler<HTMLDivElement>;
renderTable: () => ReactNode;
}
/**
* 조사 복합표의 병합 셀은 업무 화면에서 유지하고 카드·헤더·로딩·빈 상태·스크롤은
* StandardDataTable이 소유하도록 연결한다.
*/
export function ExmnSurveyStandardDataTable<T extends object>({
rows,
title,
actions,
loading = false,
emptyMessage,
minTableWidth = "100%",
onScroll,
renderTable,
}: ExmnSurveyStandardDataTableProps<T>) {
/** 공통 표가 데이터 유무와 로딩 상태를 판단할 수 있도록 복합 행을 최소 열로 연결한다. */
const columns = useMemo<ColumnDef<T>[]>(
() => [
{
id: "complexRow",
accessorFn: (_row, index) => index,
},
],
[],
);
const table = useReactTable({
data: rows,
columns,
getCoreRowModel: getCoreRowModel(),
});
return (
<StandardDataTable
table={table}
title={title}
actions={actions}
loading={loading}
emptyMessage={emptyMessage}
className="!w-full !max-w-full"
scrollMode="horizontal"
minTableWidth={minTableWidth}
viewPagination={false}
flushOuterMargin
onScroll={onScroll}
renderTable={renderTable}
/>
);
}
export type { ExmnSurveyStandardDataTableProps };
@@ -12,7 +12,6 @@ import {
} from "@common/components/ui";
import { useCallback, useEffect, useMemo, useState } from "react";
import {
downloadPplExport,
getExmnBusiness,
getPplFacilities,
getPplSummaries,
@@ -25,6 +24,8 @@ import type {
} from "../model/exmn-business-types";
import { PplFacilityDetailModal } from "./PplFacilityDetailModal";
import { ExmnStandardDataTable } from "./ExmnStandardDataTable";
import { downloadExcelWithKoreanHeaders } from "@/shared/lib/download-excel";
import { fetchAllPages } from "@/shared/lib/fetch-all-pages";
const SUMMARY_TABLE_HEAD = [
{ id: "trtdstNm", title: "처리분구", widthPercent: 20 },
@@ -121,16 +122,6 @@ interface PplSummaryModalProps {
const formatNumber = (value: number | null | undefined) =>
Number(value ?? 0).toLocaleString("ko-KR", { maximumFractionDigits: 3 });
/** Blob을 PPL 집계 엑셀 다운로드로 전달한다. */
const saveBlob = (blob: Blob, fileName: string) => {
const objectUrl = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = objectUrl;
anchor.download = fileName;
anchor.click();
URL.revokeObjectURL(objectUrl);
};
/** 처리분구별 PPL 집계에서 결함 수를 따라 시설 목록과 단위 상세까지 탐색하는 모달이다. */
export function PplSummaryModal({
open,
@@ -352,10 +343,40 @@ export function PplSummaryModal({
setIsLoading(true);
setNotice(null);
try {
saveBlob(
await downloadPplExport(business.exmnBizId, trtdstId || undefined),
`${business.bizNm}_PPL집계.xlsx`,
);
const result = await fetchAllPages({
fetchPage: (page, size) =>
getPplSummaries(business.exmnBizId, {
trtdstId: trtdstId || undefined,
page,
size,
}),
getItems: (page) => page.content ?? [],
getTotal: (page) => Number(page.totalElements ?? 0),
});
const excelRows = result.items.map((row) => ({
trtdstNm: row.trtdstNm,
trgtPrlg: row.trgtPrlg,
drvngDstnc: row.drvngDstnc,
pplExmnWholAnmlyArtclCnt: row.pplExmnWholAnmlyArtclCnt,
pplExmnStrctDfcCnt: row.pplExmnStrctDfcCnt,
pplExmnOperDfcCnt: row.pplExmnOperDfcCnt,
pplExmnEtcCnt: row.pplExmnEtcCnt,
}));
await downloadExcelWithKoreanHeaders({
data: excelRows as unknown as JSON[],
fileName: `${business.bizNm}_PPL집계.xlsx`,
sheetNameArray: ["처리분구별 PPL 집계"],
headerRange: "A1:G1",
headerMapping: {
trtdstNm: "처리분구",
trgtPrlg: "대상연장(m)",
drvngDstnc: "주행거리(m)",
pplExmnWholAnmlyArtclCnt: "PPL 전체이상",
pplExmnStrctDfcCnt: "구조결함",
pplExmnOperDfcCnt: "운영결함",
pplExmnEtcCnt: "기타",
},
});
} catch (error) {
setNotice({
type: "error",
@@ -5,7 +5,10 @@ import useStore from "@/store/useStore";
import {
STANDARD_ROW_NUMBER_HEADER,
StandardDataTable,
StandardTableSortHeader,
getStandardRowNumber,
sortStandardTableRows,
type UiTableSort,
} from "@common/business_components";
import {
Button,
@@ -143,6 +146,8 @@ export function TrtdstManagementPage() {
pageIndex: 0,
pageSize: 10,
});
// 서버가 반환한 전체 활성 처리분구를 페이지 절단 전에 정렬하는 상태다.
const [sort, setSort] = useState<UiTableSort>();
// DataTable 선택 행은 저장·재조회 중에도 로컬 키 기준으로 명확히 추적한다.
const [selectedRowKey, setSelectedRowKey] = useState<string | null>(null);
// 조회·삭제 참조 확인·저장 요청 중 화면의 중복 조작을 막는다.
@@ -255,6 +260,7 @@ export function TrtdstManagementPage() {
}
setCtpvCd("0");
setSggCd("0");
setSort(undefined);
if (await fetchRows(EMPTY_APPLIED_REGION)) {
setAppliedRegion(EMPTY_APPLIED_REGION);
}
@@ -275,6 +281,8 @@ export function TrtdstManagementPage() {
return;
}
setEditingRowKey(selectedRow.localKey);
setSort(undefined);
setPagination((current) => ({ ...current, pageIndex: 0 }));
};
/** 선택한 행정구역으로 빈 신규 행을 만들고 처리분구명은 그리드에서 직접 입력받는다. */
@@ -289,6 +297,8 @@ export function TrtdstManagementPage() {
}
const localKey = createLocalKey();
setSort(undefined);
setPagination((current) => ({ ...current, pageIndex: 0 }));
setRows((current) =>
withDescendingRowNumbers([
...current,
@@ -464,13 +474,30 @@ export function TrtdstManagementPage() {
}
};
const sortedRows = useMemo(
() => sortStandardTableRows(rows, sort),
[rows, sort],
);
/** 인라인 편집과 변경 상태 표시를 유지한 채 공통 DataTable 열로 변환한다. */
const columns = useMemo<ColumnDef<TrtdstTableRow>[]>(
() =>
TRTDST_TABLE_HEAD.map((head) => ({
id: head.id,
accessorKey: head.id,
header: head.title,
header: () => (
<StandardTableSortHeader
label={head.title}
sortKey={head.id}
sort={sort}
onSortChange={(nextSort) => {
setSort(nextSort);
setPagination((current) => ({ ...current, pageIndex: 0 }));
setSelectedRowKey(null);
setEditingRowKey(null);
}}
/>
),
cell: ({ row, getValue }) => {
const item = row.original;
const isNameEditable =
@@ -505,11 +532,11 @@ export function TrtdstManagementPage() {
},
meta: { width: head.width, align: "center" as const },
})),
[editingRowKey],
[editingRowKey, sort],
);
const table = useReactTable({
data: rows,
data: sortedRows,
columns,
state: {
pagination,
@@ -11,6 +11,7 @@ import {
postRawJson,
putData,
} from "@/components/lib/api";
import { fetchAllPages } from "@/shared/lib/fetch-all-pages";
const API_PATH = "v1/obsltPplBiz";
const EXCEL_DOWNLOAD_PAGE_SIZE = 500;
@@ -24,30 +25,35 @@ const unwrapData = <T>(body: unknown): T => {
};
/** 목록 검색조건과 페이지 번호로 활성 사업 목록을 조회한다. */
export const getObsltPplBusinesses = async (params: URLSearchParams): Promise<ObsltPplPage> =>
export const getObsltPplBusinesses = async (
params: URLSearchParams,
): Promise<ObsltPplPage> =>
getData(`${API_PATH}?${params.toString()}`) as Promise<ObsltPplPage>;
/** 적용된 조회조건을 유지하며 모든 페이지를 순회해 Excel 다운로드 대상 전체를 모은다. */
export const getAllObsltPplBusinesses = async (searchParams: URLSearchParams): Promise<ObsltPplBusiness[]> => {
const rows: ObsltPplBusiness[] = [];
let page = 0;
let totalPages = 1;
while (page < totalPages) {
const params = new URLSearchParams(searchParams);
params.set("page", String(page));
params.set("size", String(EXCEL_DOWNLOAD_PAGE_SIZE));
params.set("sort", "obsltSwagePplBizSn,asc");
const result = await getObsltPplBusinesses(params);
rows.push(...result.content);
totalPages = result.totalPages;
page += 1;
}
return rows;
export const getAllObsltPplBusinesses = async (
searchParams: URLSearchParams,
sort = "obsltSwagePplBizSn,desc",
): Promise<ObsltPplBusiness[]> => {
const result = await fetchAllPages<ObsltPplPage, ObsltPplBusiness>({
batchSize: EXCEL_DOWNLOAD_PAGE_SIZE,
fetchPage: (page, size) => {
const params = new URLSearchParams(searchParams);
params.set("page", String(page));
params.set("size", String(size));
params.set("sort", sort);
return getObsltPplBusinesses(params);
},
getItems: (page) => page.content ?? [],
getTotal: (page) => Number(page.totalElements ?? 0),
});
return result.items;
};
/** 수정 화면에서 사업 상세 한 건을 조회한다. */
export const getObsltPplBusiness = async (id: string): Promise<ObsltPplBusiness> =>
export const getObsltPplBusiness = async (
id: string,
): Promise<ObsltPplBusiness> =>
getData(`${API_PATH}/${encodeURIComponent(id)}`) as Promise<ObsltPplBusiness>;
/** 등록 화면의 입력값으로 사업 한 건을 생성한다. */
@@ -61,7 +67,10 @@ export const updateObsltPplBusiness = async (
id: string,
request: ObsltPplBusinessSaveRequest,
): Promise<ObsltPplBusiness> =>
putData(`${API_PATH}/${encodeURIComponent(id)}`, request) as Promise<ObsltPplBusiness>;
putData(
`${API_PATH}/${encodeURIComponent(id)}`,
request,
) as Promise<ObsltPplBusiness>;
/** 수정 화면에서 선택 사업을 논리삭제한다. */
export const deleteObsltPplBusiness = async (id: string): Promise<void> => {
@@ -72,4 +81,6 @@ export const deleteObsltPplBusiness = async (id: string): Promise<void> => {
export const uploadObsltPplExcel = async (
rows: ObsltPplBusinessSaveRequest[],
): Promise<ObsltPplImportResult> =>
unwrapData<ObsltPplImportResult>(await postRawJson(`${API_PATH}/excel/upload`, rows));
unwrapData<ObsltPplImportResult>(
await postRawJson(`${API_PATH}/excel/upload`, rows),
);
@@ -1,4 +1,5 @@
import * as XLSX from "xlsx";
import XlsxPopulate from "xlsx-populate/browser/xlsx-populate";
import { formatDate } from "../../common/util";
import type { ObsltPplBusiness, ObsltPplBusinessSaveRequest } from "../model/obslt-ppl-types";
@@ -35,6 +36,13 @@ const FIRST_DATA_COLUMN_INDEX = 1;
const DATA_COLUMN_COUNT = 21;
const DOWNLOAD_FILENAME = "노후하수관로 정밀조사 및 정비사업 현황.xlsx";
/** API 문자열에 남은 Excel 개행 이스케이프를 실제 줄바꿈으로 정리한다. */
const excelTextValue = (value: unknown) =>
String(value ?? "")
.replace(/_x000d_/gi, "")
.replace(/\r\n?/g, "\n")
.trim();
/** 줄바꿈과 공백이 포함된 병합 헤더를 비교 가능한 문자열로 정규화한다. */
const normalizeHeader = (value: string) => value.replace(/\s+/g, "").trim();
@@ -179,14 +187,16 @@ export const parseObsltPplExcel = async (file: File): Promise<ObsltPplBusinessSa
const downloadHeaderRows = (contract: SheetContract) => {
const rows = Array.from({ length: DATA_START_ROW_INDEX }, () => Array<string | number>(22).fill(""));
const topHeaders = [
"순번", "정밀조사차수", contract.businessCodeHeader, "시도", "시군", "환경청", "사업명",
"추진현황", "착수년도", "준공(예정)년도", contract.extensionHeader, "", "", "",
contract.progressHeader, "", "사업추진부진사유", "사업담당자(지자체)", "", "비고", "",
"순번", "정밀\n조사\n차수", contract.businessCodeHeader.replace("사업코드", "\n사업코드"),
"시도", "시군", "환경청", "사업명", "추진현황", "착수\n년도", "준공(예정)\n년도",
contract.extensionHeader, "", "", "", contract.progressHeader.replace("추진현황", " 추진현황"),
"", "사업추진 부진 사유", "사업 담당자(지자체)", "", "비고", "",
];
const bottomHeaders = [
"", "", "", "", "", "", "", "", "", "", "계획연장(km)", "상반기(예상)연장(km)",
"잔여연장(km)", "증빙서류제출여부", contract.startDateHeader, "준공(예정)일자(yyyy.mm.dd)",
"", "이름", "연락처", "", "공단의견",
"잔여연장(km)", "증빙서류\n제출여부", contract.startDateHeader.replace("(yyyy.mm.dd)", "\n(yyyy.mm.dd)"),
"준공(예정)일자\n(yyyy.mm.dd)", "", "이름", "연락처",
"(필요시 사업 담당자 추가 작성)", "공단의견",
];
topHeaders.forEach((value, index) => { rows[5]![index + FIRST_DATA_COLUMN_INDEX] = value; });
bottomHeaders.forEach((value, index) => { rows[6]![index + FIRST_DATA_COLUMN_INDEX] = value; });
@@ -197,26 +207,26 @@ const downloadHeaderRows = (contract: SheetContract) => {
const downloadDataRow = (row: ObsltPplBusiness, index: number): Array<string | number> => [
"",
index + 1,
row.prcisExmnCycl ?? "",
row.bizCd ?? "",
row.ctpvNm ?? "",
row.sigunNm ?? "",
row.envoNm ?? "",
row.bizNm ?? "",
row.prplPrst ?? "",
row.bgngYr ?? "",
row.cmcnYr ?? "",
excelTextValue(row.prcisExmnCycl),
excelTextValue(row.bizCd),
excelTextValue(row.ctpvNm),
excelTextValue(row.sigunNm),
excelTextValue(row.envoNm),
excelTextValue(row.bizNm),
excelTextValue(row.prplPrst),
excelTextValue(row.bgngYr),
excelTextValue(row.cmcnYr),
row.planPrlg ?? "",
row.frhfyrExpcPrlg ?? "",
row.rmndPrlg ?? "",
row.evdncDcmntSbmsnStts ?? "",
excelTextValue(row.evdncDcmntSbmsnStts),
formatDate(row.bgngYmd),
formatDate(row.cmcnYmd),
row.bizPrplDlyRsnCn ?? "",
row.bizPicNm ?? "",
row.bizPicCnpl ?? "",
row.rmrkCn ?? "",
row.pbcpOpnnCn ?? "",
excelTextValue(row.bizPrplDlyRsnCn),
excelTextValue(row.bizPicNm),
excelTextValue(row.bizPicCnpl),
excelTextValue(row.rmrkCn),
excelTextValue(row.pbcpOpnnCn),
];
/** 조회 결과를 정밀조사·정비사업 두 시트의 재업로드 가능한 위치 구조로 생성한다. */
@@ -224,17 +234,123 @@ export const buildObsltPplWorkbook = (businesses: ObsltPplBusiness[]): XLSX.Work
const workbook = XLSX.utils.book_new();
SHEET_CONTRACTS.forEach((contract) => {
const data = businesses.filter((row) => row.bizSe === contract.sheetName);
const headerRows = downloadHeaderRows(contract);
headerRows[1]![2] =
contract.sheetName === "정밀조사"
? "노후하수관로 정밀조사(2차) 사업추진 현황"
: "노후하수관로 정비사업 사업추진 현황";
headerRows[4]![1] = "조회 건수";
headerRows[4]![4] = `${data.length.toLocaleString("ko-KR")}건`;
headerRows[4]![7] = `${contract.extensionHeader} 합계`;
headerRows[4]![11] =
Math.round(
data.reduce((sum, row) => sum + (row.planPrlg ?? 0), 0) * 1000,
) / 1000;
const worksheet = XLSX.utils.aoa_to_sheet([
...downloadHeaderRows(contract),
...headerRows,
...data.map(downloadDataRow),
]);
worksheet["!cols"] = Array.from({ length: 22 }, (_, index) => ({ wch: index === 7 ? 30 : 16 }));
worksheet["!merges"] = [
{ s: { r: 1, c: 2 }, e: { r: 2, c: 10 } },
{ s: { r: 4, c: 1 }, e: { r: 4, c: 3 } },
{ s: { r: 4, c: 4 }, e: { r: 4, c: 6 } },
{ s: { r: 4, c: 7 }, e: { r: 4, c: 10 } },
{ s: { r: 4, c: 11 }, e: { r: 4, c: 13 } },
...[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 17].map((column) => ({
s: { r: 5, c: column },
e: { r: 6, c: column },
})),
{ s: { r: 5, c: 11 }, e: { r: 5, c: 13 } },
{ s: { r: 5, c: 15 }, e: { r: 5, c: 16 } },
{ s: { r: 5, c: 18 }, e: { r: 5, c: 19 } },
];
const widths = [
3, 7, 9, 16, 12, 12, 16, 34, 10, 9, 11, 13, 16, 13, 11, 16, 16,
34, 11, 16, 34, 24,
];
worksheet["!cols"] = widths.map((wch) => ({ wch }));
XLSX.utils.book_append_sheet(workbook, worksheet, contract.sheetName);
});
return workbook;
};
/** 생성한 두 시트 workbook을 브라우저 다운로드로 저장한다. */
export const downloadObsltPplExcel = (businesses: ObsltPplBusiness[]) => {
XLSX.writeFileXLSX(buildObsltPplWorkbook(businesses), DOWNLOAD_FILENAME);
/** 확정 원본의 제목·병합 헤더·구분색과 고정 본문 높이를 적용해 브라우저 다운로드를 시작한다. */
export const downloadObsltPplExcel = async (
businesses: ObsltPplBusiness[],
) => {
const workbook = buildObsltPplWorkbook(businesses);
const data = XLSX.write(workbook, { type: "array", bookType: "xlsx" });
const styledWorkbook = await XlsxPopulate.fromDataAsync(data);
SHEET_CONTRACTS.forEach((contract) => {
const rowCount = businesses.filter(
(business) => business.bizSe === contract.sheetName,
).length;
const styledSheet = styledWorkbook.sheet(contract.sheetName);
styledSheet.range("C2:K3").style({
bold: true,
fontFamily: "맑은 고딕",
fontSize: 16,
horizontalAlignment: "center",
verticalAlignment: "center",
border: true,
});
styledSheet.range("B5:N5").style({
bold: true,
fontFamily: "맑은 고딕",
fontSize: 10,
horizontalAlignment: "center",
verticalAlignment: "center",
fill: "FFF2CC",
border: true,
});
styledSheet.range("B6:V7").style({
bold: true,
fontFamily: "맑은 고딕",
fontSize: 10,
horizontalAlignment: "center",
verticalAlignment: "center",
wrapText: true,
border: true,
});
styledSheet.range("B6:H7").style("fill", "B4C6E7");
styledSheet.range("I6:Q7").style("fill", "FFF2CC");
styledSheet.range("R6:V7").style("fill", "FCE4D6");
styledSheet.row(2).height(26);
styledSheet.row(3).height(26);
styledSheet.row(5).height(22);
styledSheet.row(6).height(30);
styledSheet.row(7).height(34);
if (rowCount > 0) {
const dataStartRow = DATA_START_ROW_INDEX + 1;
const dataEndRow = DATA_START_ROW_INDEX + rowCount;
styledSheet.range(`B${dataStartRow}:V${dataEndRow}`).style({
fontFamily: "맑은 고딕",
fontSize: 10,
horizontalAlignment: "center",
verticalAlignment: "center",
wrapText: false,
border: true,
});
styledSheet
.range(`L${dataStartRow}:N${dataEndRow}`)
.style("numberFormat", "#,##0.###");
["H", "R", "U", "V"].forEach((column) =>
styledSheet
.range(`${column}${dataStartRow}:${column}${dataEndRow}`)
.style({ horizontalAlignment: "left", wrapText: true }),
);
for (let row = dataStartRow; row <= dataEndRow; row += 1)
styledSheet.row(row).height(22);
}
});
const blob = await styledWorkbook.outputAsync();
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = DOWNLOAD_FILENAME;
link.click();
setTimeout(() => URL.revokeObjectURL(url), 0);
};
@@ -8,7 +8,9 @@ import { useRouter, useSearchParams } from "next/navigation";
import {
StandardDataTable,
StandardTableSortHeader,
getStandardRowNumber,
type UiTableSort,
} from "@common/business_components";
import {
Button,
@@ -183,12 +185,32 @@ export function ObsltPplListPage() {
const [search, setSearch] = useState<ObsltPplSearch>(initialSearch);
const [appliedSearch, setAppliedSearch] =
useState<ObsltPplSearch>(initialSearch);
const [sort, setSort] = useState<UiTableSort>();
/** 현재 선택한 시도에 속한 시군구만 선택 후보로 제공한다. */
const availableSgg = useMemo(() => {
return buildObsltPplRepresentativeSggOptions(sgg, ctpvCd);
}, [ctpvCd, sgg]);
/** 정렬 변경 시 동일 검색조건으로 서버 첫 페이지를 다시 조회한다. */
const handleSortChange = useCallback(
(nextSort?: UiTableSort) => {
setSort(nextSort);
setPage(1);
const queryString = createListQueryString(
1,
appliedSearch,
appliedCtpvCd,
appliedSggCd,
);
router.replace(
`/oper/obsltPplBiz${queryString ? `?${queryString}` : ""}`,
{ scroll: false },
);
},
[appliedCtpvCd, appliedSearch, appliedSggCd, router],
);
/** 공통 DataTable이 표시할 22개 업무 컬럼과 폭·정렬 규칙을 구성한다. */
const columns = useMemo<ColumnDef<ObsltPplTableRow>[]>(
() =>
@@ -196,7 +218,19 @@ export function ObsltPplListPage() {
id: column.id,
accessorFn: (row) => row[column.id],
header: () => (
<span className="whitespace-pre-line">{column.title}</span>
<StandardTableSortHeader
label={column.title.replace(/\n/g, " ")}
title={
<span className="whitespace-pre-line">{column.title}</span>
}
sortKey={
column.id === "rowNumber"
? "obsltSwagePplBizSn"
: String(column.id)
}
sort={sort}
onSortChange={handleSortChange}
/>
),
cell: ({ getValue }) => {
const displayValue = formatTableValue(getValue());
@@ -214,7 +248,7 @@ export function ObsltPplListPage() {
align: column.align ?? "center",
},
})),
[],
[handleSortChange, sort],
);
const pagination: PaginationState = {
pageIndex: page - 1,
@@ -258,11 +292,17 @@ export function ObsltPplListPage() {
/** 확정 검색조건과 페이지를 서버 요청 파라미터로 변환해 목록을 갱신한다. */
const load = useCallback(
async (targetPage = page, targetSearch = appliedSearch) => {
async (
targetPage = page,
targetSearch = appliedSearch,
nextSort: UiTableSort | undefined = sort,
) => {
const params = new URLSearchParams({
page: String(Math.max(targetPage - 1, 0)),
size: String(DEFAULT_LIST_SIZE),
sort: "obsltSwagePplBizSn,desc",
sort: nextSort
? `${nextSort.key},${nextSort.direction}`
: "obsltSwagePplBizSn,desc",
});
Object.entries(targetSearch).forEach(([key, value]) => {
if (value.trim()) params.set(key, value.trim());
@@ -295,7 +335,7 @@ export function ObsltPplListPage() {
setLoading(false);
}
},
[appliedSearch, page],
[appliedSearch, page, sort],
);
useEffect(() => {
@@ -368,8 +408,11 @@ export function ObsltPplListPage() {
Object.entries(appliedSearch).forEach(([key, value]) => {
if (value.trim()) params.set(key, value.trim());
});
const exportRows = await getAllObsltPplBusinesses(params);
downloadObsltPplExcel(exportRows);
const exportRows = await getAllObsltPplBusinesses(
params,
sort ? `${sort.key},${sort.direction}` : undefined,
);
await downloadObsltPplExcel(exportRows);
} catch (error) {
setNotice(
error instanceof Error ? error.message : "엑셀을 내려받지 못했습니다.",
@@ -407,6 +450,7 @@ export function ObsltPplListPage() {
setSggCd("0");
setAppliedCtpvCd("0");
setAppliedSggCd("0");
setSort(undefined);
setNotice("");
setPage(1);
router.replace("/oper/obsltPplBiz?bizSe=정밀조사", { scroll: false });
@@ -5,27 +5,27 @@ export const windowPopupDefinitions = {
key: "cfmtnBgt",
title: "최종확정예산사업",
width: 1400,
height: 720,
height: 900,
requiredParams: ["bizSe"],
},
exmnBusinessRegister: {
key: "exmnBusinessRegister",
title: "조사사업 등록",
width: 1000,
height: 620,
height: 720,
},
exmnResultRegister: {
key: "exmnResultRegister",
title: "조사 결과 업로드",
width: 1000,
height: 650,
width: 1100,
height: 760,
requiredParams: ["exmnBizId"],
},
exmnSurveyDetail: {
key: "exmnSurveyDetail",
title: "조사 상세현황",
width: 1500,
height: 650,
height: 820,
requiredParams: ["exmnBizId", "trtdstId", "trtdstNm", "jobType"],
},
dredgingRegister: {
@@ -51,8 +51,8 @@ export const windowPopupDefinitions = {
oldPpUpload: {
key: "oldPpUpload",
title: "노후 하수관로 정비사업 등록",
width: 860,
height: 360,
width: 1000,
height: 420,
},
oldPpTable: {
key: "oldPpTable",
@@ -64,8 +64,8 @@ export const windowPopupDefinitions = {
floodPreUpload: {
key: "floodPreUpload",
title: "도시침수예방사업 등록",
width: 860,
height: 360,
width: 1000,
height: 420,
},
floodPreTable: {
key: "floodPreTable",
@@ -138,30 +138,30 @@ export const windowPopupDefinitions = {
agedPipePopup: {
key: "agedPipePopup",
title: "노후 하수관로 목록",
width: 850,
height: 600,
width: 1160,
height: 820,
placement: "browser-left",
alignTop: true,
offsetLeft: 0,
offsetTop: 200,
offsetTop: 70,
},
sinkholePopup: {
key: "sinkholePopup",
title: "지반침하발생현황",
width: 1380,
height: 820,
height: 960,
},
sbmrgDngrMapPopup: {
key: "sbmrgDngrMapPopup",
title: "침수위험지도 목록",
width: 1160,
height: 720,
height: 820,
},
floodDngrMapPopup: {
key: "floodDngrMapPopup",
title: "도시침수지도 목록",
width: 1160,
height: 720,
height: 820,
},
pipeAnalysisPopup: {
key: "pipeAnalysisPopup",
@@ -172,14 +172,14 @@ export const windowPopupDefinitions = {
pipeAnalysisDetailPopup: {
key: "pipeAnalysisDetailPopup",
title: "관로현황",
width: 1180,
height: 820,
width: 1280,
height: 900,
},
repairHistoryPopup: {
key: "repairHistoryPopup",
title: "보수이력",
width: 1260,
height: 760,
height: 820,
requiredParams: ["zoneId"],
},
fldngPredcPrst: {
@@ -192,7 +192,7 @@ export const windowPopupDefinitions = {
key: "agedPipeStatusByCtpvPopup",
title: "시도별 노후관로현황",
width: 1160,
height: 790,
height: 820,
},
fldngHstryPopup: {
key: "fldngHstryPopup",
@@ -13,6 +13,7 @@ import {
getStandardRowNumber,
type ScrollMode,
StandardDataTable,
StandardTableSortHeader,
type UiHeadProps,
} from "@common/business_components";
import type { UiTableSort } from "@common/business_components/ui/table";
@@ -95,27 +96,13 @@ export default function MapPopupDataTable<T extends Record<string, unknown>>({
const title = ROW_NUMBER_IDS.has(head.id) ? "순번" : head.title;
if (!head.sortable || !onSortChange) return title;
const key = head.sortKey ?? head.id;
const direction = sort?.key === key ? sort.direction : undefined;
return (
<button
type="button"
className="inline-flex w-full items-center justify-center gap-1.5"
aria-label={`${title} 정렬`}
onClick={() => {
if (direction === undefined) {
onSortChange({ key, direction: "asc" });
} else if (direction === "asc") {
onSortChange({ key, direction: "desc" });
} else {
onSortChange(undefined);
}
}}
>
<span>{title}</span>
<span className="text-[8px] leading-none" aria-hidden="true">
{direction === "asc" ? "▲" : direction === "desc" ? "▼" : "↕"}
</span>
</button>
<StandardTableSortHeader
label={title}
sortKey={key}
sort={sort}
onSortChange={onSortChange}
/>
);
},
cell: ({ row, getValue }) => {
@@ -10,6 +10,18 @@ type PopupSearchPanelProps = {
contentClassName?: string;
};
type PopupSearchFieldProps = {
label: ReactNode;
children: ReactNode;
className?: string;
labelClassName?: string;
};
type PopupSearchActionsProps = {
children: ReactNode;
className?: string;
};
/** WindowPopup 조회조건의 외곽 카드, 패딩과 가로 스크롤 규칙을 한 곳에서 관리한다. */
export default function PopupSearchPanel({
children,
@@ -24,9 +36,59 @@ export default function PopupSearchPanel({
className,
)}
>
<div className={cn("min-w-0", contentClassName)}>{children}</div>
<div
className={cn(
"flex min-w-0 flex-wrap items-center gap-x-5 gap-y-3",
contentClassName,
)}
>
{children}
</div>
</section>
);
}
export type { PopupSearchPanelProps };
/** 조회조건 라벨과 입력 컨트롤을 같은 행에 배치한다. */
export function PopupSearchField({
label,
children,
className,
labelClassName,
}: PopupSearchFieldProps) {
return (
<label className={cn("flex shrink-0 items-center gap-2", className)}>
<span
className={cn(
"shrink-0 whitespace-nowrap text-[1.3rem] font-medium text-label",
labelClassName,
)}
>
{label}
</span>
{children}
</label>
);
}
/** 조회·초기화 버튼을 검색조건의 우측 끝에 같은 간격으로 배치한다. */
export function PopupSearchActions({
children,
className,
}: PopupSearchActionsProps) {
return (
<div
className={cn(
"ml-auto flex min-w-fit shrink-0 items-center justify-end gap-2",
className,
)}
>
{children}
</div>
);
}
export type {
PopupSearchActionsProps,
PopupSearchFieldProps,
PopupSearchPanelProps,
};
+87 -59
View File
@@ -8,6 +8,7 @@ type ExcelDownloadWithHeaderProps = {
};
type ExcelRow = Record<string, unknown>;
const EXCEL_DATA_ROWS_PER_SHEET = 500_000;
// 엑셀 시트명 제한 문자와 최대 길이를 정리해 다운로드 시 런타임 오류를 방지한다.
const normalizeSheetName = (sheetName: string | undefined) => {
@@ -15,6 +16,18 @@ const normalizeSheetName = (sheetName: string | undefined) => {
return normalized.slice(0, 31) || "Sheet1";
};
/** 다중 시트일 때 접미사가 잘리지 않도록 시트명 길이를 함께 조정한다. */
const buildSheetName = (
baseName: string | undefined,
sheetIndex: number,
sheetCount: number,
) => {
const normalizedBase = normalizeSheetName(baseName);
if (sheetCount === 1) return normalizedBase;
const suffix = `_${sheetIndex + 1}`;
return `${normalizedBase.slice(0, 31 - suffix.length)}${suffix}`;
};
// 한글을 두 글자 폭으로 계산해 내려받은 파일의 기본 열 너비를 읽기 좋게 맞춘다.
const getDisplayWidth = (value: unknown) =>
Array.from(String(value ?? "")).reduce(
@@ -38,7 +51,9 @@ export const downloadExcelWithKoreanHeaders = async ({
throw new Error("엑셀 다운로드는 브라우저에서만 사용할 수 있습니다.");
}
const dataRows = (Array.isArray(data) ? data : [data]) as unknown as ExcelRow[];
const dataRows = (Array.isArray(data)
? data
: [data]) as unknown as ExcelRow[];
if (dataRows.length === 0) {
throw new Error("데이터가 없습니다.");
}
@@ -49,71 +64,84 @@ export const downloadExcelWithKoreanHeaders = async ({
const koreanHeaders = englishHeaders.map(
(header) => headerMapping[header] || header,
);
const worksheetRows = [
koreanHeaders,
...dataRows.map((row) => englishHeaders.map((header) => row[header])),
];
const worksheet = XLSX.utils.aoa_to_sheet(worksheetRows);
// SheetJS가 지원하는 범위에서는 기존 공통 다운로드와 유사한 헤더 스타일을 적용한다.
const decodedHeaderRange = XLSX.utils.decode_range(headerRange);
for (
let rowIndex = decodedHeaderRange.s.r;
rowIndex <= decodedHeaderRange.e.r;
rowIndex += 1
) {
const workbook = XLSX.utils.book_new();
const sheetCount = Math.max(
1,
Math.ceil(dataRows.length / EXCEL_DATA_ROWS_PER_SHEET),
);
for (let sheetIndex = 0; sheetIndex < sheetCount; sheetIndex += 1) {
const sheetRows = dataRows.slice(
sheetIndex * EXCEL_DATA_ROWS_PER_SHEET,
(sheetIndex + 1) * EXCEL_DATA_ROWS_PER_SHEET,
);
const worksheetRows = [
koreanHeaders,
...sheetRows.map((row) => englishHeaders.map((header) => row[header])),
];
const worksheet = XLSX.utils.aoa_to_sheet(worksheetRows);
// SheetJS가 지원하는 범위에서는 기존 공통 다운로드와 유사한 헤더 스타일을 적용한다.
for (
let columnIndex = decodedHeaderRange.s.c;
columnIndex <= decodedHeaderRange.e.c;
columnIndex += 1
let rowIndex = decodedHeaderRange.s.r;
rowIndex <= decodedHeaderRange.e.r;
rowIndex += 1
) {
const cellAddress = XLSX.utils.encode_cell({
r: rowIndex,
c: columnIndex,
});
const headerCell = worksheet[cellAddress] as
| (typeof worksheet[string] & { s?: Record<string, unknown> })
| undefined;
if (headerCell) {
headerCell.s = {
fill: { patternType: "solid", fgColor: { rgb: "9BC1E7" } },
font: { bold: true, sz: 13 },
alignment: {
horizontal: "center",
vertical: "center",
wrapText: true,
},
border: {
top: { style: "thin", color: { rgb: "FFFFFF" } },
bottom: { style: "thin", color: { rgb: "FFFFFF" } },
left: { style: "thin", color: { rgb: "FFFFFF" } },
right: { style: "thin", color: { rgb: "FFFFFF" } },
},
};
for (
let columnIndex = decodedHeaderRange.s.c;
columnIndex <= decodedHeaderRange.e.c;
columnIndex += 1
) {
const cellAddress = XLSX.utils.encode_cell({
r: rowIndex,
c: columnIndex,
});
const headerCell = worksheet[cellAddress] as
| ((typeof worksheet)[string] & { s?: Record<string, unknown> })
| undefined;
if (headerCell) {
headerCell.s = {
fill: { patternType: "solid", fgColor: { rgb: "9BC1E7" } },
font: { bold: true, sz: 13 },
alignment: {
horizontal: "center",
vertical: "center",
wrapText: true,
},
border: {
top: { style: "thin", color: { rgb: "FFFFFF" } },
bottom: { style: "thin", color: { rgb: "FFFFFF" } },
left: { style: "thin", color: { rgb: "FFFFFF" } },
right: { style: "thin", color: { rgb: "FFFFFF" } },
},
};
}
}
}
worksheet["!autofilter"] = { ref: headerRange };
if (sheetCount === 1) {
worksheet["!merges"] = mergeRanges.map((range) =>
XLSX.utils.decode_range(range),
);
}
worksheet["!cols"] = englishHeaders.map((header, columnIndex) => {
const sampledValues = sheetRows
.slice(0, 200)
.map((row) => row[header])
.concat(koreanHeaders[columnIndex]);
const width = Math.max(...sampledValues.map(getDisplayWidth), 10);
return { wch: Math.min(width + 2, 40) };
});
XLSX.utils.book_append_sheet(
workbook,
worksheet,
buildSheetName(sheetNameArray[0], sheetIndex, sheetCount),
);
}
worksheet["!autofilter"] = { ref: headerRange };
worksheet["!merges"] = mergeRanges.map((range) =>
XLSX.utils.decode_range(range),
);
worksheet["!cols"] = englishHeaders.map((header, columnIndex) => {
const sampledValues = dataRows
.slice(0, 200)
.map((row) => row[header])
.concat(koreanHeaders[columnIndex]);
const width = Math.max(...sampledValues.map(getDisplayWidth), 10);
return { wch: Math.min(width + 2, 40) };
});
const workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(
workbook,
worksheet,
normalizeSheetName(sheetNameArray[0]),
);
const normalizedFileName = fileName.toLowerCase().endsWith(".xlsx")
? fileName
: `${fileName}.xlsx`;
+48
View File
@@ -0,0 +1,48 @@
type FetchAllPagesOptions<TResponse, TItem> = {
fetchPage: (page: number, size: number) => Promise<TResponse>;
getItems: (response: TResponse) => TItem[];
getTotal: (response: TResponse) => number;
batchSize?: number;
};
export type FetchAllPagesResult<TItem> = {
items: TItem[];
total: number;
};
const DEFAULT_BATCH_SIZE = 2000;
const MAX_BATCH_COUNT = 10000;
/**
* 서버의 Pageable 최대 크기에 의존하지 않고 모든 페이지를 순회한다.
* 화면 전체건수와 Excel 데이터 행 수가 달라지는 문제를 공통으로 방지한다.
*/
export const fetchAllPages = async <TResponse, TItem>({
fetchPage,
getItems,
getTotal,
batchSize = DEFAULT_BATCH_SIZE,
}: FetchAllPagesOptions<TResponse, TItem>): Promise<
FetchAllPagesResult<TItem>
> => {
const normalizedBatchSize = Math.max(1, Math.trunc(batchSize));
const items: TItem[] = [];
let total = 0;
for (let page = 0; page < MAX_BATCH_COUNT; page += 1) {
const response = await fetchPage(page, normalizedBatchSize);
const pageItems = getItems(response);
if (page === 0) total = Math.max(0, Number(getTotal(response) ?? 0));
items.push(...pageItems);
if (pageItems.length === 0 || items.length >= total) break;
}
if (items.length !== total) {
throw new Error(
`전체 ${total.toLocaleString("ko-KR")}건 중 ${items.length.toLocaleString("ko-KR")}건만 조회되어 Excel 생성을 중단했습니다.`,
);
}
return { items, total };
};
@@ -0,0 +1,19 @@
"use client";
import { useEffect } from "react";
import { setBusinessUserMessageHandler } from "@common/business_components";
import { useAlertStore } from "../lib/use-alert-store";
/** 공통 비즈니스 컴포넌트의 사용자 알림을 SN 공통 메시지 다이얼로그로 연결한다. */
export function BusinessUserMessageBridge() {
const setMessage = useAlertStore((state) => state.setMessage);
useEffect(
() => setBusinessUserMessageHandler((message) => setMessage(message)),
[setMessage],
);
return null;
}