540 lines
22 KiB
TypeScript
540 lines
22 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 {
|
|
Button,
|
|
Dialog,
|
|
DialogClose,
|
|
DialogContent,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
LoadingWithAnimation,
|
|
} from "@common/components";
|
|
import DataGrid from "@/components/module/DataGrid";
|
|
import { VerificationResultHistoryPagination } from "@/components/module/VerificationResultHistoryPagination";
|
|
import type { CellEditedCallbackParams, ColumnInfo } from "@/components/type/DataGrid.type";
|
|
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 { useAlertStore } from "@/shared/lib";
|
|
|
|
type VerificationResultGridRow = Record<string, string | number>;
|
|
|
|
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 {
|
|
/* RealGrid 체크 API 미지원 등 */
|
|
}
|
|
}
|
|
|
|
const PRCS_YN_LABEL: Record<string, string> = {
|
|
"0": "미통과",
|
|
"1": "통과",
|
|
"2": "예외처리",
|
|
"3": "반려",
|
|
};
|
|
|
|
/** History와 동일 + 편집 가능한 처리의견(예외사유, API 필드명 `prcsOpnnExpln`과 동일) */
|
|
const verificationSubmitResultColumnInfo: ColumnInfo = {
|
|
columns: [
|
|
{ name: "ctpvNm", fieldName: "ctpvNm", header: { text: "시도" }, editable: false },
|
|
{ name: "sggNm", fieldName: "sggNm", 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: "prcsYn", fieldName: "prcsYn", header: { text: "처리여부" }, editable: false },
|
|
{ name: "pbcpAprvYn", fieldName: "pbcpAprvYn", 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: [
|
|
"ctpvNm",
|
|
"sggNm",
|
|
"vrfcTrgtNm",
|
|
"rsnExpln",
|
|
"nowVlCn",
|
|
"compVlCn",
|
|
"prcsYn",
|
|
"pbcpAprvYn",
|
|
"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) {
|
|
/** 부모가 매 렌더마다 새 객체를 넘기면 queryContext 참조만 바뀌어 loadInitial이 재실행되므로 필드 값 기준으로 안정화 */
|
|
const stableQueryContext = useMemo((): VerificationResultDialogQueryContext | null => {
|
|
if (!queryContext) return null;
|
|
return queryContext;
|
|
}, [
|
|
queryContext?.sewerBzmnCd,
|
|
queryContext?.ctpvNm,
|
|
queryContext?.sggNm,
|
|
queryContext?.statsYr,
|
|
queryContext?.statsCd,
|
|
]);
|
|
|
|
const [normalPageIndex, setNormalPageIndex] = useState(0);
|
|
const [imrPageIndex, setImrPageIndex] = useState(0);
|
|
const [normalPageCount, setNormalPageCount] = useState(1);
|
|
const [imrPageCount, setImrPageCount] = useState(1);
|
|
const [normalLoading, setNormalLoading] = useState(false);
|
|
const [imrLoading, setImrLoading] = useState(false);
|
|
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
|
|
|
const [normalRows, setNormalRows] = useState<VerificationResultGridRow[]>([]);
|
|
const [imrRows, setImrRows] = useState<VerificationResultGridRow[]>([]);
|
|
const [normalCheckedIndices, setNormalCheckedIndices] = useState<Set<number>>(
|
|
() => new Set(),
|
|
);
|
|
const [imrCheckedIndices, setImrCheckedIndices] = useState<Set<number>>(() => new Set());
|
|
|
|
const normalGridRef = useRef<RealGridReact | null>(null);
|
|
const imrGridRef = useRef<RealGridReact | null>(null);
|
|
/** 메인 입력 화면처럼 편집 값은 그리드에 두고, 제출 시에만 스냅샷 조회 → Enter 시 rows 재주입 충돌 방지 */
|
|
const getNormalGridDataRef = useRef<(() => VerificationResultGridRow[]) | null>(null);
|
|
const getImrGridDataRef = useRef<(() => VerificationResultGridRow[]) | null>(null);
|
|
const alert = useAlertStore((state) => state.setMessage);
|
|
|
|
const onNormalPrcsOpnnExplnEdited = useCallback((params: CellEditedCallbackParams) => {
|
|
if (params.fieldName !== "prcsOpnnExpln") return;
|
|
const text = String(params.newValue ?? "");
|
|
const hasValue = text.trim().length > 0;
|
|
setRowCheckByItemIndex(normalGridRef, params.itemIndex, hasValue);
|
|
setNormalCheckedIndices((prev) => {
|
|
const next = new Set(prev);
|
|
if (hasValue) next.add(params.itemIndex);
|
|
else next.delete(params.itemIndex);
|
|
return next;
|
|
});
|
|
}, []);
|
|
|
|
const onImrPrcsOpnnExplnEdited = useCallback((params: CellEditedCallbackParams) => {
|
|
if (params.fieldName !== "prcsOpnnExpln") return;
|
|
const text = String(params.newValue ?? "");
|
|
const hasValue = text.trim().length > 0;
|
|
setRowCheckByItemIndex(imrGridRef, params.itemIndex, hasValue);
|
|
setImrCheckedIndices((prev) => {
|
|
const next = new Set(prev);
|
|
if (hasValue) next.add(params.itemIndex);
|
|
else next.delete(params.itemIndex);
|
|
return next;
|
|
});
|
|
}, []);
|
|
|
|
const resetState = useCallback(() => {
|
|
setNormalPageIndex(0);
|
|
setImrPageIndex(0);
|
|
setNormalPageCount(1);
|
|
setImrPageCount(1);
|
|
setNormalLoading(false);
|
|
setImrLoading(false);
|
|
setErrorMessage(null);
|
|
setNormalRows([]);
|
|
setImrRows([]);
|
|
setNormalCheckedIndices(new Set());
|
|
setImrCheckedIndices(new Set());
|
|
}, []);
|
|
|
|
const mapItemToRow = useCallback((item: VerificationResultReportItem): VerificationResultGridRow => {
|
|
return {
|
|
vrfcSn: item.vrfcSn,
|
|
ctpvNm: item.ctpvNm,
|
|
sggNm: item.sggNm,
|
|
vrfcTrgtNm: item.vrfcTrgtNm,
|
|
rsnExpln: item.rsnExpln,
|
|
nowVlCn: item.nowVlCn ?? "-",
|
|
compVlCn: item.compVlCn ?? "-",
|
|
prcsYn: PRCS_YN_LABEL[item.prcsYn] ?? item.prcsYn,
|
|
pbcpAprvYn: item.pbcpAprvYn,
|
|
regDt: (item.regDt ?? "").slice(0, 16),
|
|
mdfcnDt: (item.mdfcnDt ?? "").slice(0, 16),
|
|
prcsOpnnExpln: item.prcsOpnnExpln ?? "",
|
|
};
|
|
}, []);
|
|
|
|
const fetchVerificationResultPage = useCallback(
|
|
async (vrfcMthdCd: "NV" | "IMR", pageIndex: number) => {
|
|
if (!stableQueryContext) return null;
|
|
const param: VerificationResultListRequest = {
|
|
sewerBzmnCd: stableQueryContext.sewerBzmnCd,
|
|
ctpvNm: stableQueryContext.ctpvNm,
|
|
sggNm: stableQueryContext.sggNm,
|
|
statsYr: stableQueryContext.statsYr,
|
|
statsCd: stableQueryContext.statsCd,
|
|
pbcpAprvYn: "N",
|
|
vrfcMthdCd,
|
|
prcsYnList: "0",
|
|
page: pageIndex + 1,
|
|
size: 10000,
|
|
};
|
|
return statisticsApi.getVerificationResultList(param);
|
|
},
|
|
[stableQueryContext],
|
|
);
|
|
|
|
const loadInitial = useCallback(async () => {
|
|
if (!stableQueryContext) {
|
|
setErrorMessage("조회 조건이 없어 검증 결과를 불러올 수 없습니다.");
|
|
return;
|
|
}
|
|
setErrorMessage(null);
|
|
setNormalLoading(true);
|
|
setImrLoading(true);
|
|
setNormalPageIndex(0);
|
|
setImrPageIndex(0);
|
|
try {
|
|
const [nvRes, imrRes] = await Promise.all([
|
|
fetchVerificationResultPage("NV", 0),
|
|
fetchVerificationResultPage("IMR", 0),
|
|
]);
|
|
const nvList = nvRes?.data?.verifyResultList ?? [];
|
|
const imrList = imrRes?.data?.verifyResultList ?? [];
|
|
setNormalRows(nvList.map(mapItemToRow));
|
|
setImrRows(imrList.map(mapItemToRow));
|
|
setNormalPageCount(nvRes?.data?.pageInfo?.totalPages ?? 1);
|
|
setImrPageCount(imrRes?.data?.pageInfo?.totalPages ?? 1);
|
|
} catch {
|
|
setNormalRows([]);
|
|
setImrRows([]);
|
|
setNormalPageCount(1);
|
|
setImrPageCount(1);
|
|
setErrorMessage("검증 결과 조회 중 오류가 발생했습니다.");
|
|
} finally {
|
|
setNormalLoading(false);
|
|
setImrLoading(false);
|
|
setNormalCheckedIndices(new Set());
|
|
setImrCheckedIndices(new Set());
|
|
}
|
|
}, [fetchVerificationResultPage, mapItemToRow, stableQueryContext]);
|
|
|
|
const handleNormalPageChange = useCallback(
|
|
async (pageIndex: number) => {
|
|
if (pageIndex === normalPageIndex || !stableQueryContext) return;
|
|
setErrorMessage(null);
|
|
setNormalLoading(true);
|
|
try {
|
|
const response = await fetchVerificationResultPage("NV", pageIndex);
|
|
const list = response?.data?.verifyResultList ?? [];
|
|
setNormalRows(list.map(mapItemToRow));
|
|
setNormalPageCount(response?.data?.pageInfo?.totalPages ?? 1);
|
|
setNormalPageIndex(pageIndex);
|
|
} catch {
|
|
setErrorMessage("일반검증 내역 조회 중 오류가 발생했습니다.");
|
|
} finally {
|
|
setNormalLoading(false);
|
|
setNormalCheckedIndices(new Set());
|
|
}
|
|
},
|
|
[fetchVerificationResultPage, mapItemToRow, normalPageIndex, stableQueryContext],
|
|
);
|
|
|
|
const handleImrPageChange = useCallback(
|
|
async (pageIndex: number) => {
|
|
if (pageIndex === imrPageIndex || !stableQueryContext) return;
|
|
setErrorMessage(null);
|
|
setImrLoading(true);
|
|
try {
|
|
const response = await fetchVerificationResultPage("IMR", pageIndex);
|
|
const list = response?.data?.verifyResultList ?? [];
|
|
setImrRows(list.map(mapItemToRow));
|
|
setImrPageCount(response?.data?.pageInfo?.totalPages ?? 1);
|
|
setImrPageIndex(pageIndex);
|
|
} catch {
|
|
setErrorMessage("I-MR 검증내역 조회 중 오류가 발생했습니다.");
|
|
} finally {
|
|
setImrLoading(false);
|
|
setImrCheckedIndices(new Set());
|
|
}
|
|
},
|
|
[fetchVerificationResultPage, mapItemToRow, imrPageIndex, stableQueryContext],
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (!open) {
|
|
resetState();
|
|
return;
|
|
}
|
|
void loadInitial();
|
|
}, [loadInitial, open, resetState]);
|
|
|
|
/** 조회된 행에 예외사유 텍스트가 있으면 체크바·체크 인덱스와 맞춤 */
|
|
useEffect(() => {
|
|
if (normalLoading) return;
|
|
const id = requestAnimationFrame(() => {
|
|
setNormalCheckedIndices((prev) => {
|
|
const next = new Set(prev);
|
|
normalRows.forEach((row, itemIndex) => {
|
|
const has = String(row.prcsOpnnExpln ?? "").trim().length > 0;
|
|
setRowCheckByItemIndex(normalGridRef, itemIndex, has);
|
|
if (has) next.add(itemIndex);
|
|
});
|
|
return next;
|
|
});
|
|
});
|
|
return () => cancelAnimationFrame(id);
|
|
}, [normalRows, normalLoading]);
|
|
|
|
useEffect(() => {
|
|
if (imrLoading) return;
|
|
const id = requestAnimationFrame(() => {
|
|
setImrCheckedIndices((prev) => {
|
|
const next = new Set(prev);
|
|
imrRows.forEach((row, itemIndex) => {
|
|
const has = String(row.prcsOpnnExpln ?? "").trim().length > 0;
|
|
setRowCheckByItemIndex(imrGridRef, itemIndex, has);
|
|
if (has) next.add(itemIndex);
|
|
});
|
|
return next;
|
|
});
|
|
});
|
|
return () => cancelAnimationFrame(id);
|
|
}, [imrRows, imrLoading]);
|
|
|
|
const handleExceptionSubmitClick = useCallback(async () => {
|
|
const nvGrid = getNormalGridDataRef.current?.() ?? [];
|
|
const imGrid = getImrGridDataRef.current?.() ?? [];
|
|
const payload: VerificationExceptionSubmitItem[] = [];
|
|
const pushChecked = (
|
|
indices: Set<number>,
|
|
gridRows: VerificationResultGridRow[],
|
|
idRows: VerificationResultGridRow[],
|
|
) => {
|
|
indices.forEach((idx) => {
|
|
const prcsOpnnExpln = String(gridRows[idx]?.prcsOpnnExpln ?? "").trim();
|
|
if (!prcsOpnnExpln) return;
|
|
const vrfcSn = Number(idRows[idx]?.vrfcSn);
|
|
if (!Number.isFinite(vrfcSn)) return;
|
|
payload.push({ vrfcSn, prcsOpnnExpln });
|
|
});
|
|
};
|
|
pushChecked(normalCheckedIndices, nvGrid, normalRows);
|
|
pushChecked(imrCheckedIndices, imGrid, imrRows);
|
|
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,
|
|
userId: userInfo.userUnqId,
|
|
exceptionList: payload,
|
|
});
|
|
if (response.status === "success") {
|
|
alert("예외 제출 처리되었습니다.");
|
|
try {
|
|
await onExceptionSubmitted?.();
|
|
} catch {
|
|
alert("예외 제출은 완료되었으나 화면 데이터를 다시 불러오지 못했습니다. 조회로 새로고침 해주세요.");
|
|
}
|
|
onOpenChange(false);
|
|
} else {
|
|
alert(response.message ?? "예외 제출 처리 중 오류가 발생했습니다.");
|
|
}
|
|
} catch {
|
|
alert("예외 제출 처리 중 오류가 발생했습니다.");
|
|
}
|
|
}, [
|
|
alert,
|
|
imrCheckedIndices,
|
|
imrRows,
|
|
normalCheckedIndices,
|
|
normalRows,
|
|
onExceptionSubmitted,
|
|
onOpenChange,
|
|
stableQueryContext,
|
|
]);
|
|
|
|
const renderGrid = (
|
|
rows: VerificationResultGridRow[],
|
|
setRows: Dispatch<SetStateAction<VerificationResultGridRow[]>>,
|
|
pageIndex: number,
|
|
pageCount: number,
|
|
onPageIndexChange: (page: number) => void,
|
|
loading: boolean,
|
|
gridRef: MutableRefObject<RealGridReact | null>,
|
|
getGridDataRef: MutableRefObject<(() => VerificationResultGridRow[]) | null>,
|
|
onPrcsOpnnExplnCellEdited: (params: CellEditedCallbackParams) => void,
|
|
setCheckedIndices: Dispatch<SetStateAction<Set<number>>>,
|
|
) => {
|
|
if (!loading && rows.length === 0) {
|
|
return <p className="p-6 text-center text-[1.3rem] text-muted-foreground">{emptyMessage}</p>;
|
|
}
|
|
|
|
return (
|
|
<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={onPrcsOpnnExplnCellEdited}
|
|
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={onPageIndexChange}
|
|
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>
|
|
);
|
|
};
|
|
|
|
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">
|
|
{errorMessage}
|
|
</p>
|
|
)}
|
|
<section className="rounded-md border border-border">
|
|
<h3 className="border-b border-border px-4 py-2 text-[1.35rem] font-semibold">일반검증 내역</h3>
|
|
{renderGrid(
|
|
normalRows,
|
|
setNormalRows,
|
|
normalPageIndex,
|
|
normalPageCount,
|
|
handleNormalPageChange,
|
|
normalLoading,
|
|
normalGridRef,
|
|
getNormalGridDataRef,
|
|
onNormalPrcsOpnnExplnEdited,
|
|
setNormalCheckedIndices,
|
|
)}
|
|
</section>
|
|
<section className="rounded-md border border-border">
|
|
<h3 className="border-b border-border px-4 py-2 text-[1.35rem] font-semibold">I-MR 검증내역</h3>
|
|
{renderGrid(
|
|
imrRows,
|
|
setImrRows,
|
|
imrPageIndex,
|
|
imrPageCount,
|
|
handleImrPageChange,
|
|
imrLoading,
|
|
imrGridRef,
|
|
getImrGridDataRef,
|
|
onImrPrcsOpnnExplnEdited,
|
|
setImrCheckedIndices,
|
|
)}
|
|
</section>
|
|
</div>
|
|
<DialogFooter>
|
|
<Button type="button" color="primary" onClick={handleExceptionSubmitClick}>
|
|
예외 제출
|
|
</Button>
|
|
<DialogClose asChild>
|
|
<Button type="button" color="gray">
|
|
닫기
|
|
</Button>
|
|
</DialogClose>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|