Merge branch 'iipbs/sn/dev' of https://www.onasys.co.kr:3000/ONAANDALL/frontend into iipbs/sn/dev
This commit is contained in:
@@ -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,
|
||||
|
||||
+41
-18
@@ -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) => (
|
||||
|
||||
+17
-10
@@ -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;
|
||||
};
|
||||
|
||||
+16
-8
@@ -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()}>
|
||||
엑셀다운로드
|
||||
|
||||
+34
-25
@@ -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("");
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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`;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user