From 67b3d978a41791365031289f839104020b37868f Mon Sep 17 00:00:00 2001 From: rsone001 Date: Tue, 21 Apr 2026 11:47:27 +0900 Subject: [PATCH 1/3] =?UTF-8?q?[st]=20feat:=20=EA=B2=80=EC=A6=9D=EA=B2=B0?= =?UTF-8?q?=EA=B3=BC=20=EB=B3=B4=EA=B8=B0=20=EC=9E=91=EC=97=85=EC=A4=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../button/VerificationResultButton.tsx | 16 + .../VerificationResultHistoryDialog.tsx | 273 ++++++++++++++++++ .../VerificationResultHistoryPagination.tsx | 99 +++++++ .../input/SeweragePenetrationRateInp.tsx | 126 +++++--- ...tatistics-input-sew-supply-rate-service.ts | 11 +- .../statistics-sewage-pipe-input-service.ts | 6 +- .../statistics/api/statistics-service.ts | 10 + .../statistics-sewage-pipe-input-interface.ts | 11 +- ...tics-sewage-supply-rate-input-interface.ts | 22 +- .../statistics/model/statistics-interface.ts | 62 ++++ 10 files changed, 574 insertions(+), 62 deletions(-) create mode 100644 apps/st/src/components/button/VerificationResultButton.tsx create mode 100644 apps/st/src/components/module/VerificationResultHistoryDialog.tsx create mode 100644 apps/st/src/components/module/VerificationResultHistoryPagination.tsx diff --git a/apps/st/src/components/button/VerificationResultButton.tsx b/apps/st/src/components/button/VerificationResultButton.tsx new file mode 100644 index 000000000..0e0124a99 --- /dev/null +++ b/apps/st/src/components/button/VerificationResultButton.tsx @@ -0,0 +1,16 @@ +import { Button } from "@common/components"; + +interface VerificationResultButtonProps { + VerificationResultButtonFunc: () => void; +} + +const VerificationResultButton = ({ VerificationResultButtonFunc }: VerificationResultButtonProps) => { + return ( + + ); +}; + +export default VerificationResultButton; \ No newline at end of file diff --git a/apps/st/src/components/module/VerificationResultHistoryDialog.tsx b/apps/st/src/components/module/VerificationResultHistoryDialog.tsx new file mode 100644 index 000000000..067a11655 --- /dev/null +++ b/apps/st/src/components/module/VerificationResultHistoryDialog.tsx @@ -0,0 +1,273 @@ +"use client"; + +import { type Dispatch, type SetStateAction, useCallback, useEffect, useMemo, useState } from "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 { ColumnInfo } from "@/components/type/DataGrid.type"; +import { statisticsAPi } from "@/entities/statistics/api/statistics-service"; +import type { VerificationResultListRequest, VerificationResultReportItem } from "@/entities/statistics/model/statistics-interface"; + +type VerificationResultGridRow = Record; + +const PRCS_YN_LABEL: Record = { + "0": "미통과", + "1": "통과", + "2": "예외처리", + "3": "반려", +}; + +const verificationResultColumnInfo: 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 }, + ], + columnLayout: ["ctpvNm", "sggNm", "vrfcTrgtNm", "rsnExpln", "nowVlCn", "compVlCn", "prcsYn", "pbcpAprvYn", "regDt"], +}; + +export interface VerificationResultHistoryDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + queryContext?: { + sewerBzmnCd: string; + ctpvNm: string; + sggNm: string; + statsYr: string; + statsCd: string; + } | null; + title?: string; + emptyMessage?: string; +} + +export function VerificationResultHistoryDialog({ + open, + onOpenChange, + queryContext, + title = "검증 결과 내역", + emptyMessage = "표시할 검증 결과가 없습니다.", +}: VerificationResultHistoryDialogProps) { + const [normalEntries, setNormalEntries] = useState([]); + const [imrEntries, setImrEntries] = useState([]); + 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(null); + + const resetState = useCallback(() => { + setNormalEntries([]); + setImrEntries([]); + setNormalPageIndex(0); + setImrPageIndex(0); + setNormalPageCount(1); + setImrPageCount(1); + setNormalLoading(false); + setImrLoading(false); + setErrorMessage(null); + }, []); + + const fetchVerificationResultPage = useCallback( + async (vrfcMthdCd: "NV" | "IMR", 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: "0,2,3", + page: pageIndex + 1, + size: 30, + }; + return statisticsAPi.getVerificationResultList(param); + }, + [queryContext], + ); + + const loadInitial = useCallback(async () => { + if (!queryContext) { + 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), + ]); + setNormalEntries(nvRes?.data?.verifyResultList ?? []); + setImrEntries(imrRes?.data?.verifyResultList ?? []); + setNormalPageCount(nvRes?.data?.pageInfo?.totalPages ?? 1); + setImrPageCount(imrRes?.data?.pageInfo?.totalPages ?? 1); + } catch { + setNormalEntries([]); + setImrEntries([]); + setNormalPageCount(1); + setImrPageCount(1); + setErrorMessage("검증 결과 내역 조회 중 오류가 발생했습니다."); + } finally { + setNormalLoading(false); + setImrLoading(false); + } + }, [fetchVerificationResultPage, queryContext]); + + const handleNormalPageChange = useCallback( + async (pageIndex: number) => { + if (pageIndex === normalPageIndex || !queryContext) return; + setErrorMessage(null); + setNormalLoading(true); + try { + const response = await fetchVerificationResultPage("NV", pageIndex); + setNormalEntries(response?.data?.verifyResultList ?? []); + setNormalPageCount(response?.data?.pageInfo?.totalPages ?? 1); + setNormalPageIndex(pageIndex); + } catch { + setErrorMessage("일반검증 내역 조회 중 오류가 발생했습니다."); + } finally { + setNormalLoading(false); + } + }, + [fetchVerificationResultPage, normalPageIndex, queryContext], + ); + + const handleImrPageChange = useCallback( + async (pageIndex: number) => { + if (pageIndex === imrPageIndex || !queryContext) return; + setErrorMessage(null); + setImrLoading(true); + try { + const response = await fetchVerificationResultPage("IMR", pageIndex); + setImrEntries(response?.data?.verifyResultList ?? []); + setImrPageCount(response?.data?.pageInfo?.totalPages ?? 1); + setImrPageIndex(pageIndex); + } catch { + setErrorMessage("I-MR 검증내역 조회 중 오류가 발생했습니다."); + } finally { + setImrLoading(false); + } + }, + [fetchVerificationResultPage, imrPageIndex, queryContext], + ); + + useEffect(() => { + if (!open) { + resetState(); + return; + } + void loadInitial(); + }, [loadInitial, open, resetState]); + + const mapRows = (entries: VerificationResultReportItem[]): VerificationResultGridRow[] => + entries.map((item) => ({ + 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), + })); + + const normalRows = useMemo(() => mapRows(normalEntries), [normalEntries]); + const imrRows = useMemo(() => mapRows(imrEntries), [imrEntries]); + + const noopSetRows = ((_: SetStateAction[]>) => {}) as Dispatch< + SetStateAction[]> + >; + + const renderGrid = ( + rows: VerificationResultGridRow[], + pageIndex: number, + pageCount: number, + onPageIndexChange: (page: number) => void, + loading: boolean, + ) => { + if (!loading && rows.length === 0) { + return

{emptyMessage}

; + } + + return ( +
+ + + {loading && ( +
+
+ +
+
+ )} +
+ ); + }; + + return ( + + + + {title} + +
+ {errorMessage && ( +

+ {errorMessage} +

+ )} +
+

일반검증 내역

+ {renderGrid(normalRows, normalPageIndex, normalPageCount, handleNormalPageChange, normalLoading)} +
+
+

I-MR 검증내역

+ {renderGrid(imrRows, imrPageIndex, imrPageCount, handleImrPageChange, imrLoading)} +
+
+ + + + + +
+
+ ); +} diff --git a/apps/st/src/components/module/VerificationResultHistoryPagination.tsx b/apps/st/src/components/module/VerificationResultHistoryPagination.tsx new file mode 100644 index 000000000..e3fa59851 --- /dev/null +++ b/apps/st/src/components/module/VerificationResultHistoryPagination.tsx @@ -0,0 +1,99 @@ +"use client"; + +import { + ChevronLeftIcon, + ChevronRightIcon, + DoubleArrowLeftIcon, + DoubleArrowRightIcon, +} from "@radix-ui/react-icons"; +import { Button, cn } from "@common/components"; + +export type VerificationResultHistoryPaginationProps = { + pageCount: number; + pageIndex: number; + onPageIndexChange: (pageIndex: number) => void; + className?: string; +}; + +export function VerificationResultHistoryPagination({ + pageCount, + pageIndex: currentPage, + onPageIndexChange, + className, +}: VerificationResultHistoryPaginationProps) { + const maxVisiblePages = 10; + let startPage = Math.max(0, currentPage - maxVisiblePages / 2); + const endPage = Math.min(pageCount, startPage + maxVisiblePages); + + if (endPage - startPage < maxVisiblePages) { + startPage = Math.max(0, endPage - maxVisiblePages); + } + + const pageNumbers = Array(endPage - startPage) + .fill(0) + .map((_, i) => startPage + i); + + const canPrevious = currentPage > 0; + const canNext = pageCount > 0 && currentPage < pageCount - 1; + + return ( +
+ + +
+ {pageNumbers.map((pageNumber) => ( + + ))} +
+ + +
+ ); +} diff --git a/apps/st/src/components/statistics/input/SeweragePenetrationRateInp.tsx b/apps/st/src/components/statistics/input/SeweragePenetrationRateInp.tsx index 5b8d9c678..f9ac2a347 100644 --- a/apps/st/src/components/statistics/input/SeweragePenetrationRateInp.tsx +++ b/apps/st/src/components/statistics/input/SeweragePenetrationRateInp.tsx @@ -6,8 +6,9 @@ import DataGrid from "@/components/module/DataGrid"; import SubmitButton from "@/components/button/SumitButton"; import SaveButton from "@/components/button/SaveButton"; import LastYearDataButton from "@/components/button/LastYearDataButton"; -import SubmitExceptionButton from "@/components/button/SubmitExceptionButton"; import CancelSubmitButton from "@/components/button/CancelSubmitButton"; +import VerificationResultButton from "@/components/button/VerificationResultButton"; +import { VerificationResultHistoryDialog } from "@/components/module/VerificationResultHistoryDialog"; import { LoadingWithAnimation } from "@common/components"; import { ConfirmDialog } from "@/shared/ui/confirm-dialog"; import SubmitDailog from "./SubmitDailog"; @@ -30,7 +31,10 @@ import type { SewerageSupplyRateSaveRequest, SewerageSupplyRateStatusInfo, } from "@/entities/statistics/model/input/statistics-sewage-supply-rate-input-interface"; -import type { cancelSubmitRequest, SubmitExceptionRequest } from "@/entities/statistics/model/statistics-interface"; +import type { + cancelSubmitRequest, + SubmitExceptionRequest, +} from "@/entities/statistics/model/statistics-interface"; import type { StatisticsInputSubmitInfoFormData } from "@/entities/statistics/model/statistics-input-submit-info-schema"; import { SeweragePenetrationRateInpColumn } from "@/components/util/column/input/SeweragePenetrationRateInpColumn"; import { doList } from "@/components/util/column/input/DoList"; @@ -69,6 +73,7 @@ const SeweragePenetrationRateInp = () => { const [lastSearchedValues, setLastSearchedValues] = useState(null); const [statusInfo, setStatusInfo] = useState(null); const [showDailog, setShowDailog] = useState(false); + const [verificationHistoryOpen, setVerificationHistoryOpen] = useState(false); /** 예외제출 버튼으로 연 다이얼로그인지 (확인 시 executeSubmit 대신 SubmitExceptionFunc 경로) */ const [isExceptionSubmitFlow, setIsExceptionSubmitFlow] = useState(false); const [statsCd, setStatsCd] = useState(''); @@ -88,6 +93,7 @@ const SeweragePenetrationRateInp = () => { }); // 최상위 관리자일땐 모든 상황에서 edit 가능하게 수정 필요. + const columnInfo = useMemo(() => { const { columns, columnLayout } = SeweragePenetrationRateInpColumn; const base = (isEnterable || isAdminInfo) ? columns : columns.map((col) => ({ ...col, editable: false as const })); @@ -348,25 +354,31 @@ const SeweragePenetrationRateInp = () => { const response = await statisticsInputSewSupplyRateApi.submitSewerageSupplyRate(requestData); if (response.status === 'success') { - const imrParam = { - user_id: userInfo.userUnqId, - sewer_bzmn_cd: searchValues?.bsnmCd ?? '', - stats_cd: statsCd, - stats_yr: searchValues?.year ?? '', - lae_sbmsn_stts_cd: (statusInfo?.laeSbmsnSttsCd === '4' ? '4' : '2'), - }; + + if (response.data.finalStatus === '5') { + const imrParam = { + user_id: userInfo.userUnqId, + sewer_bzmn_cd: searchValues?.bsnmCd ?? '', + stats_cd: statsCd, + stats_yr: searchValues?.year ?? '', + lae_sbmsn_stts_cd: (statusInfo?.laeSbmsnSttsCd === '4' ? '4' : '2'), + gen_verify_stts_cd: response.data.nvpassed ? '1' : '2', + }; - try { - await statisticsAPi.callImrService(imrParam); - } catch { - await fetchSewerageSupplyRate(getSewerageSupplyRateParams(searchValues ?? {}), true); - setIsLoading(false); - alert('IMR 검증 중 오류가 발생했습니다.\n관리자에게 문의하세요.'); - return; + try { + await statisticsAPi.callImrService(imrParam); + await fetchSewerageSupplyRate(getSewerageSupplyRateParams(searchValues ?? {}), true); + alert('제출되었습니다.'); + } catch { + await fetchSewerageSupplyRate(getSewerageSupplyRateParams(searchValues ?? {}), true); + setIsLoading(false); + alert('IMR 검증 중 오류가 발생했습니다.\n관리자에게 문의하세요.'); + return; + } + } else { + await fetchSewerageSupplyRate(getSewerageSupplyRateParams(searchValues ?? {}), false); + alert('제출되었습니다.'); } - - await fetchSewerageSupplyRate(getSewerageSupplyRateParams(searchValues ?? {}), false); - alert('제출되었습니다.'); } else { alert(response.message || '데이터 제출 중 오류가 발생했습니다.'); } @@ -465,25 +477,31 @@ const SeweragePenetrationRateInp = () => { const response = await statisticsInputSewSupplyRateApi.saveSewerageSupplyRate(requestData); if (response.status === 'success') { - // const imrParam = { - // user_id: userInfo.userUnqId, - // sewer_bzmn_cd: searchValues?.bsnmCd ?? '', - // stats_cd: statsCd, - // stats_yr: searchValues?.year ?? '', - // lae_sbmsn_stts_cd: (statusInfo?.laeSbmsnSttsCd === '4' ? '3' : '1'), - // }; - - // try { - // await statisticsAPi.callImrService(imrParam); - // } catch { - // await fetchSewerageSupplyRate(getSewerageSupplyRateParams(searchValues ?? {}), true); - // setIsLoading(false); - // alert('IMR 검증 중 오류가 발생했습니다.\n관리자에게 문의하세요.'); - // return; - // } - - await fetchSewerageSupplyRate(getSewerageSupplyRateParams(searchValues ?? {}), true); - alert('저장되었습니다.'); + + if (response.data === '5') { + const imrParam = { + user_id: userInfo.userUnqId, + sewer_bzmn_cd: searchValues?.bsnmCd ?? '', + stats_cd: statsCd, + stats_yr: searchValues?.year ?? '', + lae_sbmsn_stts_cd: (statusInfo?.laeSbmsnSttsCd === '4' ? '3' : '1'), + gen_verify_stts_cd: '1', + }; + + try { + await statisticsAPi.callImrService(imrParam); + await fetchSewerageSupplyRate(getSewerageSupplyRateParams(searchValues ?? {}), true); + alert('저장되었습니다.'); + } catch { + await fetchSewerageSupplyRate(getSewerageSupplyRateParams(searchValues ?? {}), true); + setIsLoading(false); + alert('IMR 검증 중 오류가 발생했습니다.\n관리자에게 문의하세요.'); + return; + } + } else { + await fetchSewerageSupplyRate(getSewerageSupplyRateParams(searchValues ?? {}), true); + alert('저장되었습니다.'); + } } else { alert(response.message || '데이터 저장 중 오류가 발생했습니다.'); } @@ -526,6 +544,14 @@ const SeweragePenetrationRateInp = () => { } }; + const openVerificationHistoryDialog = () => { + if (!searchValues?.year || !searchValues?.sido || !searchValues?.bsnmCd || !statsCd) { + alert("먼저 조회를 진행해주세요."); + return; + } + setVerificationHistoryOpen(true); + }; + const gugunOkOrNotDoList = doList.includes(lastSearchedValues?.bsnmCd || '') ? Boolean(lastSearchedValues?.gugun && lastSearchedValues.gugun !== 'ALL') : true; @@ -547,12 +573,11 @@ const SeweragePenetrationRateInp = () => {
{showEnterableToolbarButtons && ( <> - {isAdminInfo && statusInfo && statusInfo.laeSbmsnSttsCd !== '0' && ( - submitFunc(true)} /> - )} + getLastYearDataFunc(searchValues ?? {})} /> submitFunc(false)} /> - )} {statusInfo?.laeSbmsnSttsCd === '2' && } @@ -593,7 +618,7 @@ const SeweragePenetrationRateInp = () => { return errors .map((e) => { const prefix = e.vrfcCd === 'IMR' ? 'IMR검증 - ' : '일반검증 - '; - return `${prefix}${e.rsnExpln}`; + return `${prefix}${e.rsnExpln}(검증 기준값: ${e.compVlCn} / 현재 입력값: ${e.nowVlCn})`; }) .filter(Boolean) .join('\n'); @@ -614,6 +639,21 @@ const SeweragePenetrationRateInp = () => { submitMode={isExceptionSubmitFlow ? 'exception' : 'normal'} isLoading={isLoading} /> + ); }; diff --git a/apps/st/src/entities/statistics/api/input/statistics-input-sew-supply-rate-service.ts b/apps/st/src/entities/statistics/api/input/statistics-input-sew-supply-rate-service.ts index 32dfb9211..6adccc917 100644 --- a/apps/st/src/entities/statistics/api/input/statistics-input-sew-supply-rate-service.ts +++ b/apps/st/src/entities/statistics/api/input/statistics-input-sew-supply-rate-service.ts @@ -7,7 +7,8 @@ import { type SewerageSupplyRateApiResponse, type CheckInputPeriodResponse, type SewerageSupplyRateSaveRequest, - type SewerageSupplyRateSaveApiResponse, + type SewerageSupplyRateSubmitResponse, + type SewerageSupplyRateSaveResponse, } from "../../model/input/statistics-sewage-supply-rate-input-interface"; export class StatisticsInputSewSupplyRateApiService implements StatisticsInputSewSupplyRateService { @@ -26,16 +27,16 @@ export class StatisticsInputSewSupplyRateApiService implements StatisticsInputSe return response.data; } - public async saveSewerageSupplyRate(params: SewerageSupplyRateSaveRequest): Promise { - const response = await stClient.post( + public async saveSewerageSupplyRate(params: SewerageSupplyRateSaveRequest): Promise { + const response = await stClient.post( `${ENDPOINT.ST_SERVICE.INPUT}/sewerage-supply-rate/save`, params, ); return response.data; } - public async submitSewerageSupplyRate(params: SewerageSupplyRateSaveRequest): Promise { - const response = await stClient.post( + public async submitSewerageSupplyRate(params: SewerageSupplyRateSaveRequest): Promise { + const response = await stClient.post( `${ENDPOINT.ST_SERVICE.INPUT}/sewerage-supply-rate/submit`, params, ); diff --git a/apps/st/src/entities/statistics/api/input/statistics-sewage-pipe-input-service.ts b/apps/st/src/entities/statistics/api/input/statistics-sewage-pipe-input-service.ts index af6aad7fb..02d0144c4 100644 --- a/apps/st/src/entities/statistics/api/input/statistics-sewage-pipe-input-service.ts +++ b/apps/st/src/entities/statistics/api/input/statistics-sewage-pipe-input-service.ts @@ -11,7 +11,7 @@ import type { SeweragePipeInputPipeDiameterResponse, SeweragePipeInputPipeTypeResponse, SeweragePipeInputRegionalFactStatusSaveRequest, - SeweragePipeInputRegionalFactStatusSaveApiResponse, + SeweragePipeInputRegionalFactStatusSaveResponse, SeweragePipeInputRegionalResponse, SeweragePipeRequestParams, StatisticsInputSewPipeService, @@ -82,8 +82,8 @@ export class StatisticsInputSewPipeApiService implements StatisticsInputSewPipeS return response.data; } - public async saveRegionalFactStatus(param: SeweragePipeInputRegionalFactStatusSaveRequest): Promise { - const response = await stClient.post( + public async saveRegionalFactStatus(param: SeweragePipeInputRegionalFactStatusSaveRequest): Promise { + const response = await stClient.post( `${ENDPOINT.ST_SERVICE.INPUT}/sewage-pipe/regional-facility-status/save`, param, ); diff --git a/apps/st/src/entities/statistics/api/statistics-service.ts b/apps/st/src/entities/statistics/api/statistics-service.ts index c709e1c9e..3bc051782 100644 --- a/apps/st/src/entities/statistics/api/statistics-service.ts +++ b/apps/st/src/entities/statistics/api/statistics-service.ts @@ -22,6 +22,8 @@ import { type ReviewReturnSaveResponse, type ReviewSubmitRequest, type ReviewSubmitResponse, + type VerificationResultListRequest, + type VerificationResultListResponse, } from "../model/statistics-interface"; export class StatisticsAPiService implements StatisticsService { @@ -121,6 +123,14 @@ export class StatisticsAPiService implements StatisticsService { ); return response.data; } + + public async getVerificationResultList(param: VerificationResultListRequest): Promise { + const response = await stClient.get( + `${ENDPOINT.ST_SERVICE.INPUTVERIFY}/results`, + { params: param }, + ); + return response.data; + } } export const statisticsAPi = new StatisticsAPiService(); \ No newline at end of file diff --git a/apps/st/src/entities/statistics/model/input/statistics-sewage-pipe-input-interface.ts b/apps/st/src/entities/statistics/model/input/statistics-sewage-pipe-input-interface.ts index 79f9fd1db..299078872 100644 --- a/apps/st/src/entities/statistics/model/input/statistics-sewage-pipe-input-interface.ts +++ b/apps/st/src/entities/statistics/model/input/statistics-sewage-pipe-input-interface.ts @@ -1,5 +1,3 @@ -import type { SewerageSupplyRateSaveApiResponse } from "./statistics-sewage-supply-rate-input-interface"; - export interface SeweragePipeRequestParams { ctpvNm?: string; sggNm?: string; @@ -119,8 +117,11 @@ export interface SeweragePipeInputRegionalFactStatusSaveRequest { saveList: SeweragePipeInputRegionalFactStatusSaveItem[]; } -/** 지역별 시설현황 저장 API 응답 — 보급률 저장과 동일 래핑 */ -export type SeweragePipeInputRegionalFactStatusSaveApiResponse = SewerageSupplyRateSaveApiResponse; +export interface SeweragePipeInputRegionalFactStatusSaveResponse { + data: string; + message?: string; + status: string; +} /** 시설별 설치 현황 — 목록 항목 */ export interface SeweragePipeInputFactInstallStatusItem { @@ -519,5 +520,5 @@ export interface StatisticsInputSewPipeService { getFacilityAdminAreaList(param: SeweragePipeFacilityAdminAreaListRequest): Promise; saveRegionalFactStatus( param: SeweragePipeInputRegionalFactStatusSaveRequest, - ): Promise; + ): Promise; } \ No newline at end of file diff --git a/apps/st/src/entities/statistics/model/input/statistics-sewage-supply-rate-input-interface.ts b/apps/st/src/entities/statistics/model/input/statistics-sewage-supply-rate-input-interface.ts index 59481dc5b..1c14dfab8 100644 --- a/apps/st/src/entities/statistics/model/input/statistics-sewage-supply-rate-input-interface.ts +++ b/apps/st/src/entities/statistics/model/input/statistics-sewage-supply-rate-input-interface.ts @@ -36,6 +36,8 @@ export interface SewerageSupplyRateCommonInfo { export interface VerificationResultItem { vrfcCd: string; rsnExpln: string; + nowVlCn: string; + compVlCn: string; } /** 필드명 → 검증 결과 배열. 예: { popltnSm: [{ type, rsn }], wstrPrmTrt: [...] } */ @@ -150,12 +152,20 @@ export interface SewerageSupplyRateSaveRequest { superAdmin?: string; } -export interface SewerageSupplyRateSaveData { - httpStatus?: number; - code?: string; +export interface SewerageSupplyRateSaveResponse { + data: string; + message?: string; + status: string; } -export type SewerageSupplyRateSaveApiResponse = ApiResponse; +export interface SewerageSupplyRateSubmitResponse { + data: { + finalStatus: string; + nvpassed: boolean; + } + message?: string; + status: string; +} // 하수도 통계 입력 기간 조회 export interface CheckInputPeriodResponse { @@ -167,6 +177,6 @@ export interface CheckInputPeriodResponse { export interface StatisticsInputSewSupplyRateService { getSewerageSupplyRateList(params: SewerageSupplyRateRequestParams): Promise; checkInputPeriod(params: string): Promise; - saveSewerageSupplyRate(params: SewerageSupplyRateSaveRequest): Promise; - submitSewerageSupplyRate(params: SewerageSupplyRateSaveRequest): Promise; + saveSewerageSupplyRate(params: SewerageSupplyRateSaveRequest): Promise; + submitSewerageSupplyRate(params: SewerageSupplyRateSaveRequest): Promise; } \ No newline at end of file diff --git a/apps/st/src/entities/statistics/model/statistics-interface.ts b/apps/st/src/entities/statistics/model/statistics-interface.ts index c20b175e0..800849c5e 100644 --- a/apps/st/src/entities/statistics/model/statistics-interface.ts +++ b/apps/st/src/entities/statistics/model/statistics-interface.ts @@ -41,6 +41,67 @@ export interface ImrRequest { stats_cd: string; stats_yr: string; lae_sbmsn_stts_cd: string; + gen_verify_stts_cd?: string; +} + +export interface VerificationResultListRequest { + statsYr: string; + sewerBzmnCd?: string; + ctpvNm?: string; + sggNm?: string; + statsCd?: string; + /** 검증 방법 코드 (예: IMR) */ + vrfcMthdCd?: string; + /** 처리여부 코드 목록, 콤마 구분 */ + prcsYnList?: string; + pbcpAprvYn?: string; + page?: number; + size?: number; +} + +/** 검증 결과 리포트 목록 한 행 (`verifyResultList` 원소) */ +export interface VerificationResultReportItem { + statsYr: string; + statsCd: string; + statsNm: string; + sewerBzmnCd: string; + ctpvNm: string; + sggNm: string; + admdstNm: string; + vrfcSn: number; + vrfcMthdCd: string; + vrfcMthdNm: string; + vrfcTrgtNm: string; + regDt: string; + rsnExpln: string; + nowVlCn: string; + compVlCn: string | null; + prcsYn: string; + prcsOpnnExpln: string | null; + prcrId: string | null; + rjctOpnnCn: string | null; + pbcpAprvYn: string; + mdfrId: string | null; + mdfcnDt: string; +} + +export interface VerificationResultListPageInfo { + totalElements: number; + totalPages: number; + currentPage: number; + size: number; +} + +export interface VerificationResultListData { + verifyResultList: VerificationResultReportItem[]; + pageInfo: VerificationResultListPageInfo; +} + +/** 검증 결과 리포트 조회 API 응답 */ +export interface VerificationResultListResponse { + status: string; + message?: string; + data?: VerificationResultListData; } export interface SubmitExceptionRequest { @@ -196,4 +257,5 @@ export interface StatisticsService { getReviewOpinionDetail(param: getReviewOpinionDetailRequest): Promise; saveReviewReturn(param: ReviewReturnSaveRequest): Promise; submitReview(param :ReviewSubmitRequest): Promise; + getVerificationResultList(param: VerificationResultListRequest): Promise; } \ No newline at end of file From 9c7e71e65a6d7c0aee4044f3ae0a9032c67ff5cd Mon Sep 17 00:00:00 2001 From: rsone001 Date: Tue, 21 Apr 2026 17:57:00 +0900 Subject: [PATCH 2/3] =?UTF-8?q?[st]=20feat:=20=EC=9E=85=EB=A0=A5=20-=20?= =?UTF-8?q?=EC=A0=9C=EC=B6=9C=20=EC=8B=9C=20imr=20=EC=9A=94=EC=B2=AD=20?= =?UTF-8?q?=EB=B0=8F=20=EC=83=81=ED=83=9C=20=ED=99=95=EC=9D=B8,=20?= =?UTF-8?q?=EC=A7=84=ED=96=89=20progress=20bar=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/st/src/app/providers/app-provider.tsx | 2 + .../button/VerificationResultButton.tsx | 4 +- apps/st/src/components/css/DataGrid.css | 12 +- apps/st/src/components/module/SearchForm.tsx | 6 +- .../module/SidoSggFacilityNameSearchForm.tsx | 6 +- .../module/VerificationResultDialog.tsx | 40 +++++++ .../VerificationResultHistoryDialog.tsx | 11 +- .../input/SeweragePenetrationRateInp.tsx | 109 ++++++++++++------ .../statistics/management/LogHistory.tsx | 8 +- .../management/LogHistoryDetailDialog.tsx | 4 +- .../review/SeweragePenetrationRateRvw.tsx | 10 +- .../statistics/api/statistics-service.ts | 17 ++- .../statistics/model/statistics-interface.ts | 27 ++++- apps/st/src/shared/lib/index.ts | 1 + .../shared/lib/use-global-progress-store.ts | 16 +++ .../src/shared/providers/store-provider.tsx | 3 + .../src/shared/store/global-progress-store.ts | 45 ++++++++ apps/st/src/shared/store/index.ts | 1 + .../src/shared/ui/global-progress-overlay.tsx | 23 ++++ 19 files changed, 273 insertions(+), 72 deletions(-) create mode 100644 apps/st/src/components/module/VerificationResultDialog.tsx create mode 100644 apps/st/src/shared/lib/use-global-progress-store.ts create mode 100644 apps/st/src/shared/store/global-progress-store.ts create mode 100644 apps/st/src/shared/ui/global-progress-overlay.tsx diff --git a/apps/st/src/app/providers/app-provider.tsx b/apps/st/src/app/providers/app-provider.tsx index 70e69eee8..4e64d241c 100644 --- a/apps/st/src/app/providers/app-provider.tsx +++ b/apps/st/src/app/providers/app-provider.tsx @@ -12,6 +12,7 @@ import { AlertDialog } from "@/shared/ui/alert-dialog"; import { PromptDialog } from "@/shared/ui/prompt-dialog"; import { ConfirmDialog } from "@/shared/ui/confirm-dialog"; import { SelectPromptDialog } from "@/shared/ui/select-prompt-dialog"; +import { GlobalProgressOverlay } from "@/shared/ui/global-progress-overlay"; import { PromptProvider, StoreProvider } from "@/shared/providers"; import { AlertDialog as RsoneAlertDialog } from "@rsone/components"; @@ -36,6 +37,7 @@ const AppProvider = ({ children }: Readonly<{ children: React.ReactNode }>) => { + diff --git a/apps/st/src/components/button/VerificationResultButton.tsx b/apps/st/src/components/button/VerificationResultButton.tsx index 0e0124a99..580f6d0d0 100644 --- a/apps/st/src/components/button/VerificationResultButton.tsx +++ b/apps/st/src/components/button/VerificationResultButton.tsx @@ -6,9 +6,9 @@ interface VerificationResultButtonProps { const VerificationResultButton = ({ VerificationResultButtonFunc }: VerificationResultButtonProps) => { return ( - ); }; diff --git a/apps/st/src/components/css/DataGrid.css b/apps/st/src/components/css/DataGrid.css index 8d56c48f8..c1b346ad5 100644 --- a/apps/st/src/components/css/DataGrid.css +++ b/apps/st/src/components/css/DataGrid.css @@ -16,12 +16,6 @@ @apply !bg-[#5987bf] !text-[#fff]; } - .rg-grid .rg-header .rg-table tr td, - .rg-grid .rg-head .rg-table tr td { - border: none; - border-bottom: 1px solid #e5e7eb; - } - .rg-grid .rg-body .rg-table .rg-data-row .rg-data-cell { @apply !bg-white; } @@ -138,4 +132,10 @@ .rg-grid .rg-body .rg-table tr.rg-alternate-row td, .rg-grid .rg-body .rg-table tr.rg-alternate-row .rg-data-cell { background-color: #ffffff !important; +} + +.rg-grid .rg-header .rg-table tr td, +.rg-grid .rg-head .rg-table tr td { + border: none; + border-bottom: 1px solid #e5e7eb; } \ No newline at end of file diff --git a/apps/st/src/components/module/SearchForm.tsx b/apps/st/src/components/module/SearchForm.tsx index 4d16417ed..b5cdb06a4 100644 --- a/apps/st/src/components/module/SearchForm.tsx +++ b/apps/st/src/components/module/SearchForm.tsx @@ -5,7 +5,7 @@ import { useEffect, useState } from "react"; import useSWR from "swr"; import { useAlertStore, useUserStore } from "@/shared/lib"; -import { statisticsAPi } from "@/entities/statistics/api/statistics-service"; +import { statisticsApi } from "@/entities/statistics/api/statistics-service"; import type { ApiResponse, SearchCondition, @@ -33,14 +33,14 @@ import { doList } from "../util/column/input/DoList"; const getSidoListFetcher = async ( key: [string, string], ): Promise> => { - return statisticsAPi.getSidoList({ instSeCd: key[1] }); + return statisticsApi.getSidoList({ instSeCd: key[1] }); }; const getSggListFetcher = async ( key: [string, string, string, string], ): Promise> => { const gugunSkipYn = doList.includes(key[3]) ? "N" : "Y"; - return statisticsAPi.getSggAllList(key[1], { + return statisticsApi.getSggAllList(key[1], { instSeCd: key[2], gugunSkipYn, }); diff --git a/apps/st/src/components/module/SidoSggFacilityNameSearchForm.tsx b/apps/st/src/components/module/SidoSggFacilityNameSearchForm.tsx index 13b436ce4..d8647037e 100644 --- a/apps/st/src/components/module/SidoSggFacilityNameSearchForm.tsx +++ b/apps/st/src/components/module/SidoSggFacilityNameSearchForm.tsx @@ -19,7 +19,7 @@ import CompactDataGrid from "./CompactDataGrid"; import SaveButton from "../button/SaveButton"; import { useAlertStore } from "@/shared/lib"; -import { statisticsAPi } from "@/entities/statistics/api/statistics-service"; +import { statisticsApi } from "@/entities/statistics/api/statistics-service"; import type { ApiResponse, SearchCondition, @@ -91,13 +91,13 @@ const adminRegionColumnInfo: ColumnInfo = { }; const getSidoListFetcher = async (): Promise> => { - return statisticsAPi.getSidoList(); + return statisticsApi.getSidoList(); }; const getSggAllListFetcher = async ( key: [string, string], ): Promise> => { - return statisticsAPi.getSggAllList(key[1]); + return statisticsApi.getSggAllList(key[1]); }; type SidoSggFacilityNameSearchFormProps = { diff --git a/apps/st/src/components/module/VerificationResultDialog.tsx b/apps/st/src/components/module/VerificationResultDialog.tsx new file mode 100644 index 000000000..5e11dd6ac --- /dev/null +++ b/apps/st/src/components/module/VerificationResultDialog.tsx @@ -0,0 +1,40 @@ +"use client"; + +import { + Button, + Dialog, + DialogClose, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@common/components"; + +export interface VerificationResultDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export function VerificationResultDialog({ + open, + onOpenChange, +}: VerificationResultDialogProps) { + return ( + + + + 검증 결과 + +
+ + + + + + +
+ ); +} + diff --git a/apps/st/src/components/module/VerificationResultHistoryDialog.tsx b/apps/st/src/components/module/VerificationResultHistoryDialog.tsx index 067a11655..3be4a6a9e 100644 --- a/apps/st/src/components/module/VerificationResultHistoryDialog.tsx +++ b/apps/st/src/components/module/VerificationResultHistoryDialog.tsx @@ -14,7 +14,7 @@ import { import DataGrid from "@/components/module/DataGrid"; import { VerificationResultHistoryPagination } from "@/components/module/VerificationResultHistoryPagination"; import type { ColumnInfo } from "@/components/type/DataGrid.type"; -import { statisticsAPi } from "@/entities/statistics/api/statistics-service"; +import { statisticsApi } from "@/entities/statistics/api/statistics-service"; import type { VerificationResultListRequest, VerificationResultReportItem } from "@/entities/statistics/model/statistics-interface"; type VerificationResultGridRow = Record; @@ -37,8 +37,9 @@ const verificationResultColumnInfo: ColumnInfo = { { 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 }, ], - columnLayout: ["ctpvNm", "sggNm", "vrfcTrgtNm", "rsnExpln", "nowVlCn", "compVlCn", "prcsYn", "pbcpAprvYn", "regDt"], + columnLayout: ["ctpvNm", "sggNm", "vrfcTrgtNm", "rsnExpln", "nowVlCn", "compVlCn", "prcsYn", "pbcpAprvYn", "regDt", "mdfcnDt"], }; export interface VerificationResultHistoryDialogProps { @@ -93,12 +94,13 @@ export function VerificationResultHistoryDialog({ sggNm: queryContext.sggNm, statsYr: queryContext.statsYr, statsCd: queryContext.statsCd, + pbcpAprvYn: 'N', vrfcMthdCd, prcsYnList: "0,2,3", page: pageIndex + 1, size: 30, }; - return statisticsAPi.getVerificationResultList(param); + return statisticsApi.getVerificationResultList(param); }, [queryContext], ); @@ -191,6 +193,7 @@ export function VerificationResultHistoryDialog({ prcsYn: PRCS_YN_LABEL[item.prcsYn] ?? item.prcsYn, pbcpAprvYn: item.pbcpAprvYn, regDt: (item.regDt ?? "").slice(0, 16), + mdfcnDt: (item.mdfcnDt ?? "").slice(0, 16), })); const normalRows = useMemo(() => mapRows(normalEntries), [normalEntries]); @@ -243,7 +246,7 @@ export function VerificationResultHistoryDialog({ - {title} + {`${title} - ${queryContext?.statsYr} ${queryContext?.ctpvNm} ${queryContext?.sggNm}`}
{errorMessage && ( diff --git a/apps/st/src/components/statistics/input/SeweragePenetrationRateInp.tsx b/apps/st/src/components/statistics/input/SeweragePenetrationRateInp.tsx index f9ac2a347..c37700dae 100644 --- a/apps/st/src/components/statistics/input/SeweragePenetrationRateInp.tsx +++ b/apps/st/src/components/statistics/input/SeweragePenetrationRateInp.tsx @@ -9,14 +9,15 @@ import LastYearDataButton from "@/components/button/LastYearDataButton"; import CancelSubmitButton from "@/components/button/CancelSubmitButton"; import VerificationResultButton from "@/components/button/VerificationResultButton"; import { VerificationResultHistoryDialog } from "@/components/module/VerificationResultHistoryDialog"; +import { VerificationResultDialog } from "@/components/module/VerificationResultDialog"; import { LoadingWithAnimation } from "@common/components"; import { ConfirmDialog } from "@/shared/ui/confirm-dialog"; import SubmitDailog from "./SubmitDailog"; -import { useAlertStore } from "@/shared/lib"; +import { useAlertStore, useGlobalProgressStore } from "@/shared/lib"; import { useAdminStore } from "@/store/admin-store"; import { statisticsInputSewSupplyRateApi } from "@/entities/statistics/api/input/statistics-input-sew-supply-rate-service"; -import { statisticsAPi } from "@/entities/statistics/api/statistics-service"; +import { statisticsApi } from "@/entities/statistics/api/statistics-service"; import { toGridRows } from "@/components/util/grid/gridFunction"; import { getFooterSummary } from "@/components/util/grid/footerSummary"; import { getUserInfo } from "../../../../../../packages/common/components/src/api"; @@ -33,6 +34,7 @@ import type { } from "@/entities/statistics/model/input/statistics-sewage-supply-rate-input-interface"; import type { cancelSubmitRequest, + ImrStatusResponse, SubmitExceptionRequest, } from "@/entities/statistics/model/statistics-interface"; import type { StatisticsInputSubmitInfoFormData } from "@/entities/statistics/model/statistics-input-submit-info-schema"; @@ -49,6 +51,32 @@ const hasVerificationErrors = (verification?: SewerageSupplyRateVerificationResu return Object.values(verification).some((errors) => Array.isArray(errors) && errors.length > 0); }; +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +/** IMR 상태가 COMPLETED 이고 job_prog 가 100일 때까지 폴링 */ +async function waitForImrJobCompleted( + jobId: string, + getStatus: (id: string) => Promise, + options?: { + pollIntervalMs?: number; + maxAttempts?: number; + onProgress?: (jobProgress: number) => void; + }, +): Promise { + const pollIntervalMs = options?.pollIntervalMs ?? 2000; + const maxAttempts = options?.maxAttempts ?? 150; + for (let i = 0; i < maxAttempts; i++) { + const res = await getStatus(jobId); + options?.onProgress?.(Math.max(0, Math.min(100, Number(res.job_prog) || 0))); + if (res.status === "COMPLETED" && Number(res.job_prog) === 100) return; + if (res.status === "FAILED" || res.status === "ERROR" || res.status === "CANCELLED") { + throw new Error(res.error_msg || `IMR 검증이 ${res.status} 상태로 종료되었습니다.`); + } + await sleep(pollIntervalMs); + } + throw new Error("IMR 검증 완료 대기 시간이 초과되었습니다."); +} + function getSewerageSupplyRateParams( values: SearchFormValues, overrides?: { yr?: string; demoYr?: string; mode?: string } @@ -74,11 +102,16 @@ const SeweragePenetrationRateInp = () => { const [statusInfo, setStatusInfo] = useState(null); const [showDailog, setShowDailog] = useState(false); const [verificationHistoryOpen, setVerificationHistoryOpen] = useState(false); + const [verificationResultDialogOpen, setVerificationResultDialogOpen] = useState(false); /** 예외제출 버튼으로 연 다이얼로그인지 (확인 시 executeSubmit 대신 SubmitExceptionFunc 경로) */ const [isExceptionSubmitFlow, setIsExceptionSubmitFlow] = useState(false); const [statsCd, setStatsCd] = useState(''); const [footerFromApi, setFooterFromApi] = useState<{ sewerBzmnWholPpltnCnt?: number; sewerBzmnDomplWholPpltnCnt?: number; sewerBzmnFrgnrWholPpltnCnt?: number; sewerBzmnArea?: number } | null>(null); const { confirm } = useConfirm(); + const showGlobalProgress = useGlobalProgressStore((state) => state.show); + const setGlobalProgress = useGlobalProgressStore((state) => state.setProgress); + const hideGlobalProgress = useGlobalProgressStore((state) => state.hide); + const globalProgressVisible = useGlobalProgressStore((state) => state.visible); const { isAdminInfo } = useAdminStore(); @@ -218,7 +251,7 @@ const SeweragePenetrationRateInp = () => { try { setIsLoading(true); - const response = await statisticsAPi.submitException(params); + const response = await statisticsApi.submitException(params); if (response.status === 'success') { await fetchSewerageSupplyRate(getSewerageSupplyRateParams(searchValues ?? {}), false); @@ -277,7 +310,7 @@ const SeweragePenetrationRateInp = () => { const executeSubmit = async (submitInfo: StatisticsInputSubmitInfoFormData) => { try { - setIsLoading(true); + showGlobalProgress({ label: "제출 진행 중...", progress: 0 }); const userInfo = await getUserInfo(); const currentData = getGridDataRef.current?.(); @@ -354,6 +387,7 @@ const SeweragePenetrationRateInp = () => { const response = await statisticsInputSewSupplyRateApi.submitSewerageSupplyRate(requestData); if (response.status === 'success') { + setGlobalProgress(50); if (response.data.finalStatus === '5') { const imrParam = { @@ -366,16 +400,32 @@ const SeweragePenetrationRateInp = () => { }; try { - await statisticsAPi.callImrService(imrParam); + const imrResponse = await statisticsApi.callImrService(imrParam); + if (imrResponse.job_id) { + await waitForImrJobCompleted(imrResponse.job_id, (id) => + statisticsApi.getImrStatus(id), + { + onProgress: (jobProgress) => { + setGlobalProgress(50 + Math.round(jobProgress / 2)); + }, + }, + ); + } + setGlobalProgress(100); await fetchSewerageSupplyRate(getSewerageSupplyRateParams(searchValues ?? {}), true); - alert('제출되었습니다.'); - } catch { + setVerificationResultDialogOpen(true); + } catch (e) { await fetchSewerageSupplyRate(getSewerageSupplyRateParams(searchValues ?? {}), true); setIsLoading(false); - alert('IMR 검증 중 오류가 발생했습니다.\n관리자에게 문의하세요.'); + const msg = + e instanceof Error && e.message + ? e.message + : 'IMR 검증 중 오류가 발생했습니다.\n관리자에게 문의하세요.'; + alert(msg); return; } } else { + setGlobalProgress(100); await fetchSewerageSupplyRate(getSewerageSupplyRateParams(searchValues ?? {}), false); alert('제출되었습니다.'); } @@ -385,14 +435,14 @@ const SeweragePenetrationRateInp = () => { } catch { alert('데이터 제출 중 오류가 발생했습니다.'); } finally { - setIsLoading(false); + hideGlobalProgress(); } }; const saveFunc = async () => { try { if (await confirm('저장 하시겠습니까?')) { - setIsLoading(true); + showGlobalProgress({ label: "저장 진행 중...", progress: 0 }); const userInfo = await getUserInfo(); const currentData = getGridDataRef.current?.(); @@ -477,31 +527,10 @@ const SeweragePenetrationRateInp = () => { const response = await statisticsInputSewSupplyRateApi.saveSewerageSupplyRate(requestData); if (response.status === 'success') { - - if (response.data === '5') { - const imrParam = { - user_id: userInfo.userUnqId, - sewer_bzmn_cd: searchValues?.bsnmCd ?? '', - stats_cd: statsCd, - stats_yr: searchValues?.year ?? '', - lae_sbmsn_stts_cd: (statusInfo?.laeSbmsnSttsCd === '4' ? '3' : '1'), - gen_verify_stts_cd: '1', - }; - - try { - await statisticsAPi.callImrService(imrParam); - await fetchSewerageSupplyRate(getSewerageSupplyRateParams(searchValues ?? {}), true); - alert('저장되었습니다.'); - } catch { - await fetchSewerageSupplyRate(getSewerageSupplyRateParams(searchValues ?? {}), true); - setIsLoading(false); - alert('IMR 검증 중 오류가 발생했습니다.\n관리자에게 문의하세요.'); - return; - } - } else { - await fetchSewerageSupplyRate(getSewerageSupplyRateParams(searchValues ?? {}), true); - alert('저장되었습니다.'); - } + setGlobalProgress(50); + setGlobalProgress(100); + await fetchSewerageSupplyRate(getSewerageSupplyRateParams(searchValues ?? {}), true); + alert('저장되었습니다.'); } else { alert(response.message || '데이터 저장 중 오류가 발생했습니다.'); } @@ -511,7 +540,7 @@ const SeweragePenetrationRateInp = () => { } catch { alert('데이터 저장 중 오류가 발생했습니다.'); } finally { - setIsLoading(false); + hideGlobalProgress(); } }; @@ -530,7 +559,7 @@ const SeweragePenetrationRateInp = () => { statsCd: statsCd, }; - const response = await statisticsAPi.cancelSubmit(requestData); + const response = await statisticsApi.cancelSubmit(requestData); if (response.status === 'success') { fetchSewerageSupplyRate(getSewerageSupplyRateParams(searchValues ?? {}), true); @@ -585,7 +614,7 @@ const SeweragePenetrationRateInp = () => {
)} - {isLoading &&
+ {isLoading && !globalProgressVisible &&
@@ -654,6 +683,10 @@ const SeweragePenetrationRateInp = () => { : null } /> + ); }; diff --git a/apps/st/src/components/statistics/management/LogHistory.tsx b/apps/st/src/components/statistics/management/LogHistory.tsx index ff1747c33..e2abaf60d 100644 --- a/apps/st/src/components/statistics/management/LogHistory.tsx +++ b/apps/st/src/components/statistics/management/LogHistory.tsx @@ -7,7 +7,7 @@ import useSWR from "swr"; import { useAlertStore } from "@/shared/lib"; import { CodeSelectWithAll } from "@/entities/code"; -import { statisticsAPi } from "@/entities/statistics/api/statistics-service"; +import { statisticsApi } from "@/entities/statistics/api/statistics-service"; import type { ApiResponse, SearchCondition, @@ -36,13 +36,13 @@ import { toGridRows } from "@/components/util/grid/gridFunction"; const PAGE_SIZE = 100; const getSidoListFetcher = async (): Promise> => { - return statisticsAPi.getSidoList(); + return statisticsApi.getSidoList(); }; const getSggListFetcher = async ( key: [string, string], ): Promise> => { - return statisticsAPi.getSggList(key[1]); + return statisticsApi.getSggList(key[1]); }; const LogHistory = () => { @@ -158,7 +158,7 @@ const LogHistory = () => { try { setIsLoading(true); - const response = await statisticsAPi.getLogList(param); + const response = await statisticsApi.getLogList(param); if (response.status === "success") { const lostList = response.data?.statsLogList ?? []; diff --git a/apps/st/src/components/statistics/management/LogHistoryDetailDialog.tsx b/apps/st/src/components/statistics/management/LogHistoryDetailDialog.tsx index b451b3a14..09667bc88 100644 --- a/apps/st/src/components/statistics/management/LogHistoryDetailDialog.tsx +++ b/apps/st/src/components/statistics/management/LogHistoryDetailDialog.tsx @@ -12,7 +12,7 @@ import { DialogTitle, } from "@common/components"; -import { statisticsAPi } from "@/entities/statistics/api/statistics-service"; +import { statisticsApi } from "@/entities/statistics/api/statistics-service"; export interface LogHistoryDetailDialogProps { open: boolean; @@ -30,7 +30,7 @@ export function LogHistoryDetailDialog({ const sn = row.statsLogSn; void (async () => { - const response = await statisticsAPi.getLogDetail( + const response = await statisticsApi.getLogDetail( sn !== undefined && sn !== null ? String(sn) : "", ); console.log(response.data); diff --git a/apps/st/src/components/statistics/review/SeweragePenetrationRateRvw.tsx b/apps/st/src/components/statistics/review/SeweragePenetrationRateRvw.tsx index 3af748260..b0f38ed02 100644 --- a/apps/st/src/components/statistics/review/SeweragePenetrationRateRvw.tsx +++ b/apps/st/src/components/statistics/review/SeweragePenetrationRateRvw.tsx @@ -13,7 +13,7 @@ import { useConfirm } from "@frontend-opensource/use-react-hooks"; import { SeweragePenetrationRateInfoList } from "@/components/util/column/review/SeweragePenetrationRateRvwColumn"; import type { SewerageSupplyRateReviewItem, statsStatusInfo } from "@/entities/statistics/model/statistics-sewerage-penetration-review-interface"; import type { ReviewOpinionSaveRequest } from "@/entities/statistics/model/statistics-interface"; -import { statisticsAPi } from "@/entities/statistics/api/statistics-service"; +import { statisticsApi } from "@/entities/statistics/api/statistics-service"; import { statisticsManagementApi } from "@/entities/statistics/api/statistics-management-service"; import type { SearchFormValues } from "@/components/type/SearchForm.type"; import { toGridRows } from "@/components/util/grid/gridFunction"; @@ -141,7 +141,7 @@ const SeweragePenetrationRateRvw = () => { try { const userInfo = await getUserInfo(); - const response = await statisticsAPi.submitReview({ + const response = await statisticsApi.submitReview({ statsYr: reviewOpinionContext.statsYr, sewerBzmnCd: reviewOpinionContext.sewerBzmnCd, statsCd: reviewOpinionContext.statsCd, @@ -169,7 +169,7 @@ const SeweragePenetrationRateRvw = () => { } try { - const detailRes = await statisticsAPi.getReviewOpinionDetail({ + const detailRes = await statisticsApi.getReviewOpinionDetail({ statsYr: reviewOpinionContext.statsYr, sewerBzmnCd: reviewOpinionContext.sewerBzmnCd, statsCd: reviewOpinionContext.statsCd, @@ -191,7 +191,7 @@ const SeweragePenetrationRateRvw = () => { const saveReviewOpinion = async (param: ReviewOpinionSaveRequest) => { try { - const response = await statisticsAPi.saveReviewOpinion(param); + const response = await statisticsApi.saveReviewOpinion(param); if (response.status !== "success") { alert(response.message || "저장에 실패했습니다."); return; @@ -220,7 +220,7 @@ const SeweragePenetrationRateRvw = () => { if (await confirm('반려 처리 하시겠습니까?')) { try { const userInfo = await getUserInfo(); - const response = await statisticsAPi.saveReviewReturn({ + const response = await statisticsApi.saveReviewReturn({ statsYr: reviewOpinionContext.statsYr, sewerBzmnCd: reviewOpinionContext.sewerBzmnCd, statsCd: reviewOpinionContext.statsCd, diff --git a/apps/st/src/entities/statistics/api/statistics-service.ts b/apps/st/src/entities/statistics/api/statistics-service.ts index f899aee6c..b7aec86ac 100644 --- a/apps/st/src/entities/statistics/api/statistics-service.ts +++ b/apps/st/src/entities/statistics/api/statistics-service.ts @@ -24,9 +24,11 @@ import { type StatisticsService, type SubmitExceptionRequest, type SubmitExceptionResponse, + type ImrResponse, + type ImrStatusResponse, } from "../model/statistics-interface"; -export class StatisticsAPiService implements StatisticsService { +export class StatisticsApiService implements StatisticsService { public async getSidoList(params?: { instSeCd?: string; }): Promise> { @@ -70,14 +72,21 @@ export class StatisticsAPiService implements StatisticsService { return response.data; } - public async callImrService(param: ImrRequest): Promise { - const response = await client.post( + public async callImrService(param: ImrRequest): Promise { + const response = await client.post( `${ENDPOINT.STATVERIFY_SERVICE.IMR}`, param, ); return response.data; } + public async getImrStatus(param: string): Promise { + const response = await client.get( + `${ENDPOINT.STATVERIFY_SERVICE.IMR}/status/${param}`, + ); + return response.data; + } + // 예외 제출 public async submitException( param: SubmitExceptionRequest, @@ -157,4 +166,4 @@ export class StatisticsAPiService implements StatisticsService { } } -export const statisticsAPi = new StatisticsAPiService(); +export const statisticsApi = new StatisticsApiService(); diff --git a/apps/st/src/entities/statistics/model/statistics-interface.ts b/apps/st/src/entities/statistics/model/statistics-interface.ts index 800849c5e..5c95fb8bf 100644 --- a/apps/st/src/entities/statistics/model/statistics-interface.ts +++ b/apps/st/src/entities/statistics/model/statistics-interface.ts @@ -44,6 +44,30 @@ export interface ImrRequest { gen_verify_stts_cd?: string; } +export interface ImrResponse { + anls_sn: number; + job_id: string; + total_otlr_cnt: number; + status_code: string; + error_msg: string; + processing_time_ms: number; + job_prog: number; + queue_position: number; +} + +export interface ImrStatusResponse { + job_id: string; + anls_sn: number; + status: string; + job_prog: number; + total_otlr_cnt: number; + total_nml_cnt: number; + total_excl_cnt: number; + processing_time_ms: number; + error_msg: string; + queue_position: string; +} + export interface VerificationResultListRequest { statsYr: string; sewerBzmnCd?: string; @@ -249,7 +273,7 @@ export interface StatisticsService { getSggList(sidoName: string, params?: { instSeCd?: string; gugunSkipYn?: "Y" | "N" }): Promise>; getSggAllList(sidoName: string, params?: { instSeCd?: string; gugunSkipYn?: "Y" | "N" }): Promise>; cancelSubmit(param: cancelSubmitRequest): Promise; - callImrService(param: ImrRequest): Promise; + callImrService(param: ImrRequest): Promise; submitException(param: SubmitExceptionRequest): Promise; getLogList(param: StatisticsLogRequest): Promise; getLogDetail(param: string): Promise; @@ -258,4 +282,5 @@ export interface StatisticsService { saveReviewReturn(param: ReviewReturnSaveRequest): Promise; submitReview(param :ReviewSubmitRequest): Promise; getVerificationResultList(param: VerificationResultListRequest): Promise; + getImrStatus(param: string): Promise; } \ No newline at end of file diff --git a/apps/st/src/shared/lib/index.ts b/apps/st/src/shared/lib/index.ts index 5ac79bf08..e2ddab13d 100644 --- a/apps/st/src/shared/lib/index.ts +++ b/apps/st/src/shared/lib/index.ts @@ -2,6 +2,7 @@ export { getCookie, setCookie, removeCookie } from "./cookie"; export { useMutationWrapper } from "./use-mutation-wrapper"; export { useAlertStore } from "./use-alert-store"; export { useDataTable } from "./use-data-table"; +export { useGlobalProgressStore } from "./use-global-progress-store"; export { useMenuStore } from "./use-menu-store"; export { useUserStore } from "./use-user-store"; export { usePrompt } from "./use-prompt"; diff --git a/apps/st/src/shared/lib/use-global-progress-store.ts b/apps/st/src/shared/lib/use-global-progress-store.ts new file mode 100644 index 000000000..4161ad1d4 --- /dev/null +++ b/apps/st/src/shared/lib/use-global-progress-store.ts @@ -0,0 +1,16 @@ +import { useContext } from "react"; +import { useStore } from "zustand"; +import type { GlobalProgressStore } from "../store/global-progress-store"; +import { StoreContext } from "../providers/store-provider"; + +export const useGlobalProgressStore = ( + selector: (store: GlobalProgressStore) => T, +): T => { + const storeContext = useContext(StoreContext); + + if (!storeContext) { + throw new Error("useGlobalProgressStore must be used within StoreProvider"); + } + + return useStore(storeContext.globalProgressStore, selector); +}; diff --git a/apps/st/src/shared/providers/store-provider.tsx b/apps/st/src/shared/providers/store-provider.tsx index a0ec17b61..44d6d89f0 100644 --- a/apps/st/src/shared/providers/store-provider.tsx +++ b/apps/st/src/shared/providers/store-provider.tsx @@ -4,6 +4,7 @@ import { createContext, useRef } from "react"; import { createAlertStore, createAttachmentStore, + createGlobalProgressStore, createMenuStore, createUserStore, } from "../store"; @@ -11,6 +12,7 @@ import { type StoreContextType = { alertStore: ReturnType; attachmentStore: ReturnType; + globalProgressStore: ReturnType; menuStore: ReturnType; userStore: ReturnType; }; @@ -25,6 +27,7 @@ export const StoreProvider = ({ children }: { children: React.ReactNode }) => { storeRef.current = { alertStore: createAlertStore(), attachmentStore: createAttachmentStore(), + globalProgressStore: createGlobalProgressStore(), menuStore: createMenuStore(), userStore: createUserStore(), }; diff --git a/apps/st/src/shared/store/global-progress-store.ts b/apps/st/src/shared/store/global-progress-store.ts new file mode 100644 index 000000000..70a08debf --- /dev/null +++ b/apps/st/src/shared/store/global-progress-store.ts @@ -0,0 +1,45 @@ +import { createStore } from "zustand/vanilla"; + +export type GlobalProgressState = { + visible: boolean; + label: string; + progress: number; +}; + +export type GlobalProgressActions = { + show: (payload?: { label?: string; progress?: number }) => void; + setLabel: (label: string) => void; + setProgress: (progress: number) => void; + hide: () => void; +}; + +export type GlobalProgressStore = GlobalProgressState & GlobalProgressActions; + +export const defaultGlobalProgressState: GlobalProgressState = { + visible: false, + label: "진행 중...", + progress: 0, +}; + +export const createGlobalProgressStore = ( + initState: GlobalProgressState = defaultGlobalProgressState, +) => { + return createStore()((set) => ({ + ...initState, + show: (payload) => + set(() => ({ + visible: true, + label: payload?.label ?? defaultGlobalProgressState.label, + progress: Math.max(0, Math.min(100, payload?.progress ?? 0)), + })), + setLabel: (label) => set(() => ({ label })), + setProgress: (progress) => + set(() => ({ progress: Math.max(0, Math.min(100, progress)) })), + hide: () => + set(() => ({ + visible: false, + progress: 0, + label: defaultGlobalProgressState.label, + })), + })); +}; diff --git a/apps/st/src/shared/store/index.ts b/apps/st/src/shared/store/index.ts index a41d6f928..c5014150e 100644 --- a/apps/st/src/shared/store/index.ts +++ b/apps/st/src/shared/store/index.ts @@ -1,4 +1,5 @@ export { createAlertStore } from "./alert-store"; export { createAttachmentStore } from "./attachment-store"; +export { createGlobalProgressStore } from "./global-progress-store"; export { createMenuStore, type BbsType } from "./menu-store"; export { createUserStore, type IUserInfo } from "./user-store"; diff --git a/apps/st/src/shared/ui/global-progress-overlay.tsx b/apps/st/src/shared/ui/global-progress-overlay.tsx new file mode 100644 index 000000000..c9007148b --- /dev/null +++ b/apps/st/src/shared/ui/global-progress-overlay.tsx @@ -0,0 +1,23 @@ +"use client"; + +import { Progress } from "@common/components"; +import { useGlobalProgressStore } from "@/shared/lib"; + +export const GlobalProgressOverlay = () => { + const visible = useGlobalProgressStore((state) => state.visible); + const label = useGlobalProgressStore((state) => state.label); + const progress = useGlobalProgressStore((state) => state.progress); + + if (!visible) return null; + + return ( +
+
+

+ {label} {progress}% +

+ +
+
+ ); +}; From a5e50fc5762507257bb76c72a3d88f0ba18d8c62 Mon Sep 17 00:00:00 2001 From: bicochan Date: Tue, 21 Apr 2026 18:09:11 +0900 Subject: [PATCH 3/3] =?UTF-8?q?[st]=20feat:=20=ED=86=B5=EA=B3=84=20?= =?UTF-8?q?=EA=B2=80=EC=A6=9D=20=EB=A6=AC=ED=8F=AC=ED=8A=B8=20=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=20=EC=9E=91=EC=97=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/st/src/components/module/DataGrid.tsx | 36 +- .../StatisticsVerificationReport.tsx | 1280 +++++++++-------- apps/st/src/components/type/DataGrid.type.ts | 16 + .../api/statistics-approval-service.ts | 17 +- apps/st/src/entities/statistics/index.ts | 7 +- .../model/statistics-approval-interface.ts | 23 +- .../statistics-verification-report-columns.ts | 18 +- apps/st/src/shared/config/api.ts | 2 +- 8 files changed, 812 insertions(+), 587 deletions(-) diff --git a/apps/st/src/components/module/DataGrid.tsx b/apps/st/src/components/module/DataGrid.tsx index 801a8711f..8c8e0d75c 100644 --- a/apps/st/src/components/module/DataGrid.tsx +++ b/apps/st/src/components/module/DataGrid.tsx @@ -319,8 +319,10 @@ const DataGrid = ({ rowData, setRowData, getGridDataRef, + getGridRef, cellStyleCallback, cellTooltipCallback, + onCellEdited, onRowClick, onItemChecked, onItemAllChecked, @@ -345,6 +347,7 @@ const DataGrid = ({ const cellTooltipCallbackRef = useRef( cellTooltipCallback, ); + const onCellEditedRef = useRef(onCellEdited); const onScrollToBottomRef = useRef<(() => void) | undefined>( onScrollToBottom, ); @@ -357,6 +360,7 @@ const DataGrid = ({ onScrollToBottomRef.current = onScrollToBottom; onItemCheckedRef.current = onItemChecked; onItemAllCheckedRef.current = onItemAllChecked; + onCellEditedRef.current = onCellEdited; // highlightedRowId가 변경될 때 해당 행으로 스크롤 및 선택 useEffect(() => { @@ -1149,6 +1153,33 @@ const DataGrid = ({ }; }, [onItemChecked, onItemAllChecked]); + useEffect(() => { + const gridView = gridRef.current?.gridView; + if (!gridView) return; + + gridView.onCellEdited = (grid, itemIndex, _row, field) => { + const fieldName = columns[field]?.fieldName; + if (!fieldName) return; + + const newValue = grid.getValue(itemIndex, fieldName); + const prevRow = rowDataRef.current[itemIndex]; + const oldValue = prevRow?.[fieldName] ?? ""; + + onCellEditedRef.current?.({ + itemIndex, + fieldName, + oldValue, + newValue, + }); + }; + + return () => { + if (gridView.onCellEdited) { + gridView.onCellEdited = undefined; + } + }; + }, [columns, setRowData]); + return (
{ if (!grid) return; ( @@ -1259,9 +1289,9 @@ const DataGrid = ({ buildFieldsFromColumns(columns), ); } - if (dataProvider?.setRows && rowData?.length) { + if (dataProvider?.setRows) dataProvider.setRows(rowData); - } + if (getGridRef) getGridRef.current = gridRef.current; const gridView = gridRef.current?.gridView ?? grid; if (!gridView?.columnByName) return; setTimeout(() => { diff --git a/apps/st/src/components/statistics/verification/StatisticsVerificationReport.tsx b/apps/st/src/components/statistics/verification/StatisticsVerificationReport.tsx index 3b6f86c4f..837433b8d 100644 --- a/apps/st/src/components/statistics/verification/StatisticsVerificationReport.tsx +++ b/apps/st/src/components/statistics/verification/StatisticsVerificationReport.tsx @@ -1,567 +1,713 @@ -"use client"; - -import type { Dispatch, SetStateAction } from "react"; -import { useCallback, useEffect, useMemo, useState } from "react"; - -import { useConfirm } from "@frontend-opensource/use-react-hooks"; -import dayjs from "dayjs"; - -import { useAlertStore, useMutationWrapper, useUserStore } from "@/shared/lib"; -import { CodeSelect } from "@/entities/code"; -import { - type ApprovalRequest, - statisticsApprovalApi, - statisticsVerificationDetailColumnInfo, - type StatisticsVerificationFilterState, - type StatisticsVerificationSubFilterState, - statisticsVerificationSummaryColumnInfo, - type StatsInputStatusList, - useStAnlsSidoCombo, - useStAnlsSigunguCombo, - useStatsVrfcRptData, - useStatsVrfcRsltData, - type VerifyResultItem, -} from "@/entities/statistics"; -import { - Button, - Combobox, - ComboboxChip, - ComboboxChips, - ComboboxChipsInput, - ComboboxContent, - ComboboxEmpty, - ComboboxItem, - ComboboxList, - ComboboxValue, - Label, - LoadingWithAnimation, - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, - useComboboxAnchor, -} from "@common/components"; - -import DataGrid from "@/components/module/DataGrid"; -import { toGridRows } from "@/components/util/grid/gridFunction"; - -const getYearOptions = () => { - const currentYear = dayjs().year(); - const yearOptionsRange = Array.from( - { length: currentYear - 2017 }, - (_, i) => { - const year = 2017 + i; - return { label: String(year), value: String(year) }; - }, - ); - return yearOptionsRange.map((year) => ({ - label: year.label, - value: year.value, - })); -}; - -function isSameStatsInputStatusRow( - a: StatsInputStatusList, - b: StatsInputStatusList, -): boolean { - return ( - a.ctpvNm === b.ctpvNm && a.sggNm === b.sggNm && a.statsCd === b.statsCd - ); -} - -function isSameVerifyResultRow( - a: VerifyResultItem, - b: VerifyResultItem, -): boolean { - if (a.vrfcSn != null && b.vrfcSn != null) return a.vrfcSn === b.vrfcSn; - return ( - a.statsYr === b.statsYr && - a.statsCd === b.statsCd && - a.ctpvNm === b.ctpvNm && - a.sggNm === b.sggNm && - a.vrfcTrgtNm === b.vrfcTrgtNm && - a.vrfcMthdCd === b.vrfcMthdCd - ); -} - -function useGridCheckboxSelection( - sourceRows: T[], - isSameRow: (a: T, b: T) => boolean, - setSelected: Dispatch>, -) { - const handleItemChecked = useCallback( - (itemIndex: number, checked: boolean) => { - const origin = sourceRows[itemIndex]; - if (origin === undefined) return; - setSelected((prev) => { - if (checked) { - if (prev.some((r) => isSameRow(r, origin))) return prev; - return [...prev, origin]; - } - return prev.filter((r) => !isSameRow(r, origin)); - }); - }, - [sourceRows, isSameRow, setSelected], - ); - - const handleAllChecked = useCallback( - (checked: boolean) => { - if (checked) setSelected([...sourceRows]); - else setSelected([]); - }, - [sourceRows, setSelected], - ); - - return { handleItemChecked, handleAllChecked }; -} - -const DEFAULT_FILTERS: StatisticsVerificationFilterState = { - ctpvNm: null, - sggNm: null, - statsYr: String(dayjs().year() - 1), - sewerBzmnCd: null, - statsCd: null, -}; - -const DEFAULT_SUB_FILTERS: StatisticsVerificationSubFilterState = { - ...DEFAULT_FILTERS, - prcsYnList: "1,2,3", - pbcpAprvYn: "N", -}; - -const STATUS_OPTIONS = [ - { label: "수정완료", value: "1" }, - { label: "예외통과", value: "2" }, - { label: "반려", value: "3" }, -]; - -const APPROVAL_STATUS_OPTIONS = [ - { label: "승인", value: "Y" }, - { label: "미승인", value: "N" }, -]; - -function StatisticsVerificationReport() { - const userInfo = useUserStore((state) => state.userInfo); - const { setMessage: alert } = useAlertStore((state) => state); - const { confirm } = useConfirm(); - const [filters, setFilters] = - useState(DEFAULT_FILTERS); - const [subFilters, setSubFilters] = - useState(DEFAULT_SUB_FILTERS); - const [submittedMainFilters, setSubmittedMainFilters] = - useState(DEFAULT_FILTERS); - const [submittedDetailParams, setSubmittedDetailParams] = useState<{ - main: StatisticsVerificationFilterState; - sub: StatisticsVerificationSubFilterState; - }>({ - main: DEFAULT_FILTERS, - sub: DEFAULT_SUB_FILTERS, - }); - const [selectedSummaryRows, setSelectedSummaryRows] = useState< - StatsInputStatusList[] - >([]); - const [selectedDetailRows, setSelectedDetailRows] = useState< - VerifyResultItem[] - >([]); - - const { data: sidoData } = useStAnlsSidoCombo(); - const { data: sigunguData } = useStAnlsSigunguCombo( - filters.sewerBzmnCd ?? "", - ); - - const statisticsVerificationReportQuery = - useStatsVrfcRptData(submittedMainFilters); - const statisticsVerificationResultQuery = useStatsVrfcRsltData( - submittedDetailParams, - ); - - const statisticsVerificationReportData = - statisticsVerificationReportQuery.data ?? []; - const statisticsVerificationReportGridRows = useMemo( - () => toGridRows(statisticsVerificationReportData), - [statisticsVerificationReportData], - ); - const statisticsVerificationResultData = useMemo( - () => - statisticsVerificationResultQuery.data?.pages.flatMap( - (page) => page.verifyResultList ?? [], - ) ?? [], - [statisticsVerificationResultQuery.data], - ); - const statisticsVerificationResultGridRows = useMemo( - () => toGridRows(statisticsVerificationResultData), - [statisticsVerificationResultData], - ); - - const isLoading = - statisticsVerificationReportQuery.isFetching || - statisticsVerificationResultQuery.isFetching; - const isAppendingDetail = - statisticsVerificationResultQuery.isFetchingNextPage; - const canLoadMoreDetail = Boolean( - statisticsVerificationResultQuery.hasNextPage, - ); - - const { - handleItemChecked: handleReportItemChecked, - handleAllChecked: handleReportAllChecked, - } = useGridCheckboxSelection( - statisticsVerificationReportData, - isSameStatsInputStatusRow, - setSelectedSummaryRows, - ); - - const { - handleItemChecked: handleResultItemChecked, - handleAllChecked: handleResultAllChecked, - } = useGridCheckboxSelection( - statisticsVerificationResultData, - isSameVerifyResultRow, - setSelectedDetailRows, - ); - - const { mutateAsync: onApproveStats } = useMutationWrapper({ - mutationFn: (approvalRequests: ApprovalRequest[]) => - statisticsApprovalApi.approveStats(approvalRequests), - onSuccess: (data) => { - alert(data.message ?? "승인이 완료되었습니다."); - }, - }); - - useEffect(() => { - if (!statisticsVerificationReportQuery.isError) return; - alert("통계검증리포트 조회 중 오류가 발생했습니다."); - setSelectedSummaryRows([]); - }, [ - alert, - statisticsVerificationReportQuery.errorUpdatedAt, - statisticsVerificationReportQuery.isError, - ]); - - function handleSearch( - nextFilters: StatisticsVerificationFilterState, - nextSubFilters: StatisticsVerificationSubFilterState, - ) { - setSelectedSummaryRows([]); - setSelectedDetailRows([]); - setSubmittedMainFilters({ ...nextFilters }); - setSubmittedDetailParams({ - main: { ...nextFilters }, - sub: { ...nextSubFilters }, - }); - } - - async function handleLoadMoreDetail() { - if (isLoading || isAppendingDetail || !canLoadMoreDetail) return; - - try { - await statisticsVerificationResultQuery.fetchNextPage(); - } catch { - alert("추가 데이터를 불러오는 중 오류가 발생했습니다."); - } - } - - function handleDetailSearchByAreaC() { - setSelectedDetailRows([]); - setSubmittedDetailParams({ - main: { ...filters }, - sub: { ...subFilters }, - }); - } - - function handleFilterChange< - K extends keyof StatisticsVerificationFilterState, - >(key: K) { - return ( - value?: StatisticsVerificationFilterState[K], - optionData?: { label: string; value: string }, - ) => { - if (key === "sewerBzmnCd") { - setFilters((prev) => ({ - ...prev, - ctpvNm: optionData?.label, - sewerBzmnCd: optionData?.value, - sggNm: "", - })); - return; - } - setFilters((prev) => ({ ...prev, [key]: value })); - }; - } - - function handleSubFilterChange< - K extends keyof StatisticsVerificationSubFilterState, - >(key: K) { - return (value?: StatisticsVerificationSubFilterState[K]) => { - setSubFilters((prev) => ({ ...prev, [key]: value })); - }; - } - - function handleReset() { - setFilters(DEFAULT_FILTERS); - setSubFilters(DEFAULT_SUB_FILTERS); - setSelectedSummaryRows([]); - setSelectedDetailRows([]); - handleSearch(DEFAULT_FILTERS, DEFAULT_SUB_FILTERS); - } - - async function handleApprove() { - const commonParam = { - statsYr: filters.statsYr, - sewerBzmnCd: filters.sewerBzmnCd ?? "", - statsCd: filters.statsCd ?? "", - mdfrId: userInfo?.userUnqId ?? "", - }; - const approvalRequests = selectedSummaryRows.map((row) => ({ - ...commonParam, - rjctOpnnCn: row.rjctOpnnCn ?? "", - lastCfmtnPicNm: row.laePicNm ?? "", - lastCfmtnPicTelNo: row.laePicTelNo ?? "", - lastCfmtnPicDeptNm: row.laeKbrdrDeptNm ?? "", - lastCfmtnPicJbgdNm: row.laeKbrdrJbgdNm ?? "", - })); - try { - if (await confirm("승인하시겠습니까?")) { - await onApproveStats(approvalRequests); - } - } catch { - alert("승인 중 오류가 발생했습니다."); - } - } - - function handleReject() {} - - return ( -
-
-
- ({ - label: item.adstNm, - value: item.adstCd, - })) ?? [] - } - /> - ({ - label: item.adstNm, - value: item.adstCd, - })) ?? [] - } - /> - -
- - -
-
-
- - - -
-
- - {}} - showHeaderCheckbox={true} - gridHeight={30} - /> - -

- 검증 결과 -

-
-
- - -
-
- - -
-
- -
- {}} - showHeaderCheckbox={true} - /> - - {(isLoading || isAppendingDetail) && ( -
-
- -
-
- )} -
-
- ); -} - -interface FilterSelectProps { - label: string; - onChange: ( - value?: string, - optionData?: { label: string; value: string }, - ) => void; - options: Array<{ label: string; value: string }>; - value?: string; -} - -function FilterCombobox({ - label, - onChange, - options, - value, -}: FilterSelectProps) { - const anchor = useComboboxAnchor(); - const items = useMemo( - () => options.map((option) => option.value), - [options], - ); - const selectedValues = useMemo(() => { - if (!value) return []; - return value.split(",").filter(Boolean); - }, [value]); - - return ( -
- - { - if (next == null) { - onChange(undefined); - return; - } - const list = Array.isArray(next) ? next : [next]; - onChange(list.length > 0 ? list.join(",") : undefined); - }} - > - - - {(values: string[]) => ( - <> - {values.map((v) => ( - - {options.find((o) => o.value === v) - ?.label ?? v} - - ))} - - - )} - - - - 검색 결과가 없습니다. - - {(item: string) => ( - - {options.find((o) => o.value === item)?.label ?? - item} - - )} - - - -
- ); -} - -function FilterSelect({ label, onChange, options, value }: FilterSelectProps) { - return ( -
- - -
- ); -} - -export default StatisticsVerificationReport; +"use client"; + +import type { Dispatch, SetStateAction } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; + +import { useConfirm } from "@frontend-opensource/use-react-hooks"; +import dayjs from "dayjs"; +import type { RealGridReact } from "realgrid-react"; + +import { useAlertStore, useMutationWrapper, useUserStore } from "@/shared/lib"; +import { CodeSelect } from "@/entities/code"; +import { + type ApproveRequest, + type RejectRequest, + statisticsApprovalApi, + type StatisticsVerificationFilterState, + statisticsVerificationReportColumnInfo, + statisticsVerificationResultColumnInfo, + type StatisticsVerificationSubFilterState, + type StatsInputStatusList, + useStAnlsSidoCombo, + useStAnlsSigunguCombo, + useStatsVrfcRptData, + useStatsVrfcRsltData, + type VerifyResultItem, +} from "@/entities/statistics"; +import { + Button, + Combobox, + ComboboxChip, + ComboboxChips, + ComboboxChipsInput, + ComboboxContent, + ComboboxEmpty, + ComboboxItem, + ComboboxList, + ComboboxValue, + Label, + LoadingWithAnimation, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, + useComboboxAnchor, +} from "@common/components"; + +import DataGrid from "@/components/module/DataGrid"; +import type { CellEditedCallbackParams } from "@/components/type/DataGrid.type"; +import { toGridRows } from "@/components/util/grid/gridFunction"; + +const getYearOptions = () => { + const currentYear = dayjs().year(); + const yearOptionsRange = Array.from( + { length: currentYear - 2017 }, + (_, i) => { + const year = 2017 + i; + return { label: String(year), value: String(year) }; + }, + ); + return yearOptionsRange.map((year) => ({ + label: year.label, + value: year.value, + })); +}; + +function isSameStatsInputStatusRow( + a: StatsInputStatusList, + b: StatsInputStatusList, +): boolean { + return ( + a.ctpvNm === b.ctpvNm && a.sggNm === b.sggNm && a.statsCd === b.statsCd + ); +} + +function isSameVerifyResultRow( + a: VerifyResultItem, + b: VerifyResultItem, +): boolean { + if (a.vrfcSn != null && b.vrfcSn != null) return a.vrfcSn === b.vrfcSn; + return ( + a.statsYr === b.statsYr && + a.statsCd === b.statsCd && + a.ctpvNm === b.ctpvNm && + a.sggNm === b.sggNm && + a.vrfcTrgtNm === b.vrfcTrgtNm && + a.vrfcMthdCd === b.vrfcMthdCd + ); +} + +function useGridCheckboxSelection( + sourceRows: T[], + isSameRow: (a: T, b: T) => boolean, + setSelected: Dispatch>, +) { + const handleItemChecked = useCallback( + (itemIndex: number, checked: boolean) => { + const origin = sourceRows[itemIndex]; + if (origin === undefined) return; + setSelected((prev) => { + if (checked) { + if (prev.some((r) => isSameRow(r, origin))) return prev; + return [...prev, origin]; + } + return prev.filter((r) => !isSameRow(r, origin)); + }); + }, + [sourceRows, isSameRow, setSelected], + ); + + const handleAllChecked = useCallback( + (checked: boolean) => { + if (checked) setSelected([...sourceRows]); + else setSelected([]); + }, + [sourceRows, setSelected], + ); + + return { handleItemChecked, handleAllChecked }; +} + +const DEFAULT_FILTERS: StatisticsVerificationFilterState = { + ctpvNm: null, + sggNm: null, + statsYr: String(dayjs().year() - 1), + sewerBzmnCd: null, + statsCd: null, +}; + +const DEFAULT_SUB_FILTERS: StatisticsVerificationSubFilterState = { + ...DEFAULT_FILTERS, + prcsYnList: "1,2,3", + pbcpAprvYn: "N", +}; + +const STATUS_OPTIONS = [ + { label: "수정완료", value: "1" }, + { label: "예외통과", value: "2" }, + { label: "반려", value: "3" }, +]; + +const APPROVAL_STATUS_OPTIONS = [ + { label: "승인", value: "Y" }, + { label: "미승인", value: "N" }, +]; + +function StatisticsVerificationReport() { + const userInfo = useUserStore((state) => state.userInfo); + const { setMessage: alert } = useAlertStore((state) => state); + const { confirm } = useConfirm(); + const [filters, setFilters] = + useState(DEFAULT_FILTERS); + const [subFilters, setSubFilters] = + useState(DEFAULT_SUB_FILTERS); + const [submittedFilters, setSubmittedFilters] = + useState(DEFAULT_FILTERS); + const [submittedParams, setSubmittedParams] = useState<{ + main: StatisticsVerificationFilterState; + sub: StatisticsVerificationSubFilterState; + }>({ + main: DEFAULT_FILTERS, + sub: DEFAULT_SUB_FILTERS, + }); + const [selectedReportRows, setSelectedReportRows] = useState< + StatsInputStatusList[] + >([]); + const [selectedResultRows, setSelectedResultRows] = useState< + VerifyResultItem[] + >([]); + const editedResultRowMapRef = useRef>( + new Map(), + ); + const resultGridRef = useRef(null); + const prevLengthRef = useRef(0); + + const { data: sidoData } = useStAnlsSidoCombo(); + const { data: sigunguData } = useStAnlsSigunguCombo( + filters.sewerBzmnCd ?? "", + ); + + const statisticsVerificationReportQuery = + useStatsVrfcRptData(submittedFilters); + const statisticsVerificationResultQuery = + useStatsVrfcRsltData(submittedParams); + + const statisticsVerificationReportData = + statisticsVerificationReportQuery.data ?? []; + const statisticsVerificationReportGridRows = useMemo( + () => toGridRows(statisticsVerificationReportData), + [statisticsVerificationReportData], + ); + + const statisticsVerificationResultData = useMemo( + () => + statisticsVerificationResultQuery.data?.pages.flatMap( + (page) => page.verifyResultList ?? [], + ) ?? [], + [statisticsVerificationResultQuery.data], + ); + const statisticsVerificationResultGridRows = useMemo( + () => toGridRows(statisticsVerificationResultData), + [statisticsVerificationResultData], + ); + + function mergeWithEditedMap(rows: Record[]) { + const editedMap = editedResultRowMapRef.current; + if (editedMap.size === 0) return rows; + return rows.map((row) => { + const vrfcSn = Number(row.vrfcSn); + const edited = editedMap.get(vrfcSn); + return edited ? { ...row, ...edited } : row; + }); + } + + function captureCheckedVrfcSn(): number[] { + const gridView = resultGridRef.current?.gridView; + const dataProvider = resultGridRef.current?.dataProvider; + + if (!gridView || !dataProvider) return []; + + const checkedRows = gridView.getCheckedRows?.(true, false, true) ?? []; + return checkedRows + .map((row) => Number(dataProvider.getValue?.(row, "vrfcSn"))) + .filter((key) => !Number.isNaN(key)); + } + + function restoreCheckedVrfcSn( + keys: number[], + rows: Record[], + ) { + const gridView = resultGridRef.current?.gridView; + + if (!gridView || keys.length === 0) return; + + const keySet = new Set(keys); + const rowsToCheck = rows + .map((row, index) => ({ index, key: Number(row.vrfcSn) })) + .filter((item) => !Number.isNaN(item.key) && keySet.has(item.key)) + .map((item) => item.index); + + if (rowsToCheck.length === 0) return; + + gridView.checkRows?.(rowsToCheck, true, false); + } + + useEffect(() => { + const dataProvider = resultGridRef.current?.dataProvider; + const nextRows = statisticsVerificationResultGridRows; + const prevLength = prevLengthRef.current; + + if (!dataProvider || nextRows.length === 0) { + prevLengthRef.current = nextRows.length; + return; + } + + if (nextRows.length > prevLength && prevLength > 0) { + // 무한스크롤 시 데이터 처리 + const newRows = nextRows.slice(prevLength); + const mergedNewRows = mergeWithEditedMap(newRows); + dataProvider.addRows?.(mergedNewRows); + } else { + // 초기 조회 또는 재조회 시 데이터 처리 + const checkedKeys = captureCheckedVrfcSn(); + const mergedRows = mergeWithEditedMap(nextRows); + dataProvider.setRows?.(mergedRows); + restoreCheckedVrfcSn(checkedKeys, mergedRows); + } + + prevLengthRef.current = nextRows.length; + }, [statisticsVerificationResultGridRows]); + + const isLoading = + statisticsVerificationReportQuery.isFetching || + statisticsVerificationResultQuery.isFetching; + const isAppendingDetail = + statisticsVerificationResultQuery.isFetchingNextPage; + const canLoadMoreDetail = Boolean( + statisticsVerificationResultQuery.hasNextPage, + ); + + const { + handleItemChecked: handleReportItemChecked, + handleAllChecked: handleReportAllChecked, + } = useGridCheckboxSelection( + statisticsVerificationReportData, + isSameStatsInputStatusRow, + setSelectedReportRows, + ); + + const { + handleItemChecked: handleResultItemChecked, + handleAllChecked: handleResultAllChecked, + } = useGridCheckboxSelection( + statisticsVerificationResultData, + isSameVerifyResultRow, + setSelectedResultRows, + ); + + const { mutateAsync: onApproveStats } = useMutationWrapper({ + mutationFn: (approvalRequests: ApproveRequest[]) => + statisticsApprovalApi.approveStats(approvalRequests), + onSuccess: (data) => { + alert(data.message ?? "승인이 완료되었습니다."); + }, + }); + + const { mutateAsync: onRejectStats } = useMutationWrapper({ + mutationFn: (rejectRequests: RejectRequest[]) => + statisticsApprovalApi.rejectStats(rejectRequests), + onSuccess: (data) => { + alert(data.message ?? "반려가 완료되었습니다."); + }, + }); + + useEffect(() => { + if (!statisticsVerificationReportQuery.isError) return; + alert("통계검증리포트 조회 중 오류가 발생했습니다."); + setSelectedReportRows([]); + }, [ + alert, + statisticsVerificationReportQuery.errorUpdatedAt, + statisticsVerificationReportQuery.isError, + ]); + + function handleSearch( + nextFilters: StatisticsVerificationFilterState, + nextSubFilters: StatisticsVerificationSubFilterState, + ) { + setSelectedReportRows([]); + setSelectedResultRows([]); + setSubmittedFilters({ ...nextFilters }); + setSubmittedParams({ + main: { ...nextFilters }, + sub: { ...nextSubFilters }, + }); + } + + async function handleLoadMoreDetail() { + if (isLoading || isAppendingDetail || !canLoadMoreDetail) return; + + try { + await statisticsVerificationResultQuery.fetchNextPage(); + } catch { + alert("추가 데이터를 불러오는 중 오류가 발생했습니다."); + } + } + + function handleResultSearch() { + setSelectedResultRows([]); + setSubmittedParams({ + main: { ...filters }, + sub: { ...subFilters }, + }); + } + + function handleFilterChange< + K extends keyof StatisticsVerificationFilterState, + >(key: K) { + return ( + value?: StatisticsVerificationFilterState[K], + optionData?: { label: string; value: string }, + ) => { + if (key === "sewerBzmnCd") { + setFilters((prev) => ({ + ...prev, + ctpvNm: optionData?.label, + sewerBzmnCd: optionData?.value, + sggNm: "", + })); + return; + } + setFilters((prev) => ({ ...prev, [key]: value })); + }; + } + + function handleSubFilterChange< + K extends keyof StatisticsVerificationSubFilterState, + >(key: K) { + return (value?: StatisticsVerificationSubFilterState[K]) => { + setSubFilters((prev) => ({ ...prev, [key]: value })); + }; + } + + function handleCellEdited(params: CellEditedCallbackParams) { + const { itemIndex, fieldName, newValue } = params; + const dataProvider = resultGridRef.current?.dataProvider as + | { getValue?: (row: number, field: string) => string | number } + | undefined; + const vrfcSn = Number(dataProvider?.getValue?.(itemIndex, "vrfcSn")); + if (Number.isNaN(vrfcSn)) return; + + editedResultRowMapRef.current?.set(vrfcSn, { + ...(editedResultRowMapRef.current?.get(vrfcSn) ?? {}), + [fieldName]: newValue, + }); + } + + function handleReset() { + setFilters(DEFAULT_FILTERS); + setSubFilters(DEFAULT_SUB_FILTERS); + setSelectedReportRows([]); + setSelectedResultRows([]); + editedResultRowMapRef.current = new Map(); + prevLengthRef.current = 0; + handleSearch(DEFAULT_FILTERS, DEFAULT_SUB_FILTERS); + } + + async function handleApprove() { + const commonParam = { + statsYr: filters.statsYr, + sewerBzmnCd: filters.sewerBzmnCd ?? "", + statsCd: filters.statsCd ?? "", + mdfrId: userInfo?.userUnqId ?? "", + }; + const approvalRequests = selectedReportRows.map((row) => ({ + ...commonParam, + rjctOpnnCn: row.rjctOpnnCn ?? "", + lastCfmtnPicNm: row.laePicNm ?? "", + lastCfmtnPicTelNo: row.laePicTelNo ?? "", + lastCfmtnPicDeptNm: row.laeKbrdrDeptNm ?? "", + lastCfmtnPicJbgdNm: row.laeKbrdrJbgdNm ?? "", + })); + try { + if (await confirm("승인하시겠습니까?")) { + await onApproveStats(approvalRequests); + } + } catch { + alert("승인 중 오류가 발생했습니다."); + } + } + + async function handleReject() { + const commonParam = { + statsYr: filters.statsYr, + sewerBzmnCd: filters.sewerBzmnCd ?? "", + statsCd: filters.statsCd ?? "", + mdfrId: userInfo?.userUnqId ?? "", + }; + + const requestRows = selectedResultRows.map((row) => { + const vrfcSn = Number(row.vrfcSn); + const editedRow = Number.isNaN(vrfcSn) + ? undefined + : editedResultRowMapRef.current?.get(vrfcSn); + return { ...row, ...(editedRow ?? {}) }; + }); + + const hasEmptyRjctOpnnCn = requestRows.some( + (row) => !String(row?.rjctOpnnCn ?? "").trim(), + ); + if (hasEmptyRjctOpnnCn) { + alert("선택한 항목의 반려의견을 작성해주세요."); + return; + } + + const rejectRequests = requestRows.map((row) => { + const vrfcSn = Number(row.vrfcSn); + + return { + ...commonParam, + vrfcSn: Number.isNaN(vrfcSn) ? 0 : vrfcSn, + rjctOpnnCn: String(row.rjctOpnnCn ?? ""), + }; + }); + + try { + if (await confirm("반려하시겠습니까?")) { + await onRejectStats(rejectRequests); + } + } catch { + alert("반려 중 오류가 발생했습니다."); + } + } + + return ( +
+
+
+ ({ + label: item.adstNm, + value: item.adstCd, + })) ?? [] + } + /> + ({ + label: item.adstNm, + value: item.adstCd, + })) ?? [] + } + /> + +
+ + +
+
+
+ + + +
+
+ + {}} + showHeaderCheckbox={true} + gridHeight={30} + /> + +

+ 검증 결과 +

+
+
+ + +
+
+ + +
+
+ +
+ {}} + showHeaderCheckbox={true} + onCellEdited={handleCellEdited} + getGridRef={resultGridRef} + /> + + {(isLoading || isAppendingDetail) && ( +
+
+ +
+
+ )} +
+
+ ); +} + +interface FilterSelectProps { + label: string; + onChange: ( + value?: string, + optionData?: { label: string; value: string }, + ) => void; + options: Array<{ label: string; value: string }>; + value?: string; +} + +function FilterCombobox({ + label, + onChange, + options, + value, +}: FilterSelectProps) { + const anchor = useComboboxAnchor(); + const items = useMemo( + () => options.map((option) => option.value), + [options], + ); + const selectedValues = useMemo(() => { + if (!value) return []; + return value.split(",").filter(Boolean); + }, [value]); + + return ( +
+ + { + if (next == null) { + onChange(undefined); + return; + } + const list = Array.isArray(next) ? next : [next]; + onChange(list.length > 0 ? list.join(",") : undefined); + }} + > + + + {(values: string[]) => ( + <> + {values.map((v) => ( + + {options.find((o) => o.value === v) + ?.label ?? v} + + ))} + + + )} + + + + 검색 결과가 없습니다. + + {(item: string) => ( + + {options.find((o) => o.value === item)?.label ?? + item} + + )} + + + +
+ ); +} + +function FilterSelect({ label, onChange, options, value }: FilterSelectProps) { + return ( +
+ + +
+ ); +} + +export default StatisticsVerificationReport; diff --git a/apps/st/src/components/type/DataGrid.type.ts b/apps/st/src/components/type/DataGrid.type.ts index a8efd11ff..0ee95e977 100644 --- a/apps/st/src/components/type/DataGrid.type.ts +++ b/apps/st/src/components/type/DataGrid.type.ts @@ -1,6 +1,7 @@ import React from "react"; import { ValueType } from "realgrid"; +import type { RealGridReact } from "realgrid-react"; import { type SewerageSupplyRateStatusInfo } from "@/entities/statistics/model/input/statistics-sewage-supply-rate-input-interface"; @@ -241,6 +242,17 @@ export type CellTooltipCallback = ( params: CellTooltipCallbackParams, ) => string | undefined; +/** 셀 편집 완료 시 호출되는 콜백 (RealGrid onEditRowChanged) */ +export type CellEditedCallbackParams = { + itemIndex: number; + fieldName: string; + oldValue: string | number; + newValue: string | number; +}; +export type CellEditedCallback = ( + params: CellEditedCallbackParams, +) => void; + export type DataGridProps = { columnInfo: ColumnInfo; rowStartNum?: number; // 헤더 행 개수 (1부터 시작, 예: 2면 1행과 2행까지 헤더로 간주) @@ -259,10 +271,14 @@ export type DataGridProps = { cellStyleCallback?: CellStyleCallback; /** 셀 hover 시 툴팁 문구 (예: verificationResult의 rsnExpln) */ cellTooltipCallback?: CellTooltipCallback; + /** 셀 편집 완료 시 호출 (RealGrid onEditRowChanged) */ + onCellEdited?: CellEditedCallback; /** 저장 등 시점에 그리드 현재 데이터를 가져올 수 있도록 할당 (편집 반영된 값) */ getGridDataRef?: { current: (() => Record[]) | null; }; + /** 외부에서 gridRef에 직접 접근하기 위한 ref (setRows/addRows/setValue 직접 호출용) */ + getGridRef?: React.MutableRefObject; onRowClick?: (rowData: Record) => void; // 행 클릭 이벤트 /** CheckBar 행 체크 변경 */ onItemChecked?: (itemIndex: number, checked: boolean) => void; diff --git a/apps/st/src/entities/statistics/api/statistics-approval-service.ts b/apps/st/src/entities/statistics/api/statistics-approval-service.ts index bbfb9f943..2d961a66e 100644 --- a/apps/st/src/entities/statistics/api/statistics-approval-service.ts +++ b/apps/st/src/entities/statistics/api/statistics-approval-service.ts @@ -2,17 +2,28 @@ import { stClient } from "@/shared/api/client"; import { ENDPOINT } from "@/shared/config"; import type { - ApprovalRequest, ApprovalResponse, + ApproveRequest, + RejectRequest, StatisticsApprovalService, } from "../model/statistics-approval-interface"; class StatisticsApprovalApiService implements StatisticsApprovalService { public async approveStats( - params: ApprovalRequest[], + params: ApproveRequest[], ): Promise { const response = await stClient.post( - `${ENDPOINT.ST_SERVICE.APPROVAL}?type=VRFC&action=APPROVE`, + `${ENDPOINT.ST_SERVICE.APPROVAL}/approve?type=VRFC`, + params, + ); + return response.data; + } + + public async rejectStats( + params: RejectRequest[], + ): Promise { + const response = await stClient.post( + `${ENDPOINT.ST_SERVICE.APPROVAL}/reject?type=VRFC`, params, ); return response.data; diff --git a/apps/st/src/entities/statistics/index.ts b/apps/st/src/entities/statistics/index.ts index fe8e63faa..9c29551aa 100644 --- a/apps/st/src/entities/statistics/index.ts +++ b/apps/st/src/entities/statistics/index.ts @@ -64,12 +64,13 @@ export { export { verificationHistoryColumns } from "./model/statistics-analysis/verification-history-columns"; export { getVerificationListColumns } from "./model/statistics-analysis/verification-list-columns"; export { - type ApprovalRequest, type ApprovalResponse, + type ApproveRequest, + type RejectRequest, } from "./model/statistics-approval-interface"; export { - statisticsVerificationDetailColumnInfo, - statisticsVerificationSummaryColumnInfo, + statisticsVerificationReportColumnInfo, + statisticsVerificationResultColumnInfo, } from "./model/statistics-verification-report/statistics-verification-report-columns"; export { type StatisticsVerificationFilterState, diff --git a/apps/st/src/entities/statistics/model/statistics-approval-interface.ts b/apps/st/src/entities/statistics/model/statistics-approval-interface.ts index 9c0947967..4abf87b8d 100644 --- a/apps/st/src/entities/statistics/model/statistics-approval-interface.ts +++ b/apps/st/src/entities/statistics/model/statistics-approval-interface.ts @@ -1,13 +1,21 @@ -export interface ApprovalRequest { +export interface ApproveRequest { statsYr: string; sewerBzmnCd: string; statsCd: string; mdfrId: string; - rjctOpnnCn: string; - lastCfmtnPicNm: string; - lastCfmtnPicTelNo: string; - lastCfmtnPicDeptNm: string; - lastCfmtnPicJbgdNm: string; + lastCfmtnPicNm?: string; + lastCfmtnPicTelNo?: string; + lastCfmtnPicDeptNm?: string; + lastCfmtnPicJbgdNm?: string; +} + +export interface RejectRequest { + vrfcSn: number; + statsYr: string; + sewerBzmnCd: string; + statsCd: string; + mdfrId: string; + rjctOpnnCn?: string; } export interface ApprovalResponse { @@ -18,5 +26,6 @@ export interface ApprovalResponse { } export interface StatisticsApprovalService { - approveStats(params: ApprovalRequest[]): Promise; + approveStats(params: ApproveRequest[]): Promise; + rejectStats(params: RejectRequest[]): Promise; } diff --git a/apps/st/src/entities/statistics/model/statistics-verification-report/statistics-verification-report-columns.ts b/apps/st/src/entities/statistics/model/statistics-verification-report/statistics-verification-report-columns.ts index 3ed4f115c..1ebf0337c 100644 --- a/apps/st/src/entities/statistics/model/statistics-verification-report/statistics-verification-report-columns.ts +++ b/apps/st/src/entities/statistics/model/statistics-verification-report/statistics-verification-report-columns.ts @@ -2,7 +2,7 @@ import type { ColumnInfo } from "@/components/type/DataGrid.type"; const READ_ONLY_STYLE = "rg-cell-readonly"; -export const statisticsVerificationSummaryColumnInfo: ColumnInfo = { +export const statisticsVerificationReportColumnInfo: ColumnInfo = { columns: [ { name: "ctpvNm", @@ -111,19 +111,21 @@ export const statisticsVerificationSummaryColumnInfo: ColumnInfo = { ], }; -export const statisticsVerificationDetailColumnInfo: ColumnInfo = { +export const statisticsVerificationResultColumnInfo: ColumnInfo = { columns: [ { name: "ctpvNm", fieldName: "ctpvNm", header: { text: "시도" }, styleName: READ_ONLY_STYLE, + editable: false, }, { name: "sggNm", fieldName: "sggNm", header: { text: "시군구" }, styleName: READ_ONLY_STYLE, + editable: false, }, { name: "statsNm", @@ -131,6 +133,7 @@ export const statisticsVerificationDetailColumnInfo: ColumnInfo = { header: { text: "지표" }, width: 140, styleName: READ_ONLY_STYLE, + editable: false, }, { name: "vrfcTrgtNm", @@ -138,6 +141,7 @@ export const statisticsVerificationDetailColumnInfo: ColumnInfo = { header: { text: "세부항목" }, width: 180, styleName: READ_ONLY_STYLE, + editable: false, }, { name: "regDt", @@ -145,6 +149,7 @@ export const statisticsVerificationDetailColumnInfo: ColumnInfo = { header: { text: "검증날짜" }, width: 150, styleName: READ_ONLY_STYLE, + editable: false, }, { name: "rsnExpln", @@ -152,6 +157,7 @@ export const statisticsVerificationDetailColumnInfo: ColumnInfo = { header: { text: "오류내역" }, width: 200, styleName: READ_ONLY_STYLE, + editable: false, }, { name: "nowVlCn", @@ -159,6 +165,7 @@ export const statisticsVerificationDetailColumnInfo: ColumnInfo = { header: { text: "입력값" }, width: 90, styleName: READ_ONLY_STYLE, + editable: false, }, { name: "compVlCn", @@ -166,6 +173,7 @@ export const statisticsVerificationDetailColumnInfo: ColumnInfo = { header: { text: "비교값" }, width: 90, styleName: READ_ONLY_STYLE, + editable: false, }, { name: "prcsYn", @@ -173,6 +181,7 @@ export const statisticsVerificationDetailColumnInfo: ColumnInfo = { header: { text: "상태" }, width: 90, styleName: READ_ONLY_STYLE, + editable: false, }, { name: "prcsOpnnExpln", @@ -180,13 +189,14 @@ export const statisticsVerificationDetailColumnInfo: ColumnInfo = { header: { text: "예외사유" }, width: 170, styleName: READ_ONLY_STYLE, + editable: false, }, { name: "rjctOpnnCn", fieldName: "rjctOpnnCn", header: { text: "반려의견" }, width: 170, - styleName: READ_ONLY_STYLE, + editable: true, }, { name: "pbcpAprvYn", @@ -194,12 +204,14 @@ export const statisticsVerificationDetailColumnInfo: ColumnInfo = { header: { text: "승인여부" }, width: 90, styleName: READ_ONLY_STYLE, + editable: false, }, { name: "vrfcSn", fieldName: "vrfcSn", header: { text: "vrfcSn" }, visible: false, + editable: false, }, ], columnLayout: [ diff --git a/apps/st/src/shared/config/api.ts b/apps/st/src/shared/config/api.ts index 984135262..73aa77322 100644 --- a/apps/st/src/shared/config/api.ts +++ b/apps/st/src/shared/config/api.ts @@ -73,7 +73,7 @@ export const ENDPOINT = { INPUTCOMMON: "/st-input-service/api/v1/common", INPUTVERIFY: "/st-input-service/api/v1/verify", TOTAL: "/st-management-service/api/v1/stats", - APPROVAL: "/st-management-service/api/stats/approval", + APPROVAL: "/st-management-service/api/stats", }, STATVERIFY_SERVICE: { VRFCTGT_MNG: "/statverify-service/api/v1/vrfc-trgt-mng",