[st] - 입력단 메뉴 작업중

This commit is contained in:
2026-06-15 10:38:33 +09:00
parent 49e42757bc
commit e3eb542e59
43 changed files with 7637 additions and 947 deletions
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -210,7 +210,7 @@ const PublicSewageFacilityInp = () => {
const currentData =
st60001GridGetRowsRef.current?.() ?? rowDataMap.get('ST00060001') ?? [];
if (!treatAsNoApplicable && (!currentData || currentData.length === 0)) {
alert('제출할 데이터가 없습니다.');
alert('해당사항 없음을 체크해주세요.');
return;
}
const verificationBlocked = getVerificationPeriodActionBlockedMessage(periodCheckList);
@@ -291,15 +291,15 @@ const PublicSewageFacilityInp = () => {
const currentData =
st60001GridGetRowsRef.current?.() ?? rowDataMap.get('ST00060001') ?? [];
if (!treatAsNoApplicable && (!currentData || currentData.length === 0)) {
alert('제출할 데이터가 없습니다.');
const submitList = treatAsNoApplicable ? [] : buildFacilitySaveList(currentData);
if (!treatAsNoApplicable && submitList.length === 0) {
alert('해당사항 없음을 체크해주세요.');
return;
}
try {
showGlobalProgress({ label: '서버에 요청 중...', progress: 0 });
const userInfo = await getUserInfo();
const submitList = treatAsNoApplicable ? [] : buildFacilitySaveList(currentData);
const activeTabStatus = tabStatusInfoMap.get(activeTab) ?? null;
const statsCdForTab = tabStatsCdMap.get(activeTab) ?? activeTab;
const requestData = {
@@ -530,19 +530,19 @@ const PublicSewageFacilityInp = () => {
if (!(await confirm('저장 하시겠습니까?'))) return;
if (activeTab === 'ST00060001') {
const noApplicable = getNoApplicableForRequest();
const treatAsNoApplicable = noApplicable || isNotApplicable;
const currentData =
st60001GridGetRowsRef.current?.() ?? rowDataMap.get('ST00060001') ?? [];
const saveList = treatAsNoApplicable ? [] : buildFacilitySaveList(currentData);
if (!treatAsNoApplicable && saveList.length === 0) {
alert('해당사항 없음을 체크해주세요.');
return;
}
try {
showGlobalProgress({ label: '저장 진행 중...', progress: 0 });
const userInfo = await getUserInfo();
const noApplicable = getNoApplicableForRequest();
const treatAsNoApplicable = noApplicable || isNotApplicable;
const currentData =
st60001GridGetRowsRef.current?.() ?? rowDataMap.get('ST00060001') ?? [];
if (!treatAsNoApplicable && (!currentData || currentData.length === 0)) {
alert('저장할 데이터가 없습니다.');
return;
}
const saveList = treatAsNoApplicable ? [] : buildFacilitySaveList(currentData);
const param: PublicSewageFacilitySaveRequest = {
statsYr: lastSearchedValues.year ?? '',
ctpvNm: lastSearchedValues.sido ?? '',
@@ -1309,6 +1309,11 @@ const PublicSewageFacilityInp = () => {
open={verificationHistoryOpen}
onOpenChange={setVerificationHistoryOpen}
queryContext={verificationQueryContext}
onExceptionResetSubmitted={async () => {
const ctx = lastSearchedValues ?? searchValues;
if (!ctx || !searchParamRef.current) return;
await loadPublicSewageFacilityActiveTabData(activeTab, searchParamRef.current, ctx);
}}
/>
<SubmitDailog
open={showDailog}
File diff suppressed because it is too large Load Diff
@@ -2119,6 +2119,12 @@ const SewagePipeInp = () => {
}
: null
}
onExceptionResetSubmitted={async () => {
const values = lastSearchedValues ?? searchValues;
if (!values?.year || !values?.sido || !values?.bsnmCd) return;
const refreshedParam = buildSewagePipeSearchParam(values);
await loadSewagePipeActiveTabData(activeTab, refreshedParam, values, true, false);
}}
/>
<SubmitDailog
open={showDailog}
@@ -80,6 +80,12 @@ const SewagePipeMaintainInpTabList = [
const firstTabKey = RepairStatusInpColumn.tabKey;
const SEWAGE_PIPE_MAINTAIN_COLUMN_GUIDE_TAB_KEYS = Array.from(
new Set(
SewagePipeMaintainInpTabList.map((tab) => tab.tabKey).filter(Boolean),
),
);
const toGridColumnInfo = (tab: (typeof SewagePipeMaintainInpTabList)[number]): ColumnInfo => ({
columns: tab.columns ?? [],
columnLayout: tab.columnLayout ?? [],
@@ -302,6 +308,9 @@ const SewagePipeMaintainInp = () => {
const [rowDataMap, setRowDataMap] = useState<
Map<string, Record<string, string | number>[]>
>(new Map());
const [columnGuidesByTab, setColumnGuidesByTab] = useState<
Record<string, Record<string, string>>
>({});
const searchDoneRef = useRef(false);
const periodEnterableRef = useRef(true);
@@ -328,6 +337,37 @@ const SewagePipeMaintainInp = () => {
}
}, [activeTabId, inputFocusNonce]);
useEffect(() => {
if (activeTabId !== STATISTICS_INPUT_URL_PATH.SEWAGE_PIPE_MAINTAIN) return;
let cancelled = false;
void (async () => {
const entries = await Promise.all(
SEWAGE_PIPE_MAINTAIN_COLUMN_GUIDE_TAB_KEYS.map(async (tabKey) => {
try {
const res = await statisticsApi.getColumnGuides(tabKey);
if (res.status !== "success" || !Array.isArray(res.data)) {
return [tabKey, {}] as const;
}
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;
}
return [tabKey, next] as const;
} catch {
return [tabKey, {}] as const;
}
}),
);
if (cancelled) return;
setColumnGuidesByTab(Object.fromEntries(entries));
})();
return () => {
cancelled = true;
};
}, [activeTabId]);
const commitToolbarSnapshotForLoadedTab = (
tabKey: string,
statusInfo: SewerageStatusInfo | null,
@@ -951,22 +991,22 @@ const SewagePipeMaintainInp = () => {
tabStatusInfoMap.get(tabKey) ?? null,
inputPeriodEnterable,
) || isAdminInfo;
const columns = (tab.columns ?? []).map((col: ColumnConfig) =>
canEdit
? col
: {
...col,
editable: false as const,
styleName: col.styleName ?? "rg-cell-readonly",
},
);
const rawColumns = tab.columns ?? [];
const base = canEdit
? rawColumns
: rawColumns.map((col) => ({ ...col, editable: false as const }));
const columns = base
.map((col) => {
const guide = columnGuidesByTab[tabKey]?.[col.fieldName]?.trim();
return guide ? { ...col, tooltip: guide } : col;
});
map.set(tabKey, {
columns,
columnLayout: tab.columnLayout ?? [],
});
}
return map;
}, [tabStatusInfoMap, inputPeriodEnterable, isAdminInfo]);
}, [tabStatusInfoMap, inputPeriodEnterable, isAdminInfo, columnGuidesByTab]);
const searchContextForToolbar = lastSearchedValues ?? searchValues;
const activeTabStatus = tabStatusInfoMap.get(activeTab) ?? null;
@@ -1120,7 +1160,7 @@ const SewagePipeMaintainInp = () => {
return (
<TabsContent key={tabKey} value={tabKey}>
<DataGrid
key={tabKey}
key={`${tabKey}-${tabEnterableForGrid ? "enterable" : "readonly"}`}
columnInfo={
columnInfoByTab.get(tabKey) ??
toGridColumnInfo(info)
@@ -1163,6 +1203,17 @@ const SewagePipeMaintainInp = () => {
open={verificationHistoryOpen}
onOpenChange={setVerificationHistoryOpen}
queryContext={verificationQueryContext}
onExceptionResetSubmitted={async () => {
const values = lastSearchedValues ?? searchValues;
if (!values || !searchParamRef.current) return;
await loadActiveTabData(
activeTabRef.current,
searchParamRef.current,
values,
true,
false,
);
}}
/>
<ConfirmDialog isOpen={false} />
<SubmitDailog
@@ -1176,6 +1227,17 @@ const SewagePipeMaintainInp = () => {
open={verificationResultDialogOpen}
onOpenChange={setVerificationResultDialogOpen}
queryContext={verificationQueryContext}
onExceptionSubmitted={async () => {
const values = lastSearchedValues ?? searchValues;
if (!values || !searchParamRef.current) return;
await loadActiveTabData(
activeTabRef.current,
searchParamRef.current,
values,
true,
false,
);
}}
/>
</>
);
@@ -1,5 +1,6 @@
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
@@ -25,8 +26,11 @@ import SaveButton from "@/components/button/SaveButton";
import SubmitButton from "@/components/button/SumitButton";
import VerificationResultButton from "@/components/button/VerificationResultButton";
import DataGrid, { applyDropDownColumnToGrid } 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 SubmitDailog from "./SubmitDailog";
import type { SearchFormValues } from "@/components/type/SearchForm.type";
import type {
CellStyleCallbackParams,
@@ -45,22 +49,27 @@ import {
toCompactYmd,
} from "@/components/util/grid/ymdFormat";
import { statisticsInputSewageReservoirPumpApi } from "@/entities/statistics/api/input/statistics-input-sewage-reservoir-pump-service";
import { statisticsInputSewSupplyRateApi } from "@/entities/statistics/api/input/statistics-input-sew-supply-rate-service";
import { statisticsApi } from "@/entities/statistics/api/statistics-service";
import { isInputPeriodCurrent } from "@/entities/statistics/model/input/statistics-sewage-supply-rate-input-interface";
import {
SEWAGE_RESERVOIR_STATS_CD,
type SewageReservoirItem,
type SewageReservoirRequestParams,
type SewageReservoirSaveItem,
type RainfallPumpStationItem,
type RainfallPumpStationSaveItem,
type SewageReservoirVerificationResult,
} from "@/entities/statistics/model/input/statistics-input-sewage-reservoir-pump-interface";
import type { StatisticsInputSubmitInfoFormData } from "@/entities/statistics/model/statistics-input-submit-info-schema";
import type {
ApiResponse,
cancelSubmitRequest,
ImrStatusResponse,
Sgg,
SewerageStatusInfo,
} from "@/entities/statistics/model/statistics-interface";
import { useAlertStore, useStatisticsInputToolbarGroupGate } from "@/shared/lib";
import { useAlertStore, useGlobalProgressStore, useStatisticsInputToolbarGroupGate } from "@/shared/lib";
import { ConfirmDialog } from "@/shared/ui/confirm-dialog";
import { useAdminStore } from "@/store/admin-store";
@@ -80,6 +89,12 @@ const firstTabKey = SewageReservoirPumpInpTabList[0]?.tabKey ?? SEWAGE_RESERVOIR
const rainfallPumpTabKey =
RainfallPumpStationInpColumn.tabKey ?? "rainfallPumpStationInp";
const SEWAGE_RESERVOIR_PUMP_COLUMN_GUIDE_TAB_KEYS = Array.from(
new Set(
SewageReservoirPumpInpTabList.map((tab) => tab.tabKey).filter(Boolean),
),
);
type GridRowWithVerification = Record<string, string | number> & {
verificationResult?: SewageReservoirVerificationResult;
};
@@ -210,11 +225,7 @@ function buildSggNmColumn(
sggOptions: Sgg[],
): ColumnConfig {
if (!canEdit) {
return {
...col,
editable: false as const,
styleName: col.styleName ?? "rg-cell-readonly",
};
return { ...col, editable: false as const };
}
if (sggOptions.length === 0) {
return { ...col, editable: true };
@@ -272,31 +283,7 @@ function toGridNumber(value: string | number | null | undefined): number {
return Number.isFinite(n) ? n : 0;
}
/** API 조회 항목 → 저장/삭제 API 형식 (조회 직후 스냅샷용) */
function buildSewageReservoirSaveItemFromApiItem(
item: SewageReservoirItem,
): SewageReservoirSaveItem {
return buildSewageReservoirSaveItem(
item as unknown as Record<string, string | number>,
);
}
function buildSewageReservoirDeleteList(
loadedSnapshot: SewageReservoirSaveItem[],
currentRows: Record<string, string | number>[],
): SewageReservoirSaveItem[] {
const currentFcltSnSet = new Set(
currentRows
.map((row) => String(row.fcltSn ?? "").trim())
.filter((sn) => sn !== ""),
);
return loadedSnapshot.filter((item) => {
const sn = String(item.fcltSn ?? "").trim();
return sn !== "" && !currentFcltSnSet.has(sn);
});
}
/** 시군구명(`sggNm`) 기준 행정구역 코드·명 */
/** 시군구명(`sggNm`) 기준 `admdstCd`(bsnmCd)·`admdstNm`(시군구명) */
function resolveSewageReservoirAdmdst(
sggNm: string,
sggOptions: Sgg[],
@@ -304,7 +291,7 @@ function resolveSewageReservoirAdmdst(
const trimmed = sggNm.trim();
const matched = sggOptions.find((s) => s.gugun === trimmed);
if (matched) {
return { admdstCd: matched.adstDstrCd, admdstNm: matched.gugun };
return { admdstCd: matched.bsnmCd, admdstNm: matched.gugun };
}
return { admdstCd: "", admdstNm: trimmed };
}
@@ -318,10 +305,12 @@ function buildSewageReservoirSaveItem(
String(row.sggNm ?? ""),
sggOptions,
);
const fcltSn = String(row.fcltSn ?? "").trim();
return {
admdstCd,
admdstNm,
fcltSn: String(row.fcltSn ?? ""),
// 항목 추가 직후 저장(신규) — 그리드 fcltSn 없음 → API에는 "1"
fcltSn: fcltSn,
fcltNm: String(row.fcltNm ?? ""),
fcltPstnCn: String(row.fcltPstnCn ?? ""),
oprtngStrtYmd: toCompactYmd(row.oprtngStrtYmd),
@@ -345,9 +334,9 @@ function buildSewageReservoirSaveItem(
pumpFcltN3PerPpqty: toGridNumber(row.pumpFcltN3PerPpqty),
pumpFcltN3PumpCnt: toGridNumber(row.pumpFcltN3PumpCnt),
pumpFcltN3RsrvPumpCnt: toGridNumber(row.pumpFcltN3RsrvPumpCnt),
prcsgFcltYn: String(row.prcsgFcltYn ?? ""),
ddrztnEqpmYn: String(row.ddrztnEqpmYn ?? ""),
oslfEtgtYn: String(row.oslfEtgtYn ?? ""),
prcsgFcltYn: String(row.prcsgFcltYn ? (row.prcsgFcltYn === '무' ? 'N' : 'Y') : ""),
ddrztnEqpmYn: String(row.ddrztnEqpmYn ? (row.prcsgFcltYn === '무' ? 'N' : 'Y') : ""),
oslfEtgtYn: String(row.oslfEtgtYn ? (row.prcsgFcltYn === '무' ? 'N' : 'Y') : ""),
mltvMthCn: nullableTrimmedString(row.mltvMthCn),
impdMthCn: nullableTrimmedString(row.impdMthCn),
impdPrcsMthdCn: nullableTrimmedString(row.impdPrcsMthdCn),
@@ -362,6 +351,56 @@ function buildSewageReservoirSaveItem(
};
}
/** 그리드 행 → ST00040002 저장/삭제 API 항목 */
function buildRainfallPumpStationSaveItem(
row: Record<string, string | number>,
sggOptions: Sgg[] = [],
): RainfallPumpStationSaveItem {
const { admdstCd, admdstNm } = resolveSewageReservoirAdmdst(
String(row.sggNm ?? ""),
sggOptions,
);
const fcltSn = String(row.fcltSn ?? "").trim();
return {
admdstCd,
admdstNm,
fcltSn: fcltSn,
fcltNm: String(row.fcltNm ?? ""),
fcltPstnCn: String(row.fcltPstnCn ?? ""),
fcltyKndCn: String(row.fcltyKndCn ?? ""),
oprtngStrtYmd: toCompactYmd(row.oprtngStrtYmd),
planDesignFreqCnt: toGridNumber(row.planDesignFreqCnt),
wtrStrgZoneArea: toGridNumber(row.wtrStrgZoneArea),
rsvrYn: String(row.rsvrYn ? (row.rsvrYn === "무" ? "N" : "Y") : ""),
rsvrImpndQnt: toGridNumber(row.rsvrImpndQnt),
abstnRgnImpdQnt: toGridNumber(row.abstnRgnImpdQnt),
grssPpqty: toGridNumber(row.grssPpqty),
pumpFcltN1DmtrExpln: String(row.pumpFcltN1DmtrExpln ?? ""),
pumpFcltN1PerPpqty: toGridNumber(row.pumpFcltN1PerPpqty),
pumpFcltN1PumpCnt: toGridNumber(row.pumpFcltN1PumpCnt),
pumpFcltN1RsrvPumpCnt: toGridNumber(row.pumpFcltN1RsrvPumpCnt),
pumpFcltN2DmtrExpln: String(row.pumpFcltN2DmtrExpln ?? ""),
pumpFcltN2PerPpqty: toGridNumber(row.pumpFcltN2PerPpqty),
pumpFcltN2PumpCnt: toGridNumber(row.pumpFcltN2PumpCnt),
pumpFcltN2RsrvPumpCnt: toGridNumber(row.pumpFcltN2RsrvPumpCnt),
pumpFcltN3DmtrExpln: String(row.pumpFcltN3DmtrExpln ?? ""),
pumpFcltN3PerPpqty: toGridNumber(row.pumpFcltN3PerPpqty),
pumpFcltN3PumpCnt: toGridNumber(row.pumpFcltN3PumpCnt),
pumpFcltN3RsrvPumpCnt: toGridNumber(row.pumpFcltN3RsrvPumpCnt),
prcsgFcltYn: String(row.prcsgFcltYn ? (row.prcsgFcltYn === "무" ? "N" : "Y") : ""),
ddrztnEqpmYn: String(row.ddrztnEqpmYn ? (row.prcsgFcltYn === "무" ? "N" : "Y") : ""),
oslfEtgtYn: String(row.oslfEtgtYn ? (row.prcsgFcltYn === "무" ? "N" : "Y") : ""),
dschgWtrsExpln: String(row.dschgWtrsExpln ?? ""),
prcptnLastDspsQnt: toGridNumber(row.prcptnLastDspsQnt),
ctmnLastDspsQnt: toGridNumber(row.ctmnLastDspsQnt),
prcptnLastDspsMthdExpln: String(row.prcptnLastDspsMthdExpln ?? ""),
ctmnLastDspsMthdExpln: String(row.ctmnLastDspsMthdExpln ?? ""),
oprtngDayCnt: toGridNumber(row.oprtngDayCnt),
grssMltvQnt: toGridNumber(row.grssMltvQnt),
rmrk: nullableTrimmedString(row.rmrk),
};
}
function mapRainfallPumpStationListToGridRows(
list: RainfallPumpStationItem[],
ctpvNm: string,
@@ -386,20 +425,72 @@ function mapRainfallPumpStationListToGridRows(
});
}
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 || `IMR 검증이 ${res.status} 상태로 종료되었습니다.`);
}
await sleep(pollIntervalMs);
}
throw new Error("IMR 검증 완료 대기 시간이 초과되었습니다.");
}
// 하수도 통계 관리 > 04. 하수저류 시설 및 빗물 펌프장
const SewageReservoirPumpInp = () => {
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);
const submitFunc = () => {
console.log("submit");
void (async () => {
const tabKey = activeTabRef.current;
if (
tabKey !== SEWAGE_RESERVOIR_STATS_CD &&
tabKey !== rainfallPumpTabKey
) {
alert("제출할 수 없는 탭입니다.");
return;
}
if (!lastSearchedValues) {
alert("먼저 조회를 진행해주세요.");
return;
}
if (!buildGugunOkForToolbar(lastSearchedValues)) {
alert("시군구를 선택해주세요.");
return;
}
const noApplicable = isNotApplicableByTab.get(tabKey) ?? false;
const currentData = getCurrentRowsForTab(tabKey);
if (!noApplicable && currentData.length === 0) {
alert("해당사항 없음을 체크해주세요.");
return;
}
if (await confirm("제출 하시겠습니까?")) {
setShowSubmitDialog(true);
}
})();
};
/** 마지막 조회 시 서버에 있던 하수저류시설 목록 (deleteList 비교 기준) */
const loadedSewageReservoirSnapshotRef = useRef<SewageReservoirSaveItem[]>([]);
const cnacelSubmitFunc = () => {
void (async () => {
try {
@@ -530,24 +621,35 @@ const SewageReservoirPumpInp = () => {
.filter((idx) => idx >= 0),
);
const removedRows = currentRows.filter((_, idx) =>
checkedIndexSet.has(idx),
);
const nextRows = currentRows.filter((_, idx) => !checkedIndexSet.has(idx));
if (nextRows.length === currentRows.length) {
if (removedRows.length === 0) {
alert("삭제할 항목을 선택해주세요.");
return;
}
if (tabKey === SEWAGE_RESERVOIR_STATS_CD) {
setSewageReservoirDeleteList((prev) => [...prev, ...removedRows]);
} else if (tabKey === rainfallPumpTabKey) {
setRainfallPumpStationDeleteList((prev) => [...prev, ...removedRows]);
}
setRowDataMap((prev) => {
const next = new Map(prev);
next.set(tabKey, nextRows);
return next;
});
};
const saveFunc = () => {
void (async () => {
const tabKey = activeTabRef.current;
if (tabKey !== SEWAGE_RESERVOIR_STATS_CD) {
if (
tabKey !== SEWAGE_RESERVOIR_STATS_CD &&
tabKey !== rainfallPumpTabKey
) {
alert("저장할 수 없는 탭입니다.");
return;
}
@@ -564,41 +666,63 @@ const SewageReservoirPumpInp = () => {
commitGridForTab(tabKey);
const noApplicable = isNotApplicableByTab.get(tabKey) ?? false;
const currentData = getCurrentRowsForTab(tabKey);
const isReservoirTab = tabKey === SEWAGE_RESERVOIR_STATS_CD;
const saveList = noApplicable
? []
: currentData.map((row) =>
buildSewageReservoirSaveItem(row, sggOptions),
);
const deleteList = buildSewageReservoirDeleteList(
loadedSewageReservoirSnapshotRef.current,
currentData,
);
if (!noApplicable && saveList.length === 0 && deleteList.length === 0) {
alert("저장할 변경 사항이 없습니다.");
: isReservoirTab
? currentData.map((row) =>
buildSewageReservoirSaveItem(row, sggOptions),
)
: currentData.map((row) =>
buildRainfallPumpStationSaveItem(row, sggOptions),
);
const deleteList = isReservoirTab
? sewageReservoirDeleteList
.filter((row) => String(row.fcltSn ?? "").trim() !== "")
.map((row) => buildSewageReservoirSaveItem(row, sggOptions))
: rainfallPumpStationDeleteList
.filter((row) => String(row.fcltSn ?? "").trim() !== "")
.map((row) => buildRainfallPumpStationSaveItem(row, sggOptions));
if (!noApplicable && saveList.length === 0) {
alert("해당사항 없음을 체크해주세요.");
return;
}
const requestBase = {
statsYr: lastSearchedValues.year ?? "",
ctpvNm: lastSearchedValues.sido ?? "",
sggNm:
lastSearchedValues.gugun === "ALL"
? ""
: (lastSearchedValues.gugun ?? ""),
sewerBzmnCd: lastSearchedValues.bsnmCd ?? "",
...(isAdminInfo ? { superAdmin: "superAdmin" } : {}),
...(noApplicable ? { noApplicable: true } : {}),
};
try {
setIsLoading(true);
const userInfo = await getUserInfo();
const response =
await statisticsInputSewageReservoirPumpApi.saveSewageReservoir({
statsYr: lastSearchedValues.year ?? "",
ctpvNm: lastSearchedValues.sido ?? "",
sggNm:
lastSearchedValues.gugun === "ALL"
? ""
: (lastSearchedValues.gugun ?? ""),
sewerBzmnCd: lastSearchedValues.bsnmCd ?? "",
const response = isReservoirTab
? await statisticsInputSewageReservoirPumpApi.saveSewageReservoir({
...requestBase,
userId: userInfo.userUnqId,
...(isAdminInfo ? { superAdmin: "superAdmin" } : {}),
...(noApplicable ? { noApplicable: true } : {}),
saveList,
deleteList,
saveList: saveList as SewageReservoirSaveItem[],
deleteList: deleteList as SewageReservoirSaveItem[],
})
: await statisticsInputSewageReservoirPumpApi.saveRainfallPumpStation({
...requestBase,
userId: userInfo.userUnqId,
saveList: saveList as RainfallPumpStationSaveItem[],
deleteList: deleteList as RainfallPumpStationSaveItem[],
});
if (response.status === "success") {
if (isReservoirTab) {
setSewageReservoirDeleteList([]);
} else {
setRainfallPumpStationDeleteList([]);
}
await loadActiveTabData(tabKey, lastSearchedValues, false, false);
alert("저장되었습니다.");
} else {
@@ -612,6 +736,180 @@ const SewageReservoirPumpInp = () => {
})();
};
const executeSubmit = async (submitInfo: StatisticsInputSubmitInfoFormData) => {
const tabKey = activeTabRef.current;
if (
tabKey !== SEWAGE_RESERVOIR_STATS_CD &&
tabKey !== rainfallPumpTabKey
) {
alert("제출할 수 없는 탭입니다.");
return;
}
if (!lastSearchedValues) {
alert("먼저 조회를 진행해주세요.");
return;
}
if (!buildGugunOkForToolbar(lastSearchedValues)) {
alert("시군구를 선택해주세요.");
return;
}
commitGridForTab(tabKey);
const noApplicable = isNotApplicableByTab.get(tabKey) ?? false;
const currentData = getCurrentRowsForTab(tabKey);
const isReservoirTab = tabKey === SEWAGE_RESERVOIR_STATS_CD;
const submitList = noApplicable
? []
: isReservoirTab
? currentData.map((row) => buildSewageReservoirSaveItem(row, sggOptions))
: currentData.map((row) => buildRainfallPumpStationSaveItem(row, sggOptions));
const deleteList = isReservoirTab
? sewageReservoirDeleteList
.filter((row) => String(row.fcltSn ?? "").trim() !== "")
.map((row) => buildSewageReservoirSaveItem(row, sggOptions))
: rainfallPumpStationDeleteList
.filter((row) => String(row.fcltSn ?? "").trim() !== "")
.map((row) => buildRainfallPumpStationSaveItem(row, sggOptions));
const activeTabStatus = tabStatusInfoMap.get(tabKey) ?? null;
const statsCdForTab = tabStatsCdMap.get(tabKey) ?? tabKey;
const verificationQuery = {
sewerBzmnCd: lastSearchedValues.bsnmCd ?? "",
ctpvNm: lastSearchedValues.sido ?? "",
sggNm:
lastSearchedValues.gugun === "ALL"
? ""
: (lastSearchedValues.gugun ?? ""),
statsYr: lastSearchedValues.year ?? "",
statsCd: statsCdForTab,
};
const reloadSubmittedTab = async () => {
if (isReservoirTab) {
setSewageReservoirDeleteList([]);
} else {
setRainfallPumpStationDeleteList([]);
}
await loadActiveTabData(tabKey, lastSearchedValues, false, false);
};
if (!noApplicable && submitList.length === 0) {
alert("해당사항 없음을 체크해주세요.");
return;
}
const submitRequestBase = {
statsYr: lastSearchedValues.year ?? "",
ctpvNm: lastSearchedValues.sido ?? "",
sggNm:
lastSearchedValues.gugun === "ALL"
? ""
: (lastSearchedValues.gugun ?? ""),
sewerBzmnCd: lastSearchedValues.bsnmCd ?? "",
...(noApplicable ? { noApplicable: true } : {}),
laePicNm: submitInfo.laePicNm,
laeKbrdrJbgdNm: submitInfo.laeKbrdrJbgdNm,
laeKbrdrDeptNm: submitInfo.laeKbrdrDeptNm,
laePicTelNo: submitInfo.laePicTelNo,
laeIdfrNm: submitInfo.laeIdfrNm,
laeIdfrJbgdNm: submitInfo.laeIdfrJbgdNm,
laeIdfrDeptNm: submitInfo.laeIdfrDeptNm,
laeIdfrTelNo: submitInfo.laeIdfrTelNo,
};
try {
showGlobalProgress({ label: "서버에 요청 중...", progress: 0 });
const userInfo = await getUserInfo();
setGlobalProgressLabel("일반 검증 진행 중...");
const response = isReservoirTab
? await statisticsInputSewageReservoirPumpApi.submitSewageReservoir({
...submitRequestBase,
userId: userInfo.userUnqId,
submitList: submitList as SewageReservoirSaveItem[],
deleteList: deleteList as SewageReservoirSaveItem[],
})
: await statisticsInputSewageReservoirPumpApi.submitRainfallPumpStation({
...submitRequestBase,
userId: userInfo.userUnqId,
submitList: submitList as RainfallPumpStationSaveItem[],
deleteList: deleteList as RainfallPumpStationSaveItem[],
});
if (response.status !== "success") {
alert(response.message ?? "데이터 제출 중 오류가 발생했습니다.");
return;
}
const finalStatus = response.data?.finalStatus;
const nvpassed = response.data?.nvpassed;
if (finalStatus === "5") {
setGlobalProgress(50);
setGlobalProgressLabel("IMR 검증 진행 중...");
const imrParam = {
user_id: userInfo.userUnqId,
sewer_bzmn_cd: lastSearchedValues.bsnmCd ?? "",
stats_cd: statsCdForTab,
stats_yr: lastSearchedValues.year ?? "",
lae_sbmsn_stts_cd:
activeTabStatus?.laeSbmsnSttsCd === "4" ? "4" : "2",
gen_verify_stts_cd: 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 reloadSubmittedTab();
await openVerificationResultDialogIfNeeded(
verificationQuery,
setVerificationResultDialogOpen,
{ notify: alert },
);
} catch (e) {
await reloadSubmittedTab();
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 reloadSubmittedTab();
await openVerificationResultDialogIfNeeded(
verificationQuery,
setVerificationResultDialogOpen,
{ notify: alert },
);
}
} catch {
alert("데이터 제출 중 오류가 발생했습니다.");
} finally {
hideGlobalProgress();
}
};
const [sewageReservoirDeleteList, setSewageReservoirDeleteList] = useState<
Record<string, string | number>[]
>([]);
const [rainfallPumpStationDeleteList, setRainfallPumpStationDeleteList] =
useState<Record<string, string | number>[]>([]);
const [activeTab, setActiveTab] = useState(firstTabKey);
const [rowDataMap, setRowDataMap] = useState<
Map<string, Record<string, string | number>[]>
@@ -634,7 +932,13 @@ const SewageReservoirPumpInp = () => {
>(new Map());
const [tabStatsCdMap, setTabStatsCdMap] = useState<Map<string, string>>(new Map());
const [verificationHistoryOpen, setVerificationHistoryOpen] = useState(false);
const [verificationResultDialogOpen, setVerificationResultDialogOpen] =
useState(false);
const [showSubmitDialog, setShowSubmitDialog] = useState(false);
const [sggOptions, setSggOptions] = useState<Sgg[]>([]);
const [columnGuidesByTab, setColumnGuidesByTab] = useState<
Record<string, Record<string, string>>
>({});
const reservoirGridRef = useRef<RealGridReact | null>(null);
const rainfallPumpGridRef = useRef<RealGridReact | null>(null);
const reservoirGridGetRowsRef =
@@ -643,6 +947,36 @@ const SewageReservoirPumpInp = () => {
useRef<(() => Record<string, string | number>[]) | null>(null);
const sggOptionsSidoRef = useRef<string | null>(null);
useEffect(() => {
let cancelled = false;
void (async () => {
const entries = await Promise.all(
SEWAGE_RESERVOIR_PUMP_COLUMN_GUIDE_TAB_KEYS.map(async (tabKey) => {
try {
const res = await statisticsApi.getColumnGuides(tabKey);
if (res.status !== "success" || !Array.isArray(res.data)) {
return [tabKey, {}] as const;
}
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;
}
return [tabKey, next] as const;
} catch {
return [tabKey, {}] as const;
}
}),
);
if (cancelled) return;
setColumnGuidesByTab(Object.fromEntries(entries));
})();
return () => {
cancelled = true;
};
}, []);
const searchDoneRef = useRef(false);
const periodEnterableRef = useRef(true);
const activeTabRef = useRef(activeTab);
@@ -732,9 +1066,7 @@ const SewageReservoirPumpInp = () => {
const { commonInfo, statusInfo, sewageReservoirList } = response.data;
const list = sewageReservoirList ?? [];
loadedSewageReservoirSnapshotRef.current = list
.filter((item) => String(item.fcltSn ?? "").trim() !== "")
.map(buildSewageReservoirSaveItemFromApiItem);
setSewageReservoirDeleteList([]);
const gridRows = mapSewageReservoirListToGridRows(
list,
commonInfo.ctpvNm,
@@ -771,6 +1103,7 @@ const SewageReservoirPumpInp = () => {
const { commonInfo, statusInfo, rainfallPumpStationList } = response.data;
const list = rainfallPumpStationList ?? [];
setRainfallPumpStationDeleteList([]);
const gridRows = mapRainfallPumpStationListToGridRows(
list,
commonInfo.ctpvNm,
@@ -837,7 +1170,8 @@ const SewageReservoirPumpInp = () => {
}, []);
const handleReset = () => {
loadedSewageReservoirSnapshotRef.current = [];
setSewageReservoirDeleteList([]);
setRainfallPumpStationDeleteList([]);
setRowDataMap(new Map());
setTabStatusInfoMap(new Map());
setTabStatsCdMap(new Map());
@@ -860,6 +1194,25 @@ const SewageReservoirPumpInp = () => {
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
: [];
const periodOk = isInputPeriodCurrent(periods);
periodEnterableRef.current = periodOk;
setInputPeriodEnterable(periodOk);
} catch {
alert("입력 기간 확인 중 오류가 발생했습니다.");
return;
}
const yearChangedFromLastSearch =
lastSearchedValues != null &&
(lastSearchedValues.year ?? "") !== (values.year ?? "");
@@ -957,12 +1310,13 @@ const SewageReservoirPumpInp = () => {
baseShowEnterableToolbarButtons && toolbarGroupAllowed;
const showActionToolbarCard =
baseShowActionToolbarCard && toolbarGroupAllowed;
// 항목추가/제거 버튼은 "데이터가 있든 없든", "과거 데이터(입력 불가 상태)여도"
// 조회를 하고나면 항상 보여야 합니다.
const isReservoirOrRainfallTab =
activeTab === SEWAGE_RESERVOIR_STATS_CD || activeTab === rainfallPumpTabKey;
const showArticleToolbarCard =
hasSearched && isReservoirOrRainfallTab && toolbarGroupAllowed;
hasSearched &&
isReservoirOrRainfallTab &&
toolbarGroupAllowed &&
(showSaveInToolbar || showEnterableToolbarButtons);
const isNotApplicableForActiveTab = isNotApplicableByTab.get(activeTab) ?? false;
const showNotApplicableForActiveTab =
loadedTabKeysRef.current.has(activeTab) &&
@@ -1012,20 +1366,6 @@ const SewageReservoirPumpInp = () => {
const sggNm =
ctx.gugun && ctx.gugun !== "ALL" ? ctx.gugun : "";
if (isRainfallPumpTab) {
const newRow = createEmptyRainfallPumpStationRow(ctx.sido, sggNm);
setRowDataMap((prev) => {
const next = new Map(prev);
const current = next.get(rainfallPumpTabKey) ?? [];
next.set(rainfallPumpTabKey, [...current, newRow]);
return next;
});
const statusInfo = tabStatusInfoMap.get(rainfallPumpTabKey) ?? null;
commitToolbarSnapshotForLoadedTab(rainfallPumpTabKey, statusInfo, true, ctx);
return;
}
let list = sggOptions;
if (sggOptionsSidoRef.current !== ctx.sido || list.length === 0) {
list = await loadSggOptions(ctx.sido);
@@ -1036,24 +1376,23 @@ const SewageReservoirPumpInp = () => {
return;
}
const tabKey = SEWAGE_RESERVOIR_STATS_CD;
const labels = list.map((s) => s.gugun).filter(Boolean);
const newRow = createEmptySewageReservoirRow(ctx.sido, sggNm);
const tabKey = activeTabKey;
const newRow = isReservoirTab
? createEmptySewageReservoirRow(ctx.sido, sggNm)
: createEmptyRainfallPumpStationRow(ctx.sido, sggNm);
commitGridForTab(tabKey);
const currentRows = getCurrentRowsForTab(tabKey);
flushSync(() => {
setSggOptions(list);
setRowDataMap((prev) => {
const next = new Map(prev);
const current = next.get(tabKey) ?? [];
next.set(tabKey, [...current, newRow]);
next.set(tabKey, [...currentRows, newRow]);
return next;
});
});
// 드롭다운 컬럼은 columnInfoByTab(buildSggNmColumn) 렌더링으로 반영한다.
// 행 추가 직후 setColumnProperty를 강제 재적용하면 타이밍 이슈로
// `fieldIndex is out of bounds`가 발생할 수 있어 제거한다.
const statusInfo = tabStatusInfoMap.get(tabKey) ?? null;
commitToolbarSnapshotForLoadedTab(tabKey, statusInfo, true, ctx);
};
@@ -1068,24 +1407,29 @@ const SewageReservoirPumpInp = () => {
inputPeriodEnterable,
) || isAdminInfo;
const isReservoirTab = tabKey === SEWAGE_RESERVOIR_STATS_CD;
const columns = (tab.columns ?? []).map((col: ColumnConfig) => {
if (isReservoirTab && col.fieldName === "ctpvNm") {
return {
...col,
editable: false as const,
styleName: col.styleName ?? "rg-cell-readonly",
};
const isRainfallPumpTab = tabKey === rainfallPumpTabKey;
const rawColumns = tab.columns ?? [];
const base = canEdit
? rawColumns
: rawColumns.map((col) => ({ ...col, editable: false as const }));
const columns = base.map((col: ColumnConfig) => {
if (
(isReservoirTab || isRainfallPumpTab) &&
col.fieldName === "ctpvNm"
) {
return { ...col, editable: false as const };
}
if (isReservoirTab && col.fieldName === "sggNm") {
return buildSggNmColumn(col, canEdit, sggOptions);
if (
(isReservoirTab || isRainfallPumpTab) &&
col.fieldName === "sggNm" &&
canEdit
) {
return buildSggNmColumn(col, true, sggOptions);
}
return canEdit
? col
: {
...col,
editable: false as const,
styleName: col.styleName ?? "rg-cell-readonly",
};
return col;
}).map((col) => {
const guide = columnGuidesByTab[tabKey]?.[col.fieldName]?.trim();
return guide ? { ...col, tooltip: guide } : col;
});
map.set(tabKey, {
columns,
@@ -1093,7 +1437,7 @@ const SewageReservoirPumpInp = () => {
});
}
return map;
}, [tabStatusInfoMap, inputPeriodEnterable, isAdminInfo, sggOptions]);
}, [tabStatusInfoMap, inputPeriodEnterable, isAdminInfo, sggOptions, columnGuidesByTab]);
const verificationCellStyle = ({
fieldName,
@@ -1220,7 +1564,7 @@ const SewageReservoirPumpInp = () => {
return (
<TabsContent key={tabKey} value={tabKey}>
<DataGrid
key={tabKey}
key={`${tabKey}-${tabEnterableForGrid ? "enterable" : "readonly"}`}
getGridRef={
isReservoirTab
? reservoirGridRef
@@ -1265,7 +1609,7 @@ const SewageReservoirPumpInp = () => {
showHeaderCheckbox
statusInfo={tabStatusInfo}
isExcelImport={
tabRowDataExists(rowDataMap, tabKey) &&
loadedTabKeysRef.current.has(tabKey) &&
tabEnterableForGrid &&
gugunOk
}
@@ -1281,6 +1625,28 @@ const SewageReservoirPumpInp = () => {
open={verificationHistoryOpen}
onOpenChange={setVerificationHistoryOpen}
queryContext={verificationQueryContext}
onExceptionResetSubmitted={async () => {
const values = lastSearchedValues ?? searchValues;
if (!values) return;
await loadActiveTabData(activeTabRef.current, values, false, false);
}}
/>
<SubmitDailog
open={showSubmitDialog}
onOpenChange={setShowSubmitDialog}
statusInfo={activeTabStatus}
onConfirm={executeSubmit}
isLoading={isLoading}
/>
<VerificationResultDialog
open={verificationResultDialogOpen}
onOpenChange={setVerificationResultDialogOpen}
queryContext={verificationQueryContext}
onExceptionSubmitted={async () => {
const values = lastSearchedValues ?? searchValues;
if (!values) return;
await loadActiveTabData(activeTabRef.current, values, false, false);
}}
/>
<ConfirmDialog isOpen={false} />
</>
@@ -1,34 +1,539 @@
import { useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import SearchForm from "@/components/module/SearchForm";
import TabDataGrid from "@/components/module/TabDataGrid";
import SubmitButton from "@/components/button/SumitButton";
import SaveButton from "@/components/button/SaveButton";
import SubmitExceptionButton from "@/components/button/SubmitExceptionButton";
import { useConfirm } from "@frontend-opensource/use-react-hooks";
import { useAlertStore } from "@/shared/lib";
import { statisticsInputSewSupplyRateApi } from "@/entities/statistics/api/input/statistics-input-sew-supply-rate-service";
import { statisticsInputSewerageFinanceApi } from "@/entities/statistics/api/input/statistics-input-sewerage-finance-service";
import { statisticsApi } from "@/entities/statistics/api/statistics-service";
import type {
CauserBurdenItem,
SewerageChargeItem,
SewerageFinanceRequestParams,
SewerageFinanceSettleItem,
SewerageFinanceVerificationResult,
SewerageUsageFeeItem,
} from "@/entities/statistics/model/input/statistics-input-sewerage-finance-interface";
import { isInputPeriodCurrent } from "@/entities/statistics/model/input/statistics-sewage-supply-rate-input-interface";
import type { cancelSubmitRequest, SewerageStatusInfo } from "@/entities/statistics/model/statistics-interface";
import { LoadingWithAnimation, Tabs, TabsContent, TabsList, TabsTrigger } from "@common/components";
import { getUserInfo } from "../../../../../../packages/common/components/src/api";
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 "@/components/module/DataGrid";
import SearchForm from "@/components/module/SearchForm";
import { VerificationResultDialog } from "@/components/module/VerificationResultDialog";
import { VerificationResultHistoryDialog } from "@/components/module/VerificationResultHistoryDialog";
import type { ColumnConfig, ColumnInfo } from "@/components/type/DataGrid.type";
import type { SearchFormValues } from "@/components/type/SearchForm.type";
import { doList } from "@/components/util/column/input/DoList";
import { CauserBurdenInpColumn } from "@/components/util/column/input/sewerageFinance/CauserBurdenInpColumn";
import { SewerageChargeInpColumn } from "@/components/util/column/input/sewerageFinance/SewerageChargeInpColumn";
import { SewerageFinanceSettleInpColumn } from "@/components/util/column/input/sewerageFinance/SewerageFinanceSettleInpColumn";
import { SewerageUsageFeeInpColumn } from "@/components/util/column/input/sewerageFinance/SewerageUsageFeeInpColumn";
import { toGridRows } from "@/components/util/grid/gridFunction";
import { useAdminStore } from "@/store/admin-store";
import { SewerageFinanceSettleInpColumn } from "@/components/util/column/input/sewerageFinance/SewerageFinanceSettleInpColumn"
import { SewerageChargeInpColumn } from "@/components/util/column/input/sewerageFinance/SewerageChargeInpColumn"
import { SewerageUsageFeeInpColumn } from "@/components/util/column/input/sewerageFinance/SewerageUsageFeeInpColumn"
import { CauserBurdenInpColumn } from "@/components/util/column/input/sewerageFinance/CauserBurdenInpColumn"
const SewerageFinanceInpTabList = [
SewerageFinanceSettleInpColumn,
SewerageChargeInpColumn,
SewerageUsageFeeInpColumn,
CauserBurdenInpColumn,
] as const;
const firstTabKey = SewerageFinanceSettleInpColumn.tabKey;
const SEWERAGE_FINANCE_COLUMN_GUIDE_TAB_KEYS = Array.from(
new Set(SewerageFinanceInpTabList.map((tab) => tab.tabKey).filter(Boolean)),
);
type GridRowWithVerification = Record<string, string | number> & {
verificationResult?: SewerageFinanceVerificationResult;
};
type TabToolbarSnapshot = {
showSaveInToolbar: boolean;
showEnterableToolbarButtons: boolean;
showActionToolbarCard: boolean;
};
const toGridColumnInfo = (tab: (typeof SewerageFinanceInpTabList)[number]): ColumnInfo => ({
columns: tab.columns ?? [],
columnLayout: tab.columnLayout ?? [],
});
function buildSewerageFinanceSearchParam(
values: SearchFormValues,
overrides?: { mode?: string },
): SewerageFinanceRequestParams {
return {
statsYr: values.year,
sewerBzmnCd: values.bsnmCd,
ctpvNm: values.sido,
sggNm: values.gugun === "ALL" ? "" : values.gugun,
...(overrides?.mode ? { mode: overrides.mode } : {}),
};
}
const computeTabEnterable = (
statusInfo: SewerageStatusInfo | null | undefined,
periodEnterable: boolean,
): boolean => {
if (statusInfo?.laeSbmsnSttsCd) {
if (["2", "3"].includes(statusInfo.laeSbmsnSttsCd)) {
return false;
}
return periodEnterable;
}
return periodEnterable;
};
function buildGugunOkForToolbar(values: SearchFormValues | null): boolean {
return doList.includes(values?.bsnmCd || "")
? Boolean(values?.gugun && values.gugun !== "ALL")
: true;
}
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?.laeSbmsnSttsCd === "2";
const showSaveInToolbar = (enterable || isAdminInfo) && gugunOk;
const showEnterableToolbarButtons = enterable && gugunOk;
const showSubmittedInInputPeriodToolbar = isSubmitted && periodEnterable && gugunOk;
const showActionToolbarCard =
(hasRowData || treatAsSearched) &&
(showSaveInToolbar ||
showEnterableToolbarButtons ||
showSubmittedInInputPeriodToolbar ||
(isSubmitted && isAdminInfo));
return { showSaveInToolbar, showEnterableToolbarButtons, showActionToolbarCard };
}
function tabRowDataExists(
m: Map<string, Record<string, string | number>[]>,
tabKey: string,
): boolean {
const rows = m.get(tabKey);
return Array.isArray(rows) && rows.length > 0;
}
function mapRegionalListToGridRows<
T extends { verificationResult?: SewerageFinanceVerificationResult },
>(list: T[], ctpvNm: string): GridRowWithVerification[] {
return list.map((item) => {
const { verificationResult, ...rest } = item;
const row = toGridRows([{ ctpvNm, ...rest }])[0] as GridRowWithVerification;
if (verificationResult) row.verificationResult = verificationResult;
return row;
});
}
// 하수도 통계 관리 > 09. 하수도재정
const SewerageFinanceInp = () => {
const { setMessage: alert } = useAlertStore((state) => state);
const { confirm } = useConfirm();
const { isAdminInfo } = useAdminStore();
// tab list
const SewerageFinanceInpTabList = [
SewerageFinanceSettleInpColumn,
SewerageChargeInpColumn,
SewerageUsageFeeInpColumn,
CauserBurdenInpColumn
];
const [isLoading, setIsLoading] = useState(false);
const [activeTab, setActiveTab] = useState<string>(firstTabKey);
const [rowDataMap, setRowDataMap] = useState<Map<string, Record<string, string | number>[]>>(
new Map(),
);
const [searchValues, setSearchValues] = useState<SearchFormValues | null>(null);
const [lastSearchedValues, setLastSearchedValues] = useState<SearchFormValues | null>(null);
const [hasSearched, setHasSearched] = useState(false);
const [inputPeriodEnterable, setInputPeriodEnterable] = useState(true);
const [tabStatusInfoMap, setTabStatusInfoMap] = useState<
Map<string, SewerageStatusInfo | null>
>(new Map());
const [tabStatsCdMap, setTabStatsCdMap] = useState<Map<string, string>>(new Map());
const [toolbarSnapshotByTab, setToolbarSnapshotByTab] = useState<
Map<string, TabToolbarSnapshot>
>(new Map());
const [columnGuidesByTab, setColumnGuidesByTab] = useState<
Record<string, Record<string, string>>
>({});
const [verificationHistoryOpen, setVerificationHistoryOpen] = useState(false);
const [verificationResultDialogOpen, setVerificationResultDialogOpen] = useState(false);
const submitExceptionFunc = () => {
// 예외 제출 로직
const searchParamRef = useRef<SewerageFinanceRequestParams | null>(null);
const searchDoneRef = useRef(false);
const periodEnterableRef = useRef(true);
const activeTabRef = useRef(activeTab);
const getGridDataRef = useRef<(() => Record<string, string | number>[]) | null>(null);
activeTabRef.current = activeTab;
useEffect(() => {
let cancelled = false;
void (async () => {
const entries = await Promise.all(
SEWERAGE_FINANCE_COLUMN_GUIDE_TAB_KEYS.map(async (tabKey) => {
try {
const res = await statisticsApi.getColumnGuides(tabKey);
if (res.status !== "success" || !Array.isArray(res.data)) {
return [tabKey, {}] as const;
}
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;
}
return [tabKey, next] as const;
} catch {
return [tabKey, {}] as const;
}
}),
);
if (cancelled) return;
setColumnGuidesByTab(Object.fromEntries(entries));
})();
return () => {
cancelled = true;
};
}, []);
const commitToolbarSnapshotForLoadedTab = (
tabKey: string,
statusInfo: SewerageStatusInfo | null,
hasRowData: boolean,
searchContext: SearchFormValues,
) => {
setToolbarSnapshotByTab((prev) => {
const next = new Map(prev);
next.set(
tabKey,
computeTabToolbarSnapshot(
statusInfo,
hasRowData,
searchDoneRef.current,
searchContext,
periodEnterableRef.current,
isAdminInfo,
),
);
return next;
});
};
const lastYearDataFunc = () => {
// 지난연도 데이터 가져오기 로직
const applyRowMap = (
mergeIntoExistingMap: boolean,
apply: (m: Map<string, Record<string, string | number>[]>) => void,
) => {
if (mergeIntoExistingMap) {
setRowDataMap((prev) => {
const next = new Map(prev);
apply(next);
return next;
});
} else {
const next = new Map<string, Record<string, string | number>[]>();
apply(next);
setRowDataMap(next);
}
};
const loadActiveTabData = async (
tabKey: string,
param: SewerageFinanceRequestParams,
searchContext: SearchFormValues,
mergeIntoExistingMap: boolean,
updateLastSearchedValues: boolean,
overrides?: { mode?: string },
): Promise<boolean> => {
const requestParam = overrides?.mode ? { ...param, mode: overrides.mode } : param;
if (tabKey === SewerageFinanceSettleInpColumn.tabKey) {
const response =
await statisticsInputSewerageFinanceApi.getSewerageFinanceSettleList(requestParam);
if (response.status !== "success" || !response.data) {
alert(response.message || "데이터 조회 중 오류가 발생했습니다.");
return false;
}
const { commonInfo, statusInfo, sewerageFinanceSettleList } = response.data;
const gridRows = mapRegionalListToGridRows(
(sewerageFinanceSettleList ?? []) as SewerageFinanceSettleItem[],
commonInfo.ctpvNm,
);
applyRowMap(mergeIntoExistingMap, (m) => {
m.set(SewerageFinanceSettleInpColumn.tabKey, gridRows);
});
setTabStatusInfoMap((prev) => new Map(prev).set(tabKey, statusInfo));
setTabStatsCdMap((prev) => new Map(prev).set(tabKey, commonInfo.statsCd));
if (updateLastSearchedValues) setLastSearchedValues(searchContext);
commitToolbarSnapshotForLoadedTab(tabKey, statusInfo, gridRows.length > 0, searchContext);
return true;
}
if (tabKey === SewerageChargeInpColumn.tabKey) {
const response =
await statisticsInputSewerageFinanceApi.getSewerageChargeList(requestParam);
if (response.status !== "success" || !response.data) {
alert(response.message || "데이터 조회 중 오류가 발생했습니다.");
return false;
}
const { commonInfo, statusInfo, sewerageChargeList } = response.data;
const gridRows = mapRegionalListToGridRows(
(sewerageChargeList ?? []) as SewerageChargeItem[],
commonInfo.ctpvNm,
);
applyRowMap(mergeIntoExistingMap, (m) => {
m.set(SewerageChargeInpColumn.tabKey, gridRows);
});
setTabStatusInfoMap((prev) => new Map(prev).set(tabKey, statusInfo));
setTabStatsCdMap((prev) => new Map(prev).set(tabKey, commonInfo.statsCd));
if (updateLastSearchedValues) setLastSearchedValues(searchContext);
commitToolbarSnapshotForLoadedTab(tabKey, statusInfo, gridRows.length > 0, searchContext);
return true;
}
if (tabKey === SewerageUsageFeeInpColumn.tabKey) {
const response =
await statisticsInputSewerageFinanceApi.getSewerageUsageFeeList(requestParam);
if (response.status !== "success" || !response.data) {
alert(response.message || "데이터 조회 중 오류가 발생했습니다.");
return false;
}
const { commonInfo, statusInfo, sewerageUsageFeeList } = response.data;
const gridRows = mapRegionalListToGridRows(
(sewerageUsageFeeList ?? []) as SewerageUsageFeeItem[],
commonInfo.ctpvNm,
);
applyRowMap(mergeIntoExistingMap, (m) => {
m.set(SewerageUsageFeeInpColumn.tabKey, gridRows);
});
setTabStatusInfoMap((prev) => new Map(prev).set(tabKey, statusInfo));
setTabStatsCdMap((prev) => new Map(prev).set(tabKey, commonInfo.statsCd));
if (updateLastSearchedValues) setLastSearchedValues(searchContext);
commitToolbarSnapshotForLoadedTab(tabKey, statusInfo, gridRows.length > 0, searchContext);
return true;
}
if (tabKey === CauserBurdenInpColumn.tabKey) {
const response =
await statisticsInputSewerageFinanceApi.getCauserBurdenList(requestParam);
if (response.status !== "success" || !response.data) {
alert(response.message || "데이터 조회 중 오류가 발생했습니다.");
return false;
}
const { commonInfo, statusInfo, causerBurdenList } = response.data;
const gridRows = mapRegionalListToGridRows(
(causerBurdenList ?? []) as CauserBurdenItem[],
commonInfo.ctpvNm,
);
applyRowMap(mergeIntoExistingMap, (m) => {
m.set(CauserBurdenInpColumn.tabKey, gridRows);
});
setTabStatusInfoMap((prev) => new Map(prev).set(tabKey, statusInfo));
setTabStatsCdMap((prev) => new Map(prev).set(tabKey, commonInfo.statsCd));
if (updateLastSearchedValues) setLastSearchedValues(searchContext);
commitToolbarSnapshotForLoadedTab(tabKey, statusInfo, gridRows.length > 0, searchContext);
return true;
}
alert("해당 탭 조회 API가 없습니다.");
return false;
};
const getTabGridRows = (tabKey: string): Record<string, string | number>[] =>
getGridDataRef.current?.() ?? rowDataMap.get(tabKey) ?? [];
const handleReset = () => {
setRowDataMap(new Map());
setTabStatusInfoMap(new Map());
setTabStatsCdMap(new Map());
setToolbarSnapshotByTab(new Map());
setActiveTab(firstTabKey);
activeTabRef.current = firstTabKey;
setLastSearchedValues(null);
setHasSearched(false);
setInputPeriodEnterable(true);
searchParamRef.current = null;
searchDoneRef.current = false;
periodEnterableRef.current = 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 : [];
const periodOk = isInputPeriodCurrent(periods);
periodEnterableRef.current = periodOk;
setInputPeriodEnterable(periodOk);
const yearChangedFromLastSearch =
lastSearchedValues != null &&
(lastSearchedValues.year ?? "") !== (values.year ?? "");
if (yearChangedFromLastSearch) {
setRowDataMap(new Map());
setTabStatusInfoMap(new Map());
setTabStatsCdMap(new Map());
setToolbarSnapshotByTab(new Map());
activeTabRef.current = firstTabKey;
setActiveTab(firstTabKey);
}
setIsLoading(true);
searchDoneRef.current = true;
setHasSearched(true);
const searchParam = buildSewerageFinanceSearchParam(values);
searchParamRef.current = searchParam;
await loadActiveTabData(
activeTabRef.current,
searchParam,
values,
!yearChangedFromLastSearch,
true,
);
} catch {
alert("데이터 조회 중 오류가 발생했습니다.");
} finally {
setIsLoading(false);
}
};
const handleActiveTabChange = async (tabKey: string) => {
const prevTabKey = activeTabRef.current;
const latestRowsInPrevTab = getTabGridRows(prevTabKey);
if (latestRowsInPrevTab.length > 0 || rowDataMap.has(prevTabKey)) {
setRowDataMap((prev) => {
const next = new Map(prev);
next.set(prevTabKey, latestRowsInPrevTab);
return next;
});
}
activeTabRef.current = tabKey;
setActiveTab(tabKey);
if (!hasSearched || !lastSearchedValues || !searchParamRef.current) return;
if (tabRowDataExists(rowDataMap, tabKey)) return;
try {
setIsLoading(true);
await loadActiveTabData(
tabKey,
searchParamRef.current,
lastSearchedValues,
true,
false,
);
} catch {
alert("데이터 조회 중 오류가 발생했습니다.");
} finally {
setIsLoading(false);
}
};
const lastYearDataFunc = async () => {
const values = lastSearchedValues ?? searchValues;
if (!values?.sido || values.sido === "ALL") {
alert("시도를 선택해주세요.");
return;
}
if (!hasSearched) {
alert("먼저 데이터를 조회해주세요.");
return;
}
try {
setIsLoading(true);
const param = buildSewerageFinanceSearchParam(values, { mode: "PRE" });
await loadActiveTabData(
activeTabRef.current,
param,
values,
true,
false,
{ mode: "PRE" },
);
} catch {
alert("데이터 조회 중 오류가 발생했습니다.");
} finally {
setIsLoading(false);
}
};
const cnacelSubmitFunc = () => {
void (async () => {
try {
if (!(await confirm("제출된 내용을 취소하시겠습니까?"))) return;
if (!lastSearchedValues || !searchParamRef.current) {
alert("먼저 조회를 진행해주세요.");
return;
}
const userInfo = await getUserInfo();
const tabKey = activeTabRef.current;
const statsCdForTab = tabStatsCdMap.get(tabKey);
if (!statsCdForTab) {
alert("먼저 조회를 진행해주세요.");
return;
}
const requestData: cancelSubmitRequest = {
sewerBzmnCd: lastSearchedValues.bsnmCd ?? "",
ctpvNm: lastSearchedValues.sido ?? "",
sggNm:
lastSearchedValues.gugun === "ALL"
? ""
: (lastSearchedValues.gugun ?? ""),
statsYr: lastSearchedValues.year ?? "",
mdfrId: userInfo.userUnqId,
statsCd: statsCdForTab,
};
const response = await statisticsApi.cancelSubmit(requestData);
if (response.status === "success") {
setIsLoading(true);
await loadActiveTabData(
tabKey,
searchParamRef.current,
lastSearchedValues,
false,
false,
);
alert("제출 취소되었습니다.");
} else {
alert(response.message ?? "제출 취소 중 오류가 발생했습니다.");
}
} catch {
alert("제출 취소 중 오류가 발생했습니다.");
} finally {
setIsLoading(false);
}
})();
};
const submitFunc = () => {
@@ -39,26 +544,249 @@ const SewerageFinanceInp = () => {
// 저장 로직
};
const [rowDataMap, setRowDataMap] = useState<Map<string, Record<string, string | number>[]>>(new Map());
const openVerificationHistoryDialog = () => {
const ctx = lastSearchedValues ?? searchValues;
const statsCd = tabStatsCdMap.get(activeTab);
if (!ctx?.year || !ctx?.sido || !ctx?.bsnmCd || !statsCd) {
alert("먼저 조회를 진행해주세요.");
return;
}
setVerificationHistoryOpen(true);
};
const verificationQueryContext = (() => {
const ctx = lastSearchedValues ?? searchValues;
const statsCd = tabStatsCdMap.get(activeTab);
if (!ctx?.year || !ctx?.sido || !ctx?.bsnmCd || !statsCd) return null;
return {
sewerBzmnCd: ctx.bsnmCd,
ctpvNm: ctx.sido,
sggNm: ctx.gugun === "ALL" ? "" : (ctx.gugun ?? ""),
statsYr: ctx.year,
statsCd,
};
})();
const columnInfoByTab = useMemo(() => {
const map = new Map<string, ColumnInfo>();
for (const tab of SewerageFinanceInpTabList) {
const tabKey = tab.tabKey;
const canEdit =
computeTabEnterable(
tabStatusInfoMap.get(tabKey) ?? null,
inputPeriodEnterable,
) || isAdminInfo;
const rawColumns = (tab.columns ?? []) as ColumnConfig[];
const base = canEdit
? rawColumns
: rawColumns.map((col) => ({ ...col, editable: false as const }));
const columns = base.map((col) => {
const guide = columnGuidesByTab[tabKey]?.[col.fieldName]?.trim();
return guide ? { ...col, tooltip: guide } : col;
});
map.set(tabKey, {
columns,
columnLayout: tab.columnLayout ?? [],
});
}
return map;
}, [tabStatusInfoMap, inputPeriodEnterable, isAdminInfo, columnGuidesByTab]);
const activeTabStatus = tabStatusInfoMap.get(activeTab) ?? null;
const activeTabToolbar =
toolbarSnapshotByTab.get(activeTab) ??
computeTabToolbarSnapshot(
activeTabStatus,
tabRowDataExists(rowDataMap, activeTab),
hasSearched,
lastSearchedValues,
inputPeriodEnterable,
isAdminInfo,
);
const { showSaveInToolbar, showEnterableToolbarButtons, showActionToolbarCard } =
activeTabToolbar;
const verificationCellStyle = ({
fieldName,
row,
}: {
fieldName: string;
row: Record<string, string | number>;
}): 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,
}: {
fieldName: string;
row: Record<string, string | number>;
}): 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"
? "IMR검증 - "
: e.vrfcCd === "NV"
? "일반검증 - "
: "HB검증 - ";
return `${prefix}${e.rsnExpln}(검증 기준값: ${e.compVlCn} / 현재 입력값: ${e.nowVlCn})`;
})
.filter(Boolean)
.join("\n");
}
};
return (
<>
<SearchForm />
<div className="card !py-0">
<div className="flex justify-end items-center gap-[0.6rem] m-[0.6rem]">
<SubmitExceptionButton SubmitExceptionFunc={submitExceptionFunc} />
<LastYearDataButton getLastYearDataFunc={lastYearDataFunc} />
<SubmitButton submitFunc={submitFunc} />
<SaveButton saveFunc={saveFunc} />
<div className="relative">
<SearchForm
onSubmit={handleSearch}
onReset={handleReset}
onValuesChange={setSearchValues}
/>
{showActionToolbarCard && (
<div className="card !py-0">
<div className="flex justify-end items-center gap-[0.6rem] m-[0.6rem]">
<VerificationResultButton
VerificationResultButtonFunc={openVerificationHistoryDialog}
/>
{showEnterableToolbarButtons && (
<>
<LastYearDataButton
getLastYearDataFunc={() => void lastYearDataFunc()}
/>
<SubmitButton submitFunc={submitFunc} />
</>
)}
{activeTabStatus?.laeSbmsnSttsCd === "2" && (
<CancelSubmitButton cnacelSubmitFunc={cnacelSubmitFunc} />
)}
{showSaveInToolbar && <SaveButton saveFunc={saveFunc} />}
</div>
</div>
)}
{isLoading && (
<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>
)}
<div className="flex flex-col gap-[0.6rem] mx-[0.6rem]">
<Tabs
value={activeTab}
onValueChange={(v) => void handleActiveTabChange(v)}
className="w-full"
variant="default"
>
<TabsList>
{SewerageFinanceInpTabList.map((info) => (
<TabsTrigger key={info.tabKey} value={info.tabKey}>
{info.tabLabel || info.tabKey}
</TabsTrigger>
))}
</TabsList>
{SewerageFinanceInpTabList.map((info) => {
const tabKey = info.tabKey;
const rowData = rowDataMap.get(tabKey) ?? [];
const tabEnterableForGrid =
computeTabEnterable(
tabStatusInfoMap.get(tabKey) ?? null,
inputPeriodEnterable,
) || isAdminInfo;
const gugunOk = buildGugunOkForToolbar(lastSearchedValues);
const tabStatusInfo = tabStatusInfoMap.get(tabKey) ?? null;
return (
<TabsContent key={tabKey} value={tabKey}>
<DataGrid
key={`${tabKey}-${tabEnterableForGrid ? "enterable" : "readonly"}`}
columnInfo={
columnInfoByTab.get(tabKey) ?? toGridColumnInfo(info)
}
rowStartNum={info.rowStartNum}
excelExportFileSuffix={info.tabLabel || tabKey}
rowData={rowData}
setRowData={(updater) => {
setRowDataMap((prev) => {
const next = new Map(prev);
const cur = next.get(tabKey) ?? [];
const n =
typeof updater === "function"
? updater(cur)
: updater;
next.set(tabKey, n);
return next;
});
}}
showHeaderCheckbox={false}
fixedColumns={info.fixedColumns}
enableSort={false}
statusInfo={tabStatusInfo}
isExcelImport={
tabRowDataExists(rowDataMap, tabKey) &&
tabEnterableForGrid &&
gugunOk
}
isExcelExport
cellStyleCallback={verificationCellStyle}
cellTooltipCallback={verificationCellTooltip}
getGridDataRef={
activeTab === tabKey ? getGridDataRef : undefined
}
/>
</TabsContent>
);
})}
</Tabs>
</div>
</div>
<TabDataGrid
columnInfo={SewerageFinanceInpTabList}
rowDataMap={rowDataMap}
setRowDataMap={setRowDataMap}
<VerificationResultHistoryDialog
open={verificationHistoryOpen}
onOpenChange={setVerificationHistoryOpen}
queryContext={verificationQueryContext}
onExceptionResetSubmitted={async () => {
const values = lastSearchedValues ?? searchValues;
if (!values || !searchParamRef.current) return;
await loadActiveTabData(
activeTabRef.current,
searchParamRef.current,
values,
true,
false,
);
}}
/>
<VerificationResultDialog
open={verificationResultDialogOpen}
onOpenChange={setVerificationResultDialogOpen}
queryContext={verificationQueryContext}
onExceptionSubmitted={async () => {
const values = lastSearchedValues ?? searchValues;
if (!values || !searchParamRef.current) return;
await loadActiveTabData(
activeTabRef.current,
searchParamRef.current,
values,
true,
false,
);
}}
/>
</>
);
};
export default SewerageFinanceInp;
export default SewerageFinanceInp;
@@ -288,7 +288,8 @@ const SeweragePenetrationRateInp = () => {
} else {
alert(response.message);
}
} catch {
} catch (error) {
console.log(error);
alert('데이터 조회 중 오류가 발생했습니다.');
setRowData([]);
setStatusInfo(null);
@@ -790,11 +791,13 @@ const SeweragePenetrationRateInp = () => {
</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" />
{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>
</div>}
}
<DataGrid
key={(isEnterable || isAdminInfo) ? 'enterable' : 'readonly'}
columnInfo={columnInfo}
@@ -859,6 +862,11 @@ const SeweragePenetrationRateInp = () => {
}
: null
}
onExceptionResetSubmitted={async () => {
const values = lastSearchedValues ?? searchValues;
if (!values) return;
await handleSearch(values);
}}
/>
<VerificationResultDialog
open={verificationResultDialogOpen}