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

624 lines
24 KiB
TypeScript

"use client";
import { type Dispatch, type MutableRefObject, type SetStateAction, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { RealGridReact } from "realgrid-react";
import { getUserInfo } from "../../../../../packages/common/components/src/api";
import { useAlertStore } from "@/shared/lib";
import { statisticsApi } from "@/entities/statistics/api/statistics-service";
import type {
ReviewInputSaveRequest,
VerificationResultListRequest,
VerificationResultReportItem,
} from "@/entities/statistics/model/statistics-interface";
import {
Button,
Dialog,
DialogClose,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
Label,
LoadingWithAnimation,
Tabs,
TabsContent,
TabsList,
TabsTrigger,
Textarea,
} from "@common/components";
import DataGrid from "@/components/module/DataGrid";
import { VerificationResultHistoryPagination } from "@/components/module/VerificationResultHistoryPagination";
import type { CellStyleCallbackParams, ColumnInfo } from "@/components/type/DataGrid.type";
/** 통계 입력 현황 > 행 클릭 시 사용자예외처리(prcsYn=2) 내역만 조회 */
export interface ExceptionProcessingHistoryQueryContext {
sewerBzmnCd: string;
ctpvNm: string;
sggNm: string;
statsYr: string;
statsCd: string;
}
export interface ExceptionProcessingHistoryDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
queryContext?: ExceptionProcessingHistoryQueryContext | null;
emptyMessage?: string;
/** 반려 API 성공 후 (다이얼로그 내 목록 재조회 이후) 상위 화면 등에서 호출 */
onRejectSuccess?: () => void | Promise<void>;
}
type ExceptionHistoryGridRow = Record<string, string | number>;
type ExceptionHistoryTabKey = "NV" | "IMR" | "HB";
const exceptionHistoryTabs: { value: ExceptionHistoryTabKey; label: string }[] = [
{ value: "NV", label: "일반검증 내역" },
{ value: "IMR", label: "I-MR 검증내역" },
{ value: "HB", label: "HB 검증내역" },
];
const TITLE = "예외처리 내역 조회";
/** 예외처리 건만 */
const PRCS_YN_LIST = "2";
export type ExceptionRejectSubmitItem = {
vrfcSn: number;
statsYr: string;
sewerBzmnCd: string;
statsCd: string;
mdfrId: string;
rjctOpnnCn?: string;
};
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;
}
function exceptionPrcsYnNmCellStyle({ fieldName, row }: CellStyleCallbackParams): string | undefined {
if (fieldName !== "prcsYnNm") return undefined;
const code = prcsYnCodeToNumber(row.prcsYn);
if (code === 0 || code === 4) return "rg-cell-vrf-prcs-bad";
return "rg-cell-vrf-prcs-ok";
}
const exceptionHistoryColumnInfo: 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: "individualReject",
fieldName: "individualReject",
header: { text: "개별 반려" },
editable: false,
width: 104,
styleName: "rg-cell-reject-mini-btn",
},
{ name: "prcsYn", fieldName: "prcsYn", header: { text: "검증결과상태코드" }, editable: false, visible: false },
{ name: "vrfcSn", fieldName: "vrfcSn", header: { text: "vrfcSn" }, editable: false, visible: false },
],
columnLayout: [
"ctpvNm",
"admdstNm",
"vrfcTrgtNm",
"rsnExpln",
"nowVlCn",
"compVlCn",
"prcsYnNm",
"stVrfRjtComNm",
"prcsOpnnExpln",
"rjctOpnnCn",
"regDt",
"mdfcnDt",
"individualReject",
"prcsYn",
"vrfcSn",
],
};
export function ExceptionProcessingHistoryDialog({
open,
onOpenChange,
queryContext,
emptyMessage = "표시할 예외처리 내역이 없습니다.",
onRejectSuccess,
}: ExceptionProcessingHistoryDialogProps) {
const { setMessage: alert } = useAlertStore((state) => state);
const nvGridRef = useRef<RealGridReact | null>(null);
const imrGridRef = useRef<RealGridReact | null>(null);
const hbGridRef = useRef<RealGridReact | null>(null);
const [rejectReasonDialogOpen, setRejectReasonDialogOpen] = useState(false);
const [pendingRejectVrfcSns, setPendingRejectVrfcSns] = useState<number[]>([]);
const [rejectReasonText, setRejectReasonText] = useState("");
const [rejectSubmitting, setRejectSubmitting] = useState(false);
const [activeTab, setActiveTab] = useState<ExceptionHistoryTabKey>("NV");
const [normalEntries, setNormalEntries] = useState<VerificationResultReportItem[]>([]);
const [imrEntries, setImrEntries] = useState<VerificationResultReportItem[]>([]);
const [hbEntries, setHbEntries] = useState<VerificationResultReportItem[]>([]);
const [normalPageIndex, setNormalPageIndex] = useState(0);
const [imrPageIndex, setImrPageIndex] = useState(0);
const [hbPageIndex, setHbPageIndex] = useState(0);
const [normalPageCount, setNormalPageCount] = useState(1);
const [imrPageCount, setImrPageCount] = useState(1);
const [hbPageCount, setHbPageCount] = useState(1);
const [normalLoading, setNormalLoading] = useState(false);
const [imrLoading, setImrLoading] = useState(false);
const [hbLoading, setHbLoading] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const resetState = useCallback(() => {
setActiveTab("NV");
setNormalEntries([]);
setImrEntries([]);
setHbEntries([]);
setNormalPageIndex(0);
setImrPageIndex(0);
setHbPageIndex(0);
setNormalPageCount(1);
setImrPageCount(1);
setHbPageCount(1);
setNormalLoading(false);
setImrLoading(false);
setHbLoading(false);
setErrorMessage(null);
setRejectReasonDialogOpen(false);
setPendingRejectVrfcSns([]);
setRejectReasonText("");
setRejectSubmitting(false);
}, []);
const fetchPage = useCallback(
async (vrfcMthdCd: ExceptionHistoryTabKey, pageIndex: number) => {
if (!queryContext) return null;
const param: VerificationResultListRequest = {
sewerBzmnCd: queryContext.sewerBzmnCd,
ctpvNm: queryContext.ctpvNm,
sggNm: queryContext.sggNm,
statsYr: queryContext.statsYr,
statsCd: queryContext.statsCd,
vrfcMthdCd,
prcsYnList: PRCS_YN_LIST,
rejectCodeList: "0,1,2",
page: pageIndex + 1,
size: 30,
};
return statisticsApi.getVerificationResultList(param);
},
[queryContext],
);
const loadInitial = useCallback(async () => {
if (!queryContext) {
setErrorMessage("조회 조건이 없어 예외처리 내역을 불러올 수 없습니다.");
return;
}
setErrorMessage(null);
setNormalLoading(true);
setImrLoading(true);
setHbLoading(true);
setNormalPageIndex(0);
setImrPageIndex(0);
setHbPageIndex(0);
try {
const [nvRes, imrRes, hbRes] = await Promise.all([
fetchPage("NV", 0),
fetchPage("IMR", 0),
fetchPage("HB", 0),
]);
setNormalEntries(nvRes?.data?.verifyResultList ?? []);
setImrEntries(imrRes?.data?.verifyResultList ?? []);
setHbEntries(hbRes?.data?.verifyResultList ?? []);
setNormalPageCount(nvRes?.data?.pageInfo?.totalPages ?? 1);
setImrPageCount(imrRes?.data?.pageInfo?.totalPages ?? 1);
setHbPageCount(hbRes?.data?.pageInfo?.totalPages ?? 1);
} catch {
setNormalEntries([]);
setImrEntries([]);
setHbEntries([]);
setNormalPageCount(1);
setImrPageCount(1);
setHbPageCount(1);
setErrorMessage("예외처리 내역 조회 중 오류가 발생했습니다.");
} finally {
setNormalLoading(false);
setImrLoading(false);
setHbLoading(false);
}
}, [fetchPage, queryContext]);
const handleNormalPageChange = useCallback(
async (pageIndex: number) => {
if (pageIndex === normalPageIndex || !queryContext) return;
setErrorMessage(null);
setNormalLoading(true);
try {
const response = await fetchPage("NV", pageIndex);
setNormalEntries(response?.data?.verifyResultList ?? []);
setNormalPageCount(response?.data?.pageInfo?.totalPages ?? 1);
setNormalPageIndex(pageIndex);
} catch {
setErrorMessage("일반검증 내역 조회 중 오류가 발생했습니다.");
} finally {
setNormalLoading(false);
}
},
[fetchPage, normalPageIndex, queryContext],
);
const handleImrPageChange = useCallback(
async (pageIndex: number) => {
if (pageIndex === imrPageIndex || !queryContext) return;
setErrorMessage(null);
setImrLoading(true);
try {
const response = await fetchPage("IMR", pageIndex);
setImrEntries(response?.data?.verifyResultList ?? []);
setImrPageCount(response?.data?.pageInfo?.totalPages ?? 1);
setImrPageIndex(pageIndex);
} catch {
setErrorMessage("I-MR 검증내역 조회 중 오류가 발생했습니다.");
} finally {
setImrLoading(false);
}
},
[fetchPage, imrPageIndex, queryContext],
);
const handleHbPageChange = useCallback(
async (pageIndex: number) => {
if (pageIndex === hbPageIndex || !queryContext) return;
setErrorMessage(null);
setHbLoading(true);
try {
const response = await fetchPage("HB", pageIndex);
setHbEntries(response?.data?.verifyResultList ?? []);
setHbPageCount(response?.data?.pageInfo?.totalPages ?? 1);
setHbPageIndex(pageIndex);
} catch {
setErrorMessage("HB 검증내역 조회 중 오류가 발생했습니다.");
} finally {
setHbLoading(false);
}
},
[fetchPage, hbPageIndex, queryContext],
);
useEffect(() => {
if (!open) {
resetState();
return;
}
void loadInitial();
}, [loadInitial, open, resetState]);
const mapRows = (entries: VerificationResultReportItem[]): ExceptionHistoryGridRow[] =>
entries.map((item) => ({
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),
individualReject: "개별 반려",
vrfcSn: item.vrfcSn,
}));
const normalRows = useMemo(() => mapRows(normalEntries), [normalEntries]);
const imrRows = useMemo(() => mapRows(imrEntries), [imrEntries]);
const hbRows = useMemo(() => mapRows(hbEntries), [hbEntries]);
const noopSetRows = ((_: SetStateAction<Record<string, string | number>[]>) => {}) as Dispatch<
SetStateAction<Record<string, string | number>[]>
>;
const handleOpenRejectReasonDialog = useCallback(() => {
if (!queryContext) {
alert("조회 조건이 없습니다.");
return;
}
const rows =
activeTab === "NV" ? normalRows : activeTab === "IMR" ? imrRows : hbRows;
const gridRef =
activeTab === "NV" ? nvGridRef : activeTab === "IMR" ? imrGridRef : hbGridRef;
const gridView = gridRef.current?.gridView;
if (!gridView) {
alert("데이터가 존재하지 않습니다.");
return;
}
const checkedIndices = (gridView.getCheckedRows?.(true, false, true) ?? []) as number[];
if (checkedIndices.length === 0) {
alert("반려할 항목을 체크해주세요.");
return;
}
const vrfcSns: number[] = [];
for (const itemIndex of checkedIndices) {
const row = rows[itemIndex];
if (!row) continue;
const vrfcSn = Number(row.vrfcSn);
if (!Number.isFinite(vrfcSn)) continue;
vrfcSns.push(vrfcSn);
}
if (vrfcSns.length === 0) {
alert("유효한 검증번호가 있는 행을 선택해주세요.");
return;
}
setPendingRejectVrfcSns(vrfcSns);
setRejectReasonText("");
setRejectSubmitting(false);
setRejectReasonDialogOpen(true);
}, [activeTab, alert, hbRows, imrRows, normalRows, queryContext]);
const resetRejectReasonDialog = useCallback(() => {
setRejectReasonDialogOpen(false);
setPendingRejectVrfcSns([]);
setRejectReasonText("");
setRejectSubmitting(false);
}, []);
const handleRejectReasonDialogOpenChange = useCallback(
(nextOpen: boolean) => {
if (!nextOpen) resetRejectReasonDialog();
},
[resetRejectReasonDialog],
);
const handleConfirmReject = useCallback(async () => {
if (!queryContext || pendingRejectVrfcSns.length === 0) {
alert("반려할 항목이 없습니다. 다시 선택해주세요.");
resetRejectReasonDialog();
return;
}
let userInfo;
try {
userInfo = await getUserInfo();
} catch {
alert("사용자 정보를 불러오지 못했습니다.");
return;
}
const mdfrId = userInfo.userUnqId;
const trimmedReason = rejectReasonText.trim();
const items: ReviewInputSaveRequest[] = pendingRejectVrfcSns.map((vrfcSn) => ({
type: "INPUT",
statsYr: queryContext.statsYr,
sewerBzmnCd: queryContext.sewerBzmnCd,
statsCd: queryContext.statsCd,
mdfrId,
vrfcSn: String(vrfcSn),
...(trimmedReason ? { rjctOpnnCn: trimmedReason } : {}),
}));
setRejectSubmitting(true);
try {
const response = await statisticsApi.saveInputReturn(items);
if (response.resultCode.toLowerCase() !== "success") {
alert(response.message ?? "반려 처리 중 오류가 발생했습니다.");
return;
}
alert("반려 처리되었습니다.");
resetRejectReasonDialog();
await Promise.all([
loadInitial(),
onRejectSuccess?.() ?? Promise.resolve(),
]);
} catch {
alert("반려 처리 중 오류가 발생했습니다.");
} finally {
setRejectSubmitting(false);
}
}, [
alert,
loadInitial,
onRejectSuccess,
pendingRejectVrfcSns,
queryContext,
rejectReasonText,
resetRejectReasonDialog,
]);
const handleGridRowClick = useCallback(
(rowData: Record<string, string | number>) => {
const gridRef =
activeTab === "NV" ? nvGridRef : activeTab === "IMR" ? imrGridRef : hbGridRef;
const current = gridRef.current?.gridView?.getCurrent?.() as
| { fieldName?: string; column?: string }
| undefined;
const fieldName = current?.fieldName ?? current?.column;
if (fieldName !== "individualReject") return;
const vrfcSn = Number(rowData.vrfcSn);
if (!Number.isFinite(vrfcSn)) {
alert("유효한 검증번호가 있는 행을 선택해주세요.");
return;
}
setPendingRejectVrfcSns([vrfcSn]);
setRejectReasonText("");
setRejectSubmitting(false);
setRejectReasonDialogOpen(true);
},
[activeTab, alert],
);
const renderGrid = (
rows: ExceptionHistoryGridRow[],
pageIndex: number,
pageCount: number,
onPageIndexChange: (page: number) => void,
loading: boolean,
gridRef: MutableRefObject<RealGridReact | null>,
) => {
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
columnInfo={exceptionHistoryColumnInfo}
rowData={rows}
setRowData={noopSetRows}
isExcelImport={false}
isExcelExport={false}
showHeaderCheckbox={true}
enableSort={false}
getGridRef={gridRef}
cellStyleCallback={exceptionPrcsYnNmCellStyle}
onRowClick={handleGridRowClick}
/>
<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>
);
};
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) => setActiveTab(value as ExceptionHistoryTabKey)}
className="w-full"
variant="default"
>
<TabsList>
{exceptionHistoryTabs.map((tab) => (
<TabsTrigger key={tab.value} value={tab.value}>
{tab.label}
</TabsTrigger>
))}
</TabsList>
<TabsContent value="NV" className="rounded-md border border-border">
{renderGrid(
normalRows,
normalPageIndex,
normalPageCount,
handleNormalPageChange,
normalLoading,
nvGridRef,
)}
</TabsContent>
<TabsContent value="IMR" className="rounded-md border border-border">
{renderGrid(
imrRows,
imrPageIndex,
imrPageCount,
handleImrPageChange,
imrLoading,
imrGridRef,
)}
</TabsContent>
<TabsContent value="HB" className="rounded-md border border-border">
{renderGrid(
hbRows,
hbPageIndex,
hbPageCount,
handleHbPageChange,
hbLoading,
hbGridRef,
)}
</TabsContent>
</Tabs>
</div>
<DialogFooter>
<Button
type="button"
color="red"
onClick={handleOpenRejectReasonDialog}
disabled={rejectSubmitting}
>
일괄 반려
</Button>
<DialogClose asChild>
<Button type="button" color="gray">
닫기
</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={rejectReasonDialogOpen} onOpenChange={handleRejectReasonDialogOpenChange}>
<DialogContent className="max-w-[56rem]" aria-describedby={undefined}>
<DialogHeader>
<DialogTitle>예외처리 반려</DialogTitle>
</DialogHeader>
<div className="space-y-2 py-2">
<Label htmlFor="exception-reject-reason" className="text-label">
반려사유 (선택, 체크한 모든 행에 동일 적용)
</Label>
<Textarea
id="exception-reject-reason"
value={rejectReasonText}
onChange={(e) => setRejectReasonText(e.target.value)}
rows={4}
className="min-h-[8rem] p-2 !text-xl"
placeholder='미입력 시 "검증 리포트를 확인하여 주시기 바랍니다." 자동 주입'
disabled={rejectSubmitting}
/>
</div>
<DialogFooter>
<Button
type="button"
color="red"
disabled={rejectSubmitting}
onClick={() => void handleConfirmReject()}
>
반려 확정
</Button>
<DialogClose asChild>
<Button type="button" color="gray" disabled={rejectSubmitting}>
취소
</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}