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

1128 lines
44 KiB
TypeScript

import {
type RefObject,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { flushSync } from "react-dom";
import { useConfirm } from "@frontend-opensource/use-react-hooks";
import { ValueType } from "realgrid";
import type { RealGridReact } from "realgrid-react";
import { useAlertStore, useGlobalProgressStore, useStatisticsInputToolbarGroupGate, useSearchConditionSidoList } from "@/shared/lib";
import { STATISTICS_INPUT_URL_PATH } from "@/shared/lib/stats-cd-input-navigation";
import { useStatisticsInputPendingNavigation } from "@/shared/lib/use-statistics-input-pending-navigation";
import { ConfirmDialog } from "@/shared/ui/confirm-dialog";
import { nullableTrimmedString } from "@/components/util/input/nullableString";
import { statisticsInputExcretaTreatmentFacilityApi } from "@/entities/statistics/api/input/statistics-input-excreta-treatment-facility-service";
import { statisticsInputSewSupplyRateApi } from "@/entities/statistics/api/input/statistics-input-sew-supply-rate-service";
import { statisticsApi } from "@/entities/statistics/api/statistics-service";
import {
EXCRETA_TREATMENT_FACILITY_STATS_CD,
type ExcretaTreatmentFacilityItem,
type ExcretaTreatmentFacilityRequestParams,
type ExcretaTreatmentFacilitySaveItem,
type ExcretaTreatmentFacilityVerificationResult,
} from "@/entities/statistics/model/input/statistics-input-excreta-treatment-facility-interface";
import { isInputPeriodCurrent } 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 {
ApiResponse,
cancelSubmitRequest,
ImrStatusResponse,
SewerageStatusInfo,
Sgg,
Sido,
} from "@/entities/statistics/model/statistics-interface";
import {
filterBySelectedAdmdstCd,
isGugunOkForToolbar,
isSubmitOkForToolbar,
resolveNoApplicableForRequest,
toInputSearchRegion,
} from "@/entities/statistics";
import { LoadingWithAnimation } from "@common/components";
import { getUserInfo } from "../../../../../../packages/common/components/src/api";
import SubmitDailog from "./SubmitDailog";
import AddArticleButton from "@/components/button/AddArticleButton";
import CancelSubmitButton from "@/components/button/CancelSubmitButton";
import DeleteArticleButton from "@/components/button/DeleteArticleButton";
import LastYearDataButton from "@/components/button/LastYearDataButton";
import NotApplicableCheckButton from "@/components/button/NotApplicableCheckButton";
import SaveButton from "@/components/button/SaveButton";
import SubmitButton from "@/components/button/SumitButton";
import VerificationResultButton from "@/components/button/VerificationResultButton";
import DataGrid from "@/components/module/DataGrid";
import { openVerificationResultDialogIfNeeded } from "@/components/module/post-submit-verification-results";
import SearchForm from "@/components/module/SearchForm";
import { VerificationResultDialog } from "@/components/module/VerificationResultDialog";
import { VerificationResultHistoryDialog } from "@/components/module/VerificationResultHistoryDialog";
import type {
CellStyleCallbackParams,
CellTooltipCallbackParams,
ColumnConfig,
} from "@/components/type/DataGrid.type";
import type { SearchFormValues } from "@/components/type/SearchForm.type";
import { ExcretaTreatFactInpColumn } from "@/components/util/column/input/ExcretaTreatFactInpColumn";
import { toGridRows } from "@/components/util/grid/gridFunction";
import { formatCompactYmdForGrid, toCompactYmdOrNull } from "@/components/util/grid/ymdFormat";
import { toExcelExportSearchContextFromSearchForm } from "@/components/util/gridExcelExport";
import { isVerificationFailedFromCommonInfo } from "@/components/util/input/verification-failed-flag";
import type { VerificationResultLikeMap } from "@/components/util/input/verification-result-display";
import { useAdminStore } from "@/store/admin-store";
import { useTabStore } from "@/store/tab-store";
type GridRowWithVerification = Record<string, string | number> & {
verificationResult?: ExcretaTreatmentFacilityVerificationResult;
};
type TabToolbarSnapshot = {
showSaveInToolbar: boolean;
showEnterableToolbarButtons: boolean;
showActionToolbarCard: boolean;
};
const computeTabEnterable = (
statusInfo: SewerageStatusInfo | null | undefined,
periodEnterable: boolean,
): boolean => {
if (statusInfo?.lclgvSbmsnSttsCd) {
if (["2", "3"].includes(statusInfo.lclgvSbmsnSttsCd)) {
return false;
}
return periodEnterable;
}
return periodEnterable;
};
const sidoListRef: { current: Sido[] } = { current: [] };
function buildGugunOkForToolbar(values: SearchFormValues | null): boolean {
return isGugunOkForToolbar(sidoListRef.current, values);
}
function buildSubmitOkForToolbar(values: SearchFormValues | null): boolean {
return isSubmitOkForToolbar(sidoListRef.current, values);
}
function buildNoApplicableForRequest(
values: SearchFormValues | null,
checked: boolean,
isSaveListEmpty: boolean,
): boolean {
return resolveNoApplicableForRequest(sidoListRef.current, values, checked, isSaveListEmpty);
}
function computeTabToolbarSnapshot(
statusInfo: SewerageStatusInfo | null,
hasRowData: boolean,
treatAsSearched: boolean,
searchContext: SearchFormValues | null,
periodEnterable: boolean,
isAdminInfo: boolean,
): TabToolbarSnapshot {
const gugunOk = buildGugunOkForToolbar(searchContext);
const enterable = computeTabEnterable(statusInfo, periodEnterable);
const isSubmitted = statusInfo?.lclgvSbmsnSttsCd === "2";
const showSaveInToolbar = (enterable || isAdminInfo) && periodEnterable && gugunOk;
const showEnterableToolbarButtons = enterable && gugunOk;
const showSubmittedInInputPeriodToolbar =
isSubmitted && periodEnterable && gugunOk;
const showActionToolbarCard =
(hasRowData || treatAsSearched) &&
(showSaveInToolbar ||
showEnterableToolbarButtons ||
showSubmittedInInputPeriodToolbar ||
(isSubmitted && isAdminInfo));
return { showSaveInToolbar, showEnterableToolbarButtons, showActionToolbarCard };
}
function buildExcretaTreatmentFacilitySearchParam(
values: SearchFormValues,
overrides?: { mode?: string },
): ExcretaTreatmentFacilityRequestParams {
const fcltNm = values.fctName?.trim();
return {
statsYr: values.year,
ctpvNm: values.sido,
...toInputSearchRegion(sidoListRef.current, values),
...(fcltNm ? { fcltNm } : {}),
...(overrides?.mode ? { mode: overrides.mode } : {}),
};
}
function parseSggListFromResponse(res: ApiResponse<Sgg[]>): Sgg[] {
if (res.status !== "success" || res.data == null) return [];
return Array.isArray(res.data) ? res.data : [];
}
function buildSggNmColumn(
col: ColumnConfig,
canEdit: boolean,
sggOptions: Sgg[],
): ColumnConfig {
if (!canEdit) {
return { ...col, editable: false as const };
}
if (sggOptions.length === 0) {
return { ...col, editable: true };
}
const labels = sggOptions.map((s) => s.gugun);
return {
...col,
editable: true,
editor: {
type: "dropdown",
domainOnly: true,
textReadOnly: true,
commitOnSelect: true,
dropDownWhenClick: true,
values: labels,
labels,
},
values: labels,
labels,
lookupDisplay: true,
};
}
function mapExcretaTreatmentFacilityListToGridRows(
list: ExcretaTreatmentFacilityItem[],
ctpvNm: string,
): GridRowWithVerification[] {
return list.map((item) => {
const row = toGridRows([
{
...item,
ctpvNm,
cmptnStrtYmd: formatCompactYmdForGrid(item.cmptnStrtYmd),
oprtngStrtYmd: formatCompactYmdForGrid(item.oprtngStrtYmd),
},
])[0] as GridRowWithVerification;
if (item.verificationResult) {
row.verificationResult = item.verificationResult;
}
return row;
});
}
function toGridNumber(value: string | number | null | undefined): number {
const n = Number(value);
return Number.isFinite(n) ? n : 0;
}
function toApiYmd(value: string | number | Date | null | undefined): string | null {
return toCompactYmdOrNull(value);
}
function resolveExcretaTreatmentFacilityAdmdst(
row: Record<string, string | number>,
sggOptions: Sgg[],
): { admdstCd: string | null; admdstNm: string | null } {
const existingCd = nullableTrimmedString(row.admdstCd);
const existingNm = nullableTrimmedString(row.admdstNm);
if (existingCd && existingNm) {
return { admdstCd: existingCd, admdstNm: existingNm };
}
const sggNm = String(row.sggNm ?? "").trim();
const matched = sggOptions.find((s) => s.gugun === sggNm);
if (matched) {
return { admdstCd: matched.bsnmCd, admdstNm: matched.gugun };
}
return {
admdstCd: existingCd,
admdstNm: existingNm ?? nullableTrimmedString(sggNm),
};
}
/** 그리드 행 → ST00070001 저장/삭제 API 항목 */
function buildExcretaTreatmentFacilitySaveItem(
row: Record<string, string | number>,
sggOptions: Sgg[] = [],
): ExcretaTreatmentFacilitySaveItem {
const { admdstCd, admdstNm } = resolveExcretaTreatmentFacilityAdmdst(
row,
sggOptions,
);
return {
admdstCd,
admdstNm,
fcltSn: nullableTrimmedString(row.fcltSn),
fcltNm: nullableTrimmedString(row.fcltNm),
fcltPstnCn: nullableTrimmedString(row.fcltPstnCn),
telnoCn: nullableTrimmedString(row.telnoCn),
cmptnStrtYmd: toApiYmd(row.cmptnStrtYmd),
oprtngStrtYmd: toApiYmd(row.oprtngStrtYmd),
wholPjtcoAmt: toGridNumber(row.wholPjtcoAmt),
fcltCpct: toGridNumber(row.fcltCpct),
excrFcltPrcqty: toGridNumber(row.excrFcltPrcqty),
fcltOprtngDayCnt: toGridNumber(row.fcltOprtngDayCnt),
prcsExpln: nullableTrimmedString(row.prcsExpln),
linkTmplntCn: nullableTrimmedString(row.linkTmplntCn),
linkPrcsQnt: toGridNumber(row.linkPrcsQnt),
inflwWqBod: toGridNumber(row.inflwWqBod),
inflwWqTorgcb: toGridNumber(row.inflwWqTorgcb),
inflwWqSpsld: toGridNumber(row.inflwWqSpsld),
inflwWqDtn: toGridNumber(row.inflwWqDtn),
inflwWqTph: toGridNumber(row.inflwWqTph),
fcltInflwClfgCnt: toGridNumber(row.fcltInflwClfgCnt),
dschgWqBod: toGridNumber(row.dschgWqBod),
dschgWqTorgcb: toGridNumber(row.dschgWqTorgcb),
dschgWqSpsld: toGridNumber(row.dschgWqSpsld),
dschgWqDtn: toGridNumber(row.dschgWqDtn),
dschgWqTph: toGridNumber(row.dschgWqTph),
fcltDschgClfgCnt: toGridNumber(row.fcltDschgClfgCnt),
fcltWtsysNm: nullableTrimmedString(row.fcltWtsysNm),
fcltTrbtrNm: nullableTrimmedString(row.fcltTrbtrNm),
msirbCn: nullableTrimmedString(row.msirbCn),
ssirbCn: nullableTrimmedString(row.ssirbCn),
operCn: nullableTrimmedString(row.operCn),
wholNope: toGridNumber(row.wholNope),
sftyAcdntNocs: toGridNumber(row.sftyAcdntNocs),
rmrk: nullableTrimmedString(row.rmrk),
};
}
const sleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
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 || `시계열 검증이 ${res.status} 상태로 종료되었습니다.`);
}
await sleep(pollIntervalMs);
}
throw new Error("시계열 검증 완료 대기 시간이 초과되었습니다.");
}
function createEmptyExcretaTreatmentFacilityRow(
ctpvNm: string,
sggNm = "",
): GridRowWithVerification {
const row: GridRowWithVerification = { ctpvNm, sggNm };
for (const col of ExcretaTreatFactInpColumn.columns) {
const fieldName = col.fieldName;
if (!fieldName || fieldName === "ctpvNm" || fieldName === "sggNm") continue;
row[fieldName] = col.valueType === ValueType.NUMBER ? 0 : "";
}
return row;
}
const EXCRETA_TREATMENT_FACILITY_NAME_GUIDE_MESSAGE =
"분뇨처리시설 등록시 분뇨처리시설명이 동일할 경우 같은 데이터로 분류 됩니다.\n\n" +
"분뇨처리시설명이 동일 할 경우 구분을 위해 분뇨처리시설 1, 분뇨처리시설 2 형태로 구분하여 주시길 바랍니다.";
// 하수도 통계 관리 > 07. 분뇨처리시설
const ExcretaTreatmentFacilityInp = () => {
const { sidoList } = useSearchConditionSidoList({ isInput: true });
sidoListRef.current = sidoList;
const { setMessage: alert } = useAlertStore((state) => state);
const { confirm } = useConfirm();
const { isAdminInfo } = useAdminStore();
const toolbarGroupAllowed = useStatisticsInputToolbarGroupGate();
const showGlobalProgress = useGlobalProgressStore((state) => state.show);
const setGlobalProgress = useGlobalProgressStore((state) => state.setProgress);
const setGlobalProgressLabel = useGlobalProgressStore((state) => state.setLabel);
const hideGlobalProgress = useGlobalProgressStore((state) => state.hide);
useEffect(() => {
alert(EXCRETA_TREATMENT_FACILITY_NAME_GUIDE_MESSAGE);
}, [alert]);
const [rowData, setRowData] = useState<Record<string, string | number>[]>([]);
const [isNotApplicable, setIsNotApplicable] = useState(false);
const [searchValues, setSearchValues] = useState<SearchFormValues | null>(null);
const [lastSearchedValues, setLastSearchedValues] =
useState<SearchFormValues | null>(null);
const [hasSearched, setHasSearched] = useState(false);
const [statusInfo, setStatusInfo] = useState<SewerageStatusInfo | null>(null);
const [statsCd, setStatsCd] = useState("");
const [isVerificationFailed, setIsVerificationFailed] = useState(false);
const [footerVerificationResult, setFooterVerificationResult] =
useState<VerificationResultLikeMap | null>(null);
const [inputPeriodEnterable, setInputPeriodEnterable] = useState(true);
const [isLoading, setIsLoading] = useState(false);
const [sggOptions, setSggOptions] = useState<Sgg[]>([]);
const [columnGuidesByEngCol, setColumnGuidesByEngCol] = useState<
Record<string, string>
>({});
const [verificationHistoryOpen, setVerificationHistoryOpen] = useState(false);
const [verificationResultDialogOpen, setVerificationResultDialogOpen] =
useState(false);
const [showSubmitDialog, setShowSubmitDialog] = useState(false);
const [deleteList, setDeleteList] = useState<Record<string, string | number>[]>([]);
const gridRef = useRef<RealGridReact | null>(null);
const gridGetRowsRef =
useRef<(() => Record<string, string | number>[]) | null>(null);
const sggOptionsSidoRef = useRef<string | null>(null);
const [searchLoaded, setSearchLoaded] = useState(false);
const activeTabId = useTabStore((s) => s.activeTabId);
useEffect(() => {
if (activeTabId !== STATISTICS_INPUT_URL_PATH.EXCRETA_TREATMENT_FACILITY) return;
let cancelled = false;
void (async () => {
try {
const res = await statisticsApi.getColumnGuides(
EXCRETA_TREATMENT_FACILITY_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 loadSggOptions = useCallback(async (sido: string): Promise<Sgg[]> => {
if (!sido || sido === "ALL") {
setSggOptions([]);
return [];
}
try {
const res = await statisticsApi.getSggAllList(sido);
const list = parseSggListFromResponse(res);
setSggOptions(list);
return list;
} catch {
setSggOptions([]);
return [];
}
}, []);
const columnInfo = useMemo(() => {
const enterable =
(computeTabEnterable(statusInfo, inputPeriodEnterable) || isAdminInfo) && inputPeriodEnterable;
const rawColumns = ExcretaTreatFactInpColumn.columns ?? [];
const base = enterable
? rawColumns
: rawColumns.map((col) => ({ ...col, editable: false as const }));
const columns = base
.map((col: ColumnConfig) => {
if (col.fieldName === "ctpvNm") {
return { ...col, editable: false as const };
}
if (col.fieldName === "sggNm" && enterable) {
return buildSggNmColumn(col, true, sggOptions);
}
return col;
})
.map((col) => {
const guide = columnGuidesByEngCol[col.fieldName]?.trim();
return guide ? { ...col, tooltip: guide } : col;
});
return {
columns,
columnLayout: ExcretaTreatFactInpColumn.columnLayout ?? [],
};
}, [statusInfo, inputPeriodEnterable, isAdminInfo, sggOptions, columnGuidesByEngCol]);
const canEditGrid =
(computeTabEnterable(statusInfo, inputPeriodEnterable) || isAdminInfo) && inputPeriodEnterable;
const getCurrentRows = (): Record<string, string | number>[] =>
gridGetRowsRef.current?.() ?? rowData;
const commitGrid = () => {
const gridView = gridRef.current?.gridView as
| { commit?: (force?: boolean) => void; cancel?: () => void }
| undefined;
try {
gridView?.commit?.(true);
} catch {
gridView?.cancel?.();
}
};
const fetchExcretaTreatmentFacilityList = async (
values: SearchFormValues,
overrides?: { mode?: string },
): Promise<boolean> => {
const response =
await statisticsInputExcretaTreatmentFacilityApi.getExcretaTreatmentFacilityList(
buildExcretaTreatmentFacilitySearchParam(values, overrides),
);
if (response.status !== "success" || !response.data) {
alert(response.message ?? "데이터 조회 중 오류가 발생했습니다.");
setSearchLoaded(false);
return false;
}
const {
commonInfo,
statusInfo: nextStatusInfo,
excretaTreatmentFacilityList,
} = response.data;
const list = filterBySelectedAdmdstCd(
excretaTreatmentFacilityList ?? [],
sidoListRef.current,
values,
);
const gridRows = mapExcretaTreatmentFacilityListToGridRows(list, commonInfo.ctpvNm);
setRowData(gridRows);
setStatusInfo(nextStatusInfo);
setStatsCd(commonInfo.statsCd);
setIsVerificationFailed(isVerificationFailedFromCommonInfo(commonInfo));
setFooterVerificationResult(commonInfo.verificationResult ?? null);
if (overrides?.mode !== "PRE") {
setIsNotApplicable(gridRows.length === 0);
} else if (gridRows.length > 0) {
setIsNotApplicable(false);
}
setSearchLoaded(true);
return true;
};
const handleSearch = async (values: SearchFormValues) => {
if (!values.sido || values.sido === "ALL") {
alert("시도를 선택해주세요.");
return;
}
try {
const periodResponse = await statisticsInputSewSupplyRateApi.checkInputPeriod(
values.year || "",
);
if (periodResponse.status !== "success") {
alert(periodResponse.message);
return;
}
const periods = Array.isArray(periodResponse.data)
? periodResponse.data
: [];
setInputPeriodEnterable(isInputPeriodCurrent(periods));
setIsLoading(true);
setHasSearched(true);
setLastSearchedValues(values);
const loadedSgg = await loadSggOptions(values.sido);
sggOptionsSidoRef.current = values.sido;
if (loadedSgg.length === 0) {
alert("시군구 목록을 불러오지 못했습니다.");
setSearchLoaded(false);
return;
}
await fetchExcretaTreatmentFacilityList(values);
} catch {
alert("데이터 조회 중 오류가 발생했습니다.");
setRowData([]);
setStatusInfo(null);
setSearchLoaded(false);
} finally {
setIsLoading(false);
}
};
const handleReset = () => {
setDeleteList([]);
setRowData([]);
setLastSearchedValues(null);
setHasSearched(false);
setStatusInfo(null);
setStatsCd("");
setIsVerificationFailed(false);
setFooterVerificationResult(null);
setIsNotApplicable(false);
setInputPeriodEnterable(true);
setSggOptions([]);
sggOptionsSidoRef.current = null;
setSearchLoaded(false);
};
const getLastYearDataFunc = async () => {
const values = lastSearchedValues ?? searchValues;
if (!values?.sido || values.sido === "ALL") {
alert("시도를 선택해주세요.");
return;
}
if (!hasSearched || !searchLoaded) {
alert("먼저 데이터를 조회해주세요.");
return;
}
try {
setIsLoading(true);
await fetchExcretaTreatmentFacilityList(values, { mode: "PRE" });
} catch {
alert("데이터 조회 중 오류가 발생했습니다.");
} finally {
setIsLoading(false);
}
};
const openVerificationHistoryDialog = () => {
const ctx = lastSearchedValues ?? searchValues;
if (!ctx?.year || !ctx?.sido || !ctx?.bsnmCd || !statsCd) {
alert("먼저 조회를 진행해주세요.");
return;
}
setVerificationHistoryOpen(true);
};
const cnacelSubmitFunc = () => {
void (async () => {
try {
if (!(await confirm("제출된 내용을 취소하시겠습니까?"))) return;
if (!lastSearchedValues) {
alert("먼저 조회를 진행해주세요.");
return;
}
const userInfo = await getUserInfo();
const requestData: cancelSubmitRequest = {
...toInputSearchRegion(sidoListRef.current, lastSearchedValues),
ctpvNm: lastSearchedValues.sido ?? "",
statsYr: lastSearchedValues.year ?? "",
mdfrUuid: userInfo.userUnqId,
statsCd: statsCd || EXCRETA_TREATMENT_FACILITY_STATS_CD,
};
const response = await statisticsApi.cancelSubmit(requestData);
if (response.status === "success") {
setIsLoading(true);
await fetchExcretaTreatmentFacilityList(lastSearchedValues);
alert("제출 취소되었습니다.");
} else {
alert(response.message ?? "제출 취소 중 오류가 발생했습니다.");
}
} catch {
alert("제출 취소 중 오류가 발생했습니다.");
} finally {
setIsLoading(false);
}
})();
};
const saveFunc = () => {
void (async () => {
if (!lastSearchedValues) {
alert("먼저 조회를 진행해주세요.");
return;
}
if (!buildGugunOkForToolbar(lastSearchedValues)) {
alert("시군구를 선택해주세요.");
return;
}
if (!(await confirm("저장 하시겠습니까?"))) return;
commitGrid();
const noApplicable = buildNoApplicableForRequest(lastSearchedValues, isNotApplicable, getCurrentRows().length === 0);
const currentData = getCurrentRows();
const saveList = noApplicable
? []
: currentData.map((row) =>
buildExcretaTreatmentFacilitySaveItem(row, sggOptions),
);
const deleteListForSave = deleteList
.filter((row) => String(row.fcltSn ?? "").trim() !== "")
.map((row) => buildExcretaTreatmentFacilitySaveItem(row, sggOptions));
if (!noApplicable && saveList.length === 0 && buildSubmitOkForToolbar(lastSearchedValues)) {
alert("해당사항 없음을 체크해주세요.");
return;
}
try {
setIsLoading(true);
const userInfo = await getUserInfo();
const response =
await statisticsInputExcretaTreatmentFacilityApi.saveExcretaTreatmentFacilityList(
{
statsYr: lastSearchedValues.year ?? "",
ctpvNm: lastSearchedValues.sido ?? "",
...toInputSearchRegion(sidoListRef.current, lastSearchedValues),
userUuid: userInfo.userUnqId,
...(isAdminInfo ? { superAdmin: "superAdmin" } : {}),
noApplicable,
saveList,
deleteList: deleteListForSave,
},
);
if (response.status === "success") {
setDeleteList([]);
await fetchExcretaTreatmentFacilityList(lastSearchedValues);
alert("저장되었습니다.");
} else {
alert(response.message ?? "저장 중 오류가 발생했습니다.");
}
} catch {
alert("저장 중 오류가 발생했습니다.");
} finally {
setIsLoading(false);
}
})();
};
const submitFunc = () => {
void (async () => {
try {
if (!lastSearchedValues) {
alert("먼저 조회를 진행해주세요.");
return;
}
if (!buildGugunOkForToolbar(lastSearchedValues)) {
alert("시군구를 선택해주세요.");
return;
}
const noApplicable = buildNoApplicableForRequest(lastSearchedValues, isNotApplicable, getCurrentRows().length === 0);
const currentData = getCurrentRows();
if (!noApplicable && currentData.length === 0 && buildSubmitOkForToolbar(lastSearchedValues)) {
alert("해당사항 없음을 체크해주세요.");
return;
}
if (await confirm("제출 하시겠습니까?")) {
setShowSubmitDialog(true);
}
} catch {
alert("제출 중 오류가 발생했습니다.");
}
})();
};
const executeSubmit = async (submitInfo: StatisticsInputSubmitInfoFormData) => {
if (!lastSearchedValues) {
alert("먼저 조회를 진행해주세요.");
return;
}
if (!buildGugunOkForToolbar(lastSearchedValues)) {
alert("시군구를 선택해주세요.");
return;
}
commitGrid();
const noApplicable = buildNoApplicableForRequest(lastSearchedValues, isNotApplicable, getCurrentRows().length === 0);
const currentData = getCurrentRows();
const submitList = noApplicable
? []
: currentData.map((row) =>
buildExcretaTreatmentFacilitySaveItem(row, sggOptions),
);
const deleteListForSubmit = deleteList
.filter((row) => String(row.fcltSn ?? "").trim() !== "")
.map((row) => buildExcretaTreatmentFacilitySaveItem(row, sggOptions));
if (!noApplicable && submitList.length === 0 && buildSubmitOkForToolbar(lastSearchedValues)) {
alert("해당사항 없음을 체크해주세요.");
return;
}
const statsCdForSubmit = statsCd || EXCRETA_TREATMENT_FACILITY_STATS_CD;
const verificationQuery = {
...toInputSearchRegion(sidoListRef.current, lastSearchedValues),
ctpvNm: lastSearchedValues.sido ?? "",
statsYr: lastSearchedValues.year ?? "",
statsCd: statsCdForSubmit,
};
const reloadAfterSubmit = async () => {
setDeleteList([]);
await fetchExcretaTreatmentFacilityList(lastSearchedValues);
};
try {
showGlobalProgress({ label: "서버에 요청 중...", progress: 0 });
const userInfo = await getUserInfo();
setGlobalProgressLabel("일반 검증 진행 중...");
const response =
await statisticsInputExcretaTreatmentFacilityApi.submitExcretaTreatmentFacilityList(
{
statsYr: lastSearchedValues.year ?? "",
ctpvNm: lastSearchedValues.sido ?? "",
...toInputSearchRegion(sidoListRef.current, lastSearchedValues),
userUuid: userInfo.userUnqId,
lclgvPicNm: submitInfo.lclgvPicNm,
lclgvPicTelnoCn: submitInfo.lclgvPicTelnoCn,
lclgvKbrdrDeptNm: submitInfo.lclgvKbrdrDeptNm,
lclgvKbrdrJbgdNm: submitInfo.lclgvKbrdrJbgdNm,
lclgvIdfrNm: submitInfo.lclgvIdfrNm,
lclgvIdfrTelnoCn: submitInfo.lclgvIdfrTelnoCn,
lclgvIdfrDeptNm: submitInfo.lclgvIdfrDeptNm,
lclgvIdfrJbgdNm: submitInfo.lclgvIdfrJbgdNm,
noApplicable,
submitList,
deleteList: deleteListForSubmit,
},
);
if (response.status !== "success") {
alert(response.message ?? "데이터 제출 중 오류가 발생했습니다.");
return;
}
const finalStatus = response.data?.finalStatus;
const nvpassed = response.data?.nvpassed;
if (finalStatus === "5") {
setGlobalProgress(50);
setGlobalProgressLabel("시계열 검증 진행 중...");
const imrParam = {
user_id: userInfo.userUnqId,
sewer_bzmn_cd: lastSearchedValues?.bsnmCd ?? "",
stats_cd: statsCdForSubmit,
stats_yr: lastSearchedValues.year ?? "",
lae_sbmsn_stts_cd:
statusInfo?.lclgvSbmsnSttsCd === "4" ? "4" : "2",
gen_verify_stts_cd: nvpassed ? "1" : "2",
hb_pass_yn: response.data?.isHbPassed,
};
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 reloadAfterSubmit();
await openVerificationResultDialogIfNeeded(
verificationQuery,
setVerificationResultDialogOpen,
{ notify: alert },
);
} catch (e) {
await reloadAfterSubmit();
await openVerificationResultDialogIfNeeded(
verificationQuery,
setVerificationResultDialogOpen,
{ notify: alert, notifySubmitComplete: false },
);
const msg =
e instanceof Error && e.message
? e.message
: "IMR 검증 중 오류가 발생했습니다.\n관리자에게 문의하세요.";
alert(msg);
}
} else {
setGlobalProgress(100);
await reloadAfterSubmit();
await openVerificationResultDialogIfNeeded(
verificationQuery,
setVerificationResultDialogOpen,
{ notify: alert },
);
}
} catch {
alert("데이터 제출 중 오류가 발생했습니다.");
} finally {
hideGlobalProgress();
}
};
const addArticleFunc = async () => {
const ctx = lastSearchedValues ?? searchValues;
if (!hasSearched || !ctx?.sido || ctx.sido === "ALL") {
alert("먼저 조회를 진행해주세요.");
return;
}
const sggNm = ctx.gugun && ctx.gugun !== "ALL" ? ctx.gugun : "";
let list = sggOptions;
if (sggOptionsSidoRef.current !== ctx.sido || list.length === 0) {
list = await loadSggOptions(ctx.sido);
sggOptionsSidoRef.current = ctx.sido;
}
if (list.length === 0) {
alert("시군구 목록을 불러오지 못했습니다.");
return;
}
commitGrid();
const currentRows = getCurrentRows();
const newRow = createEmptyExcretaTreatmentFacilityRow(ctx.sido, sggNm);
flushSync(() => {
setSggOptions(list);
setRowData([...currentRows, newRow]);
setIsNotApplicable(false);
});
};
const deleteArticleFunc = () => {
commitGrid();
const currentRows = getCurrentRows();
if (currentRows.length === 0) {
alert("삭제할 데이터가 없습니다.");
return;
}
const gridView = gridRef.current?.gridView as
| {
getCheckedRows?: (refresh?: boolean) => number[];
getCheckedItems?: (refresh?: boolean) => number[];
}
| undefined;
const checkedIndexes =
gridView?.getCheckedRows?.(false) ??
gridView?.getCheckedItems?.(false) ??
[];
if (checkedIndexes.length === 0) {
alert("삭제할 항목을 선택해주세요.");
return;
}
const indexSet = new Set(
checkedIndexes.filter(
(idx) => Number.isInteger(idx) && idx >= 0 && idx < currentRows.length,
),
);
const removedRows = currentRows.filter((_, idx) => indexSet.has(idx));
if (removedRows.length === 0) {
alert("삭제할 항목을 선택해주세요.");
return;
}
setDeleteList((prev) => [...prev, ...removedRows]);
setRowData(currentRows.filter((_, idx) => !indexSet.has(idx)));
};
const verificationCellStyle = ({
fieldName,
row,
}: CellStyleCallbackParams): string | undefined => {
const verification = (row as GridRowWithVerification).verificationResult;
if (!verification) return;
const errors = verification[fieldName];
if (Array.isArray(errors) && errors.length > 0) {
const hasImr = errors.some((e) => e.vrfcCd === "IMR" || e.vrfcCd === "HB");
return hasImr ? "rg-cell-verify-error-imr" : "rg-cell-verify-error";
}
};
const verificationCellTooltip = ({
fieldName,
row,
}: CellTooltipCallbackParams): string | undefined => {
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"
? "시계열 검증 - "
: e.vrfcCd === "NV"
? "일반검증 - "
: "모집단 검증 -";
return `${prefix}${e.rsnExpln}
(검증 기준값: ${e.cmprCn} / 현재 입력값: ${e.nowCn})`;
})
.filter(Boolean)
.join("\n");
}
};
const searchContextForToolbar = lastSearchedValues;
const toolbarSnapshot = computeTabToolbarSnapshot(
statusInfo,
rowData.length > 0,
hasSearched,
searchContextForToolbar,
inputPeriodEnterable,
isAdminInfo,
);
const showSaveInToolbar =
toolbarSnapshot.showSaveInToolbar && toolbarGroupAllowed;
const showEnterableToolbarButtons =
toolbarSnapshot.showEnterableToolbarButtons && toolbarGroupAllowed;
const showActionToolbarCard =
toolbarSnapshot.showActionToolbarCard && toolbarGroupAllowed;
const showArticleToolbarCard =
hasSearched &&
toolbarGroupAllowed &&
(showSaveInToolbar || showEnterableToolbarButtons);
const tabEnterableForGrid =
(computeTabEnterable(statusInfo, inputPeriodEnterable) || isAdminInfo) && inputPeriodEnterable;
const gugunOk = buildGugunOkForToolbar(searchContextForToolbar);
const submitOk = buildSubmitOkForToolbar(searchContextForToolbar);
const showNotApplicable =
searchLoaded &&
hasSearched &&
rowData.length === 0 &&
computeTabEnterable(statusInfo, inputPeriodEnterable) &&
submitOk;
const verificationQueryContext = (() => {
const ctx = lastSearchedValues ?? searchValues;
if (!ctx?.year || !ctx?.sido || !ctx?.bsnmCd || !statsCd) return null;
return {
...toInputSearchRegion(sidoListRef.current, ctx),
ctpvNm: ctx.sido,
statsYr: ctx.year,
statsCd,
};
})();
const { verificationSearchDefaults, searchFormRemountKey } =
useStatisticsInputPendingNavigation({
urlPath: STATISTICS_INPUT_URL_PATH.EXCRETA_TREATMENT_FACILITY,
onAutoSearch: (values) => handleSearch(values),
});
return (
<>
<div className="relative">
<SearchForm
isInput
key={searchFormRemountKey}
defaultValues={verificationSearchDefaults ?? undefined}
fctName={true}
onSubmit={handleSearch}
onReset={handleReset}
onValuesChange={setSearchValues}
/>
{showActionToolbarCard && (
<div className="card !py-0">
<div className="m-[0.6rem] flex items-center justify-end gap-[0.6rem]">
<VerificationResultButton
VerificationResultButtonFunc={openVerificationHistoryDialog}
isVerificationFailed={isVerificationFailed}
/>
{showEnterableToolbarButtons && (
<>
<LastYearDataButton
getLastYearDataFunc={() => void getLastYearDataFunc()}
/>
{submitOk && <SubmitButton submitFunc={submitFunc} />}
</>
)}
{statusInfo?.lclgvSbmsnSttsCd === "2" && submitOk && (
<CancelSubmitButton cnacelSubmitFunc={cnacelSubmitFunc} />
)}
{showSaveInToolbar && <SaveButton saveFunc={saveFunc} />}
</div>
</div>
)}
{showArticleToolbarCard && (
<div className="card !py-0">
<div className="m-[0.6rem] flex items-center justify-end gap-[0.6rem]">
{showNotApplicable && (
<NotApplicableCheckButton
isCheck={isNotApplicable}
setIsCheck={setIsNotApplicable}
/>
)}
<AddArticleButton addArticleFunc={addArticleFunc} />
<DeleteArticleButton deleteArticleFunc={deleteArticleFunc} />
</div>
</div>
)}
{isLoading && (
<div className="absolute inset-0 z-50 flex items-center justify-center rounded-lg p-10 [data-theme='black']:bg-transparent">
<div className="relative size-[5rem]">
<LoadingWithAnimation color="primary" />
</div>
</div>
)}
<DataGrid
key={canEditGrid ? "enterable" : "readonly"}
getGridRef={gridRef as RefObject<RealGridReact | null>}
columnInfo={columnInfo}
rowStartNum={ExcretaTreatFactInpColumn.rowStartNum}
fixedColumns={ExcretaTreatFactInpColumn.fixedColumns}
rowData={rowData}
setRowData={setRowData}
getGridDataRef={gridGetRowsRef}
showHeaderCheckbox
statusInfo={statusInfo}
isExcelImport={
searchLoaded && tabEnterableForGrid && gugunOk
}
excelImportSewerBzmnCd={(lastSearchedValues ?? searchValues)?.bsnmCd}
excelExportSearchContext={toExcelExportSearchContextFromSearchForm(lastSearchedValues ?? searchValues)}
cellStyleCallback={verificationCellStyle}
footerVerificationResult={footerVerificationResult}
cellTooltipCallback={verificationCellTooltip}
/>
</div>
<VerificationResultHistoryDialog
open={verificationHistoryOpen}
onOpenChange={setVerificationHistoryOpen}
queryContext={verificationQueryContext}
onExceptionResetSubmitted={async () => {
const values = lastSearchedValues ?? searchValues;
if (!values) return;
await fetchExcretaTreatmentFacilityList(values);
}}
/>
<VerificationResultDialog
open={verificationResultDialogOpen}
onOpenChange={setVerificationResultDialogOpen}
queryContext={verificationQueryContext}
onExceptionSubmitted={async () => {
const values = lastSearchedValues ?? searchValues;
if (!values) return;
await fetchExcretaTreatmentFacilityList(values);
}}
/>
<SubmitDailog
open={showSubmitDialog}
onOpenChange={setShowSubmitDialog}
statusInfo={statusInfo}
onConfirm={executeSubmit}
isLoading={isLoading}
/>
<ConfirmDialog isOpen={false} />
</>
);
};
export default ExcretaTreatmentFacilityInp;