"use client"; import { type Dispatch, type MutableRefObject, type SetStateAction, useCallback, useEffect, useMemo, useRef, useState, } from "react"; import type { RealGridReact } from "realgrid-react"; import { getUserInfo } from "../../../../../packages/common/components/src/api"; import { statisticsApi } from "@/entities/statistics/api/statistics-service"; import type { VerificationResultListRequest, VerificationResultReportItem } from "@/entities/statistics/model/statistics-interface"; import { Button, Dialog, DialogClose, DialogContent, DialogFooter, DialogHeader, DialogTitle, LoadingWithAnimation, Tabs, TabsContent, TabsList, TabsTrigger, } from "@common/components"; import DataGrid from "@/components/module/DataGrid"; import { cellContentTooltip } from "@/components/module/verificationResultCellTooltip"; import { VerificationResultHistoryPagination } from "@/components/module/VerificationResultHistoryPagination"; import type { CellStyleCallbackParams, ColumnInfo } from "@/components/type/DataGrid.type"; import { useAlertStore } from "@/shared/lib"; type VerificationResultGridRow = Record; type VerificationHistoryTabKey = "NV" | "IMR" | "HB"; const verificationHistoryTabs: { value: VerificationHistoryTabKey; label: string }[] = [ { value: "NV", label: "일반검증 내역" }, { value: "IMR", label: "시계열 검증내역" }, { value: "HB", label: "모집단 검증내역" }, ]; const PAGE_SIZE = 30; /** vrfcSn → prcsYn (페이지 이동 후에도 취소 가능 여부 검증용) */ const createEmptyCheckedVrfcByTab = (): Record> => ({ NV: new Map(), IMR: new Map(), HB: new Map(), }); const initialTabPageState = (): Record< VerificationHistoryTabKey, { pageIndex: number; pageCount: number } > => ({ NV: { pageIndex: 0, pageCount: 1 }, IMR: { pageIndex: 0, pageCount: 1 }, HB: { pageIndex: 0, pageCount: 1 }, }); function prcsYnCodeToNumber(raw: string | number | undefined): number | undefined { if (raw === undefined || raw === null) return undefined; if (typeof raw === "number" && Number.isFinite(raw)) return raw; const s = String(raw).trim(); if (s === "") return undefined; const n = Number.parseInt(s, 10); return Number.isFinite(n) ? n : undefined; } /** 예외 제출 취소 가능: prcsYn = 2 (예외처리) */ function canExceptionReset(prcsYn: string | number | undefined): boolean { return prcsYnCodeToNumber(prcsYn) === 2; } const EXCEPTION_RESET_ONLY_PRCS_YN_2_MESSAGE = "예외 제출 취소는 처리여부가 사용자예외처리인 항목만 선택할 수 있습니다."; type GridViewWithCheckApi = { getCheckedRows?: (allItems?: boolean, visibleOnly?: boolean, exclusive?: boolean) => number[]; checkItem?: (itemIndex: number, checked: boolean, exclusive?: boolean) => void; }; function uncheckGridRow(gridRef: MutableRefObject, itemIndex: number) { try { (gridRef.current?.gridView as GridViewWithCheckApi | undefined)?.checkItem?.(itemIndex, false, false); } catch { /* ignore */ } } function syncCheckedMapFromGrid( entries: VerificationResultReportItem[], gridRef: MutableRefObject, prevMap: Map, ): Map { const next = new Map(prevMap); for (const entry of entries) { const sn = Number(entry.vrfcSn); if (Number.isFinite(sn)) next.delete(sn); } const indices = (gridRef.current?.gridView as GridViewWithCheckApi | undefined)?.getCheckedRows?.(true, false, true) ?? []; for (const itemIndex of indices) { const entry = entries[itemIndex]; if (!entry) continue; const sn = Number(entry.vrfcSn); if (!Number.isFinite(sn)) continue; next.set(sn, String(entry.prcsYn ?? "")); } return next; } function selectionHasNonExceptionResetAllowed( map: Map, currentEntries: VerificationResultReportItem[], ): boolean { for (const [vrfcSn, storedPrcsYn] of map) { const onPage = currentEntries.find((e) => Number(e.vrfcSn) === vrfcSn); const prcsYn = onPage ? onPage.prcsYn : storedPrcsYn; if (!canExceptionReset(prcsYn)) return true; } return false; } function verificationPrcsYnNmCellStyle({ fieldName, row }: CellStyleCallbackParams): string | undefined { if (fieldName !== "prcsYnNm") return undefined; const code = prcsYnCodeToNumber(row.prcsYn); if (code === 0 || code === 3) return "rg-cell-vrf-prcs-bad"; return "rg-cell-vrf-prcs-ok"; } const verificationResultColumnInfo: ColumnInfo = { columns: [ { name: "ctpvNm", fieldName: "ctpvNm", header: { text: "시도" }, editable: false }, { name: "admdstNm", fieldName: "admdstNm", header: { text: "행정구역" }, editable: false }, { name: "vrfcTrgtNm", fieldName: "vrfcTrgtNm", header: { text: "검증대상" }, editable: false }, { name: "rsnExpln", fieldName: "rsnExpln", header: { text: "사유" }, editable: false }, { name: "nowVlCn", fieldName: "nowVlCn", header: { text: "입력값" }, editable: false }, { name: "compVlCn", fieldName: "compVlCn", header: { text: "기준값" }, editable: false }, { name: "prcsYnNm", fieldName: "prcsYnNm", header: { text: "처리여부" }, editable: false }, { name: "stVrfRjtComNm", fieldName: "stVrfRjtComNm", header: { text: "반려상태" }, editable: false }, { name: "prcsOpnnExpln", fieldName: "prcsOpnnExpln", header: { text: "처리의견" }, editable: false }, { name: "rjctOpnnCn", fieldName: "rjctOpnnCn", header: { text: "반려의견" }, editable: false }, { name: "regDt", fieldName: "regDt", header: { text: "등록일시" }, editable: false }, { name: "mdfcnDt", fieldName: "mdfcnDt", header: { text: "갱신일시" }, editable: false }, { name: "prcsYn", fieldName: "prcsYn", header: { text: "검증결과상태코드" }, editable: false, visible: false }, { name: "vrfcSn", fieldName: "vrfcSn", header: { text: "검증일련번호" }, editable: false, visible: false }, ], columnLayout: [ "ctpvNm", "admdstNm", "vrfcTrgtNm", "rsnExpln", "nowVlCn", "compVlCn", "prcsYnNm", "stVrfRjtComNm", "prcsOpnnExpln", "rjctOpnnCn", "regDt", "mdfcnDt", "prcsYn", "vrfcSn", ], }; export interface VerificationResultHistoryDialogProps { open: boolean; onOpenChange: (open: boolean) => void; queryContext?: { sewerBzmnCd: string; ctpvNm: string; sggNm: string; statsYr: string; statsCd: string; } | null; title?: string; emptyMessage?: string; /** 예외 제출 취소 API 성공 후 부모(입력 화면)에서 메인 데이터를 다시 조회할 때 사용 */ onExceptionResetSubmitted?: () => void | Promise; } export function VerificationResultHistoryDialog({ open, onOpenChange, queryContext, title = "검증 결과 내역", emptyMessage = "표시할 검증 결과가 없습니다.", onExceptionResetSubmitted, }: VerificationResultHistoryDialogProps) { const stableQueryContext = useMemo(() => { if (!queryContext) return null; return queryContext; }, [ queryContext?.sewerBzmnCd, queryContext?.ctpvNm, queryContext?.sggNm, queryContext?.statsYr, queryContext?.statsCd, ]); const [activeTab, setActiveTab] = useState("NV"); const [entriesByTab, setEntriesByTab] = useState< Record >({ NV: [], IMR: [], HB: [], }); const [tabPages, setTabPages] = useState(initialTabPageState); const [loadingByTab, setLoadingByTab] = useState>({ NV: false, IMR: false, HB: false, }); const [errorMessage, setErrorMessage] = useState(null); const [resetSubmitting, setResetSubmitting] = useState(false); const [checkedVrfcByTab, setCheckedVrfcByTab] = useState(createEmptyCheckedVrfcByTab); const nvGridRef = useRef(null); const imrGridRef = useRef(null); const hbGridRef = useRef(null); const alert = useAlertStore((state) => state.setMessage); const gridRefByTab: Record> = { NV: nvGridRef, IMR: imrGridRef, HB: hbGridRef, }; const checkedVrfcByTabRef = useRef(checkedVrfcByTab); checkedVrfcByTabRef.current = checkedVrfcByTab; const loadedTabKeysRef = useRef>(new Set()); const resetState = useCallback(() => { setActiveTab("NV"); setEntriesByTab({ NV: [], IMR: [], HB: [] }); setTabPages(initialTabPageState()); setLoadingByTab({ NV: false, IMR: false, HB: false }); setErrorMessage(null); setResetSubmitting(false); setCheckedVrfcByTab(createEmptyCheckedVrfcByTab()); loadedTabKeysRef.current.clear(); }, []); const fetchVerificationResultPage = useCallback( async (vrfcMthdCd: VerificationHistoryTabKey, pageIndex: number) => { if (!stableQueryContext) return null; const param: VerificationResultListRequest = { sewerBzmnCd: stableQueryContext.sewerBzmnCd, ctpvNm: stableQueryContext.ctpvNm, sggNm: stableQueryContext.sggNm, statsYr: stableQueryContext.statsYr, statsCd: stableQueryContext.statsCd, vrfcMthdCd, prcsYnList: "0,2", rejectCodeList: "0,1,2", page: pageIndex + 1, size: PAGE_SIZE, }; return statisticsApi.getVerificationResultList(param); }, [stableQueryContext], ); const applyPageResponse = useCallback( (tab: VerificationHistoryTabKey, pageIndex: number, list: VerificationResultReportItem[], totalPages: number) => { const pageCount = Math.max(totalPages, 1); const clampedIndex = Math.min(Math.max(pageIndex, 0), pageCount - 1); setEntriesByTab((prev) => ({ ...prev, [tab]: list })); setTabPages((prev) => ({ ...prev, [tab]: { pageIndex: clampedIndex, pageCount }, })); }, [], ); const loadTabPage = useCallback( async (tab: VerificationHistoryTabKey, pageIndex: number): Promise => { if (!stableQueryContext) { setErrorMessage("조회 조건이 없어 검증 결과를 불러올 수 없습니다."); return false; } setErrorMessage(null); setLoadingByTab((prev) => ({ ...prev, [tab]: true })); try { const response = await fetchVerificationResultPage(tab, pageIndex); const list = response?.data?.verifyResultList ?? []; const totalPages = response?.data?.pageInfo?.totalPages ?? 1; const apiCurrentPage = response?.data?.pageInfo?.currentPage; const resolvedPageIndex = apiCurrentPage != null && Number.isFinite(apiCurrentPage) ? Math.max(0, Number(apiCurrentPage) - 1) : pageIndex; applyPageResponse(tab, resolvedPageIndex, list, totalPages); return true; } catch { const tabLabel = verificationHistoryTabs.find((t) => t.value === tab)?.label ?? tab; setErrorMessage(`${tabLabel} 조회 중 오류가 발생했습니다.`); return false; } finally { setLoadingByTab((prev) => ({ ...prev, [tab]: false })); } }, [applyPageResponse, fetchVerificationResultPage, stableQueryContext], ); const loadInitial = useCallback(async () => { if (!stableQueryContext) { setErrorMessage("조회 조건이 없어 검증 결과를 불러올 수 없습니다."); return; } setErrorMessage(null); setTabPages(initialTabPageState()); setEntriesByTab({ NV: [], IMR: [], HB: [] }); loadedTabKeysRef.current.clear(); const loaded = await loadTabPage("NV", 0); if (loaded) { loadedTabKeysRef.current.add("NV"); } }, [loadTabPage, stableQueryContext]); const handleActiveTabChange = useCallback( (tab: VerificationHistoryTabKey) => { setActiveTab(tab); if (loadedTabKeysRef.current.has(tab)) return; void (async () => { const loaded = await loadTabPage(tab, 0); if (loaded) { loadedTabKeysRef.current.add(tab); } })(); }, [loadTabPage], ); const handleTabPageChange = useCallback( (tab: VerificationHistoryTabKey, pageIndex: number) => { if (pageIndex === tabPages[tab].pageIndex) return; void loadTabPage(tab, pageIndex); }, [loadTabPage, tabPages], ); useEffect(() => { if (!open) { resetState(); return; } void loadInitial(); }, [loadInitial, open, resetState]); const updateCheckedVrfcForTab = useCallback( (tab: VerificationHistoryTabKey, updater: (map: Map) => void) => { setCheckedVrfcByTab((prev) => { const nextMap = new Map(prev[tab]); updater(nextMap); return { ...prev, [tab]: nextMap }; }); }, [], ); const mapRows = useCallback( (entries: VerificationResultReportItem[], checkedMap: Map): VerificationResultGridRow[] => entries.map((item) => { const vrfcSn = Number(item.vrfcSn); const isChecked = Number.isFinite(vrfcSn) && checkedMap.has(vrfcSn); return { vrfcSn: item.vrfcSn, __checked: isChecked ? 1 : 0, ctpvNm: item.ctpvNm, admdstNm: item.admdstNm ?? item.sggNm ?? "-", vrfcTrgtNm: item.vrfcTrgtNm, rsnExpln: item.rsnExpln, nowVlCn: item.nowVlCn ?? "-", compVlCn: item.compVlCn ?? "-", prcsYnNm: item.prcsYnNm ?? "-", stVrfRjtComNm: item.stVrfRjtComNm ?? "-", prcsOpnnExpln: item.prcsOpnnExpln ?? "-", rjctOpnnCn: item.rjctOpnnCn ?? "-", prcsYn: item.prcsYn ?? "", regDt: (item.regDt ?? "").slice(0, 16), mdfcnDt: (item.mdfcnDt ?? "").slice(0, 16), }; }), [], ); /** 페이지(entries) 변경 시에만 __checked 반영 — 클릭마다 rowData를 바꾸지 않아 체크 UI가 안정적 */ const rowsByTab = useMemo( () => { const checked = checkedVrfcByTabRef.current; return { NV: mapRows(entriesByTab.NV, checked.NV), IMR: mapRows(entriesByTab.IMR, checked.IMR), HB: mapRows(entriesByTab.HB, checked.HB), }; }, [entriesByTab, mapRows], ); const noopSetRows = ((_: SetStateAction[]>) => {}) as Dispatch< SetStateAction[]> >; const handleExceptionResetClick = useCallback(async () => { if (!stableQueryContext) { alert("조회 조건이 없습니다."); return; } const tab = activeTab; const currentEntries = entriesByTab[tab]; const syncedMap = syncCheckedMapFromGrid(currentEntries, gridRefByTab[tab], checkedVrfcByTab[tab]); if (syncedMap.size === 0) { alert("예외 제출 취소할 항목을 체크해주세요."); return; } if (selectionHasNonExceptionResetAllowed(syncedMap, currentEntries)) { alert(EXCEPTION_RESET_ONLY_PRCS_YN_2_MESSAGE); return; } setCheckedVrfcByTab((prev) => ({ ...prev, [tab]: syncedMap })); const vrfcSnList = Array.from(syncedMap.keys()); let mdfrId: string; try { const userInfo = await getUserInfo(); mdfrId = userInfo.userUnqId; } catch { alert("사용자 정보를 불러오지 못했습니다."); return; } setResetSubmitting(true); setErrorMessage(null); try { const response = await statisticsApi.StatiscicsExceptionReset({ statsYr: stableQueryContext.statsYr, sewerBzmnCd: stableQueryContext.sewerBzmnCd, statsCd: stableQueryContext.statsCd, mdfrId, vrfcSnList, }); if (response.status !== "success") { alert(response.message ?? "예외 제출 취소 처리 중 오류가 발생했습니다."); return; } alert("예외 제출 취소 처리되었습니다."); setCheckedVrfcByTab((prev) => ({ ...prev, [tab]: new Map() })); await loadTabPage(tab, tabPages[tab].pageIndex); try { await onExceptionResetSubmitted?.(); } catch { alert( "예외 제출 취소는 완료되었으나 화면 데이터를 다시 불러오지 못했습니다. 조회로 새로고침 해주세요.", ); } } catch { alert("예외 제출 취소 처리 중 오류가 발생했습니다."); } finally { setResetSubmitting(false); } }, [ activeTab, alert, checkedVrfcByTab, entriesByTab, loadTabPage, onExceptionResetSubmitted, stableQueryContext, tabPages, ]); const renderActiveTabPanel = (tab: VerificationHistoryTabKey) => { const entries = entriesByTab[tab]; const rows = rowsByTab[tab]; const { pageIndex, pageCount } = tabPages[tab]; const loading = loadingByTab[tab]; const gridRef = gridRefByTab[tab]; if (!loading && rows.length === 0) { return

{emptyMessage}

; } return (
{ const entry = entries[itemIndex]; if (!entry) return; const vrfcSn = Number(entry.vrfcSn); if (!Number.isFinite(vrfcSn)) return; if (checked && !canExceptionReset(entry.prcsYn)) { alert(EXCEPTION_RESET_ONLY_PRCS_YN_2_MESSAGE); uncheckGridRow(gridRef, itemIndex); return; } updateCheckedVrfcForTab(tab, (map) => { if (checked) map.set(vrfcSn, String(entry.prcsYn ?? "")); else map.delete(vrfcSn); }); }} onItemAllChecked={(checked) => { updateCheckedVrfcForTab(tab, (map) => { entries.forEach((entry, itemIndex) => { const vrfcSn = Number(entry.vrfcSn); if (!Number.isFinite(vrfcSn)) return; if (!checked) { map.delete(vrfcSn); return; } if (canExceptionReset(entry.prcsYn)) { map.set(vrfcSn, String(entry.prcsYn ?? "")); } else { uncheckGridRow(gridRef, itemIndex); } }); }); if (checked && entries.some((e) => !canExceptionReset(e.prcsYn))) { alert(EXCEPTION_RESET_ONLY_PRCS_YN_2_MESSAGE); } }} /> handleTabPageChange(tab, nextPage)} className="mx-2 mb-2" /> {loading && (
)}
); }; return ( {`${title} - ${queryContext?.statsYr} ${queryContext?.ctpvNm} ${queryContext?.sggNm}`}
{errorMessage && (

{errorMessage}

)} handleActiveTabChange(value as VerificationHistoryTabKey)} className="w-full" variant="default" > {verificationHistoryTabs.map((tab) => ( {tab.label} ))} {verificationHistoryTabs.map((tab) => ( {activeTab === tab.value ? renderActiveTabPanel(tab.value) : null} ))}
); }