[st] - 입력 반려,승인 / 검토 승인

This commit is contained in:
2026-05-14 00:18:30 +09:00
parent c1cab1455f
commit 1cde3b0998
20 changed files with 1241 additions and 174 deletions
@@ -1,7 +1,7 @@
import { Button } from "@common/components";
interface ApproveButtonProps {
approveFunc: () => void;
approveFunc: () => void | Promise<void>;
}
const ApproveButton = ({ approveFunc }: ApproveButtonProps) => {
+175 -72
View File
@@ -1,21 +1,6 @@
@tailwind components;
@layer components {
.rg-grid .rg-header .rg-table .rg-header-group-cell {
@apply !bg-[#466995] !text-[#fff];
}
.rg-grid .rg-header .rg-table {
@apply !bg-[#5987bf] !text-[#fff];
}
/* 상태바·체크바 헤더 */
.rg-grid .rg-head .rg-table tr:first-child td:first-child,
.rg-grid .rg-head .rg-table tr:first-child td:nth-child(2),
.rg-grid .rg-header .rg-table tr:first-child td:first-child,
.rg-grid .rg-header .rg-table tr:first-child td:nth-child(2) {
@apply !bg-[#5987bf] !text-[#fff];
}
.rg-grid .rg-body .rg-table .rg-data-row .rg-data-cell {
@apply !bg-white;
}
@@ -36,14 +21,67 @@
.rg-grid .rg-body .rg-table .rg-data-row .rg-data-cell.rg-cell-vrf-prcs-ok {
@apply !font-semibold !text-[#3372cf];
}
/* 개별 반려: 높이/레이아웃은 RealGrid 행에 그대로 두고, 색·테두리·정렬만 버튼 느낌 */
.rg-grid .rg-body .rg-table .rg-data-row .rg-data-cell.rg-cell-reject-mini-btn {
text-align: center !important;
vertical-align: middle !important;
box-sizing: border-box !important;
padding-left: 8px !important;
padding-right: 8px !important;
border-radius: 0.25rem !important;
border: 1px solid rgb(248 113 113 / 0.55) !important;
background-color: rgb(254 242 242) !important;
color: rgb(185 28 28) !important;
font-size: 1.2rem !important;
font-weight: 600 !important;
white-space: nowrap !important;
cursor: pointer !important;
text-decoration: none !important;
}
.rg-grid .rg-body .rg-table .rg-data-row .rg-data-cell.rg-cell-reject-mini-btn:hover {
background-color: rgb(254 226 226) !important;
border-color: rgb(239 68 68 / 0.85) !important;
color: rgb(153 27 27) !important;
}
.rg-grid .rg-body .rg-table .rg-data-row .rg-data-cell.rg-cell-reject-mini-btn:active {
background-color: rgb(254 202 202) !important;
}
.rg-grid .rg-body .rg-table .rg-data-row .rg-data-cell.rg-cell-outline-action-btn {
text-align: center !important;
vertical-align: middle !important;
box-sizing: border-box !important;
padding-left: 8px !important;
padding-right: 8px !important;
border-radius: 0.25rem !important;
border: 1px solid rgb(148 163 184 / 0.7) !important;
background-color: rgb(248 250 252) !important;
color: rgb(51 65 85) !important;
font-size: 1.2rem !important;
font-weight: 600 !important;
white-space: nowrap !important;
cursor: pointer !important;
text-decoration: none !important;
}
.rg-grid .rg-body .rg-table .rg-data-row .rg-data-cell.rg-cell-outline-action-btn:hover {
background-color: rgb(241 245 249) !important;
border-color: rgb(100 116 139 / 0.85) !important;
color: rgb(30 41 59) !important;
}
.rg-grid .rg-body .rg-table .rg-data-row .rg-data-cell.rg-cell-outline-action-btn:active {
background-color: rgb(226 232 240) !important;
}
.rg-grid .rg-body .rg-table .rg-data-row .rg-data-cell.rg-cell-action-link {
@apply !font-semibold !text-[#2563eb] underline;
cursor: pointer !important;
}
.rg-grid .rg-body .rg-table .rg-data-row .rg-data-cell.rg-cell-stats-status-pending {
@apply !text-gray-500;
}
.rg-grid .rg-body .rg-table .rg-data-row .rg-data-cell.rg-cell-stats-status-submitted {
@apply !font-semibold
@apply !font-semibold;
}
.rg-grid .rg-body .rg-table .rg-data-row .rg-data-cell.rg-cell-stats-status-approved {
@apply !font-semibold
@apply !font-semibold;
}
.rg-grid .rg-body .rg-table .rg-data-row .rg-data-cell.rg-cell-stats-status-rejected {
@apply !font-semibold !text-[#ff6c64];
@@ -117,60 +155,6 @@
.data-grid-status-bubble > span + span {
@apply ml-4 border-l border-slate-200/70 pl-4;
}
[data-theme="black"] {
.rg-grid .rg-header .rg-table .rg-header-group-cell {
@apply !bg-[#3C4043] !text-[#fff];
}
.rg-grid .rg-header .rg-table .rg-header-cell {
@apply !bg-[#62686d] !text-[#fff];
}
.rg-grid .rg-head .rg-table tr:first-child td:first-child,
.rg-grid .rg-head .rg-table tr:first-child td:nth-child(2),
.rg-grid .rg-header .rg-table tr:first-child td:first-child,
.rg-grid .rg-header .rg-table tr:first-child td:nth-child(2) {
@apply !bg-[#62686d] !text-[#fff];
}
.rg-grid .rg-body .rg-table .rg-data-row .rg-data-cell {
@apply !bg-[#545556] !text-[#fff];
}
.rg-grid .rg-body .rg-table .rg-data-row .rg-data-cell.rg-cell-vrf-prcs-bad {
@apply !text-[#f87171];
}
.rg-grid .rg-body .rg-table .rg-data-row .rg-data-cell.rg-cell-vrf-prcs-ok {
@apply !text-[#3372cf];
}
}
[data-theme="red"] {
.rg-grid .rg-header .rg-table .rg-header-group-cell {
@apply !bg-[#646469] !text-[#fff];
}
.rg-grid .rg-header .rg-table .rg-header-cell {
@apply !bg-[#8d8d94] !text-[#fff];
}
.rg-grid .rg-head .rg-table tr:first-child td:first-child,
.rg-grid .rg-head .rg-table tr:first-child td:nth-child(2),
.rg-grid .rg-header .rg-table tr:first-child td:first-child,
.rg-grid .rg-header .rg-table tr:first-child td:nth-child(2) {
@apply !bg-[#8d8d94] !text-[#fff];
}
}
[data-theme="green"] {
.rg-grid .rg-header .rg-table .rg-header-group-cell {
@apply !bg-[#518585] !text-[#fff];
}
.rg-grid .rg-header .rg-table .rg-header-cell {
@apply !bg-[#6bafae] !text-[#fff];
}
.rg-grid .rg-head .rg-table tr:first-child td:first-child,
.rg-grid .rg-head .rg-table tr:first-child td:nth-child(2),
.rg-grid .rg-header .rg-table tr:first-child td:first-child,
.rg-grid .rg-header .rg-table tr:first-child td:nth-child(2) {
@apply !bg-[#6bafae] !text-[#fff];
}
}
}
@keyframes rg-cell-stats-status-dot-ping {
@@ -203,4 +187,123 @@
.rg-grid .rg-head .rg-table tr td {
border: none;
border-bottom: 1px solid #e5e7eb;
}
}
/*
* ST 통계 그리드 헤더 색 — @layer 밖(일반 규칙).
* packages/rsone/components/grid/css/DataGrid.css 등 @layer 밖 !important와 동일 층에서
* .rg-grid … 특이도로 덮어씀.
*/
.rg-grid .rg-header .rg-table .rg-header-group-cell {
background-color: #466995 !important;
color: #ffffff !important;
}
.rg-grid .rg-header .rg-table {
background-color: #5987bf !important;
color: #ffffff !important;
}
.rg-grid .rg-head .rg-table tr:first-child td:first-child,
.rg-grid .rg-head .rg-table tr:first-child td:nth-child(2),
.rg-grid .rg-header .rg-table tr:first-child td:first-child,
.rg-grid .rg-header .rg-table tr:first-child td:nth-child(2) {
background-color: #5987bf !important;
color: #ffffff !important;
}
.rg-grid .rg-header .rg-cell,
.rg-grid .rg-head .rg-cell {
background-color: #5987bf !important;
color: #ffffff !important;
}
[data-theme="black"] .rg-grid .rg-header .rg-table .rg-header-group-cell {
background-color: #3c4043 !important;
color: #ffffff !important;
}
[data-theme="black"] .rg-grid .rg-header .rg-table .rg-header-cell {
background-color: #62686d !important;
color: #ffffff !important;
}
[data-theme="black"] .rg-grid .rg-head .rg-table tr:first-child td:first-child,
[data-theme="black"] .rg-grid .rg-head .rg-table tr:first-child td:nth-child(2),
[data-theme="black"] .rg-grid .rg-header .rg-table tr:first-child td:first-child,
[data-theme="black"] .rg-grid .rg-header .rg-table tr:first-child td:nth-child(2) {
background-color: #62686d !important;
color: #ffffff !important;
}
[data-theme="black"] .rg-grid .rg-header .rg-cell,
[data-theme="black"] .rg-grid .rg-head .rg-cell {
background-color: #62686d !important;
color: #ffffff !important;
}
[data-theme="black"] .rg-grid .rg-body .rg-table .rg-data-row .rg-data-cell {
background-color: #545556 !important;
color: #ffffff !important;
}
[data-theme="black"] .rg-grid .rg-body .rg-table .rg-data-row .rg-data-cell.rg-cell-vrf-prcs-bad {
color: #f87171 !important;
}
[data-theme="black"] .rg-grid .rg-body .rg-table .rg-data-row .rg-data-cell.rg-cell-vrf-prcs-ok {
color: #3372cf !important;
}
[data-theme="black"] .rg-grid .rg-body .rg-table .rg-data-row .rg-data-cell.rg-cell-reject-mini-btn {
background-color: rgb(127 29 29 / 0.42) !important;
border-color: rgb(248 113 113 / 0.5) !important;
color: #fecaca !important;
}
[data-theme="black"] .rg-grid .rg-body .rg-table .rg-data-row .rg-data-cell.rg-cell-reject-mini-btn:hover {
background-color: rgb(153 27 27 / 0.55) !important;
border-color: rgb(252 165 165 / 0.75) !important;
color: #fee2e2 !important;
}
[data-theme="black"] .rg-grid .rg-body .rg-table .rg-data-row .rg-data-cell.rg-cell-outline-action-btn {
background-color: rgb(71 85 105 / 0.35) !important;
border-color: rgb(148 163 184 / 0.55) !important;
color: #e2e8f0 !important;
}
[data-theme="black"] .rg-grid .rg-body .rg-table .rg-data-row .rg-data-cell.rg-cell-outline-action-btn:hover {
background-color: rgb(71 85 105 / 0.5) !important;
border-color: rgb(203 213 225 / 0.65) !important;
color: #f8fafc !important;
}
[data-theme="red"] .rg-grid .rg-header .rg-table .rg-header-group-cell {
background-color: #646469 !important;
color: #ffffff !important;
}
[data-theme="red"] .rg-grid .rg-header .rg-table .rg-header-cell {
background-color: #8d8d94 !important;
color: #ffffff !important;
}
[data-theme="red"] .rg-grid .rg-head .rg-table tr:first-child td:first-child,
[data-theme="red"] .rg-grid .rg-head .rg-table tr:first-child td:nth-child(2),
[data-theme="red"] .rg-grid .rg-header .rg-table tr:first-child td:first-child,
[data-theme="red"] .rg-grid .rg-header .rg-table tr:first-child td:nth-child(2) {
background-color: #8d8d94 !important;
color: #ffffff !important;
}
[data-theme="red"] .rg-grid .rg-header .rg-cell,
[data-theme="red"] .rg-grid .rg-head .rg-cell {
background-color: #8d8d94 !important;
color: #ffffff !important;
}
[data-theme="green"] .rg-grid .rg-header .rg-table .rg-header-group-cell {
background-color: #518585 !important;
color: #ffffff !important;
}
[data-theme="green"] .rg-grid .rg-header .rg-table .rg-header-cell {
background-color: #6bafae !important;
color: #ffffff !important;
}
[data-theme="green"] .rg-grid .rg-head .rg-table tr:first-child td:first-child,
[data-theme="green"] .rg-grid .rg-head .rg-table tr:first-child td:nth-child(2),
[data-theme="green"] .rg-grid .rg-header .rg-table tr:first-child td:first-child,
[data-theme="green"] .rg-grid .rg-header .rg-table tr:first-child td:nth-child(2) {
background-color: #6bafae !important;
color: #ffffff !important;
}
[data-theme="green"] .rg-grid .rg-header .rg-cell,
[data-theme="green"] .rg-grid .rg-head .rg-cell {
background-color: #6bafae !important;
color: #ffffff !important;
}
+18 -4
View File
@@ -378,6 +378,7 @@ const DataGrid = ({
cellTooltipCallback,
onCellEdited,
onRowClick,
onDataCellClick,
onItemChecked,
onItemAllChecked,
onScrollToBottom,
@@ -403,6 +404,7 @@ const DataGrid = ({
cellTooltipCallback,
);
const onCellEditedRef = useRef<typeof onCellEdited>(onCellEdited);
const onDataCellClickRef = useRef<typeof onDataCellClick>(onDataCellClick);
const onScrollToBottomRef = useRef<(() => void) | undefined>(
onScrollToBottom,
);
@@ -417,6 +419,7 @@ const DataGrid = ({
onItemCheckedRef.current = onItemChecked;
onItemAllCheckedRef.current = onItemAllChecked;
onCellEditedRef.current = onCellEdited;
onDataCellClickRef.current = onDataCellClick;
// highlightedRowId가 변경될 때 해당 행으로 스크롤 및 선택
useEffect(() => {
@@ -1180,7 +1183,11 @@ const DataGrid = ({
// 행 클릭 이벤트 + 검증 오류 셀 클릭 시 showMessage (공식 문서 방식)
useEffect(() => {
const gridView = gridRef.current?.gridView;
if (!gridView || (!onRowClick && !cellTooltipCallback)) return;
if (
!gridView ||
(!onRowClick && !cellTooltipCallback && !onDataCellClick)
)
return;
gridView.onCellClicked = (grid, clickData) => {
if (
@@ -1199,10 +1206,10 @@ const DataGrid = ({
};
}
).getCurrent?.();
const fieldName =
cellIndex?.fieldName ?? cellIndex?.column ?? "";
// cellTooltipCallback 있으면 해당 셀 메시지로 showMessage (공식 문서: TooltipOptions + CellIndex)
if (cellTooltipCallbackRef.current && row && cellIndex) {
const fieldName =
cellIndex.fieldName ?? cellIndex.column ?? "";
const msg = cellTooltipCallbackRef.current({
row: row as Record<string, string | number>,
fieldName,
@@ -1232,6 +1239,13 @@ const DataGrid = ({
);
}
}
if (onDataCellClickRef.current && row !== undefined) {
onDataCellClickRef.current({
row: (row ?? {}) as Record<string, string | number>,
fieldName,
itemIndex: clickData.itemIndex,
});
}
if (onRowClick)
onRowClick((row ?? {}) as Record<string, string | number>);
} catch (error) {
@@ -1242,7 +1256,7 @@ const DataGrid = ({
return () => {
if (gridView.onCellClicked) gridView.onCellClicked = undefined;
};
}, [onRowClick, cellTooltipCallback, columns]);
}, [onRowClick, onDataCellClick, cellTooltipCallback, columns]);
useEffect(() => {
const gridView = gridRef.current?.gridView;
@@ -0,0 +1,617 @@
"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=3) 내역만 조회 */
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: "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: "prcsYnNm", fieldName: "prcsYnNm", 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: "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",
"sggNm",
"vrfcTrgtNm",
"rsnExpln",
"nowVlCn",
"compVlCn",
"prcsYnNm",
"pbcpAprvYn",
"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,
pbcpAprvYn: "N",
vrfcMthdCd,
prcsYnList: PRCS_YN_LIST,
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,
sggNm: item.sggNm,
vrfcTrgtNm: item.vrfcTrgtNm,
rsnExpln: item.rsnExpln,
nowVlCn: item.nowVlCn ?? "-",
compVlCn: item.compVlCn ?? "-",
prcsYnNm: item.prcsYnNm ?? "-",
prcsYn: item.prcsYn ?? "",
pbcpAprvYn: item.pbcpAprvYn,
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">{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>
</>
);
}
@@ -45,7 +45,7 @@ function prcsYnCodeToNumber(raw: string | number | undefined): number | undefine
function verificationPrcsYnNmCellStyle({ 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";
if (code === 0 || code === 3) return "rg-cell-vrf-prcs-bad";
return "rg-cell-vrf-prcs-ok";
}
@@ -175,7 +175,7 @@ const PublicSewageFacilityInp = () => {
const searchDoneRef = useRef(false);
/** ST00060001만: RealGrid 편집값이 state에 없을 수 있어 저장/제출 시 그리드에서 직접 읽음 */
const st60001GridGetRowsRef = useRef<(() => Record<string, string | number>[]) | null>(null);
const submitFunc = () => {
void (async () => {
if (activeTab !== 'ST00060001') {
@@ -891,7 +891,11 @@ const PublicSewageFacilityInp = () => {
alert(response.message || '매핑 대상 조회 중 오류가 발생했습니다.');
return;
}
const mappedTargetRows = (response.data ?? []).map((item) => ({
const rawList = response.data ?? [];
if (rawList.length === 0) {
alert('해당 시설은 중심처리시설의 위탁시설로 연결되어있습니다.');
}
const mappedTargetRows = rawList.map((item) => ({
...item,
__checked: item.mappedYn === 'Y' ? 'Y' : '',
}));
@@ -961,7 +965,7 @@ const PublicSewageFacilityInp = () => {
onValuesChange={setSearchValues}
/>
{showActionToolbarCard && (
<div className="card !py-0">
<div className="card !py-0">
<div className="flex justify-end items-center gap-[0.6rem] mx-[0.6rem]">
<VerificationResultButton
VerificationResultButtonFunc={openVerificationHistoryDialog}
@@ -969,8 +973,8 @@ const PublicSewageFacilityInp = () => {
{showEnterableToolbarButtons && !isMainFacilityTab && (
<>
{showIsNotApplicable && <NotApplicableCheckButton isCheck={isNotApplicable} setIsCheck={setIsNotApplicable} />}
<UpdateOperateDataButton updateOperateDataButtonFunc={updateOperateDataButtonFunc} />
<SubmitButton submitFunc={submitFunc} />
<UpdateOperateDataButton updateOperateDataButtonFunc={updateOperateDataButtonFunc} />
<SubmitButton submitFunc={submitFunc} />
</>
)}
{!isMainFacilityTab && activeTabStatus?.laeSbmsnSttsCd === '2' && (
@@ -985,7 +989,7 @@ const PublicSewageFacilityInp = () => {
<div className="relative size-[5rem]">
<LoadingWithAnimation color="primary" />
</div>
</div>
</div>
)}
<Tabs value={activeTab} onValueChange={(v) => void handleActiveTabChange(v)}>
<TabsList>
@@ -196,10 +196,10 @@ const SEWAGE_PIPE_COLUMN_GUIDE_TAB_KEYS = Array.from(
new Set(SewagePipeInpColumnInfoList.map((tab) => tab.tabKey).filter(Boolean)),
);
function hasRegionalFactVerificationErrors(verification?: SeweragePipeInputVerificationResult | null): boolean {
if (!verification) return false;
return Object.values(verification).some((errors) => Array.isArray(errors) && errors.length > 0);
}
// function hasRegionalFactVerificationErrors(verification?: SeweragePipeInputVerificationResult | null): boolean {
// if (!verification) return false;
// return Object.values(verification).some((errors) => Array.isArray(errors) && errors.length > 0);
// }
const sleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
@@ -841,27 +841,27 @@ const SewagePipeInp = () => {
}
};
const getTabGridRowsForVerification = (tabKey: string): Record<string, string | number>[] => {
switch (tabKey) {
case "ST00020001":
return getGridDataRef.current?.() ?? [];
case "ST00020002":
return getFactInstallGridDataRef.current?.() ?? [];
case "ST00020003":
return getPipeTypeGridDataRef.current?.() ?? [];
case "ST00020004":
return getPipeDiameterGridDataRef.current?.() ?? [];
case "ST00020005":
return getManholeGridDataRef.current?.() ?? [];
case "ST00020006":
return [
...(rowDataMap.get("treatmentPlantConnectionInp") ?? []),
...(rowDataMap.get("adminAreaConnectionInp") ?? []),
];
default:
return [];
}
};
// const getTabGridRowsForVerification = (tabKey: string): Record<string, string | number>[] => {
// switch (tabKey) {
// case "ST00020001":
// return getGridDataRef.current?.() ?? [];
// case "ST00020002":
// return getFactInstallGridDataRef.current?.() ?? [];
// case "ST00020003":
// return getPipeTypeGridDataRef.current?.() ?? [];
// case "ST00020004":
// return getPipeDiameterGridDataRef.current?.() ?? [];
// case "ST00020005":
// return getManholeGridDataRef.current?.() ?? [];
// case "ST00020006":
// return [
// ...(rowDataMap.get("treatmentPlantConnectionInp") ?? []),
// ...(rowDataMap.get("adminAreaConnectionInp") ?? []),
// ];
// default:
// return [];
// }
// };
const SubmitExceptionFunc = async (submitInfo: StatisticsInputSubmitInfoFormData) => {
const tabKey = activeTabRef.current;
@@ -903,7 +903,7 @@ const SewagePipeInp = () => {
};
const submitFunc = async (isException: boolean) => {
const tabKey = activeTabRef.current;
// const tabKey = activeTabRef.current;
const verificationBlocked = getVerificationPeriodActionBlockedMessage(periodCheckList);
if (verificationBlocked) {
alert(verificationBlocked);
@@ -211,7 +211,7 @@ const SewagePipeMaintainInp = () => {
if (!job) return;
postVerificationSearchRef.current = null;
void handleSearch(job.values, { forcedTabKey: job.tabKey });
// eslint-disable-next-line react-hooks/exhaustive-deps -- 검색폼 remount 직후 1회 자동 조회
}, [searchFormRemountKey]);
const handleActiveTabChange = async (tabKey: string) => {
@@ -60,10 +60,10 @@ type GridRowWithVerification = Record<string, string | number> & {
/** 컬럼 가이드 API 조회용 통계코드 (하수도 보급률) */
const SEWERAGE_PENETRATION_COLUMN_GUIDE_STATS_CD = "ST00010001";
const hasVerificationErrors = (verification?: SewerageSupplyRateVerificationResult | null): boolean => {
if (!verification) return false;
return Object.values(verification).some((errors) => Array.isArray(errors) && errors.length > 0);
};
// const hasVerificationErrors = (verification?: SewerageSupplyRateVerificationResult | null): boolean => {
// if (!verification) return false;
// return Object.values(verification).some((errors) => Array.isArray(errors) && errors.length > 0);
// };
const sleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { RealGridReact } from "realgrid-react";
import useSWR, { useSWRConfig } from "swr";
@@ -6,16 +6,26 @@ import useSWR, { useSWRConfig } from "swr";
import { useAlertStore } from "@/shared/lib";
import { statisticsInputSewSupplyRateApi } from "@/entities/statistics/api/input/statistics-input-sew-supply-rate-service";
import { StatisticsInputStatusApi } from "@/entities/statistics/api/input/statistics-input-status-service";
import { statisticsApi } from "@/entities/statistics/api/statistics-service";
import {
isInputPeriodCurrent,
type CheckInputPeriodItem,
} from "@/entities/statistics/model/input/statistics-sewage-supply-rate-input-interface";
import type { StatsInputStatusListRequestParams } from "@/entities/statistics/model/input/statistics-input-status-interface";
import type { StatsInputStatusListApiResponse } from "@/entities/statistics/model/input/statistics-input-status-interface";
import type { StatsInputStatusApproveItem } from "@/entities/statistics/model/statistics-interface";
import type {
StatsInputStatusListApiResponse,
StatsInputStatusListRequestParams,
} from "@/entities/statistics/model/input/statistics-input-status-interface";
import { getUserInfo } from "../../../../../../packages/common/components/src/api";
import { LoadingWithAnimation } from "@common/components";
import ApproveButton from "@/components/button/ApproveButton";
import DataGrid from "@/components/module/DataGrid";
import type { DataCellClickCallbackParams } from "@/components/type/DataGrid.type";
import {
ExceptionProcessingHistoryDialog,
type ExceptionProcessingHistoryQueryContext,
} from "@/components/module/ExceptionProcessingHistoryDialog";
import SearchForm from "@/components/module/SearchForm";
import type { SearchFormValues } from "@/components/type/SearchForm.type";
import { doList } from "@/components/util/column/input/DoList";
@@ -44,6 +54,10 @@ const StatsInputStatusInp = () => {
const periodCheckListRef = useRef<CheckInputPeriodItem[]>([]);
const inputPeriodCurrentRef = useRef(false);
const gridRef = useRef<RealGridReact | null>(null);
const [exceptionHistoryOpen, setExceptionHistoryOpen] = useState(false);
const [exceptionHistoryContext, setExceptionHistoryContext] = useState<
ExceptionProcessingHistoryQueryContext | null
>(null);
const swrKey = searchParams
? (["stats-input-status", searchParams] as const)
@@ -56,7 +70,12 @@ const StatsInputStatusInp = () => {
revalidateOnFocus: true,
onSuccess: (data) => {
if (data.status === "success" && data.data) {
setRowData(toGridRows(data.data));
setRowData(
toGridRows(data.data).map((r) => ({
...r,
exceptionSubmitHistory: "예외제출내역",
})),
);
setHasCompletedSearch(true);
} else {
setHasCompletedSearch(false);
@@ -119,29 +138,104 @@ const StatsInputStatusInp = () => {
setHasCompletedSearch(false);
periodCheckListRef.current = [];
inputPeriodCurrentRef.current = false;
setExceptionHistoryOpen(false);
setExceptionHistoryContext(null);
};
const approveFunc = () => {
const checkedRows =
gridRef.current?.gridView?.getCheckedRows?.(true, false, true) ?? [];
if (rowData.length === 0 || checkedRows.length !== rowData.length) {
alert('모든 통계 항목을 체크해주세요.');
const approveFunc = async () => {
if (rowData.length === 0) {
alert("조회된 통계 항목이 없습니다.");
return;
}
if (!lastSearchedValues?.year?.trim() || !lastSearchedValues?.bsnmCd?.trim()) {
alert("먼저 조회를 진행해주세요.");
return;
}
const hasInvalidStatus = rowData.some((row) =>
["0", "1", "4"].includes(String(row.laeSbmsnSttsCd ?? "")),
);
// const hasInvalidStatus = rowData.some((row) =>
// ["0", "1", "4"].includes(String(row.laeSbmsnSttsCd ?? "")),
// );
if (hasInvalidStatus) {
alert("미작성이나 미제출, 혹은 보완요청(반려) 상태인 항목이 있습니다.");
return;
// if (hasInvalidStatus) {
// alert("미작성이나 미제출, 혹은 보완요청(반려) 상태인 항목이 있습니다.");
// return;
// }
try {
const userInfo = await getUserInfo();
const mdfrId = (userInfo.userUnqId ?? "").trim();
if (!mdfrId) {
alert("수정자 ID를 확인할 수 없습니다.");
return;
}
const statsYr = lastSearchedValues.year.trim();
const sewerBzmnCdDefault = lastSearchedValues.bsnmCd.trim();
const approveItems: StatsInputStatusApproveItem[] = rowData
.map((row) => {
const statsCd = String(row.statsCd ?? "").trim();
const sewerBzmnCd = String(row.sewerBzmnCd ?? sewerBzmnCdDefault).trim();
return { statsYr, sewerBzmnCd, statsCd, mdfrId };
})
.filter((item) => item.statsCd.length > 0);
if (approveItems.length === 0) {
alert("통계 코드(statsCd)가 있는 행이 없습니다.");
return;
}
const response = await statisticsApi.approveStatsInputStatus({ list: approveItems }, "INPUT");
if (response.status === "success") {
if (searchParams) {
await mutate(["stats-input-status", searchParams]);
}
alert(response.message ?? "승인되었습니다.");
} else {
alert(response.message ?? "승인에 실패했습니다.");
}
} catch {
alert("승인 처리 중 오류가 발생했습니다.");
}
// 승인 요청 로직
};
const openExceptionHistoryForRow = useCallback(
(row: Record<string, string | number>) => {
const ctx = lastSearchedValues;
if (!ctx?.year || !ctx?.sido || !ctx?.bsnmCd) {
alert("먼저 조회를 진행해주세요.");
return;
}
const statsCd = String(row.statsCd ?? "").trim();
if (!statsCd) {
alert("통계 코드가 없어 예외처리 내역을 조회할 수 없습니다.");
return;
}
setExceptionHistoryContext({
statsYr: ctx.year ?? "",
sewerBzmnCd: ctx.bsnmCd ?? "",
ctpvNm: ctx.sido ?? "",
sggNm: ctx.gugun === "ALL" ? "" : (ctx.gugun ?? ""),
statsCd,
});
setExceptionHistoryOpen(true);
},
[alert, lastSearchedValues],
);
const handleExceptionSubmitHistoryCellClick = useCallback(
({ fieldName, row }: DataCellClickCallbackParams) => {
if (fieldName !== "exceptionSubmitHistory") return;
openExceptionHistoryForRow(row);
},
[openExceptionHistoryForRow],
);
const handleExceptionHistoryRejectSuccess = useCallback(async () => {
if (!searchParams) return;
await mutate(["stats-input-status", searchParams]);
}, [mutate, searchParams]);
const gugunOkOrNotDoList = doList.includes(lastSearchedValues?.bsnmCd || "")
? Boolean(lastSearchedValues?.gugun && lastSearchedValues.gugun !== "ALL")
: true;
@@ -173,8 +267,9 @@ const StatsInputStatusInp = () => {
rowData={rowData}
setRowData={setRowData}
isExcelImport={false}
showHeaderCheckbox={true}
showHeaderCheckbox={false}
getGridRef={gridRef}
onDataCellClick={handleExceptionSubmitHistoryCellClick}
cellStyleCallback={({ row, fieldName }) => {
if (fieldName !== "laeSbmsnSttsNm") {
return undefined;
@@ -197,6 +292,12 @@ const StatsInputStatusInp = () => {
}}
/>
</div>
<ExceptionProcessingHistoryDialog
open={exceptionHistoryOpen}
onOpenChange={setExceptionHistoryOpen}
queryContext={exceptionHistoryContext}
onRejectSuccess={handleExceptionHistoryRejectSuccess}
/>
</>
);
};
@@ -8,7 +8,7 @@ import useSWR from "swr";
import { useAlertStore } from "@/shared/lib";
import { CodeSelectWithAll } from "@/entities/code";
import { statisticsApi } from "@/entities/statistics/api/statistics-service";
import type { StatisticsLogResponse } from "@/entities/statistics/model/statistics-interface";
// import type { StatisticsLogResponse } from "@/entities/statistics/model/statistics-interface";
import type {
ApiResponse,
SearchCondition,
@@ -1,7 +1,8 @@
import { useState, useMemo } from "react";
import { useCallback, useMemo, useState } from "react";
import { createColumnHelper, getCoreRowModel, useReactTable } from "@tanstack/react-table";
import DataGrid from "@/components/module/DataGrid";
import type { DataCellClickCallbackParams } from "@/components/type/DataGrid.type";
import SearchForm from "@/components/module/SearchForm";
import ApproveButton from "@/components/button/ApproveButton";
import { doList } from "@/components/util/column/input/DoList";
@@ -17,11 +18,17 @@ import {
} from "@common/components";
import { useAlertStore } from "@/shared/lib";
import { statisticsApi } from "@/entities/statistics/api/statistics-service";
import type { StatsInputStatusApproveItem } from "@/entities/statistics/model/statistics-interface";
import { StatsInputStatusInfoList } from "@/components/util/column/review/StatsInputStatusRvwColumn";
import { statisticsManagementApi } from "@/entities/statistics/api/statistics-management-service";
import type { ReviewHistoryResponse } from "@/entities/statistics/model/statistics-input-review-interface";
import type {
ReviewHistoryResponse,
StatsInputStatusReviewItem,
} from "@/entities/statistics/model/statistics-input-review-interface";
import { toGridRows } from "@/components/util/grid/gridFunction";
import type { SearchFormValues } from "@/components/type/SearchForm.type";
import { getUserInfo } from "../../../../../../packages/common/components/src/api";
type ReviewHistoryRow = ReviewHistoryResponse["data"][number];
@@ -33,6 +40,17 @@ const checkSubmitStatusCd = (code: string) => {
return code === '0' ? '미작성' : (code === '1' ? '작성중' : (code === '2' ? '제출완료' : (code === '3' ? '승인완료' : '보완요청')));
};
const isStatsInputStatusRowApprovable = (row: Record<string, string | number>) =>
String(row.submitStatusCd ?? "") === "3" && String(row.prvnStatusCd ?? "") === "2";
const mapSewerageListToGridRows = (rawList: StatsInputStatusReviewItem[]) =>
rawList.map((r) => ({
...r,
prvnStatusNm: checkPrvnStatusCd(r.prvnStatusCd || "0"),
submitStatusNm: checkSubmitStatusCd(r.submitStatusCd || "0"),
reviewHistory: "검토이력",
}));
// 하수도 통계 검토 00. 통계 입력 현황
const StatsInputStatusRvw = () => {
const { setMessage: alert } = useAlertStore((state) => state);
@@ -113,13 +131,7 @@ const StatsInputStatusRvw = () => {
if (response.status === 'success' && response.data) {
const rawList = response.data.sewerageInputList ?? [];
const convertedData = rawList.map((r) => ({
...r,
prvnStatusNm: checkPrvnStatusCd(r.prvnStatusCd || '0'),
submitStatusNm: checkSubmitStatusCd(r.submitStatusCd || '0')
}));
setRowData(toGridRows(convertedData));
setRowData(toGridRows(mapSewerageListToGridRows(rawList)));
} else {
setRowData([]);
setHasCompletedSearch(false);
@@ -136,39 +148,127 @@ const StatsInputStatusRvw = () => {
}
};
const approveFunc = () => console.log('approveFunc');
const approveFunc = async () => {
if (rowData.length === 0) {
alert("조회된 통계 항목이 없습니다.");
return;
}
if (!lastSearchedValues?.year?.trim() || !lastSearchedValues?.bsnmCd?.trim()) {
alert("먼저 조회를 진행해주세요.");
return;
}
// if (!rowData.every(isStatsInputStatusRowApprovable)) {
// alert("지자체 진행상태가 승인완료이고 진행상태가 제출완료인 항목만 승인할 수 있습니다.");
// return;
// }
try {
const userInfo = await getUserInfo();
const mdfrId = (userInfo.userUnqId ?? "").trim();
if (!mdfrId) {
alert("수정자 ID를 확인할 수 없습니다.");
return;
}
const statsYr = lastSearchedValues.year.trim();
const sewerBzmnCdDefault = lastSearchedValues.bsnmCd.trim();
const approveItems: StatsInputStatusApproveItem[] = rowData
.map((row) => {
const statsCd = String(row.statsCd ?? "").trim();
const sewerBzmnCd = String(row.sewerBzmnCd ?? sewerBzmnCdDefault).trim();
return { statsYr, sewerBzmnCd, statsCd, mdfrId };
})
.filter((item) => item.statsCd.length > 0);
if (approveItems.length === 0) {
alert("통계 코드(statsCd)가 있는 행이 없습니다.");
return;
}
const response = await statisticsApi.approveStatsInputStatus({ list: approveItems }, "REVIEW");
if (response.status === "success") {
try {
const listResponse = await statisticsManagementApi.getStatsInputStatusList({
ctpvNm: lastSearchedValues.sido,
sggNm: lastSearchedValues.gugun === "ALL" ? "" : lastSearchedValues.gugun,
statsYr: lastSearchedValues.year ?? "",
sewerBzmnCd: lastSearchedValues.bsnmCd,
});
if (listResponse.status === "success" && listResponse.data) {
const rawList = listResponse.data.sewerageInputList ?? [];
setRowData(toGridRows(mapSewerageListToGridRows(rawList)));
}
} catch {
/* 목록 갱신 실패는 별도 안내 없이 생략 */
}
alert(response.message ?? "승인되었습니다.");
} else {
alert(response.message ?? "승인에 실패했습니다.");
}
} catch {
alert("승인 처리 중 오류가 발생했습니다.");
}
};
const gugunOkOrNotDoList = doList.includes(lastSearchedValues?.bsnmCd || "")
? Boolean(lastSearchedValues?.gugun && lastSearchedValues.gugun !== "ALL")
: true;
const allSubmitStatusApproved = useMemo(
() =>
rowData.length > 0 &&
rowData.every((row) => String(row.submitStatusCd ?? "") === "3"),
[rowData],
);
const showActionToolbarCard =
hasCompletedSearch && rowData.length > 0 && gugunOkOrNotDoList;
hasCompletedSearch &&
rowData.length > 0 &&
gugunOkOrNotDoList &&
!allSubmitStatusApproved;
const handleRowClick = async (row: Record<string, string | number>) => {
setHistorySearchInfo({
ctpvNm: String(row.ctpvNm ?? ""),
sggNm: String(row.sggNm ?? ""),
tabNm: String(row.tabNm ?? ""),
});
const params = {
statsYr: searchedYear,
sewerBzmnCd: String(row.sewerBzmnCd ?? ""),
statsCd: String(row.statsCd ?? ""),
};
try {
const response = await statisticsManagementApi.getReviewHistory(params);
if (response.status === 'success') {
setHistoryRowData(response.data);
setReviewHistoryOpen(true);
} else {
alert(response.message || '검토 이력 조회 중 오류가 발생했습니다.');
const openReviewHistoryForRow = useCallback(
async (row: Record<string, string | number>) => {
if (!searchedYear) {
alert("먼저 조회를 진행해주세요.");
return;
}
} catch {
alert('검토 이력 조회 중 오류가 발생했습니다.');
}
};
setHistorySearchInfo({
ctpvNm: String(row.ctpvNm ?? ""),
sggNm: String(row.sggNm ?? ""),
tabNm: String(row.tabNm ?? ""),
});
const params = {
statsYr: searchedYear,
sewerBzmnCd: String(row.sewerBzmnCd ?? ""),
statsCd: String(row.statsCd ?? ""),
};
try {
const response = await statisticsManagementApi.getReviewHistory(params);
if (response.status === "success") {
setHistoryRowData(response.data);
setReviewHistoryOpen(true);
} else {
alert(response.message || "검토 이력 조회 중 오류가 발생했습니다.");
}
} catch {
alert("검토 이력 조회 중 오류가 발생했습니다.");
}
},
[alert, searchedYear],
);
const handleReviewHistoryCellClick = useCallback(
({ fieldName, row }: DataCellClickCallbackParams) => {
if (fieldName !== "reviewHistory") return;
void openReviewHistoryForRow(row);
},
[openReviewHistoryForRow],
);
return (
<div className="relative">
@@ -193,12 +293,12 @@ const StatsInputStatusRvw = () => {
<DataGrid
columnInfo={columnInfo}
isExcelImport={false}
showHeaderCheckbox={true}
showHeaderCheckbox={false}
rowStartNum={2}
fixedColumns={3}
rowData={rowData}
setRowData={setRowData}
onRowClick={handleRowClick}
onDataCellClick={handleReviewHistoryCellClick}
cellStyleCallback={({ row, fieldName }) => {
if (fieldName === "submitStatusNm") {
const statusCd = String(row.submitStatusCd ?? "");
@@ -279,6 +279,16 @@ export type CellEditedCallback = (
params: CellEditedCallbackParams,
) => void;
/** 데이터 셀 클릭 시 컬럼(필드)까지 전달 — 특정 컬럼만 반응할 때 사용 */
export type DataCellClickCallbackParams = {
row: Record<string, string | number>;
fieldName: string;
itemIndex: number;
};
export type DataCellClickCallback = (
params: DataCellClickCallbackParams,
) => void;
export type DataGridProps = {
columnInfo: ColumnInfo;
rowStartNum?: number; // 헤더 행 개수 (1부터 시작, 예: 2면 1행과 2행까지 헤더로 간주)
@@ -306,6 +316,8 @@ export type DataGridProps = {
/** 외부에서 gridRef에 직접 접근하기 위한 ref (setRows/addRows/setValue 직접 호출용) */
getGridRef?: React.MutableRefObject<RealGridReact | null>;
onRowClick?: (rowData: Record<string, string | number>) => void; // 행 클릭 이벤트
/** 데이터 셀 클릭 시 행 + 클릭한 필드명 전달 (`onRowClick`과 함께 쓸 수 있음) */
onDataCellClick?: DataCellClickCallback;
/** CheckBar 행 체크 변경 */
onItemChecked?: (itemIndex: number, checked: boolean) => void;
/** CheckBar 헤더 전체 선택/해제 */
@@ -10,6 +10,14 @@ export const StatsInputStatusInpColumn = {
{ name: 'sggNm', fieldName: 'sggNm', header: { text: '시군구' }, editable: false },
{ name: 'statsCd', fieldName: 'statsCd', header: { text: '메뉴코드' }, visible: false, editable: false },
{ name: 'menuTabName', fieldName: 'menuTabName', header: { text: '구분' }, editable: false, width: 220 },
{
name: 'exceptionSubmitHistory',
fieldName: 'exceptionSubmitHistory',
header: { text: '예외제출내역' },
editable: false,
width: 118,
styleName: 'rg-cell-outline-action-btn',
},
{ name: 'laeSbmsnSttsNm', fieldName: 'laeSbmsnSttsNm', header: { text: '진행상황' }, editable: false },
{ name: 'laePicNm', fieldName: 'laePicNm', header: { text: '작성자' }, editable: false },
{ name: 'laePicTelNo', fieldName: 'laePicTelNo', header: { text: '작성자연락처' }, editable: false },
@@ -27,6 +35,7 @@ export const StatsInputStatusInpColumn = {
'sggNm',
'statsCd',
'menuTabName',
'exceptionSubmitHistory',
{
name: 'groupA',
hideChildHeaders: false,
@@ -6,7 +6,7 @@ export const LogHistoryColumn = {
{ name: 'sggNm', fieldName: 'sggNm', header: { text: '시군구' }, editable: false },
{ name: 'statsYr', fieldName: 'statsYr', header: { text: '연도' }, editable: false },
{ name: 'statsLogSn', fieldName: 'statsLogSn', header: { text: '일련번호' }, editable: false, visible: false, valueType: ValueType.NUMBER, dataFormat: '#,##0' },
{ name: 'menuNm', fieldName: 'menuNm', header: { text: '메뉴명' }, editable: false },
{ name: 'menuNm', fieldName: 'menuNm', header: { text: '통계항목' }, editable: false },
{ name: 'tblNm', fieldName: 'tblNm', header: { text: '테이블명' }, visible: false, editable: false },
{ name: 'statsDataChgComNm', fieldName: 'statsDataChgComNm', header: { text: '요청 기능명' }, editable: false },
{ name: 'rgtrId', fieldName: 'rgtrId', header: { text: '등록자' }, editable: false, visible: false },
@@ -9,6 +9,13 @@ const StatsInputStatusInfoColumnRaw = {
{ name: 'sewerBzmnCd', fieldName: 'sewerBzmnCd', header: { text: '사업자코드' }, visible: false },
{ name: 'statsCd', fieldName: 'statsCd', header: { text: '메뉴코드' }, visible: false },
{ name: 'tabNm', fieldName: 'tabNm', header: { text: '구분' }, width: 220 },
{
name: 'reviewHistory',
fieldName: 'reviewHistory',
header: { text: '검토이력' },
width: 118,
styleName: 'rg-cell-outline-action-btn',
},
{ name: 'chargerNm', fieldName: 'chargerNm', header: { text: '제출자' } },
{ name: 'chargerCttpc', fieldName: 'chargerCttpc', header: { text: '연락처' } },
@@ -22,6 +29,7 @@ const StatsInputStatusInfoColumnRaw = {
'ctpvNm',
'sggNm',
'tabNm',
'reviewHistory',
'chargerNm',
'chargerCttpc',
'submitStatusNm',
@@ -7,7 +7,7 @@ import { ValueType } from "realgrid";
*/
/** ST00020003 관종별: 입력 `SeweragePipeInputPipeTypeStatusItem`·합계 `SewagePipePipeTypeFacilityStatusItem`과 동일 (yy1=1996년 이전 … yy30) */
const YY_PIPE_TYPE_FACILITY_MAX = 30;
// const YY_PIPE_TYPE_FACILITY_MAX = 30;
const rawSewagePipeTotList = [
{
@@ -12,6 +12,7 @@ import {
type ImrStatusResponse,
type ReviewerInfoRequest,
type ReviewerInfoResponse,
type ReviewInputSaveRequest,
type ReviewOpinionSaveRequest,
type ReviewOpinionSaveResponse,
type ReviewReturnSaveRequest,
@@ -33,6 +34,9 @@ import {
type StatsInputPeriodListRequest,
type StatsInputPeriodSaveApiResponse,
type StatsInputPeriodSaveRequest,
type StatsInputStatusApproveBatchRequest,
type StatsInputStatusApproveKind,
type StatsInputStatusApproveResponse,
type SubmitExceptionRequest,
type SubmitExceptionResponse,
type SubmitVerifyExceptionRequest,
@@ -214,6 +218,56 @@ export class StatisticsApiService implements StatisticsService {
return response.data;
}
/** 입력 반려 — JSON body는 `ReviewInputSaveRequest`(type 제외) 항목의 배열, `type`은 쿼리 */
public async saveInputReturn(
items: ReviewInputSaveRequest[],
): Promise<ReviewReturnSaveResponse> {
if (items.length === 0) {
throw new Error("saveInputReturn: 항목이 비어 있습니다.");
}
const [first] = items;
if (!first) {
throw new Error("saveInputReturn: 항목이 비어 있습니다.");
}
const type = first.type;
if (!items.every((i) => i.type === type)) {
throw new Error("saveInputReturn: 모든 항목의 type이 같아야 합니다.");
}
const body = items.map(({ type: _t, rjctOpnnCn, ...rest }) => {
const row: Record<string, string> = {
statsYr: rest.statsYr,
sewerBzmnCd: rest.sewerBzmnCd,
statsCd: rest.statsCd,
mdfrId: rest.mdfrId,
};
if (rest.vrfcSn != null && String(rest.vrfcSn).trim() !== "") {
row.vrfcSn = String(rest.vrfcSn);
}
const trimmed = rjctOpnnCn?.trim();
if (trimmed) row.rjctOpnnCn = trimmed;
return row;
});
const response = await stClient.post<ReviewReturnSaveResponse>(
`${ENDPOINT.ST_SERVICE.STATS}/reject`,
body,
{ params: { type } },
);
return response.data;
}
/** 통계 입력 현황 일괄 승인 — 쿼리 `type`(기본 `INPUT`), body는 `{ list }` */
public async approveStatsInputStatus(
param: StatsInputStatusApproveBatchRequest,
type: StatsInputStatusApproveKind = "INPUT",
): Promise<StatsInputStatusApproveResponse> {
const response = await stClient.post<StatsInputStatusApproveResponse>(
`${ENDPOINT.ST_SERVICE.STATS}/approve`,
param.list,
{ params: { type } },
);
return response.data;
}
public async submitReview(
param: ReviewSubmitRequest,
): Promise<ReviewSubmitResponse> {
+6
View File
@@ -74,6 +74,12 @@ export {
type ImrLogResponse,
} from "./model/imr/imr-interface";
export { imrVerificationHistoryColumns } from "./model/imr/imr-verification-history-columns";
export {
type StatsInputStatusApproveBatchRequest,
type StatsInputStatusApproveItem,
type StatsInputStatusApproveKind,
type StatsInputStatusApproveResponse,
} from "./model/statistics-interface";
export { type StatsInputStatusList } from "./model/input/statistics-input-status-interface";
export { userListColumns } from "./model/notice-talk/notice-talk-sending-columns";
export {
@@ -340,6 +340,7 @@ export interface ReviewReturnSaveRequest {
statsCd: string;
rjctOpnnCn: string;
mdfrId: string;
vrfcSn?: string;
}
export interface ReviewReturnSaveResponse {
@@ -349,6 +350,17 @@ export interface ReviewReturnSaveResponse {
updatedStts: string;
}
export interface ReviewInputSaveRequest {
type: "INPUT";
statsYr: string;
sewerBzmnCd: string;
statsCd: string;
/** 미입력·빈 문자열이면 요청 body에 포함하지 않음 */
rjctOpnnCn?: string;
mdfrId: string;
vrfcSn?: string;
}
export interface ReviewSubmitRequest {
statsYr: string;
sewerBzmnCd: string;
@@ -363,6 +375,28 @@ export interface ReviewSubmitResponse {
message?: string;
}
/** 통계 일괄 승인 `/stats/approve` 쿼리 파라미터 `type` */
export type StatsInputStatusApproveKind = "INPUT" | "REVIEW";
/** 통계 입력 현황 승인 요청 항목 (행 단위) */
export interface StatsInputStatusApproveItem {
statsYr: string;
sewerBzmnCd: string;
statsCd: string;
mdfrId: string;
}
/** 통계 입력 현황 일괄 승인 API 요청 본문 */
export interface StatsInputStatusApproveBatchRequest {
list: StatsInputStatusApproveItem[];
}
/** 통계 입력 현황 일괄 승인 API 응답 */
export interface StatsInputStatusApproveResponse {
status: string;
message?: string;
}
export interface SubmitVerifyExceptionItem {
vrfcSn: number;
prcsOpnnExpln: string;
@@ -416,6 +450,11 @@ export interface StatisticsService {
saveReviewOpinion(param: ReviewOpinionSaveRequest): Promise<ReviewOpinionSaveResponse>;
getReviewOpinionDetail(param: getReviewOpinionDetailRequest): Promise<getReviewOpinionDetailResponse>;
saveReviewReturn(param: ReviewReturnSaveRequest): Promise<ReviewReturnSaveResponse>;
saveInputReturn(items: ReviewInputSaveRequest[]): Promise<ReviewReturnSaveResponse>;
approveStatsInputStatus(
param: StatsInputStatusApproveBatchRequest,
type?: StatsInputStatusApproveKind,
): Promise<StatsInputStatusApproveResponse>;
submitReview(param: ReviewSubmitRequest): Promise<ReviewSubmitResponse>;
getVerificationResultList(param: VerificationResultListRequest): Promise<VerificationResultListResponse>;
getImrStatus(param: string): Promise<ImrStatusResponse>;