Files
frontend/apps/st/src/components/statistics/input/SeweragePenetrationRateInp.tsx
T

891 lines
44 KiB
TypeScript

import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { useConfirm } from "@frontend-opensource/use-react-hooks";
import { useAlertStore, useGlobalProgressStore, useStatisticsInputToolbarGroupGate } from "@/shared/lib";
import {
STATISTICS_INPUT_URL_PATH,
verificationSearchToFormValues,
} from "@/shared/lib/stats-cd-input-navigation";
import { ConfirmDialog } from "@/shared/ui/confirm-dialog";
import { statisticsInputSewSupplyRateApi } from "@/entities/statistics/api/input/statistics-input-sew-supply-rate-service";
import { statisticsApi } from "@/entities/statistics/api/statistics-service";
import {
type CheckInputPeriodItem,
getVerificationPeriodActionBlockedMessage,
isInputPeriodCurrent,
type SewerageSupplyRateItem,
type SewerageSupplyRateRequestParams,
type SewerageSupplyRateSaveItem,
type SewerageSupplyRateSaveRequest,
type SewerageSupplyRateVerificationResult,
} from "@/entities/statistics/model/input/statistics-sewage-supply-rate-input-interface";
import type { StatisticsInputSubmitInfoFormData } from "@/entities/statistics/model/statistics-input-submit-info-schema";
import type {
cancelSubmitRequest,
ImrStatusResponse,
SewerageStatusInfo,
SubmitExceptionRequest,
} from "@/entities/statistics/model/statistics-interface";
import { LoadingWithAnimation } from "@common/components";
import { getUserInfo } from "../../../../../../packages/common/components/src/api";
import SubmitDailog from "./SubmitDailog";
import CancelSubmitButton from "@/components/button/CancelSubmitButton";
import LastYearDataButton from "@/components/button/LastYearDataButton";
import SaveButton from "@/components/button/SaveButton";
import SubmitButton from "@/components/button/SumitButton";
import VerificationResultButton from "@/components/button/VerificationResultButton";
// import { DataGrid } from "@rsone/components";
import DataGrid from "@/components/module/DataGrid";
import SearchForm from "@/components/module/SearchForm";
import { openVerificationResultDialogIfNeeded } from "@/components/module/post-submit-verification-results";
import { VerificationResultDialog } from "@/components/module/VerificationResultDialog";
import { VerificationResultHistoryDialog } from "@/components/module/VerificationResultHistoryDialog";
import type { SearchFormValues } from "@/components/type/SearchForm.type";
import { doList } from "@/components/util/column/input/DoList";
import { SeweragePenetrationRateInpColumn } from "@/components/util/column/input/SeweragePenetrationRateInpColumn";
import { getFooterSummary } from "@/components/util/grid/footerSummary";
import { toGridRows } from "@/components/util/grid/gridFunction";
import { useAdminStore } from "@/store/admin-store";
import { useStatisticsInputFocusStore } from "@/store/statistics-input-focus-store";
import { useTabStore } from "@/store/tab-store";
type GridRowWithVerification = Record<string, string | number> & {
verificationResult?: SewerageSupplyRateVerificationResult;
};
/** 컬럼 가이드 API 조회용 통계코드 (하수도 보급률) */
const SEWERAGE_PENETRATION_COLUMN_GUIDE_STATS_CD = "ST00010001";
// const hasVerificationErrors = (verification?: SewerageSupplyRateVerificationResult | null): boolean => {
// if (!verification) return false;
// return Object.values(verification).some((errors) => Array.isArray(errors) && errors.length > 0);
// };
const sleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
/** IMR 상태가 COMPLETED 이고 job_prog 가 100일 때까지 폴링 */
async function waitForImrJobCompleted(
jobId: string,
getStatus: (id: string) => Promise<ImrStatusResponse>,
options?: {
pollIntervalMs?: number;
maxAttempts?: number;
onProgress?: (jobProgress: number) => void;
},
): Promise<void> {
const pollIntervalMs = options?.pollIntervalMs ?? 2000;
const maxAttempts = options?.maxAttempts ?? 150;
for (let i = 0; i < maxAttempts; i++) {
const res = await getStatus(jobId);
options?.onProgress?.(Math.max(0, Math.min(100, Number(res.job_prog) || 0)));
if (res.status === "COMPLETED" && Number(res.job_prog) === 100) return;
if (res.status === "FAILED" || res.status === "ERROR" || res.status === "CANCELLED") {
throw new Error(res.error_msg || `IMR 검증이 ${res.status} 상태로 종료되었습니다.`);
}
await sleep(pollIntervalMs);
}
throw new Error("IMR 검증 완료 대기 시간이 초과되었습니다.");
}
function getSewerageSupplyRateParams(
values: SearchFormValues,
overrides?: { yr?: string; demoYr?: string; mode?: string }
): SewerageSupplyRateRequestParams {
return {
ctpvNm: values.sido,
sggNm: values.gugun === 'ALL' ? '' : values.gugun,
sewerBzmnCd: values.bsnmCd,
statsYr: overrides?.yr ?? values.year,
...(overrides?.mode && { mode: overrides.mode }),
};
}
function normalizeGridString(value: unknown): string | null {
if (typeof value !== "string") {
return null;
}
return value.trim() === "" ? null : value;
}
// 하수도 통계 관리 > 01. 하수도 보급률
const SeweragePenetrationRateInp = () => {
const { setMessage: alert } = useAlertStore((state) => state);
const [rowData, setRowData] = useState<Record<string, string | number>[]>([]);
const [isLoading, setIsLoading] = useState<boolean>(false);
const [searchValues, setSearchValues] = useState<SearchFormValues | null>(null);
const [isEnterable, setIsEnterable] = useState<boolean>(true);
const [lastSearchedYear, setLastSearchedYear] = useState<string | null>(null);
const [lastSearchedValues, setLastSearchedValues] = useState<SearchFormValues | null>(null);
const [statusInfo, setStatusInfo] = useState<SewerageStatusInfo | null>(null);
/** 마지막 조회 시 check-input-period 응답 (검증기간 여부 판단) */
const [periodCheckList, setPeriodCheckList] = useState<CheckInputPeriodItem[]>([]);
const [showDailog, setShowDailog] = useState<boolean>(false);
const [verificationHistoryOpen, setVerificationHistoryOpen] = useState(false);
const [verificationResultDialogOpen, setVerificationResultDialogOpen] = useState(false);
/** 예외제출 버튼으로 연 다이얼로그인지 (확인 시 executeSubmit 대신 SubmitExceptionFunc 경로) */
const [isExceptionSubmitFlow, setIsExceptionSubmitFlow] = useState(false);
const [statsCd, setStatsCd] = useState<string>('');
/** `engColNm` → `columnGuide` (헤더 hover) */
const [columnGuidesByEngCol, setColumnGuidesByEngCol] = useState<Record<string, string>>({});
const [footerFromApi, setFooterFromApi] = useState<{ sewerBzmnWholPpltnCnt?: number; sewerBzmnDomplWholPpltnCnt?: number; sewerBzmnFrgnrWholPpltnCnt?: number; sewerBzmnArea?: number } | null>(null);
const { confirm } = useConfirm();
const showGlobalProgress = useGlobalProgressStore((state) => state.show);
const setGlobalProgress = useGlobalProgressStore((state) => state.setProgress);
const setGlobalProgressLabel = useGlobalProgressStore((state) => state.setLabel);
const hideGlobalProgress = useGlobalProgressStore((state) => state.hide);
const globalProgressVisible = useGlobalProgressStore((state) => state.visible);
const postVerificationSearchRef = useRef<SearchFormValues | null>(null);
const [verificationSearchDefaults, setVerificationSearchDefaults] =
useState<SearchFormValues | null>(null);
const [searchFormRemountKey, setSearchFormRemountKey] = useState(0);
const activeTabId = useTabStore((s) => s.activeTabId);
const inputFocusNonce = useStatisticsInputFocusStore((s) => s.nonce);
useLayoutEffect(() => {
if (activeTabId !== STATISTICS_INPUT_URL_PATH.SEWERAGE_PENETRATION_RATE) {
return;
}
const pending = useStatisticsInputFocusStore
.getState()
.takePendingForUrl(STATISTICS_INPUT_URL_PATH.SEWERAGE_PENETRATION_RATE);
if (!pending?.search) return;
const values = verificationSearchToFormValues(pending.search);
setVerificationSearchDefaults(values);
postVerificationSearchRef.current = values;
setSearchFormRemountKey((k) => k + 1);
}, [activeTabId, inputFocusNonce]);
useEffect(() => {
if (activeTabId !== STATISTICS_INPUT_URL_PATH.SEWERAGE_PENETRATION_RATE) {
return;
}
let cancelled = false;
void (async () => {
try {
const res = await statisticsApi.getColumnGuides(SEWERAGE_PENETRATION_COLUMN_GUIDE_STATS_CD);
if (cancelled) return;
if (res.status !== "success" || !Array.isArray(res.data)) {
setColumnGuidesByEngCol({});
return;
}
const next: Record<string, string> = {};
for (const item of res.data) {
const key = item.engColNm?.trim();
const guide = item.columnGuide?.trim();
if (key && guide) next[key] = guide;
}
setColumnGuidesByEngCol(next);
} catch {
if (!cancelled) setColumnGuidesByEngCol({});
}
})();
return () => {
cancelled = true;
};
}, [activeTabId]);
const { isAdminInfo } = useAdminStore();
const toolbarGroupAllowed = useStatisticsInputToolbarGroupGate();
const getGridDataRef = useRef<(() => Record<string, string | number>[]) | null>(null);
const _footerFieldNames = ['sewerBzmnWholPpltnCnt', 'sewerBzmnDomplWholPpltnCnt', 'sewerBzmnFrgnrWholPpltnCnt', 'sewerBzmnArea'] as const;
const footerWithApiDefault = (fieldName: (typeof _footerFieldNames)[number], numberFormat: string) => ({
expression: 'sum' as const,
numberFormat,
valueCallback: (grid: Parameters<typeof getFooterSummary>[0], column: Parameters<typeof getFooterSummary>[1]) =>
(footerFromApi && footerFromApi[fieldName] != null ? footerFromApi[fieldName] : null) ?? getFooterSummary(grid, column),
});
// 최상위 관리자일땐 모든 상황에서 edit 가능하게 수정 필요.
const columnInfo = useMemo(() => {
const { columns, columnLayout } = SeweragePenetrationRateInpColumn;
const base = (isEnterable || isAdminInfo) ? columns : columns.map((col) => ({ ...col, editable: false as const }));
const withHeaderTooltips = base.map((col) => {
const guide = columnGuidesByEngCol[col.fieldName]?.trim();
return {
...col,
tooltip: guide && guide.length > 0 ? guide : undefined,
};
});
return {
columnLayout,
columns: withHeaderTooltips.map((col) => {
if (col.fieldName === 'pbadmsWholPpltnCnt') return { ...col, footer: footerWithApiDefault('sewerBzmnWholPpltnCnt', '#,##0') };
if (col.fieldName === 'domplWholPpltnCnt') return { ...col, footer: footerWithApiDefault('sewerBzmnDomplWholPpltnCnt', '#,##0') };
if (col.fieldName === 'frgnrWholPpltnCnt') return { ...col, footer: footerWithApiDefault('sewerBzmnFrgnrWholPpltnCnt', '#,##0') };
// 그리드 필드명은 refArea(행정참고자료면적). API commonInfo.sewerBzmnArea와 매핑
if (col.fieldName === 'refArea') return { ...col, footer: footerWithApiDefault('sewerBzmnArea', '#,##0.000') };
return col;
}),
};
}, [isEnterable, isAdminInfo, footerFromApi, columnGuidesByEngCol]);
const handleReset = () => {
setRowData([]);
setStatusInfo(null);
setPeriodCheckList([]);
setLastSearchedYear(null);
setLastSearchedValues(null);
setFooterFromApi(null);
setVerificationSearchDefaults(null);
setSearchFormRemountKey((k) => k + 1);
};
const fetchSewerageSupplyRate = async (params: SewerageSupplyRateRequestParams, isEnterable: boolean) => {
try {
setIsLoading(true);
const response = await statisticsInputSewSupplyRateApi.getSewerageSupplyRateList(params);
if (response.status === 'success' && response.data) {
const commonInfo = response.data.commonInfo;
const statusInfo = response.data.statusInfo;
const convertedData: SewerageSupplyRateItem[] = response.data.sewerageSupplyRateList.map((d: SewerageSupplyRateItem) => ({
...d,
ctpvNm: commonInfo.ctpvNm,
spcCtpnRgnCn: d.spcCtpnRgnCn === '' || d.spcCtpnRgnCn === null ? '해당없음' : d.spcCtpnRgnCn,
swagePplPsplyRt: commonInfo.swagePplPsplyRt,
}));
setRowData(
convertedData.map((d) => {
const row = toGridRows([d])[0] as GridRowWithVerification;
if (d.verificationResult) row.verificationResult = d.verificationResult;
return row;
})
);
setStatusInfo(statusInfo);
setFooterFromApi(
commonInfo
? {
sewerBzmnWholPpltnCnt: commonInfo.sewerBzmnWholPpltnCnt,
sewerBzmnDomplWholPpltnCnt: commonInfo.sewerBzmnDomplWholPpltnCnt,
sewerBzmnFrgnrWholPpltnCnt: commonInfo.sewerBzmnFrgnrWholPpltnCnt,
sewerBzmnArea: commonInfo.sewerBzmnArea,
}
: null
);
setStatsCd(commonInfo.statsCd);
if (statusInfo && statusInfo.laeSbmsnSttsCd) {
if (['2', '3'].includes(statusInfo.laeSbmsnSttsCd)) {
setIsEnterable(false);
} else {
setIsEnterable(isEnterable);
}
} else {
setIsEnterable(isEnterable);
}
} else {
alert(response.message);
}
} catch {
alert('데이터 조회 중 오류가 발생했습니다.');
setRowData([]);
setStatusInfo(null);
setFooterFromApi(null);
} finally {
setIsLoading(false);
}
};
const handleSearch = async (values: SearchFormValues) => {
if (values.sido === '' || values.sido === 'ALL') {
alert('시도를 선택해주세요.');
return;
}
try {
const response = await statisticsInputSewSupplyRateApi.checkInputPeriod(values.year || '');
if (response.status === 'success') {
const periods = Array.isArray(response.data) ? response.data : [];
setPeriodCheckList(periods);
const checkEnterable = isInputPeriodCurrent(periods);
setLastSearchedYear(values.year || null);
await fetchSewerageSupplyRate(getSewerageSupplyRateParams(values), checkEnterable);
setLastSearchedValues(values);
} else {
alert(response.message);
}
} catch {
alert('데이터 조회 중 오류가 발생했습니다.');
}
};
useEffect(() => {
const values = postVerificationSearchRef.current;
if (!values) return;
postVerificationSearchRef.current = null;
void handleSearch(values);
}, [searchFormRemountKey]);
const SubmitExceptionFunc = async (submitInfo: StatisticsInputSubmitInfoFormData) => {
const verificationBlocked = getVerificationPeriodActionBlockedMessage(periodCheckList);
if (verificationBlocked) {
alert(verificationBlocked);
return;
}
const userInfo = await getUserInfo();
const params: SubmitExceptionRequest = {
...submitInfo,
sewerBzmnCd: searchValues?.bsnmCd ?? '',
ctpvNm: searchValues?.sido ?? '',
sggNm: searchValues?.gugun === "ALL" ? "" : (searchValues?.gugun ?? ''),
statsYr: searchValues?.year ?? '',
userId: userInfo.userUnqId,
statsCd: statsCd,
};
// if (isAdminInfo) {
// params.superAdmin = 'superAdmin';
// }
try {
setIsLoading(true);
const response = await statisticsApi.submitException(params);
if (response.status === 'success') {
await fetchSewerageSupplyRate(getSewerageSupplyRateParams(searchValues ?? {}), false);
alert('제출되었습니다.');
} else {
alert(response.message || '데이터 제출 중 오류가 발생했습니다.');
}
} catch {
alert('데이터 제출 중 오류가 발생했습니다.');
} finally {
setIsLoading(false);
}
};
const getLastYearDataFunc = async (values: SearchFormValues) => {
if (values.sido === '' || values.sido === 'ALL') {
alert('시도를 선택해주세요.');
return;
}
// 실제 조회된 연도가 없으면 실행 불가
if (!lastSearchedYear) {
alert('먼저 데이터를 조회해주세요.');
return;
}
await fetchSewerageSupplyRate(getSewerageSupplyRateParams(values, { mode: 'PRE' }), true);
};
const submitFunc = async (isException: boolean) => {
if (!isException) {
// const isError = rowData.filter((d) =>
// hasVerificationErrors((d as GridRowWithVerification).verificationResult)
// );
// if (isError.length > 0) {
// alert(`데이터에 오류가 있습니다.
// 수정 후 제출해주세요.`);
// return;
// }
if (await confirm('제출 하시겠습니까?')) {
setIsExceptionSubmitFlow(false);
setShowDailog(true);
}
} else {
if (await confirm('예외 제출 시 검증 결과에 오류가 있어도 제출 처리됩니다.')) {
setIsExceptionSubmitFlow(true);
setShowDailog(true);
}
}
};
const executeExceptionSubmit = async (submitInfo: StatisticsInputSubmitInfoFormData) => {
await SubmitExceptionFunc(submitInfo);
};
const executeSubmit = async (submitInfo: StatisticsInputSubmitInfoFormData) => {
const verificationBlocked = getVerificationPeriodActionBlockedMessage(periodCheckList);
if (verificationBlocked) {
alert(verificationBlocked);
return;
}
try {
showGlobalProgress({ label: '서버에 요청 중...', progress: 0 });
const userInfo = await getUserInfo();
const currentData = getGridDataRef.current?.();
if (!currentData || currentData.length === 0) {
alert('제출할 데이터가 없습니다.');
return;
}
const columnFormatMap = new Map<string, string>();
SeweragePenetrationRateInpColumn.columns.forEach((col) => {
if (col.fieldName && col.dataFormat) {
columnFormatMap.set(col.fieldName, col.dataFormat);
}
});
const formatNumber = (value: string | number | null | undefined, fieldName: string): number => {
if (value === null || value === undefined || value === '') return 0;
const num = typeof value === 'string' ? parseFloat(value.replace(/,/g, '')) : value;
if (isNaN(num)) return 0;
const format = columnFormatMap.get(fieldName);
if (!format) return num;
const decimalPlaces = format.match(/\.(\d+)/)?.[1]?.length ?? 0;
return decimalPlaces > 0 ? parseFloat(num.toFixed(decimalPlaces)) : Math.round(num);
};
const convertedData: SewerageSupplyRateSaveItem[] = currentData.map((d) => ({
sggNm: normalizeGridString(d.sggNm),
admdstCd: normalizeGridString(d.admdstCd),
admdstNm: normalizeGridString(d.admdstNm),
trbtrNm: normalizeGridString(d.trbtrNm),
spcCtpnRgnCn:
d.spcCtpnRgnCn === '해당없음' ? null : normalizeGridString(d.spcCtpnRgnCn),
ts1SwtrmtPpltnCnt: formatNumber(d.ts1SwtrmtPpltnCnt, 'ts1SwtrmtPpltnCnt'),
ts2SwtrmtPpltnCnt: formatNumber(d.ts2SwtrmtPpltnCnt, 'ts2SwtrmtPpltnCnt'),
ts3SwtrmtPpltnCnt: formatNumber(d.ts3SwtrmtPpltnCnt, 'ts3SwtrmtPpltnCnt'),
ts1WwtPrcsPpltnCnt: formatNumber(d.ts1WwtPrcsPpltnCnt, 'ts1WwtPrcsPpltnCnt'),
ts2WwtPrcsPpltnCnt: formatNumber(d.ts2WwtPrcsPpltnCnt, 'ts2WwtPrcsPpltnCnt'),
ts3WwtPrcsPpltnCnt: formatNumber(d.ts3WwtPrcsPpltnCnt, 'ts3WwtPrcsPpltnCnt'),
yetCntnPpltnCnt: formatNumber(d.yetCntnPpltnCnt, 'yetCntnPpltnCnt'),
staaArea: formatNumber(d.staaArea, 'staaArea'),
unprcsPpltnCnt: formatNumber(d.unprcsPpltnCnt, 'unprcsPpltnCnt'),
drtwPrcsPpltnCnt: formatNumber(d.drtwPrcsPpltnCnt, 'drtwPrcsPpltnCnt'),
spctnkPpltnCnt: formatNumber(d.spctnkPpltnCnt, 'spctnkPpltnCnt'),
staaExcpArea: formatNumber(d.staaExcpArea, 'staaExcpArea'),
rmrk: normalizeGridString(d.rmrk),
wholPpltnCnt: formatNumber(d.wholPpltnCnt, 'wholPpltnCnt'),
wholArea: formatNumber(d.wholArea, 'wholArea'),
wholSwtrmtPpltnCnt: formatNumber(d.wholSwtrmtPpltnCnt, 'wholSwtrmtPpltnCnt'),
wholPblSwtrmtPpltnCnt: formatNumber(d.wholPblSwtrmtPpltnCnt, 'wholPblSwtrmtPpltnCnt'),
wholWwtPrcsPpltnCnt: formatNumber(d.wholWwtPrcsPpltnCnt, 'wholWwtPrcsPpltnCnt'),
wholStaaExcpPpltnCnt: formatNumber(d.wholStaaExcpPpltnCnt, 'wholStaaExcpPpltnCnt'),
staaPsplyRt: formatNumber(d.staaPsplyRt, 'staaPsplyRt'),
altdPrcsPsplyRt: formatNumber(d.altdPrcsPsplyRt, 'altdPrcsPsplyRt'),
sewerInstlRt: formatNumber(d.sewerInstlRt, 'sewerInstlRt'),
}));
const requestData: SewerageSupplyRateSaveRequest = {
sewerBzmnCd: searchValues?.bsnmCd ?? '',
ctpvNm: searchValues?.sido ?? '',
sggNm: searchValues?.gugun === "ALL" ? "" : (searchValues?.gugun ?? ''),
statsYr: searchValues?.year ?? '',
userId: userInfo.userUnqId,
submitList: convertedData,
sewerBzmnFrgnrWholPpltnCnt: footerFromApi?.sewerBzmnFrgnrWholPpltnCnt,
laePicNm: submitInfo.laePicNm,
laeKbrdrJbgdNm: submitInfo.laeKbrdrJbgdNm,
laeKbrdrDeptNm: submitInfo.laeKbrdrDeptNm,
laePicTelNo: submitInfo.laePicTelNo,
laeIdfrNm: submitInfo.laeIdfrNm,
laeIdfrJbgdNm: submitInfo.laeIdfrJbgdNm,
laeIdfrDeptNm: submitInfo.laeIdfrDeptNm,
laeIdfrTelNo: submitInfo.laeIdfrTelNo,
};
setGlobalProgressLabel('일반 검증 진행 중...');
const response = await statisticsInputSewSupplyRateApi.submitSewerageSupplyRate(requestData);
if (response.status === 'success') {
setGlobalProgress(50);
if (response.data.finalStatus === '5') {
setGlobalProgressLabel('IMR 검증 진행 중...');
const imrParam = {
user_id: userInfo.userUnqId,
sewer_bzmn_cd: searchValues?.bsnmCd ?? '',
stats_cd: statsCd,
stats_yr: searchValues?.year ?? '',
lae_sbmsn_stts_cd: (statusInfo?.laeSbmsnSttsCd === '4' ? '4' : '2'),
gen_verify_stts_cd: response.data.nvpassed ? '1' : '2',
};
try {
const imrResponse = await statisticsApi.callImrService(imrParam);
if (imrResponse.job_id) {
await waitForImrJobCompleted(imrResponse.job_id, (id) =>
statisticsApi.getImrStatus(id),
{
onProgress: (jobProgress) => {
setGlobalProgress(50 + Math.round(jobProgress / 2));
},
},
);
}
setGlobalProgress(100);
await fetchSewerageSupplyRate(getSewerageSupplyRateParams(searchValues ?? {}), true);
await openVerificationResultDialogIfNeeded(
searchValues?.year && searchValues?.sido && searchValues?.bsnmCd && statsCd
? {
sewerBzmnCd: searchValues.bsnmCd,
ctpvNm: searchValues.sido,
sggNm: searchValues.gugun === "ALL" ? "" : (searchValues.gugun ?? ""),
statsYr: searchValues.year,
statsCd,
}
: null,
setVerificationResultDialogOpen,
{ notify: alert },
);
} catch (e) {
await fetchSewerageSupplyRate(getSewerageSupplyRateParams(searchValues ?? {}), true);
setIsLoading(false);
await openVerificationResultDialogIfNeeded(
searchValues?.year && searchValues?.sido && searchValues?.bsnmCd && statsCd
? {
sewerBzmnCd: searchValues.bsnmCd,
ctpvNm: searchValues.sido,
sggNm: searchValues.gugun === "ALL" ? "" : (searchValues.gugun ?? ""),
statsYr: searchValues.year,
statsCd,
}
: null,
setVerificationResultDialogOpen,
{ notify: alert, notifySubmitComplete: false },
);
const msg =
e instanceof Error && e.message
? e.message
: 'IMR 검증 중 오류가 발생했습니다.\n관리자에게 문의하세요.';
alert(msg);
return;
}
} else {
setGlobalProgress(100);
await fetchSewerageSupplyRate(getSewerageSupplyRateParams(searchValues ?? {}), false);
await openVerificationResultDialogIfNeeded(
searchValues?.year && searchValues?.sido && searchValues?.bsnmCd && statsCd
? {
sewerBzmnCd: searchValues.bsnmCd,
ctpvNm: searchValues.sido,
sggNm: searchValues.gugun === "ALL" ? "" : (searchValues.gugun ?? ""),
statsYr: searchValues.year,
statsCd,
}
: null,
setVerificationResultDialogOpen,
{ notify: alert },
);
}
} else {
alert(response.message || '데이터 제출 중 오류가 발생했습니다.');
}
} catch {
alert('데이터 제출 중 오류가 발생했습니다.');
} finally {
hideGlobalProgress();
}
};
const saveFunc = async () => {
try {
if (await confirm('저장 하시겠습니까?')) {
const verificationBlocked = getVerificationPeriodActionBlockedMessage(periodCheckList);
if (verificationBlocked) {
alert(verificationBlocked);
return;
}
showGlobalProgress({ label: "저장 진행 중...", progress: 0 });
const userInfo = await getUserInfo();
const currentData = getGridDataRef.current?.();
if (!currentData || currentData.length === 0) {
alert('저장할 데이터가 없습니다.');
return;
}
// 컬럼 정의에서 dataFormat 추출 (소수점 자리수 맞추기 위해)
const columnFormatMap = new Map<string, string>();
SeweragePenetrationRateInpColumn.columns.forEach((col) => {
if (col.fieldName && col.dataFormat) {
columnFormatMap.set(col.fieldName, col.dataFormat);
}
});
// 숫자 포맷팅 함수 (dataFormat에 따라 소수점 자리수 맞춤)
const formatNumber = (value: string | number | null | undefined, fieldName: string): number => {
if (value === null || value === undefined || value === '') return 0;
const num = typeof value === 'string' ? parseFloat(value.replace(/,/g, '')) : value;
if (isNaN(num)) return 0;
const format = columnFormatMap.get(fieldName);
if (!format) return num;
// #,##0.000 → 소수점 3자리, #,##0.0 → 소수점 1자리, #,##0 → 정수
const decimalPlaces = format.match(/\.(\d+)/)?.[1]?.length ?? 0;
return decimalPlaces > 0 ? parseFloat(num.toFixed(decimalPlaces)) : Math.round(num);
};
const convertedData: SewerageSupplyRateSaveItem[] = currentData.map((d) => ({
// 행정구역
sggNm: normalizeGridString(d.sggNm), // 시군구
admdstCd: normalizeGridString(d.admdstCd), // 행정코드
admdstNm: normalizeGridString(d.admdstNm), // 행정구역명
trbtrNm: normalizeGridString(d.trbtrNm), // 지류
spcCtpnRgnCn:
d.spcCtpnRgnCn === '해당없음'
? null
: normalizeGridString(d.spcCtpnRgnCn), // 특별대책지역
// 공공하수처리인구(명) - 물리적/생물학적/고도처리
ts1SwtrmtPpltnCnt: formatNumber(d.ts1SwtrmtPpltnCnt, 'ts1SwtrmtPpltnCnt'), // 물리적처리
ts2SwtrmtPpltnCnt: formatNumber(d.ts2SwtrmtPpltnCnt, 'ts2SwtrmtPpltnCnt'), // 생물학적처리
ts3SwtrmtPpltnCnt: formatNumber(d.ts3SwtrmtPpltnCnt, 'ts3SwtrmtPpltnCnt'), // 고도처리
// 폐수처리인구(명) - 물리적/생물학적/고도처리
ts1WwtPrcsPpltnCnt: formatNumber(d.ts1WwtPrcsPpltnCnt, 'ts1WwtPrcsPpltnCnt'),
ts2WwtPrcsPpltnCnt: formatNumber(d.ts2WwtPrcsPpltnCnt, 'ts2WwtPrcsPpltnCnt'),
ts3WwtPrcsPpltnCnt: formatNumber(d.ts3WwtPrcsPpltnCnt, 'ts3WwtPrcsPpltnCnt'),
yetCntnPpltnCnt: formatNumber(d.yetCntnPpltnCnt, 'yetCntnPpltnCnt'), // 미접속인구
staaArea: formatNumber(d.staaArea, 'staaArea'), // 하수처리구역 내 면적(㎢)
// 하수처리구역 외
unprcsPpltnCnt: formatNumber(d.unprcsPpltnCnt, 'unprcsPpltnCnt'), // 미처리인구
drtwPrcsPpltnCnt: formatNumber(d.drtwPrcsPpltnCnt, 'drtwPrcsPpltnCnt'), // 오수처리인구
spctnkPpltnCnt: formatNumber(d.spctnkPpltnCnt, 'spctnkPpltnCnt'), // 정화조인구
staaExcpArea: formatNumber(d.staaExcpArea, 'staaExcpArea'), // 하수처리구역 외 면적(㎢)
rmrk: normalizeGridString(d.rmrk), // 비고
// 합계·소계(계산필드 저장용)
wholPpltnCnt: formatNumber(d.wholPpltnCnt, 'wholPpltnCnt'), // 총인구(명)
wholArea: formatNumber(d.wholArea, 'wholArea'), // 총면적(㎢)
wholSwtrmtPpltnCnt: formatNumber(d.wholSwtrmtPpltnCnt, 'wholSwtrmtPpltnCnt'), // 하수처리구역 내 합계
wholPblSwtrmtPpltnCnt: formatNumber(d.wholPblSwtrmtPpltnCnt, 'wholPblSwtrmtPpltnCnt'), // 공공하수처리인구 소계
wholWwtPrcsPpltnCnt: formatNumber(d.wholWwtPrcsPpltnCnt, 'wholWwtPrcsPpltnCnt'), // 폐수처리인구 소계
wholStaaExcpPpltnCnt: formatNumber(d.wholStaaExcpPpltnCnt, 'wholStaaExcpPpltnCnt'), // 하수처리구역 외 합계
// 보급률(%)
staaPsplyRt: formatNumber(d.staaPsplyRt, 'staaPsplyRt'), // 공공하수처리구역 인구보급률
altdPrcsPsplyRt: formatNumber(d.altdPrcsPsplyRt, 'altdPrcsPsplyRt'), // 고도처리 인구보급률
sewerInstlRt: formatNumber(d.sewerInstlRt, 'sewerInstlRt'), // 하수도 설치율
}));
const requestData: SewerageSupplyRateSaveRequest = {
sewerBzmnCd: searchValues?.bsnmCd ?? '', // 하수도 사업자 코드
ctpvNm: searchValues?.sido ?? '', // 시도
sggNm: searchValues?.gugun === "ALL" ? "" : (searchValues?.gugun ?? ''),
statsYr: searchValues?.year ?? '', // 통계 연도
userId: userInfo.userUnqId, // 사용자
saveList: convertedData, // 저장 데이터
sewerBzmnFrgnrWholPpltnCnt: footerFromApi?.sewerBzmnFrgnrWholPpltnCnt // 행정참고자료 외국인 합계
};
// if (isAdminInfo) {
// requestData.superAdmin = 'superAdmin';
// }
const response = await statisticsInputSewSupplyRateApi.saveSewerageSupplyRate(requestData);
if (response.status === 'success') {
setGlobalProgress(50);
setGlobalProgress(100);
await fetchSewerageSupplyRate(getSewerageSupplyRateParams(searchValues ?? {}), true);
alert('저장되었습니다.');
} else {
alert(response.message || '데이터 저장 중 오류가 발생했습니다.');
}
} else {
// 저장 취소
}
} catch {
alert('데이터 저장 중 오류가 발생했습니다.');
} finally {
hideGlobalProgress();
}
};
const cnacelSubmitFunc = async () => {
try {
if (await confirm('제출된 내용을 취소하시겠습니까?')) {
const verificationBlocked = getVerificationPeriodActionBlockedMessage(periodCheckList);
if (verificationBlocked) {
alert(verificationBlocked);
return;
}
const userInfo = await getUserInfo();
const requestData: cancelSubmitRequest = {
sewerBzmnCd: searchValues?.bsnmCd ?? '',
ctpvNm: searchValues?.sido ?? '',
sggNm: searchValues?.gugun === "ALL" ? "" : (searchValues?.gugun ?? ""),
statsYr: searchValues?.year ?? '',
mdfrId: userInfo.userUnqId,
statsCd: statsCd,
};
const response = await statisticsApi.cancelSubmit(requestData);
if (response.status === 'success') {
fetchSewerageSupplyRate(getSewerageSupplyRateParams(searchValues ?? {}), true);
alert('제출 취소되었습니다.');
} else {
alert(response.message ?? '제출 취소 중 오류가 발생했습니다.');
}
}
} catch {
alert('제출 취소 중 오류가 발생했습니다.');
}
};
const openVerificationHistoryDialog = () => {
if (!searchValues?.year || !searchValues?.sido || !searchValues?.bsnmCd || !statsCd) {
alert("먼저 조회를 진행해주세요.");
return;
}
setVerificationHistoryOpen(true);
};
const gugunOkOrNotDoList = doList.includes(lastSearchedValues?.bsnmCd || '')
? Boolean(lastSearchedValues?.gugun && lastSearchedValues.gugun !== 'ALL')
: true;
const inputPeriodCurrent = isInputPeriodCurrent(periodCheckList);
const isSubmitted = statusInfo?.laeSbmsnSttsCd === '2';
const baseShowSaveInToolbar = (isEnterable || isAdminInfo) && gugunOkOrNotDoList;
const baseShowEnterableToolbarButtons = isEnterable && gugunOkOrNotDoList;
const showSubmittedInInputPeriodToolbar =
isSubmitted && inputPeriodCurrent && gugunOkOrNotDoList;
const showSaveInToolbar = baseShowSaveInToolbar && toolbarGroupAllowed;
const showEnterableToolbarButtons = baseShowEnterableToolbarButtons && toolbarGroupAllowed;
const showActionToolbarCard =
rowData.length > 0 &&
toolbarGroupAllowed &&
(baseShowSaveInToolbar ||
baseShowEnterableToolbarButtons ||
showSubmittedInInputPeriodToolbar ||
(isSubmitted && isAdminInfo));
return (
<>
<div className="relative">
<SearchForm
key={searchFormRemountKey}
defaultValues={verificationSearchDefaults ?? undefined}
onSubmit={handleSearch}
onReset={handleReset}
onValuesChange={setSearchValues}
/>
{showActionToolbarCard && (
<div className="card !py-0">
<div className="flex justify-end items-center gap-[0.6rem] mx-[0.6rem]">
<VerificationResultButton
VerificationResultButtonFunc={openVerificationHistoryDialog}
/>
{showEnterableToolbarButtons && (
<>
<LastYearDataButton getLastYearDataFunc={() => getLastYearDataFunc(searchValues ?? {})} />
<SubmitButton submitFunc={() => submitFunc(false)} />
</>
)}
{statusInfo?.laeSbmsnSttsCd === '2' && <CancelSubmitButton cnacelSubmitFunc={cnacelSubmitFunc} />}
{showSaveInToolbar && <SaveButton saveFunc={saveFunc} />}
</div>
</div>
)}
{isLoading && !globalProgressVisible && <div className="absolute inset-0 z-50 [data-theme='black']:bg-transparent rounded-lg p-10 flex justify-center items-center">
<div className="relative size-[5rem]">
<LoadingWithAnimation color="primary" />
</div>
</div>}
<DataGrid
key={(isEnterable || isAdminInfo) ? 'enterable' : 'readonly'}
columnInfo={columnInfo}
isExcelImport={(rowData.length > 0) && isEnterable && (doList.includes(lastSearchedValues?.bsnmCd || '') ? Boolean(lastSearchedValues?.gugun && lastSearchedValues.gugun !== 'ALL') : true)}
rowStartNum={3}
fixedColumns={1}
rowData={rowData}
setRowData={setRowData}
statusInfo={statusInfo}
getGridDataRef={getGridDataRef}
cellStyleCallback={({ row, fieldName }) => {
const verification = (row as GridRowWithVerification).verificationResult;
if (!verification) return;
const errors = verification[fieldName];
if (Array.isArray(errors) && errors.length > 0) {
// IMR 검증은 노란색으로 구분
const hasImr = errors.some((e) => e.vrfcCd === 'IMR' || e.vrfcCd === 'HB');
return hasImr ? 'rg-cell-verify-error-imr' : 'rg-cell-verify-error';
}
}}
cellTooltipCallback={({ row, fieldName }) => {
const verification = (row as GridRowWithVerification).verificationResult;
if (!verification) return;
const errors = verification[fieldName];
if (Array.isArray(errors) && errors.length > 0) {
return errors
.map((e) => {
const prefix = e.vrfcCd === 'IMR' ? 'IMR검증 - ' : (e.vrfcCd === 'NM' ? '일반검증 - ' : 'HB검증 -');
return `${prefix}${e.rsnExpln}
(검증 기준값: ${e.compVlCn} / 현재 입력값: ${e.nowVlCn})`;
})
.filter(Boolean)
.join('\n');
}
}}
/>
</div>
<ConfirmDialog isOpen={false} />
<SubmitDailog
open={showDailog}
onOpenChange={(open) => {
setShowDailog(open);
if (!open) setIsExceptionSubmitFlow(false);
}}
statusInfo={statusInfo}
onConfirm={executeSubmit}
onExceptionConfirm={executeExceptionSubmit}
submitMode={isExceptionSubmitFlow ? 'exception' : 'normal'}
isLoading={isLoading}
/>
<VerificationResultHistoryDialog
open={verificationHistoryOpen}
onOpenChange={setVerificationHistoryOpen}
queryContext={
searchValues?.year && searchValues?.sido && searchValues?.bsnmCd && statsCd
? {
sewerBzmnCd: searchValues.bsnmCd,
ctpvNm: searchValues.sido,
sggNm: searchValues.gugun === "ALL" ? "" : (searchValues.gugun ?? ""),
statsYr: searchValues.year,
statsCd,
}
: null
}
/>
<VerificationResultDialog
open={verificationResultDialogOpen}
onOpenChange={setVerificationResultDialogOpen}
queryContext={
searchValues?.year && searchValues?.sido && searchValues?.bsnmCd && statsCd
? {
sewerBzmnCd: searchValues.bsnmCd,
ctpvNm: searchValues.sido,
sggNm:
searchValues.gugun === "ALL"
? ""
: (searchValues.gugun ?? ""),
statsYr: searchValues.year,
statsCd,
}
: null
}
onExceptionSubmitted={async () => {
const values = lastSearchedValues ?? searchValues;
if (!values) return;
await handleSearch(values);
}}
/>
</>
);
};
export default SeweragePenetrationRateInp;