Files
frontend/apps/st/src/components/module/VerificationResultHistoryDialog.tsx
T

614 lines
24 KiB
TypeScript

"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<string, string | number>;
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<VerificationHistoryTabKey, Map<number, string>> => ({
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<RealGridReact | null>, itemIndex: number) {
try {
(gridRef.current?.gridView as GridViewWithCheckApi | undefined)?.checkItem?.(itemIndex, false, false);
} catch {
/* ignore */
}
}
function syncCheckedMapFromGrid(
entries: VerificationResultReportItem[],
gridRef: MutableRefObject<RealGridReact | null>,
prevMap: Map<number, string>,
): Map<number, string> {
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<number, string>,
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<void>;
}
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<VerificationHistoryTabKey>("NV");
const [entriesByTab, setEntriesByTab] = useState<
Record<VerificationHistoryTabKey, VerificationResultReportItem[]>
>({
NV: [],
IMR: [],
HB: [],
});
const [tabPages, setTabPages] = useState(initialTabPageState);
const [loadingByTab, setLoadingByTab] = useState<Record<VerificationHistoryTabKey, boolean>>({
NV: false,
IMR: false,
HB: false,
});
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [resetSubmitting, setResetSubmitting] = useState(false);
const [checkedVrfcByTab, setCheckedVrfcByTab] = useState(createEmptyCheckedVrfcByTab);
const nvGridRef = useRef<RealGridReact | null>(null);
const imrGridRef = useRef<RealGridReact | null>(null);
const hbGridRef = useRef<RealGridReact | null>(null);
const alert = useAlertStore((state) => state.setMessage);
const gridRefByTab: Record<VerificationHistoryTabKey, MutableRefObject<RealGridReact | null>> = {
NV: nvGridRef,
IMR: imrGridRef,
HB: hbGridRef,
};
const checkedVrfcByTabRef = useRef(checkedVrfcByTab);
checkedVrfcByTabRef.current = checkedVrfcByTab;
const loadedTabKeysRef = useRef<Set<VerificationHistoryTabKey>>(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<boolean> => {
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<number, string>) => void) => {
setCheckedVrfcByTab((prev) => {
const nextMap = new Map(prev[tab]);
updater(nextMap);
return { ...prev, [tab]: nextMap };
});
},
[],
);
const mapRows = useCallback(
(entries: VerificationResultReportItem[], checkedMap: Map<number, string>): 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<Record<string, string | number>[]>) => {}) as Dispatch<
SetStateAction<Record<string, string | number>[]>
>;
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 <p className="p-6 text-center text-[1.3rem] text-muted-foreground h-[65vh] flex justify-center items-center">{emptyMessage}</p>;
}
return (
<div className="relative space-y-2">
<DataGrid
key={`verification-history-grid-${tab}`}
columnInfo={verificationResultColumnInfo}
rowData={rows}
setRowData={noopSetRows}
isExcelImport={false}
isExcelExport={false}
showHeaderCheckbox
enableSort={false}
getGridRef={gridRef}
cellStyleCallback={verificationPrcsYnNmCellStyle}
cellTooltipCallback={cellContentTooltip}
onItemChecked={(itemIndex, checked) => {
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);
}
}}
/>
<VerificationResultHistoryPagination
key={`verification-history-pagination-${tab}`}
pageCount={pageCount}
pageIndex={pageIndex}
onPageIndexChange={(nextPage) => handleTabPageChange(tab, nextPage)}
className="mx-2 mb-2"
/>
{loading && (
<div className="absolute inset-0 z-10 rounded-md [data-theme='black']:bg-transparent bg-white/40 flex items-center justify-center">
<div className="relative size-[5rem]">
<LoadingWithAnimation color="primary" />
</div>
</div>
)}
</div>
);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-[80%]" aria-describedby={undefined}>
<DialogHeader>
<DialogTitle>{`${title} - ${queryContext?.statsYr} ${queryContext?.ctpvNm} ${queryContext?.sggNm}`}</DialogTitle>
</DialogHeader>
<div className="max-h-[85vh] space-y-5 overflow-auto">
{errorMessage && (
<p className="rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-[1.2rem] text-destructive">
{errorMessage}
</p>
)}
<Tabs
value={activeTab}
onValueChange={(value) => handleActiveTabChange(value as VerificationHistoryTabKey)}
className="w-full"
variant="default"
>
<TabsList>
{verificationHistoryTabs.map((tab) => (
<TabsTrigger key={tab.value} value={tab.value}>
{tab.label}
</TabsTrigger>
))}
</TabsList>
{verificationHistoryTabs.map((tab) => (
<TabsContent
key={tab.value}
value={tab.value}
className="rounded-md border border-border"
>
{activeTab === tab.value ? renderActiveTabPanel(tab.value) : null}
</TabsContent>
))}
</Tabs>
</div>
<DialogFooter>
<Button
type="button"
color="primary"
disabled={resetSubmitting || loadingByTab[activeTab]}
onClick={() => void handleExceptionResetClick()}
>
예외 제출 취소
</Button>
<DialogClose asChild>
<Button type="button" color="gray" disabled={resetSubmitting}>
닫기
</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
);
}