From f94b18a64dc0da7bb72200a39ac48822be3f137c Mon Sep 17 00:00:00 2001 From: rsone001 Date: Wed, 13 May 2026 13:18:24 +0900 Subject: [PATCH] =?UTF-8?q?[st]=20feat:=20=EC=9E=85=EB=A0=A5=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5/=EC=A0=9C=EC=B6=9C=20=EB=A1=9C=EC=A7=81=20=EC=88=98?= =?UTF-8?q?=EC=A0=95,=20=ED=86=B5=EA=B3=84=EC=9E=85=EB=A0=A5=EA=B4=80?= =?UTF-8?q?=EB=A6=AC,=20=EA=B2=80=ED=86=A0=20=EB=B0=98=EB=A0=A4/=EC=A0=9C?= =?UTF-8?q?=EC=B6=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/st/src/components/css/DataGrid.css | 39 +++ apps/st/src/components/module/DataGrid.tsx | 42 ++- .../module/VerificationResultDialog.tsx | 11 +- .../VerificationResultHistoryDialog.tsx | 64 ++-- .../input/PublicSewageFacilityInp.tsx | 36 ++ .../statistics/input/SewagePipeInp.tsx | 22 +- .../input/SeweragePenetrationRateInp.tsx | 57 ++-- .../statistics/input/StatsInputStatusInp.tsx | 72 +++- .../management/StatisticsInputManagement.tsx | 76 +++-- .../StatisticsInputPeriodDialog.tsx | 18 +- .../review/PublicSewageFacilityRvw.tsx | 2 +- .../statistics/review/ReturnReasonDialog.tsx | 4 +- .../statistics/review/ReviewSubmitDialog.tsx | 196 +++++++++++ .../statistics/review/SewagePipeRvw.tsx | 136 ++++++-- .../review/SeweragePenetrationRateRvw.tsx | 71 ++-- .../statistics/review/StatsInputStatusRvw.tsx | 64 +++- .../column/input/StatsInputStatusInpColumn.ts | 10 +- .../util/column/review/SewagePipeRvwColumn.ts | 313 +++++++++++++++++- .../review/StatsInputStatusRvwColumn.ts | 1 - .../api/statistics-management-service.ts | 15 + .../statistics/api/statistics-service.ts | 31 +- .../statistics-input-status-interface.ts | 4 +- ...tics-sewage-supply-rate-input-interface.ts | 8 +- .../statistics/model/statistics-interface.ts | 69 +++- ...public-sewage-facility-review-interface.ts | 2 +- ...statistics-sewage-pipe-review-interface.ts | 112 +++++++ 26 files changed, 1234 insertions(+), 241 deletions(-) create mode 100644 apps/st/src/components/statistics/review/ReviewSubmitDialog.tsx diff --git a/apps/st/src/components/css/DataGrid.css b/apps/st/src/components/css/DataGrid.css index 8a2d8d928..a2e447269 100644 --- a/apps/st/src/components/css/DataGrid.css +++ b/apps/st/src/components/css/DataGrid.css @@ -36,6 +36,38 @@ .rg-grid .rg-body .rg-table .rg-data-row .rg-data-cell.rg-cell-vrf-prcs-ok { @apply !font-semibold !text-[#3372cf]; } + .rg-grid .rg-body .rg-table .rg-data-row .rg-data-cell.rg-cell-stats-status-pending { + @apply !text-gray-500; + } + .rg-grid .rg-body .rg-table .rg-data-row .rg-data-cell.rg-cell-stats-status-submitted { + @apply !font-semibold + } + .rg-grid .rg-body .rg-table .rg-data-row .rg-data-cell.rg-cell-stats-status-approved { + @apply !font-semibold + } + .rg-grid .rg-body .rg-table .rg-data-row .rg-data-cell.rg-cell-stats-status-rejected { + @apply !font-semibold !text-[#ff6c64]; + white-space: nowrap !important; + } + .rg-grid .rg-body .rg-table .rg-data-row .rg-data-cell.rg-cell-stats-status-rejected > * { + display: inline-flex !important; + align-items: center !important; + justify-content: center; + width: max-content; + margin: 0 auto; + } + .rg-grid .rg-body .rg-table .rg-data-row .rg-data-cell.rg-cell-stats-status-rejected > *::after { + content: ""; + display: inline-block; + margin-left: 0.25rem; + vertical-align: middle; + width: 0.6125rem; + height: 0.6125rem; + border-radius: 9999px; + background: #ff6c64; + box-shadow: 0 0 0 0 rgba(255, 108, 100, 0.75); + animation: rg-cell-stats-status-dot-ping 1s cubic-bezier(0, 0, 0.2, 1) infinite; + } .rg-grid .rg-body .rg-table .rg-data-row .rg-data-cell.rg-cell-total { @apply !bg-[#e3eaf2]; } @@ -141,6 +173,13 @@ } } +@keyframes rg-cell-stats-status-dot-ping { + 75%, + 100% { + box-shadow: 0 0 0 0.5rem rgba(255, 108, 100, 0); + } +} + .tabs-content { margin-top: 0.6rem !important; } diff --git a/apps/st/src/components/module/DataGrid.tsx b/apps/st/src/components/module/DataGrid.tsx index d513e90a9..a9b615c7e 100644 --- a/apps/st/src/components/module/DataGrid.tsx +++ b/apps/st/src/components/module/DataGrid.tsx @@ -918,9 +918,17 @@ const DataGrid = ({ rowIndicator: { visible: false }, }); - // footer가 있는 컬럼이 있으면 footer 높이 설정 - const hasFooter = columns.some((col) => col.footer); - if (hasFooter) { + const applyFooterSettings = ( + targetGridView: typeof gridView, + ) => { + const hasFooter = columns.some((col) => col.footer); + if (!hasFooter) return; + + targetGridView.setOptions({ + summaryMode: "aggregate", + footer: { visible: true }, + }); + // footer가 배열이거나 multiRow가 true인 footer가 있으면 멀티푸터 설정 (여러 행) const hasArrayFooter = columns.some( (col) => col.footer && Array.isArray(col.footer), @@ -935,23 +943,24 @@ const DataGrid = ({ ); }); if (hasArrayFooter || hasMultiRowFooter) { - // footer 배열의 길이를 확인하여 행 수 결정 const arrayFooterLengths = columns .filter((col) => col.footer && Array.isArray(col.footer)) .map((col) => (col.footer as unknown[]).length); const maxFooterLength = arrayFooterLengths.length > 0 ? Math.max(...arrayFooterLengths) - : 2; // 기본값 2 + : 2; const footerHeights = Array.from( { length: maxFooterLength }, () => ({ height: 40 }), ); - gridView.setFooters?.(footerHeights); + targetGridView.setFooters?.(footerHeights); } else { - gridView.footer.height = 40; + targetGridView.footer.height = 40; } - } + }; + + applyFooterSettings(gridView); // 고정 컬럼 설정 if (fixedColumns !== undefined && fixedColumns > 0) { @@ -960,10 +969,6 @@ const DataGrid = ({ }); } - gridView.setOptions({ - summaryMode: "aggregate", - }); - // footerUserSpans가 있는 경우 setColumnLayout으로 설정 const checkFooterUserSpans = (layout: unknown[]): boolean => { return layout.some((item) => { @@ -986,7 +991,9 @@ const DataGrid = ({ }); }; - if (checkFooterUserSpans(columnLayout)) { + const hasFooterUserSpans = checkFooterUserSpans(columnLayout); + + if (hasFooterUserSpans) { // setColumnLayout은 그리드가 완전히 렌더링된 후 호출해야 함 setTimeout(() => { if (gridRef.current?.gridView && columnLayout) { @@ -1049,6 +1056,10 @@ const DataGrid = ({ (currentGridView as GridViewWithSetColumns).setColumns?.( columnsWithFooter, ); + applyFooterSettings(currentGridView); + if (hasFooterUserSpans) { + currentGridView.setColumnLayout?.(columnLayout); + } // setColumns 호출 후 footer 배열의 valueCallback을 다시 설정 setTimeout(() => { @@ -1089,6 +1100,11 @@ const DataGrid = ({ ); } }); + applyFooterSettings(currentGridView); + if (hasFooterUserSpans) { + currentGridView.setColumnLayout?.(columnLayout); + } + currentGridView.refresh?.(); }, 50); } catch (e) { console.warn("Failed to set columns with footer array:", e); diff --git a/apps/st/src/components/module/VerificationResultDialog.tsx b/apps/st/src/components/module/VerificationResultDialog.tsx index b72132bbc..acd460f87 100644 --- a/apps/st/src/components/module/VerificationResultDialog.tsx +++ b/apps/st/src/components/module/VerificationResultDialog.tsx @@ -109,6 +109,8 @@ export interface VerificationResultDialogProps { queryContext?: VerificationResultDialogQueryContext | null; title?: string; emptyMessage?: string; + /** 예외 제출 API 성공 후 부모(입력 화면)에서 메인 데이터를 다시 조회할 때 사용 */ + onExceptionSubmitted?: () => void | Promise; } export function VerificationResultDialog({ @@ -117,6 +119,7 @@ export function VerificationResultDialog({ queryContext, title = "검증 결과", emptyMessage = "표시할 검증 결과가 없습니다.", + onExceptionSubmitted, }: VerificationResultDialogProps) { /** 부모가 매 렌더마다 새 객체를 넘기면 queryContext 참조만 바뀌어 loadInitial이 재실행되므로 필드 값 기준으로 안정화 */ const stableQueryContext = useMemo((): VerificationResultDialogQueryContext | null => { @@ -385,6 +388,11 @@ export function VerificationResultDialog({ }); if (response.status === "success") { alert("예외 제출 처리되었습니다."); + try { + await onExceptionSubmitted?.(); + } catch { + alert("예외 제출은 완료되었으나 화면 데이터를 다시 불러오지 못했습니다. 조회로 새로고침 해주세요."); + } onOpenChange(false); } else { alert(response.message ?? "예외 제출 처리 중 오류가 발생했습니다."); @@ -396,9 +404,10 @@ export function VerificationResultDialog({ alert, imrCheckedIndices, imrRows, - loadInitial, normalCheckedIndices, normalRows, + onExceptionSubmitted, + onOpenChange, stableQueryContext, ]); diff --git a/apps/st/src/components/module/VerificationResultHistoryDialog.tsx b/apps/st/src/components/module/VerificationResultHistoryDialog.tsx index bc4e9d9e9..8e026b46b 100644 --- a/apps/st/src/components/module/VerificationResultHistoryDialog.tsx +++ b/apps/st/src/components/module/VerificationResultHistoryDialog.tsx @@ -24,12 +24,12 @@ import { VerificationResultHistoryPagination } from "@/components/module/Verific import type { CellStyleCallbackParams, ColumnInfo } from "@/components/type/DataGrid.type"; type VerificationResultGridRow = Record; -type VerificationHistoryTabKey = "NV" | "IMR" | "HR"; +type VerificationHistoryTabKey = "NV" | "IMR" | "HB"; const verificationHistoryTabs: { value: VerificationHistoryTabKey; label: string }[] = [ { value: "NV", label: "일반검증 내역" }, { value: "IMR", label: "I-MR 검증내역" }, - { value: "HR", label: "HR 검증내역" }, + { value: "HB", label: "HB 검증내역" }, ]; function prcsYnCodeToNumber(raw: string | number | undefined): number | undefined { @@ -90,32 +90,32 @@ export function VerificationResultHistoryDialog({ const [activeTab, setActiveTab] = useState("NV"); const [normalEntries, setNormalEntries] = useState([]); const [imrEntries, setImrEntries] = useState([]); - const [hrEntries, setHrEntries] = useState([]); + const [hbEntries, setHbEntries] = useState([]); const [normalPageIndex, setNormalPageIndex] = useState(0); const [imrPageIndex, setImrPageIndex] = useState(0); - const [hrPageIndex, setHrPageIndex] = useState(0); + const [hbPageIndex, setHbPageIndex] = useState(0); const [normalPageCount, setNormalPageCount] = useState(1); const [imrPageCount, setImrPageCount] = useState(1); - const [hrPageCount, setHrPageCount] = useState(1); + const [hbPageCount, setHbPageCount] = useState(1); const [normalLoading, setNormalLoading] = useState(false); const [imrLoading, setImrLoading] = useState(false); - const [hrLoading, setHrLoading] = useState(false); + const [hbLoading, setHbLoading] = useState(false); const [errorMessage, setErrorMessage] = useState(null); const resetState = useCallback(() => { setActiveTab("NV"); setNormalEntries([]); setImrEntries([]); - setHrEntries([]); + setHbEntries([]); setNormalPageIndex(0); setImrPageIndex(0); - setHrPageIndex(0); + setHbPageIndex(0); setNormalPageCount(1); setImrPageCount(1); - setHrPageCount(1); + setHbPageCount(1); setNormalLoading(false); setImrLoading(false); - setHrLoading(false); + setHbLoading(false); setErrorMessage(null); }, []); @@ -147,34 +147,34 @@ export function VerificationResultHistoryDialog({ setErrorMessage(null); setNormalLoading(true); setImrLoading(true); - setHrLoading(true); + setHbLoading(true); setNormalPageIndex(0); setImrPageIndex(0); - setHrPageIndex(0); + setHbPageIndex(0); try { - const [nvRes, imrRes, hrRes] = await Promise.all([ + const [nvRes, imrRes, hbRes] = await Promise.all([ fetchVerificationResultPage("NV", 0), fetchVerificationResultPage("IMR", 0), - fetchVerificationResultPage("HR", 0), + fetchVerificationResultPage("HB", 0), ]); setNormalEntries(nvRes?.data?.verifyResultList ?? []); setImrEntries(imrRes?.data?.verifyResultList ?? []); - setHrEntries(hrRes?.data?.verifyResultList ?? []); + setHbEntries(hbRes?.data?.verifyResultList ?? []); setNormalPageCount(nvRes?.data?.pageInfo?.totalPages ?? 1); setImrPageCount(imrRes?.data?.pageInfo?.totalPages ?? 1); - setHrPageCount(hrRes?.data?.pageInfo?.totalPages ?? 1); + setHbPageCount(hbRes?.data?.pageInfo?.totalPages ?? 1); } catch { setNormalEntries([]); setImrEntries([]); - setHrEntries([]); + setHbEntries([]); setNormalPageCount(1); setImrPageCount(1); - setHrPageCount(1); + setHbPageCount(1); setErrorMessage("검증 결과 내역 조회 중 오류가 발생했습니다."); } finally { setNormalLoading(false); setImrLoading(false); - setHrLoading(false); + setHbLoading(false); } }, [fetchVerificationResultPage, queryContext]); @@ -216,23 +216,23 @@ export function VerificationResultHistoryDialog({ [fetchVerificationResultPage, imrPageIndex, queryContext], ); - const handleHrPageChange = useCallback( + const handleHbPageChange = useCallback( async (pageIndex: number) => { - if (pageIndex === hrPageIndex || !queryContext) return; + if (pageIndex === hbPageIndex || !queryContext) return; setErrorMessage(null); - setHrLoading(true); + setHbLoading(true); try { - const response = await fetchVerificationResultPage("HR", pageIndex); - setHrEntries(response?.data?.verifyResultList ?? []); - setHrPageCount(response?.data?.pageInfo?.totalPages ?? 1); - setHrPageIndex(pageIndex); + const response = await fetchVerificationResultPage("HB", pageIndex); + setHbEntries(response?.data?.verifyResultList ?? []); + setHbPageCount(response?.data?.pageInfo?.totalPages ?? 1); + setHbPageIndex(pageIndex); } catch { - setErrorMessage("HR 검증내역 조회 중 오류가 발생했습니다."); + setErrorMessage("HB 검증내역 조회 중 오류가 발생했습니다."); } finally { - setHrLoading(false); + setHbLoading(false); } }, - [fetchVerificationResultPage, hrPageIndex, queryContext], + [fetchVerificationResultPage, hbPageIndex, queryContext], ); useEffect(() => { @@ -260,7 +260,7 @@ export function VerificationResultHistoryDialog({ const normalRows = useMemo(() => mapRows(normalEntries), [normalEntries]); const imrRows = useMemo(() => mapRows(imrEntries), [imrEntries]); - const hrRows = useMemo(() => mapRows(hrEntries), [hrEntries]); + const hbRows = useMemo(() => mapRows(hbEntries), [hbEntries]); const noopSetRows = ((_: SetStateAction[]>) => {}) as Dispatch< SetStateAction[]> @@ -332,8 +332,8 @@ export function VerificationResultHistoryDialog({ {renderGrid(imrRows, imrPageIndex, imrPageCount, handleImrPageChange, imrLoading)} - - {renderGrid(hrRows, hrPageIndex, hrPageCount, handleHrPageChange, hrLoading)} + + {renderGrid(hbRows, hbPageIndex, hbPageCount, handleHbPageChange, hbLoading)} diff --git a/apps/st/src/components/statistics/input/PublicSewageFacilityInp.tsx b/apps/st/src/components/statistics/input/PublicSewageFacilityInp.tsx index da4e74843..fd96905cc 100644 --- a/apps/st/src/components/statistics/input/PublicSewageFacilityInp.tsx +++ b/apps/st/src/components/statistics/input/PublicSewageFacilityInp.tsx @@ -18,6 +18,7 @@ import { type CheckInputPeriodItem, getVerificationPeriodActionBlockedMessage, isInputPeriodCurrent, + type SewerageSupplyRateVerificationResult, } from '@/entities/statistics/model/input/statistics-sewage-supply-rate-input-interface'; import type { StatisticsInputSubmitInfoFormData } from '@/entities/statistics/model/statistics-input-submit-info-schema'; import type { cancelSubmitRequest, ImrStatusResponse, SewerageStatusInfo } from '@/entities/statistics/model/statistics-interface'; @@ -46,6 +47,10 @@ import { PublicSewageFactInpColumnList } from '@/components/util/column/input/Pu import { toGridRows } from '@/components/util/grid/gridFunction'; import { useAdminStore } from '@/store/admin-store'; +type GridRowWithVerification = Record & { + verificationResult?: SewerageSupplyRateVerificationResult; +}; + type TabToolbarSnapshot = { showSaveInToolbar: boolean; showEnterableToolbarButtons: boolean; @@ -1105,6 +1110,32 @@ const PublicSewageFacilityInp = () => { } showHeaderCheckbox={false} enableSort={false} + cellStyleCallback={({ row, fieldName }) => { + const verification = (row as GridRowWithVerification).verificationResult; + if (!verification) return; + const errors = verification[fieldName]; + if (Array.isArray(errors) && errors.length > 0) { + const hasImr = errors.some((e) => e.vrfcCd === "IMR"); + return hasImr ? "rg-cell-verify-error-imr" : "rg-cell-verify-error"; + } + }} + cellTooltipCallback={({ row, fieldName }) => { + const verification = (row as GridRowWithVerification).verificationResult; + if (!verification) return; + const errors = verification[fieldName]; + if (Array.isArray(errors) && errors.length > 0) { + return errors + .map((e) => { + const prefix = + e.vrfcCd === "IMR" ? "IMR검증 - " : "일반검증 - "; + const comp = e.compVlCn ?? ""; + const now = e.nowVlCn ?? ""; + return `${prefix}${e.rsnExpln}(검증 기준값: ${comp} / 현재 입력값: ${now})`; + }) + .filter(Boolean) + .join("\n"); + } + }} /> ); @@ -1128,6 +1159,11 @@ const PublicSewageFacilityInp = () => { open={verificationResultDialogOpen} onOpenChange={setVerificationResultDialogOpen} queryContext={verificationQueryContext} + onExceptionSubmitted={async () => { + const ctx = lastSearchedValues ?? searchValues; + if (!ctx || !searchParamRef.current) return; + await loadPublicSewageFacilityActiveTabData(activeTab, searchParamRef.current, ctx); + }} /> { return; } if (!isException) { - const currentData = getTabGridRowsForVerification(tabKey); - const rowsWithVerificationErrors = currentData.filter((d) => - hasRegionalFactVerificationErrors((d as GridRowWithVerification).verificationResult), - ); - if (rowsWithVerificationErrors.length > 0) { - alert("데이터에 오류가 있습니다.\n수정 후 제출해주세요."); - return; - } + // const currentData = getTabGridRowsForVerification(tabKey); + // const rowsWithVerificationErrors = currentData.filter((d) => + // hasRegionalFactVerificationErrors((d as GridRowWithVerification).verificationResult), + // ); + // if (rowsWithVerificationErrors.length > 0) { + // alert("데이터에 오류가 있습니다.\n수정 후 제출해주세요."); + // return; + // } if (await confirm("제출 하시겠습니까?")) { setIsExceptionSubmitFlow(false); setShowDailog(true); @@ -2052,6 +2052,12 @@ const SewagePipeInp = () => { } : null } + onExceptionSubmitted={async () => { + const values = lastSearchedValues ?? searchValues; + if (!values?.year || !values?.sido || !values?.bsnmCd) return; + const refreshedParam = buildSewagePipeSearchParam(values); + await loadSewagePipeActiveTabData(activeTab, refreshedParam, values, true, false); + }} /> ); diff --git a/apps/st/src/components/statistics/input/SeweragePenetrationRateInp.tsx b/apps/st/src/components/statistics/input/SeweragePenetrationRateInp.tsx index 4c048bf21..dccc909ae 100644 --- a/apps/st/src/components/statistics/input/SeweragePenetrationRateInp.tsx +++ b/apps/st/src/components/statistics/input/SeweragePenetrationRateInp.tsx @@ -104,6 +104,14 @@ function getSewerageSupplyRateParams( }; } +function normalizeGridString(value: unknown): string | null { + if (typeof value !== "string") { + return null; + } + + return value.trim() === "" ? null : value; +} + // 하수도 통계 관리 > 01. 하수도 보급률 const SeweragePenetrationRateInp = () => { const { setMessage: alert } = useAlertStore((state) => state); @@ -378,15 +386,15 @@ const SeweragePenetrationRateInp = () => { const submitFunc = async (isException: boolean) => { if (!isException) { - const isError = rowData.filter((d) => - hasVerificationErrors((d as GridRowWithVerification).verificationResult) - ); + // const isError = rowData.filter((d) => + // hasVerificationErrors((d as GridRowWithVerification).verificationResult) + // ); - if (isError.length > 0) { - alert(`데이터에 오류가 있습니다. - 수정 후 제출해주세요.`); - return; - } + // if (isError.length > 0) { + // alert(`데이터에 오류가 있습니다. + // 수정 후 제출해주세요.`); + // return; + // } if (await confirm('제출 하시겠습니까?')) { setIsExceptionSubmitFlow(false); setShowDailog(true); @@ -438,11 +446,12 @@ const SeweragePenetrationRateInp = () => { }; const convertedData: SewerageSupplyRateSaveItem[] = currentData.map((d) => ({ - sggNm: (d.sggNm as string) || null, - admdstCd: d.admdstCd as string, - admdstNm: d.admdstNm as string, - trbtrNm: d.trbtrNm as string, - spcCtpnRgnCn: d.spcCtpnRgnCn === '해당없음' ? null : (d.spcCtpnRgnCn as string | null), + sggNm: normalizeGridString(d.sggNm), + admdstCd: normalizeGridString(d.admdstCd), + admdstNm: normalizeGridString(d.admdstNm), + trbtrNm: normalizeGridString(d.trbtrNm), + spcCtpnRgnCn: + d.spcCtpnRgnCn === '해당없음' ? null : normalizeGridString(d.spcCtpnRgnCn), ts1SwtrmtPpltnCnt: formatNumber(d.ts1SwtrmtPpltnCnt, 'ts1SwtrmtPpltnCnt'), ts2SwtrmtPpltnCnt: formatNumber(d.ts2SwtrmtPpltnCnt, 'ts2SwtrmtPpltnCnt'), ts3SwtrmtPpltnCnt: formatNumber(d.ts3SwtrmtPpltnCnt, 'ts3SwtrmtPpltnCnt'), @@ -455,7 +464,7 @@ const SeweragePenetrationRateInp = () => { drtwPrcsPpltnCnt: formatNumber(d.drtwPrcsPpltnCnt, 'drtwPrcsPpltnCnt'), spctnkPpltnCnt: formatNumber(d.spctnkPpltnCnt, 'spctnkPpltnCnt'), staaExcpArea: formatNumber(d.staaExcpArea, 'staaExcpArea'), - rmrk: (d.rmrk as string) ?? '', + rmrk: normalizeGridString(d.rmrk), wholPpltnCnt: formatNumber(d.wholPpltnCnt, 'wholPpltnCnt'), wholArea: formatNumber(d.wholArea, 'wholArea'), wholSwtrmtPpltnCnt: formatNumber(d.wholSwtrmtPpltnCnt, 'wholSwtrmtPpltnCnt'), @@ -584,11 +593,14 @@ const SeweragePenetrationRateInp = () => { const convertedData: SewerageSupplyRateSaveItem[] = currentData.map((d) => ({ // 행정구역 - sggNm: (d.sggNm as string) || null, // 시군구 - admdstCd: d.admdstCd as string, // 행정코드 - admdstNm: d.admdstNm as string, // 행정구역명 - trbtrNm: d.trbtrNm as string, // 지류 - spcCtpnRgnCn: d.spcCtpnRgnCn === '해당없음' ? null : (d.spcCtpnRgnCn as string | null), // 특별대책지역 + sggNm: normalizeGridString(d.sggNm), // 시군구 + admdstCd: normalizeGridString(d.admdstCd), // 행정코드 + admdstNm: normalizeGridString(d.admdstNm), // 행정구역명 + trbtrNm: normalizeGridString(d.trbtrNm), // 지류 + spcCtpnRgnCn: + d.spcCtpnRgnCn === '해당없음' + ? null + : normalizeGridString(d.spcCtpnRgnCn), // 특별대책지역 // 공공하수처리인구(명) - 물리적/생물학적/고도처리 ts1SwtrmtPpltnCnt: formatNumber(d.ts1SwtrmtPpltnCnt, 'ts1SwtrmtPpltnCnt'), // 물리적처리 ts2SwtrmtPpltnCnt: formatNumber(d.ts2SwtrmtPpltnCnt, 'ts2SwtrmtPpltnCnt'), // 생물학적처리 @@ -604,7 +616,7 @@ const SeweragePenetrationRateInp = () => { drtwPrcsPpltnCnt: formatNumber(d.drtwPrcsPpltnCnt, 'drtwPrcsPpltnCnt'), // 오수처리인구 spctnkPpltnCnt: formatNumber(d.spctnkPpltnCnt, 'spctnkPpltnCnt'), // 정화조인구 staaExcpArea: formatNumber(d.staaExcpArea, 'staaExcpArea'), // 하수처리구역 외 면적(㎢) - rmrk: (d.rmrk as string) ?? '', // 비고 + rmrk: normalizeGridString(d.rmrk), // 비고 // 합계·소계(계산필드 저장용) wholPpltnCnt: formatNumber(d.wholPpltnCnt, 'wholPpltnCnt'), // 총인구(명) wholArea: formatNumber(d.wholArea, 'wholArea'), // 총면적(㎢) @@ -819,6 +831,11 @@ const SeweragePenetrationRateInp = () => { } : null } + onExceptionSubmitted={async () => { + const values = lastSearchedValues ?? searchValues; + if (!values) return; + await handleSearch(values); + }} /> ); diff --git a/apps/st/src/components/statistics/input/StatsInputStatusInp.tsx b/apps/st/src/components/statistics/input/StatsInputStatusInp.tsx index 5bbf6dd7a..6c37af823 100644 --- a/apps/st/src/components/statistics/input/StatsInputStatusInp.tsx +++ b/apps/st/src/components/statistics/input/StatsInputStatusInp.tsx @@ -1,5 +1,6 @@ -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; +import { RealGridReact } from "realgrid-react"; import useSWR, { useSWRConfig } from "swr"; import { useAlertStore } from "@/shared/lib"; @@ -13,6 +14,7 @@ import RejectButton from "@/components/button/RejectButton"; import DataGrid from "@/components/module/DataGrid"; import SearchForm from "@/components/module/SearchForm"; import type { SearchFormValues } from "@/components/type/SearchForm.type"; +import { doList } from "@/components/util/column/input/DoList"; import { StatsInputStatusInpColumn } from "@/components/util/column/input/StatsInputStatusInpColumn"; import { toGridRows } from "@/components/util/grid/gridFunction"; import { useTabStore } from "@/store/tab-store"; @@ -32,6 +34,10 @@ const StatsInputStatusInp = () => { ); const [searchParams, setSearchParams] = useState(null); + const [lastSearchedValues, setLastSearchedValues] = + useState(null); + const [hasCompletedSearch, setHasCompletedSearch] = useState(false); + const gridRef = useRef(null); const swrKey = searchParams ? (["stats-input-status", searchParams] as const) @@ -45,12 +51,16 @@ const StatsInputStatusInp = () => { onSuccess: (data) => { if (data.status === "success" && data.data) { setRowData(toGridRows(data.data)); + setHasCompletedSearch(true); } else { + setHasCompletedSearch(false); + setRowData([]); alert(data.message ?? "조회에 실패했습니다."); } }, onError: () => { alert("데이터 조회 중 오류가 발생했습니다."); + setHasCompletedSearch(false); setRowData([]); }, }, @@ -73,6 +83,8 @@ const StatsInputStatusInp = () => { sggNm: values.gugun === "ALL" ? "" : values.gugun, statsYr: values.year ?? "", }; + setHasCompletedSearch(false); + setLastSearchedValues(values); setSearchParams(params); mutate(["stats-input-status", params]); }; @@ -80,6 +92,8 @@ const StatsInputStatusInp = () => { const handleReset = () => { setSearchParams(null); setRowData([]); + setLastSearchedValues(null); + setHasCompletedSearch(false); }; const rejectFunc = () => { @@ -87,19 +101,44 @@ const StatsInputStatusInp = () => { }; const approveFunc = () => { + const checkedRows = + gridRef.current?.gridView?.getCheckedRows?.(true, false, true) ?? []; + + if (rowData.length === 0 || checkedRows.length !== rowData.length) { + alert('모든 통계 항목을 체크해주세요.'); + return; + } + + const hasInvalidStatus = rowData.some((row) => + ["0", "1", "4"].includes(String(row.laeSbmsnSttsCd ?? "")), + ); + + if (hasInvalidStatus) { + alert("미작성이나 미제출, 혹은 보완요청(반려) 상태인 항목이 있습니다."); + return; + } + // 승인 요청 로직 }; + const gugunOkOrNotDoList = doList.includes(lastSearchedValues?.bsnmCd || "") + ? Boolean(lastSearchedValues?.gugun && lastSearchedValues.gugun !== "ALL") + : true; + const showActionToolbarCard = + hasCompletedSearch && rowData.length > 0 && gugunOkOrNotDoList; + return ( <>
-
-
- - + {showActionToolbarCard && ( +
+
+ + +
-
+ )} {isLoading && (
@@ -115,6 +154,27 @@ const StatsInputStatusInp = () => { setRowData={setRowData} isExcelImport={false} showHeaderCheckbox={true} + getGridRef={gridRef} + cellStyleCallback={({ row, fieldName }) => { + if (fieldName !== "laeSbmsnSttsNm") { + return undefined; + } + + const statusCd = String(row.laeSbmsnSttsCd ?? ""); + if (statusCd === "0" || statusCd === "1") { + return "rg-cell-stats-status-pending"; + } + if (statusCd === "2") { + return "rg-cell-stats-status-submitted"; + } + if (statusCd === "3") { + return "rg-cell-stats-status-approved"; + } + if (statusCd === "4") { + return "rg-cell-stats-status-rejected"; + } + return undefined; + }} />
diff --git a/apps/st/src/components/statistics/management/StatisticsInputManagement.tsx b/apps/st/src/components/statistics/management/StatisticsInputManagement.tsx index 8e485e13f..2ef6b312e 100644 --- a/apps/st/src/components/statistics/management/StatisticsInputManagement.tsx +++ b/apps/st/src/components/statistics/management/StatisticsInputManagement.tsx @@ -33,7 +33,7 @@ function rowSeTypeToCode(row: Record): "INPT" | "VRFC" return "INPT"; } -function parseYmdToDate(ymd: unknown): Date | undefined { +function parseYmdToDate(ymd: string): Date | undefined { const s = String(ymd ?? "").replace(/\D/g, ""); if (s.length !== 8) return undefined; const y = Number(s.slice(0, 4)); @@ -47,6 +47,12 @@ function parseYmdToDate(ymd: unknown): Date | undefined { return dt; } +function formatYmdForDisplay(ymd: string): string { + const s = String(ymd ?? "").replace(/\D/g, ""); + if (s.length !== 8) return String(ymd ?? ""); + return `${s.slice(0, 4)}-${s.slice(4, 6)}-${s.slice(6, 8)}`; +} + const StatisticsInputManagement = () => { const { setMessage: alert } = useAlertStore((state) => state); const [isLoading, setIsLoading] = useState(false); @@ -71,16 +77,17 @@ const StatisticsInputManagement = () => { setIsLoading(true); const params: StatsInputPeriodListRequest = { statsYr: values.year, - seType: - values.clsfCd && values.clsfCd !== "ALL" - ? values.clsfCd - : "", + ...(values.clsfCd && values.clsfCd !== "ALL" + ? { seType: values.clsfCd } + : {}), }; const res = await statisticsApi.getStatsInputPeriodList(params); if (res.status === "success" && Array.isArray(res.data)) { const rowsForGrid = res.data.map((row) => ({ ...row, seType: seTypeToDisplayLabel(row.seType), + inptBgngYmd: formatYmdForDisplay(row.inptBgngYmd), + inptEndYmd: formatYmdForDisplay(row.inptEndYmd), })); setRowData(toGridRows(rowsForGrid)); } else { @@ -112,27 +119,47 @@ const StatisticsInputManagement = () => { setPeriodDialogOpen(true); }; - const handleRowClick = (row: Record) => { - const inptYmdSnRaw = row.inptYmdSn; - const inptYmdSnNum = - typeof inptYmdSnRaw === "number" - ? inptYmdSnRaw - : parseInt(String(inptYmdSnRaw ?? ""), 10); + const handleRowClick = async (row: Record) => { const seCdRaw = row.seCd; const seCdNum = typeof seCdRaw === "number" ? seCdRaw : parseInt(String(seCdRaw ?? ""), 10); - setPeriodDialogMode("edit"); - setPeriodDialogInitial({ - statsYr: String(row.statsYr ?? ""), - seType: rowSeTypeToCode(row), - inptYmdSn: Number.isFinite(inptYmdSnNum) ? inptYmdSnNum : 0, - seCd: Number.isFinite(seCdNum) ? seCdNum : 0, - inptBgngYmd: parseYmdToDate(row.inptBgngYmd), - inptEndYmd: parseYmdToDate(row.inptEndYmd), - }); - setPeriodDialogOpen(true); + const seCd = Number.isFinite(seCdNum) ? seCdNum : 0; + const statsYr = String(row.statsYr ?? ""); + const seType = rowSeTypeToCode(row); + + try { + setIsLoading(true); + const res = await statisticsApi.getStatsInputPeriodDetail({ + statsYr, + seType, + seCd, + }); + + if (res.status !== "success" || !res.data) { + alert(res.message || "상세 정보를 조회하지 못했습니다."); + return; + } + + const detail = res.data; + setPeriodDialogMode("edit"); + setPeriodDialogInitial({ + statsYr: String(detail.statsYr ?? statsYr), + seType: detail.seType === "VRFC" ? "VRFC" : "INPT", + seCd: + typeof detail.seCd === "number" && Number.isFinite(detail.seCd) + ? detail.seCd + : seCd, + inptBgngYmd: parseYmdToDate(detail.inptBgngYmd), + inptEndYmd: parseYmdToDate(detail.inptEndYmd), + }); + setPeriodDialogOpen(true); + } catch { + alert("상세 정보 조회 중 오류가 발생했습니다."); + } finally { + setIsLoading(false); + } }; const columns: ColumnConfig[] = [ @@ -166,13 +193,6 @@ const StatisticsInputManagement = () => { header: { text: "종료일" }, editable: false, }, - { - name: "inptYmdSn", - fieldName: "inptYmdSn", - header: { text: "inptYmdSn" }, - visible: false, - editable: false, - }, ]; const columnLayout = [ diff --git a/apps/st/src/components/statistics/management/StatisticsInputPeriodDialog.tsx b/apps/st/src/components/statistics/management/StatisticsInputPeriodDialog.tsx index a6765f87e..4e0c878da 100644 --- a/apps/st/src/components/statistics/management/StatisticsInputPeriodDialog.tsx +++ b/apps/st/src/components/statistics/management/StatisticsInputPeriodDialog.tsx @@ -33,7 +33,6 @@ import { getUserInfo } from "../../../../../../packages/common/components/src/ap export type StatisticsInputPeriodDialogValues = { statsYr: string; seType: "INPT" | "VRFC"; - inptYmdSn: number; seCd: number; inptBgngYmd: Date | undefined; inptEndYmd: Date | undefined; @@ -138,22 +137,17 @@ export function StatisticsInputPeriodDialog({ try { setIsSubmitting(true); const userInfo = await getUserInfo(); - const inptYmdSn = - mode === "create" - ? 0 - : (initial && Number.isFinite(initial.inptYmdSn) ? initial.inptYmdSn : 0); const param: StatsInputPeriodSaveRequest = { statsYr: yr, seType, - inptYmdSn, - seCd: seCdParsed, inptBgngYmd: bgng, inptEndYmd: end, rgtrId: userInfo.userUnqId, }; - const res = isCreate - ? await statisticsApi.saveStatsInputPeriod(param) - : await statisticsApi.updateStatsInputPeriod(param); + if (!isCreate) { + param.seCd = seCdParsed; + } + const res = await statisticsApi.saveStatsInputPeriod(param); if (res.status === "success") { alert(`${isCreate ? '등록 되었습니다.' : '수정 되었습니다.'}`); onOpenChange(false); @@ -192,8 +186,6 @@ export function StatisticsInputPeriodDialog({ const param: StatsInputPeriodDeleteRequest = { statsYr: yr, seType: initial.seType, - inptYmdSn: - Number.isFinite(initial.inptYmdSn) ? initial.inptYmdSn : 0, seCd, inptBgngYmd: bgng, inptEndYmd: end, @@ -253,7 +245,7 @@ export function StatisticsInputPeriodDialog({ +
+
+ + +
+
+ + +
+
+ + +
+
+ +