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

470 lines
18 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 { useAlertStore } from "@/shared/lib";
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,
} from "@common/components";
import { getUserInfo } from "../../../../../packages/common/components/src/api";
import DataGrid from "@/components/module/DataGrid";
import { cellContentTooltip } from "@/components/module/verificationResultCellTooltip";
import { VerificationResultHistoryPagination } from "@/components/module/VerificationResultHistoryPagination";
import type {
CellEditedCallbackParams,
CellStyleCallbackParams,
ColumnInfo,
} from "@/components/type/DataGrid.type";
type VerificationResultGridRow = Record<string, string | number>;
type VerificationMethodCd = "NV" | "IMR" | "HB";
const VERIFICATION_METHOD_CODES: VerificationMethodCd[] = ["NV", "IMR", "HB"];
const VRFC_MTHD_NAME_MAP: Record<VerificationMethodCd, string> = {
NV: "일반",
IMR: "시계열",
HB: "모집단",
};
export type VerificationExceptionSubmitItem = {
vrfcSn: number;
prcsOpnnExpln: string;
};
type GridViewWithCheckItem = {
checkItem?: (itemIndex: number, checked: boolean, exclusive?: boolean) => void;
};
function setRowCheckByItemIndex(
gridRef: MutableRefObject<RealGridReact | null>,
itemIndex: number,
checked: boolean,
) {
const gv = gridRef.current?.gridView as GridViewWithCheckItem | undefined;
try {
gv?.checkItem?.(itemIndex, checked, false);
} catch {
console.error('RealGrid 체크 API error');
}
}
const PRCS_YN_LABEL: Record<string, string> = {
"0": "미통과",
"1": "통과",
"2": "예외처리",
"3": "반려",
};
function verificationMthdCellStyle({
fieldName,
row,
}: CellStyleCallbackParams): string | undefined {
if (fieldName !== "vrfcMthdNm") return undefined;
const code = String(row.vrfcMthdCd ?? "");
if (code === "IMR") return "rg-cell-vrf-mthd-imr";
if (code === "HB") return "rg-cell-vrf-mthd-hb";
if (code === "NV") return "rg-cell-vrf-mthd-nv";
return undefined;
}
/** History와 동일 + 구분(검증유형) + 편집 가능한 처리의견(예외사유) */
const verificationSubmitResultColumnInfo: ColumnInfo = {
columns: [
{ name: "vrfcMthdNm", fieldName: "vrfcMthdNm", header: { text: "구분" }, editable: false },
{ name: "vrfcMthdCd", fieldName: "vrfcMthdCd", header: { text: "검증유형코드" }, editable: false, visible: false },
{ name: "ctpvNm", fieldName: "ctpvNm", header: { text: "시도" }, editable: false },
{ name: "admdstNm", fieldName: "admdstNm", header: { text: "행정구역" }, editable: false },
{ name: "fcltNm", fieldName: "fcltNm", header: { text: "시설명" }, editable: false },
{ name: "vrfcTrgtNm", fieldName: "vrfcTrgtNm", header: { text: "검증대상" }, editable: false },
{ name: "rsnExpln", fieldName: "rsnExpln", header: { text: "사유" }, editable: false },
{ name: "nowCn", fieldName: "nowCn", header: { text: "입력값" }, editable: false },
{ name: "cmprCn", fieldName: "cmprCn", header: { text: "기준값" }, editable: false },
{ name: "vrfcSttsNm", fieldName: "vrfcSttsNm", header: { text: "처리여부" }, editable: false },
{ name: "regDt", fieldName: "regDt", header: { text: "등록일시" }, editable: false },
{ name: "mdfcnDt", fieldName: "mdfcnDt", header: { text: "갱신일시" }, editable: false },
{
name: "prcsOpnnExpln",
fieldName: "prcsOpnnExpln",
header: { text: "예외사유" },
editable: true,
},
],
columnLayout: [
"vrfcMthdNm",
"ctpvNm",
"admdstNm",
"fcltNm",
"vrfcTrgtNm",
"rsnExpln",
"nowCn",
"cmprCn",
"vrfcSttsNm",
"regDt",
"mdfcnDt",
"prcsOpnnExpln",
],
};
export interface VerificationResultDialogQueryContext {
sewerBzmnCd: string;
ctpvNm: string;
sggNm?: string;
statsYr: string;
statsCd: string;
}
export interface VerificationResultDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
queryContext?: VerificationResultDialogQueryContext | null;
title?: string;
emptyMessage?: string;
/** 예외 제출 API 성공 후 부모(입력 화면)에서 메인 데이터를 다시 조회할 때 사용 */
onExceptionSubmitted?: () => void | Promise<void>;
}
export function VerificationResultDialog({
open,
onOpenChange,
queryContext,
title = "검증 결과",
emptyMessage = "표시할 검증 결과가 없습니다.",
onExceptionSubmitted,
}: VerificationResultDialogProps) {
const stableQueryContext = useMemo((): VerificationResultDialogQueryContext | null => {
if (!queryContext) return null;
return queryContext;
}, [
queryContext?.sewerBzmnCd,
queryContext?.ctpvNm,
queryContext?.sggNm,
queryContext?.statsYr,
queryContext?.statsCd,
]);
const [pageIndex, setPageIndex] = useState(0);
const [pageCount, setPageCount] = useState(1);
const [loading, setLoading] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [rows, setRows] = useState<VerificationResultGridRow[]>([]);
const [checkedIndices, setCheckedIndices] = useState<Set<number>>(() => new Set());
const gridRef = useRef<RealGridReact | null>(null);
const getGridDataRef = useRef<(() => VerificationResultGridRow[]) | null>(null);
const alert = useAlertStore((state) => state.setMessage);
const onPrcsOpnnExplnEdited = useCallback((params: CellEditedCallbackParams) => {
if (params.fieldName !== "prcsOpnnExpln") return;
const text = String(params.newValue ?? "");
const hasValue = text.trim().length > 0;
setRowCheckByItemIndex(gridRef, params.itemIndex, hasValue);
setCheckedIndices((prev) => {
const next = new Set(prev);
if (hasValue) next.add(params.itemIndex);
else next.delete(params.itemIndex);
return next;
});
}, []);
const resetState = useCallback(() => {
setPageIndex(0);
setPageCount(1);
setLoading(false);
setErrorMessage(null);
setRows([]);
setCheckedIndices(new Set());
}, []);
const mapItemToRow = useCallback(
(item: VerificationResultReportItem, vrfcMthdCd: VerificationMethodCd): VerificationResultGridRow => {
return {
vrfcSn: item.vrfcSn,
vrfcMthdCd,
vrfcMthdNm: VRFC_MTHD_NAME_MAP[vrfcMthdCd] ?? item.vrfcMthdNm,
ctpvNm: item.ctpvNm,
admdstNm: item.admdstNm ?? item.sggNm ?? "-",
fcltNm: item.fcltNm ?? "-",
vrfcTrgtNm: item.vrfcTrgtNm,
rsnExpln: item.rsnExpln,
nowCn: item.nowCn ?? "-",
cmprCn: item.cmprCn ?? "-",
vrfcSttsNm: item.vrfcSttsNm ?? PRCS_YN_LABEL[item.vrfcSttsCd] ?? item.vrfcSttsCd,
regDt: (item.regDt ?? "").slice(0, 16),
mdfcnDt: (item.mdfcnDt ?? "").slice(0, 16),
prcsOpnnExpln: item.prcsOpnnExpln ?? "",
};
},
[],
);
const fetchVerificationResultPage = useCallback(
async (vrfcMthdCd: VerificationMethodCd, page: 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",
rejectCodeList: "0,1,2",
page: page + 1,
size: 10000,
};
return statisticsApi.getVerificationResultList(param);
},
[stableQueryContext],
);
const loadPage = useCallback(
async (nextPageIndex: number) => {
if (!stableQueryContext) {
setErrorMessage("조회 조건이 없어 검증 결과를 불러올 수 없습니다.");
return;
}
setErrorMessage(null);
setLoading(true);
try {
const responses = await Promise.all(
VERIFICATION_METHOD_CODES.map((code) =>
fetchVerificationResultPage(code, nextPageIndex),
),
);
const merged: VerificationResultGridRow[] = [];
let maxPageCount = 1;
VERIFICATION_METHOD_CODES.forEach((code, index) => {
const response = responses[index];
const list = response?.data?.verifyResultList ?? [];
merged.push(...list.map((item) => mapItemToRow(item, code)));
maxPageCount = Math.max(
maxPageCount,
response?.data?.pageInfo?.totalPages ?? 1,
);
});
setRows(merged);
setPageCount(maxPageCount);
setPageIndex(nextPageIndex);
} catch {
setRows([]);
setPageCount(1);
setErrorMessage("검증 결과 조회 중 오류가 발생했습니다.");
} finally {
setLoading(false);
setCheckedIndices(new Set());
}
},
[fetchVerificationResultPage, mapItemToRow, stableQueryContext],
);
const loadInitial = useCallback(async () => {
await loadPage(0);
}, [loadPage]);
const handlePageChange = useCallback(
async (nextPageIndex: number) => {
if (nextPageIndex === pageIndex || !stableQueryContext) return;
await loadPage(nextPageIndex);
},
[loadPage, pageIndex, stableQueryContext],
);
useEffect(() => {
if (!open) {
resetState();
return;
}
void loadInitial();
}, [loadInitial, open, resetState]);
useEffect(() => {
if (loading) return;
const id = requestAnimationFrame(() => {
setCheckedIndices((prev) => {
const next = new Set(prev);
rows.forEach((row, itemIndex) => {
const has = String(row.prcsOpnnExpln ?? "").trim().length > 0;
setRowCheckByItemIndex(gridRef, itemIndex, has);
if (has) next.add(itemIndex);
});
return next;
});
});
return () => cancelAnimationFrame(id);
}, [rows, loading]);
const handleExceptionSubmitClick = useCallback(async () => {
if (checkedIndices.size === 0) {
alert("작성된 예외 제출 항목이 없습니다.");
return;
}
const gridRows = getGridDataRef.current?.() ?? [];
const payload: VerificationExceptionSubmitItem[] = [];
for (const idx of checkedIndices) {
const prcsOpnnExpln = String(gridRows[idx]?.prcsOpnnExpln ?? "").trim();
if (!prcsOpnnExpln) {
alert("예외 제출 사유를 작성해주세요.");
return;
}
const vrfcSn = Number(rows[idx]?.vrfcSn);
if (!Number.isFinite(vrfcSn)) continue;
payload.push({ vrfcSn, prcsOpnnExpln });
}
if (payload.length === 0) {
alert("작성된 예외 제출 항목이 없습니다.");
return;
}
if (!stableQueryContext) {
alert("조회 조건이 없어 예외 제출을 할 수 없습니다.");
return;
}
try {
const userInfo = await getUserInfo();
const response = await statisticsApi.SubmitVerifyException({
sewerBzmnCd: stableQueryContext.sewerBzmnCd,
statsYr: stableQueryContext.statsYr,
statsCd: stableQueryContext.statsCd,
userUuid: userInfo.userUnqId,
exceptionList: payload,
});
if (response.status === "success") {
alert("예외 제출 처리되었습니다.");
try {
await onExceptionSubmitted?.();
} catch {
alert("예외 제출은 완료되었으나 화면 데이터를 다시 불러오지 못했습니다. 조회로 새로고침 해주세요.");
}
onOpenChange(false);
} else {
alert(response.message ?? "예외 제출 처리 중 오류가 발생했습니다.");
}
} catch {
alert("예외 제출 처리 중 오류가 발생했습니다.");
}
}, [
alert,
checkedIndices,
rows,
onExceptionSubmitted,
onOpenChange,
stableQueryContext,
]);
const titleSuffix = useMemo(() => {
if (!stableQueryContext) return "";
const sgg = stableQueryContext.sggNm?.trim() ? ` ${stableQueryContext.sggNm}` : "";
return ` - ${stableQueryContext.statsYr} ${stableQueryContext.ctpvNm}${sgg}`;
}, [stableQueryContext]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-[80%]" aria-describedby={undefined}>
<DialogHeader>
<DialogTitle>
{title}
{titleSuffix}
</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 h-[65vh] flex justify-center items-center">
{errorMessage}
</p>
)}
<section className="rounded-md border border-border">
{!loading && rows.length === 0 ? (
<p className="p-6 text-center text-[1.3rem] text-muted-foreground">
{emptyMessage}
</p>
) : (
<div className="relative space-y-2">
<DataGrid
columnInfo={verificationSubmitResultColumnInfo}
rowData={rows}
setRowData={
setRows as Dispatch<
SetStateAction<Record<string, string | number>[]>
>
}
isExcelImport={false}
isExcelExport={false}
showHeaderCheckbox
enableSort={false}
getGridRef={gridRef}
getGridDataRef={getGridDataRef}
onCellEdited={onPrcsOpnnExplnEdited}
cellStyleCallback={verificationMthdCellStyle}
cellTooltipCallback={cellContentTooltip}
onItemChecked={(itemIndex, checked) => {
setCheckedIndices((prev) => {
const next = new Set(prev);
if (checked) next.add(itemIndex);
else next.delete(itemIndex);
return next;
});
}}
onItemAllChecked={(checked) => {
setCheckedIndices(
checked ? new Set(rows.map((_, i) => i)) : new Set(),
);
}}
/>
<VerificationResultHistoryPagination
pageCount={Math.max(pageCount, 1)}
pageIndex={Math.max(pageIndex, 0)}
onPageIndexChange={handlePageChange}
className="mx-2 mb-2"
/>
{loading && (
<div className="absolute inset-0 z-10 flex items-center justify-center rounded-md bg-white/40 [data-theme='black']:bg-transparent">
<div className="relative size-[5rem]">
<LoadingWithAnimation color="primary" />
</div>
</div>
)}
</div>
)}
</section>
</div>
<DialogFooter>
<Button type="button" color="primary" onClick={handleExceptionSubmitClick}>
예외 제출
</Button>
<DialogClose asChild>
<Button type="button" color="gray">
닫기
</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
);
}