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/CompactDataGrid.tsx b/apps/st/src/components/module/CompactDataGrid.tsx index c96f22973..6de651a1d 100644 --- a/apps/st/src/components/module/CompactDataGrid.tsx +++ b/apps/st/src/components/module/CompactDataGrid.tsx @@ -71,6 +71,38 @@ function createNumberComparer(dp: RealGridDataProviderComparer): (field: string }; } +function escapeHtml(value: string): string { + return value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +function realGridHeaderWithTooltip( + header: T, +): T { + const tip = header.tooltip?.trim(); + if (!tip) return header; + return { + ...header, + tooltip: tip, + showTooltip: header.showTooltip ?? true, + template: + header.template ?? + `${escapeHtml(header.text)}`, + }; +} + +function resolveColumnTooltip(column: { + tooltip?: string; + header: { text: string; tooltip?: string; showTooltip?: boolean; template?: string }; +}): string | undefined { + const tip = column.tooltip?.trim() || column.header.tooltip?.trim(); + return tip && tip.length > 0 ? tip : undefined; +} + const CompactDataGrid = ({ columnInfo, fixedColumns, @@ -88,8 +120,10 @@ const CompactDataGrid = ({ }: DataGridProps) => { const { columns, columnLayout } = columnInfo; const gridRef = useRef(null); + const columnsRef = useRef(columns); const rowDataRef = useRef[]>(rowData); const cellStyleCallbackRef = useRef(cellStyleCallback); + columnsRef.current = columns; rowDataRef.current = rowData; cellStyleCallbackRef.current = cellStyleCallback; @@ -155,6 +189,66 @@ const CompactDataGrid = ({ }); gridView.header.height = 60; + if (columns.some((c) => Boolean(resolveColumnTooltip(c)))) { + (gridView.header as { showTooltip?: boolean }).showTooltip = true; + } + columns.forEach((column) => { + ( + gridView as { + setColumnProperty?: ( + col: string, + key: string, + value: unknown, + ) => void; + } + ).setColumnProperty?.( + column.name, + "header", + realGridHeaderWithTooltip({ + ...column.header, + tooltip: resolveColumnTooltip(column), + }), + ); + }); + ( + gridView as { + onShowHeaderTooltip?: ( + grid: unknown, + column: { + name?: string; + fieldName?: string; + header?: { tooltip?: string }; + }, + value: string, + layout?: { + name?: string; + fieldName?: string; + column?: string | { name?: string; fieldName?: string }; + }, + isEllipsis?: boolean, + ) => string | undefined; + } + ).onShowHeaderTooltip = (_grid, headerColumn, _value, layout) => { + const currentColumns = columnsRef.current; + const directTip = headerColumn?.header?.tooltip?.trim(); + if (directTip) return directTip; + + const layoutColumn = + typeof layout?.column === "string" + ? layout.column + : layout?.column?.name ?? layout?.column?.fieldName; + + const col = currentColumns.find( + (c) => + c.name === headerColumn?.name || + c.fieldName === headerColumn?.fieldName || + c.name === layout?.name || + c.fieldName === layout?.fieldName || + c.name === layoutColumn || + c.fieldName === layoutColumn, + ); + return col ? resolveColumnTooltip(col) : undefined; + }; // 헤더 체크박스 표시 여부 설정 gridView.setOptions({ @@ -235,7 +329,10 @@ const CompactDataGrid = ({ const colInfo: RealGridColumnInfo = { name: col.name, fieldName: col.fieldName, - header: col.header, + header: realGridHeaderWithTooltip({ + ...col.header, + tooltip: resolveColumnTooltip(col), + }), editable: col.editable, valueType: col.valueType, dataFormat: col.dataFormat, @@ -427,7 +524,10 @@ const CompactDataGrid = ({ fieldName={column.fieldName} valueType={column.valueType ? column.valueType : ValueType.TEXT} {...(column.valueType === ValueType.NUMBER && { numberFormat: column.dataFormat ? column.dataFormat : '#,##0.000' })} - header={column.header} + header={realGridHeaderWithTooltip({ + ...column.header, + tooltip: resolveColumnTooltip(column), + })} editable={column.editable} {...(column.styleName != null ? { styleName: column.styleName } diff --git a/apps/st/src/components/module/DataGrid.tsx b/apps/st/src/components/module/DataGrid.tsx index acadc607a..a9b615c7e 100644 --- a/apps/st/src/components/module/DataGrid.tsx +++ b/apps/st/src/components/module/DataGrid.tsx @@ -81,6 +81,42 @@ function createNumberComparer( }; } +function escapeHtml(value: string): string { + return value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +/** + * RealGrid 헤더 툴팁: `grid.header.showTooltip`이 비어 있으면 `column.header.showTooltip === true`인 컬럼만 표시됨. + * `tooltip`이 있는 헤더는 info-circle 아이콘이 함께 보이도록 template도 자동 구성한다. + */ +function realGridHeaderWithTooltip( + header: T, +): T { + const tip = header.tooltip?.trim(); + if (!tip) return header; + return { + ...header, + tooltip: tip, + showTooltip: header.showTooltip ?? true, + template: + header.template ?? + `${escapeHtml(header.text)}`, + }; +} + +function resolveColumnTooltip(column: { + tooltip?: string; + header: { text: string; tooltip?: string; showTooltip?: boolean; template?: string }; +}): string | undefined { + const tip = column.tooltip?.trim() || column.header.tooltip?.trim(); + return tip && tip.length > 0 ? tip : undefined; +} + /** 정렬 필드 값을 숫자로 파싱 (쉼표 제거) */ function toNum(v: unknown): number { if (v == null) return NaN; @@ -354,6 +390,7 @@ const DataGrid = ({ const { columns, columnLayout } = columnInfo; const gridRef = useRef(null); const fileInputRef = useRef(null); + const columnsRef = useRef(columns); const sortDirRef = useRef>({}); const originalRowsRef = useRef< Record[]> @@ -372,6 +409,7 @@ const DataGrid = ({ const onItemCheckedRef = useRef(onItemChecked); const onItemAllCheckedRef = useRef(onItemAllChecked); + columnsRef.current = columns; rowDataRef.current = rowData; cellStyleCallbackRef.current = cellStyleCallback; cellTooltipCallbackRef.current = cellTooltipCallback; @@ -813,6 +851,66 @@ const DataGrid = ({ GridFitStyle.EVEN; gridView.displayOptions.rowHeight = 30; gridView.header.height = 60; + if (columns.some((c) => Boolean(resolveColumnTooltip(c)))) { + (gridView.header as { showTooltip?: boolean }).showTooltip = true; + } + columns.forEach((column) => { + ( + gridView as { + setColumnProperty?: ( + col: string, + key: string, + value: unknown, + ) => void; + } + ).setColumnProperty?.( + column.name, + "header", + realGridHeaderWithTooltip({ + ...column.header, + tooltip: resolveColumnTooltip(column), + }), + ); + }); + ( + gridView as { + onShowHeaderTooltip?: ( + grid: unknown, + column: { + name?: string; + fieldName?: string; + header?: { tooltip?: string }; + }, + value: string, + layout?: { + name?: string; + fieldName?: string; + column?: string | { name?: string; fieldName?: string }; + }, + isEllipsis?: boolean, + ) => string | undefined; + } + ).onShowHeaderTooltip = (_grid, headerColumn, _value, layout) => { + const currentColumns = columnsRef.current; + const directTip = headerColumn?.header?.tooltip?.trim(); + if (directTip) return directTip; + + const layoutColumn = + typeof layout?.column === "string" + ? layout.column + : layout?.column?.name ?? layout?.column?.fieldName; + + const col = currentColumns.find( + (c) => + c.name === headerColumn?.name || + c.fieldName === headerColumn?.fieldName || + c.name === layout?.name || + c.fieldName === layout?.fieldName || + c.name === layoutColumn || + c.fieldName === layoutColumn, + ); + return col ? resolveColumnTooltip(col) : undefined; + }; // 헤더 체크박스 표시 여부 설정 gridView.setOptions({ @@ -820,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), @@ -837,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) { @@ -862,10 +969,6 @@ const DataGrid = ({ }); } - gridView.setOptions({ - summaryMode: "aggregate", - }); - // footerUserSpans가 있는 경우 setColumnLayout으로 설정 const checkFooterUserSpans = (layout: unknown[]): boolean => { return layout.some((item) => { @@ -888,7 +991,9 @@ const DataGrid = ({ }); }; - if (checkFooterUserSpans(columnLayout)) { + const hasFooterUserSpans = checkFooterUserSpans(columnLayout); + + if (hasFooterUserSpans) { // setColumnLayout은 그리드가 완전히 렌더링된 후 호출해야 함 setTimeout(() => { if (gridRef.current?.gridView && columnLayout) { @@ -917,7 +1022,7 @@ const DataGrid = ({ const colInfo: RealGridColumnInfo = { name: col.name, fieldName: col.fieldName, - header: col.header, + header: realGridHeaderWithTooltip(col.header), editable: col.editable, valueType: col.valueType, dataFormat: col.dataFormat, @@ -951,6 +1056,10 @@ const DataGrid = ({ (currentGridView as GridViewWithSetColumns).setColumns?.( columnsWithFooter, ); + applyFooterSettings(currentGridView); + if (hasFooterUserSpans) { + currentGridView.setColumnLayout?.(columnLayout); + } // setColumns 호출 후 footer 배열의 valueCallback을 다시 설정 setTimeout(() => { @@ -991,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); @@ -1334,6 +1448,48 @@ const DataGrid = ({ setTimeout(() => { const gv = gridRef.current?.gridView; if (!gv?.columnByName) return; + // 공식 문서: 헤더 툴팁 = header.showTooltip + onShowHeaderTooltip + ( + gv as { + onShowHeaderTooltip?: ( + grid: unknown, + column: { + name?: string; + fieldName?: string; + header?: { + tooltip?: string; + }; + }, + value: string, + layout?: { + name?: string; + fieldName?: string; + column?: string | { name?: string; fieldName?: string }; + }, + isEllipsis?: boolean, + ) => string | undefined; + } + ).onShowHeaderTooltip = (_grid, headerColumn, _value, layout) => { + const currentColumns = columnsRef.current; + const directTip = headerColumn?.header?.tooltip?.trim(); + if (directTip) return directTip; + + const layoutColumn = + typeof layout?.column === "string" + ? layout.column + : layout?.column?.name ?? layout?.column?.fieldName; + + const col = currentColumns.find( + (c) => + c.name === headerColumn?.name || + c.fieldName === headerColumn?.fieldName || + c.name === layout?.name || + c.fieldName === layout?.fieldName || + c.name === layoutColumn || + c.fieldName === layoutColumn, + ); + return col ? resolveColumnTooltip(col) : undefined; + }; // 공식 문서: 데이터 영역 툴팁 = renderer.showTooltip + onShowTooltip (displayOptions는 Ellipsis용이라 사용 안 함) if (cellTooltipCallbackRef.current) { ( @@ -1387,6 +1543,22 @@ const DataGrid = ({ renderer?: { showTooltip?: boolean }; } | null; if (gridCol) { + ( + gv as { + setColumnProperty?: ( + col: string, + key: string, + value: unknown, + ) => void; + } + ).setColumnProperty?.( + column.name, + "header", + realGridHeaderWithTooltip({ + ...column.header, + tooltip: resolveColumnTooltip(column), + }), + ); if (cellTooltipCallbackRef.current) { ( gv as { @@ -1456,7 +1628,10 @@ const DataGrid = ({ ? column.dataFormat : "#,##0.000", })} - header={column.header} + header={realGridHeaderWithTooltip({ + ...column.header, + tooltip: resolveColumnTooltip(column), + })} editable={column.editable} {...(column.width != null && { width: column.width, 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 1026fd82d..8e026b46b 100644 --- a/apps/st/src/components/module/VerificationResultHistoryDialog.tsx +++ b/apps/st/src/components/module/VerificationResultHistoryDialog.tsx @@ -13,6 +13,10 @@ import { DialogHeader, DialogTitle, LoadingWithAnimation, + Tabs, + TabsContent, + TabsList, + TabsTrigger, } from "@common/components"; import DataGrid from "@/components/module/DataGrid"; @@ -20,6 +24,13 @@ import { VerificationResultHistoryPagination } from "@/components/module/Verific import type { CellStyleCallbackParams, ColumnInfo } from "@/components/type/DataGrid.type"; type VerificationResultGridRow = Record; +type VerificationHistoryTabKey = "NV" | "IMR" | "HB"; + +const verificationHistoryTabs: { value: VerificationHistoryTabKey; label: string }[] = [ + { value: "NV", label: "일반검증 내역" }, + { value: "IMR", label: "I-MR 검증내역" }, + { value: "HB", label: "HB 검증내역" }, +]; function prcsYnCodeToNumber(raw: string | number | undefined): number | undefined { if (raw === undefined || raw === null) return undefined; @@ -76,30 +87,40 @@ export function VerificationResultHistoryDialog({ title = "검증 결과 내역", emptyMessage = "표시할 검증 결과가 없습니다.", }: VerificationResultHistoryDialogProps) { + const [activeTab, setActiveTab] = useState("NV"); const [normalEntries, setNormalEntries] = useState([]); const [imrEntries, setImrEntries] = useState([]); + const [hbEntries, setHbEntries] = useState([]); const [normalPageIndex, setNormalPageIndex] = useState(0); const [imrPageIndex, setImrPageIndex] = useState(0); + const [hbPageIndex, setHbPageIndex] = useState(0); const [normalPageCount, setNormalPageCount] = useState(1); const [imrPageCount, setImrPageCount] = useState(1); + const [hbPageCount, setHbPageCount] = useState(1); const [normalLoading, setNormalLoading] = useState(false); const [imrLoading, setImrLoading] = useState(false); + const [hbLoading, setHbLoading] = useState(false); const [errorMessage, setErrorMessage] = useState(null); const resetState = useCallback(() => { + setActiveTab("NV"); setNormalEntries([]); setImrEntries([]); + setHbEntries([]); setNormalPageIndex(0); setImrPageIndex(0); + setHbPageIndex(0); setNormalPageCount(1); setImrPageCount(1); + setHbPageCount(1); setNormalLoading(false); setImrLoading(false); + setHbLoading(false); setErrorMessage(null); }, []); const fetchVerificationResultPage = useCallback( - async (vrfcMthdCd: "NV" | "IMR", pageIndex: number) => { + async (vrfcMthdCd: VerificationHistoryTabKey, pageIndex: number) => { if (!queryContext) return null; const param: VerificationResultListRequest = { sewerBzmnCd: queryContext.sewerBzmnCd, @@ -126,26 +147,34 @@ export function VerificationResultHistoryDialog({ setErrorMessage(null); setNormalLoading(true); setImrLoading(true); + setHbLoading(true); setNormalPageIndex(0); setImrPageIndex(0); + setHbPageIndex(0); try { - const [nvRes, imrRes] = await Promise.all([ + const [nvRes, imrRes, hbRes] = await Promise.all([ fetchVerificationResultPage("NV", 0), fetchVerificationResultPage("IMR", 0), + fetchVerificationResultPage("HB", 0), ]); setNormalEntries(nvRes?.data?.verifyResultList ?? []); setImrEntries(imrRes?.data?.verifyResultList ?? []); + setHbEntries(hbRes?.data?.verifyResultList ?? []); setNormalPageCount(nvRes?.data?.pageInfo?.totalPages ?? 1); setImrPageCount(imrRes?.data?.pageInfo?.totalPages ?? 1); + setHbPageCount(hbRes?.data?.pageInfo?.totalPages ?? 1); } catch { setNormalEntries([]); setImrEntries([]); + setHbEntries([]); setNormalPageCount(1); setImrPageCount(1); + setHbPageCount(1); setErrorMessage("검증 결과 내역 조회 중 오류가 발생했습니다."); } finally { setNormalLoading(false); setImrLoading(false); + setHbLoading(false); } }, [fetchVerificationResultPage, queryContext]); @@ -187,6 +216,25 @@ export function VerificationResultHistoryDialog({ [fetchVerificationResultPage, imrPageIndex, queryContext], ); + const handleHbPageChange = useCallback( + async (pageIndex: number) => { + if (pageIndex === hbPageIndex || !queryContext) return; + setErrorMessage(null); + setHbLoading(true); + try { + const response = await fetchVerificationResultPage("HB", pageIndex); + setHbEntries(response?.data?.verifyResultList ?? []); + setHbPageCount(response?.data?.pageInfo?.totalPages ?? 1); + setHbPageIndex(pageIndex); + } catch { + setErrorMessage("HB 검증내역 조회 중 오류가 발생했습니다."); + } finally { + setHbLoading(false); + } + }, + [fetchVerificationResultPage, hbPageIndex, queryContext], + ); + useEffect(() => { if (!open) { resetState(); @@ -212,6 +260,7 @@ export function VerificationResultHistoryDialog({ const normalRows = useMemo(() => mapRows(normalEntries), [normalEntries]); const imrRows = useMemo(() => mapRows(imrEntries), [imrEntries]); + const hbRows = useMemo(() => mapRows(hbEntries), [hbEntries]); const noopSetRows = ((_: SetStateAction[]>) => {}) as Dispatch< SetStateAction[]> @@ -269,14 +318,24 @@ export function VerificationResultHistoryDialog({ {errorMessage}

)} -
-

일반검증 내역

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

I-MR 검증내역

- {renderGrid(imrRows, imrPageIndex, imrPageCount, handleImrPageChange, imrLoading)} -
+ setActiveTab(value as VerificationHistoryTabKey)} className="w-full" variant="default"> + + {verificationHistoryTabs.map((tab) => ( + + {tab.label} + + ))} + + + {renderGrid(normalRows, normalPageIndex, normalPageCount, handleNormalPageChange, normalLoading)} + + + {renderGrid(imrRows, imrPageIndex, imrPageCount, handleImrPageChange, imrLoading)} + + + {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 f0334fee8..fd96905cc 100644 --- a/apps/st/src/components/statistics/input/PublicSewageFacilityInp.tsx +++ b/apps/st/src/components/statistics/input/PublicSewageFacilityInp.tsx @@ -1,4 +1,4 @@ -import { useRef, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { useConfirm } from '@frontend-opensource/use-react-hooks'; @@ -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; @@ -131,6 +136,10 @@ type MainFacilityMappingSaveDraft = { initialPrxyCtrtCn: string; }; +const PUBLIC_SEWAGE_FACILITY_COLUMN_GUIDE_TAB_KEYS = Array.from( + new Set(PublicSewageFactInpColumnList.map((tab) => tab.tabKey).filter(Boolean)), +); + // 하수도 통계 관리 > 06. 공공하수처리시설 const PublicSewageFacilityInp = () => { const { setMessage: alert } = useAlertStore((state) => state); @@ -149,6 +158,8 @@ const PublicSewageFacilityInp = () => { const [lastSearchedValues, setLastSearchedValues] = useState(null); const [tabStatusInfoMap, setTabStatusInfoMap] = useState>(new Map()); const [tabStatsCdMap, setTabStatsCdMap] = useState>(new Map()); + /** 탭키별 `engColNm -> columnGuide` 캐시 */ + const [columnGuidesByTab, setColumnGuidesByTab] = useState>>({}); const [toolbarSnapshotByTab, setToolbarSnapshotByTab] = useState>(() => new Map()); const [inputPeriodEnterable, setInputPeriodEnterable] = useState(true); const [periodCheckList, setPeriodCheckList] = useState([]); @@ -489,6 +500,36 @@ const PublicSewageFacilityInp = () => { const firstTabKey = PublicSewageFactInpColumnList[0]?.tabKey ?? 'ST00060001'; const searchParamRef = useRef(null); + useEffect(() => { + let cancelled = false; + void (async () => { + const entries = await Promise.all( + PUBLIC_SEWAGE_FACILITY_COLUMN_GUIDE_TAB_KEYS.map(async (tabKey) => { + try { + const res = await statisticsApi.getColumnGuides(tabKey); + if (res.status !== 'success' || !Array.isArray(res.data)) { + return [tabKey, {}] as const; + } + const next: Record = {}; + for (const item of res.data) { + const key = item.engColNm?.trim(); + const guide = item.columnGuide?.trim(); + if (key && guide) next[key] = guide; + } + return [tabKey, next] as const; + } catch { + return [tabKey, {}] as const; + } + }), + ); + if (cancelled) return; + setColumnGuidesByTab(Object.fromEntries(entries)); + })(); + return () => { + cancelled = true; + }; + }, []); + const commitToolbarSnapshotForLoadedTab = ( tabKey: string, statusInfo: SewerageStatusInfo | null, @@ -783,7 +824,7 @@ const PublicSewageFacilityInp = () => { const requestData: cancelSubmitRequest = { sewerBzmnCd: lastSearchedValues?.bsnmCd ?? '', ctpvNm: lastSearchedValues?.sido ?? '', - sggNm: lastSearchedValues?.gugun ?? '', + sggNm: lastSearchedValues?.gugun === 'ALL' ? '' : lastSearchedValues?.gugun ?? '', statsYr: lastSearchedValues?.year ?? '', mdfrId: userInfo.userUnqId, statsCd: statsCdForCancel, @@ -811,8 +852,15 @@ const PublicSewageFacilityInp = () => { const getGridColumnInfo = ( tab: ColumnInfoList, + guideTabKey?: string, ): ColumnInfo => ({ - columns: tab.columns ?? [], + columns: (tab.columns ?? []).map((col) => { + const guide = columnGuidesByTab[guideTabKey ?? tab.tabKey]?.[col.fieldName]?.trim(); + return { + ...col, + tooltip: guide && guide.length > 0 ? guide : undefined, + }; + }), columnLayout: tab.columnLayout ?? [], }); @@ -955,8 +1003,8 @@ const PublicSewageFacilityInp = () => { return ( { } 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"); + } + }} /> ); @@ -1085,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); + }} /> & { }; const REGIONAL_FACT_TAB_KEY = "ST00020001"; +const SEWAGE_PIPE_COLUMN_GUIDE_TAB_KEYS = Array.from( + new Set(SewagePipeInpColumnInfoList.map((tab) => tab.tabKey).filter(Boolean)), +); function hasRegionalFactVerificationErrors(verification?: SeweragePipeInputVerificationResult | null): boolean { if (!verification) return false; @@ -355,6 +358,8 @@ const SewagePipeInp = () => { const [tabStatsCdMap, setTabStatsCdMap] = useState>(new Map()); const [verificationHistoryOpen, setVerificationHistoryOpen] = useState(false); const [periodCheckList, setPeriodCheckList] = useState([]); + /** 탭키별 `engColNm -> columnGuide` 캐시 */ + const [columnGuidesByTab, setColumnGuidesByTab] = useState>>({}); /** API 연결 전에도 검색 후 툴바 등 UI를 쓸 수 있게 함 */ const [hasSearched, setHasSearched] = useState(false); /** checkInputPeriod — columnInfo useMemo가 입력기간 변경을 반영하도록 state로도 유지 */ @@ -417,6 +422,37 @@ const SewagePipeInp = () => { } }, [activeTabId, inputFocusNonce]); + useEffect(() => { + if (activeTabId !== STATISTICS_INPUT_URL_PATH.SEWAGE_PIPE) return; + let cancelled = false; + void (async () => { + const entries = await Promise.all( + SEWAGE_PIPE_COLUMN_GUIDE_TAB_KEYS.map(async (tabKey) => { + try { + const res = await statisticsApi.getColumnGuides(tabKey); + if (res.status !== "success" || !Array.isArray(res.data)) { + return [tabKey, {}] as const; + } + const next: Record = {}; + for (const item of res.data) { + const key = item.engColNm?.trim(); + const guide = item.columnGuide?.trim(); + if (key && guide) next[key] = guide; + } + return [tabKey, next] as const; + } catch { + return [tabKey, {}] as const; + } + }), + ); + if (cancelled) return; + setColumnGuidesByTab(Object.fromEntries(entries)); + })(); + return () => { + cancelled = true; + }; + }, [activeTabId]); + const syncTopStatusIfStillOnTab = (loadedTabKey: string, nextStatsCd: string) => { if (loadedTabKey === activeTabRef.current) { setStatsCd(nextStatsCd); @@ -445,6 +481,14 @@ const SewagePipeInp = () => { }; const columnInfo = useMemo(() => { + const applyColumnGuides = (cols: ColumnConfig[] | undefined, tabKey: string) => + (cols ?? []).map((col: ColumnConfig) => { + const guide = columnGuidesByTab[tabKey]?.[col.fieldName]?.trim(); + return { + ...col, + tooltip: guide && guide.length > 0 ? guide : undefined, + }; + }); return SewagePipeInpColumnInfoList.map((tab: ColumnInfoList) => { const canEdit = computeTabEnterable(tabStatusInfoMap.get(tab.tabKey) ?? null, inputPeriodEnterable) || isAdminInfo; @@ -458,16 +502,16 @@ const SewagePipeInp = () => { if (tab.left && tab.right) { return { ...tab, - left: { ...tab.left, columns: applyEditable(tab.left.columns) }, - right: { ...tab.right, columns: applyEditable(tab.right.columns) }, + left: { ...tab.left, columns: applyColumnGuides(applyEditable(tab.left.columns), tab.tabKey) }, + right: { ...tab.right, columns: applyColumnGuides(applyEditable(tab.right.columns), tab.tabKey) }, }; } return { ...tab, - columns: applyEditable(tab.columns), + columns: applyColumnGuides(applyEditable(tab.columns), tab.tabKey), }; }); - }, [tabStatusInfoMap, isAdminInfo, inputPeriodEnterable]); + }, [tabStatusInfoMap, isAdminInfo, inputPeriodEnterable, columnGuidesByTab]); const commitToolbarSnapshotForLoadedTab = ( tabKey: string, @@ -866,14 +910,14 @@ const SewagePipeInp = () => { 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); @@ -1251,7 +1295,7 @@ const SewagePipeInp = () => { const requestData: cancelSubmitRequest = { sewerBzmnCd: searchValues?.bsnmCd ?? '', ctpvNm: searchValues?.sido ?? '', - sggNm: searchValues?.gugun ?? '', + sggNm: searchValues?.gugun === "ALL" ? "" : (searchValues?.gugun ?? ""), statsYr: searchValues?.year ?? '', mdfrId: userInfo.userUnqId, statsCd: tabStatsCdMap.get(activeTab) ?? statsCd, @@ -2008,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 4db880b03..dccc909ae 100644 --- a/apps/st/src/components/statistics/input/SeweragePenetrationRateInp.tsx +++ b/apps/st/src/components/statistics/input/SeweragePenetrationRateInp.tsx @@ -1,4 +1,4 @@ -import { useEffect,useLayoutEffect, useMemo, useRef, useState } from "react"; +import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { useConfirm } from "@frontend-opensource/use-react-hooks"; @@ -57,6 +57,9 @@ type GridRowWithVerification = Record & { verificationResult?: SewerageSupplyRateVerificationResult; }; +/** 컬럼 가이드 API 조회용 통계코드 (하수도 보급률) */ +const SEWERAGE_PENETRATION_COLUMN_GUIDE_STATS_CD = "ST00010001"; + const hasVerificationErrors = (verification?: SewerageSupplyRateVerificationResult | null): boolean => { if (!verification) return false; return Object.values(verification).some((errors) => Array.isArray(errors) && errors.length > 0); @@ -101,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); @@ -119,6 +130,8 @@ const SeweragePenetrationRateInp = () => { /** 예외제출 버튼으로 연 다이얼로그인지 (확인 시 executeSubmit 대신 SubmitExceptionFunc 경로) */ const [isExceptionSubmitFlow, setIsExceptionSubmitFlow] = useState(false); const [statsCd, setStatsCd] = useState(''); + /** `engColNm` → `columnGuide` (헤더 hover) */ + const [columnGuidesByEngCol, setColumnGuidesByEngCol] = useState>({}); const [footerFromApi, setFooterFromApi] = useState<{ sewerBzmnWholPpltnCnt?: number; sewerBzmnDomplWholPpltnCnt?: number; sewerBzmnFrgnrWholPpltnCnt?: number; sewerBzmnArea?: number } | null>(null); const { confirm } = useConfirm(); const showGlobalProgress = useGlobalProgressStore((state) => state.show); @@ -149,6 +162,35 @@ const SeweragePenetrationRateInp = () => { setSearchFormRemountKey((k) => k + 1); }, [activeTabId, inputFocusNonce]); + useEffect(() => { + if (activeTabId !== STATISTICS_INPUT_URL_PATH.SEWERAGE_PENETRATION_RATE) { + return; + } + let cancelled = false; + void (async () => { + try { + const res = await statisticsApi.getColumnGuides(SEWERAGE_PENETRATION_COLUMN_GUIDE_STATS_CD); + if (cancelled) return; + if (res.status !== "success" || !Array.isArray(res.data)) { + setColumnGuidesByEngCol({}); + return; + } + const next: Record = {}; + for (const item of res.data) { + const key = item.engColNm?.trim(); + const guide = item.columnGuide?.trim(); + if (key && guide) next[key] = guide; + } + setColumnGuidesByEngCol(next); + } catch { + if (!cancelled) setColumnGuidesByEngCol({}); + } + })(); + return () => { + cancelled = true; + }; + }, [activeTabId]); + const { isAdminInfo } = useAdminStore(); const getGridDataRef = useRef<(() => Record[]) | null>(null); @@ -167,9 +209,17 @@ const SeweragePenetrationRateInp = () => { const { columns, columnLayout } = SeweragePenetrationRateInpColumn; const base = (isEnterable || isAdminInfo) ? columns : columns.map((col) => ({ ...col, editable: false as const })); + const withHeaderTooltips = base.map((col) => { + const guide = columnGuidesByEngCol[col.fieldName]?.trim(); + return { + ...col, + tooltip: guide && guide.length > 0 ? guide : undefined, + }; + }); + return { columnLayout, - columns: base.map((col) => { + columns: withHeaderTooltips.map((col) => { if (col.fieldName === 'pbadmsWholPpltnCnt') return { ...col, footer: footerWithApiDefault('sewerBzmnWholPpltnCnt', '#,##0') }; if (col.fieldName === 'domplWholPpltnCnt') return { ...col, footer: footerWithApiDefault('sewerBzmnDomplWholPpltnCnt', '#,##0') }; if (col.fieldName === 'frgnrWholPpltnCnt') return { ...col, footer: footerWithApiDefault('sewerBzmnFrgnrWholPpltnCnt', '#,##0') }; @@ -178,7 +228,7 @@ const SeweragePenetrationRateInp = () => { return col; }), }; - }, [isEnterable, isAdminInfo, footerFromApi]); + }, [isEnterable, isAdminInfo, footerFromApi, columnGuidesByEngCol]); const handleReset = () => { setRowData([]); @@ -276,7 +326,7 @@ const SeweragePenetrationRateInp = () => { if (!values) return; postVerificationSearchRef.current = null; void handleSearch(values); - + }, [searchFormRemountKey]); const SubmitExceptionFunc = async (submitInfo: StatisticsInputSubmitInfoFormData) => { @@ -291,7 +341,7 @@ const SeweragePenetrationRateInp = () => { ...submitInfo, sewerBzmnCd: searchValues?.bsnmCd ?? '', ctpvNm: searchValues?.sido ?? '', - sggNm: searchValues?.gugun ?? '', + sggNm: searchValues?.gugun === "ALL" ? "" : (searchValues?.gugun ?? ''), statsYr: searchValues?.year ?? '', userId: userInfo.userUnqId, statsCd: statsCd, @@ -336,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); @@ -396,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'), @@ -413,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'), @@ -428,7 +479,7 @@ const SeweragePenetrationRateInp = () => { const requestData: SewerageSupplyRateSaveRequest = { sewerBzmnCd: searchValues?.bsnmCd ?? '', ctpvNm: searchValues?.sido ?? '', - sggNm: searchValues?.gugun ?? '', + sggNm: searchValues?.gugun === "ALL" ? "" : (searchValues?.gugun ?? ''), statsYr: searchValues?.year ?? '', userId: userInfo.userUnqId, submitList: convertedData, @@ -542,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'), // 생물학적처리 @@ -562,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'), // 총면적(㎢) @@ -579,7 +633,7 @@ const SeweragePenetrationRateInp = () => { const requestData: SewerageSupplyRateSaveRequest = { sewerBzmnCd: searchValues?.bsnmCd ?? '', // 하수도 사업자 코드 ctpvNm: searchValues?.sido ?? '', // 시도 - sggNm: searchValues?.gugun ?? '', + sggNm: searchValues?.gugun === "ALL" ? "" : (searchValues?.gugun ?? ''), statsYr: searchValues?.year ?? '', // 통계 연도 userId: userInfo.userUnqId, // 사용자 saveList: convertedData, // 저장 데이터 @@ -624,7 +678,7 @@ const SeweragePenetrationRateInp = () => { const requestData: cancelSubmitRequest = { sewerBzmnCd: searchValues?.bsnmCd ?? '', ctpvNm: searchValues?.sido ?? '', - sggNm: searchValues?.gugun ?? '', + sggNm: searchValues?.gugun === "ALL" ? "" : (searchValues?.gugun ?? ""), statsYr: searchValues?.year ?? '', mdfrId: userInfo.userUnqId, statsCd: statsCd, @@ -777,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/LogHistory.tsx b/apps/st/src/components/statistics/management/LogHistory.tsx index fdb9a4e1a..2c4e68dd3 100644 --- a/apps/st/src/components/statistics/management/LogHistory.tsx +++ b/apps/st/src/components/statistics/management/LogHistory.tsx @@ -8,6 +8,7 @@ import useSWR from "swr"; import { useAlertStore } from "@/shared/lib"; import { CodeSelectWithAll } from "@/entities/code"; import { statisticsApi } from "@/entities/statistics/api/statistics-service"; +import type { StatisticsLogResponse } from "@/entities/statistics/model/statistics-interface"; import type { ApiResponse, SearchCondition, @@ -129,7 +130,7 @@ const LogHistory = () => { const selectedBsnm = value === "ALL" ? (sidoList?.data.sidoList.find((v) => v.sido === sido) - ?.bsnmCd ?? "") + ?.bsnmCd ?? "") : (listForBsnm.find((v) => v.gugun === value)?.bsnmCd ?? ""); setGugun(value); setBsnmCd(selectedBsnm); @@ -150,8 +151,8 @@ const LogHistory = () => { ...(bsnmCd && { sewerBzmnCd: bsnmCd }), ...(statsDataChgComCd && statsDataChgComCd !== "ALL" && { - statsDataChgComCd, - }), + statsDataChgComCd, + }), }; try { @@ -160,15 +161,19 @@ const LogHistory = () => { const response = await statisticsApi.getLogList(param); if (response.status === "success") { - const lostList = response.data?.statsLogList ?? []; + const logListRaw = response.data?.statsLogList ?? []; + const logList = logListRaw.map((l) => ({ + ...l, + sggNm: String(l.sggNm ?? "").trim() || "-", + })); - setRowData(toGridRows(lostList)); + setRowData(toGridRows(logList)); setTotalPages(response.data?.pageInfo?.totalPages ?? 0); setPageIndex(Math.max(0, page1Based - 1)); } else { alert( response.message || - "데이터 조회 중 오류가 발생했습니다.", + "데이터 조회 중 오류가 발생했습니다.", ); } } catch { @@ -331,7 +336,7 @@ const LogHistory = () => { columnInfo={LogHistoryColumn} isExcelImport={false} rowData={rowData} - setRowData={() => {}} + setRowData={() => { }} onRowClick={handleRowClick} /> ): "INPT" | "VRFC" return "INPT"; } -/** API `YYYYMMDD` 등 → Date */ -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,15 +77,17 @@ const StatisticsInputManagement = () => { setIsLoading(true); const params: StatsInputPeriodListRequest = { statsYr: values.year, + ...(values.clsfCd && values.clsfCd !== "ALL" + ? { seType: values.clsfCd } + : {}), }; - if (values.clsfCd && values.clsfCd !== "ALL") { - params.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 { @@ -111,21 +119,47 @@ const StatisticsInputManagement = () => { setPeriodDialogOpen(true); }; - const handleRowClick = (row: Record) => { + 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), - 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[] = [ diff --git a/apps/st/src/components/statistics/management/StatisticsInputPeriodDialog.tsx b/apps/st/src/components/statistics/management/StatisticsInputPeriodDialog.tsx index 06a70a10d..4e0c878da 100644 --- a/apps/st/src/components/statistics/management/StatisticsInputPeriodDialog.tsx +++ b/apps/st/src/components/statistics/management/StatisticsInputPeriodDialog.tsx @@ -140,11 +140,13 @@ export function StatisticsInputPeriodDialog({ const param: StatsInputPeriodSaveRequest = { statsYr: yr, seType, - seCd: seCdParsed, inptBgngYmd: bgng, inptEndYmd: end, rgtrId: userInfo.userUnqId, }; + if (!isCreate) { + param.seCd = seCdParsed; + } const res = await statisticsApi.saveStatsInputPeriod(param); if (res.status === "success") { alert(`${isCreate ? '등록 되었습니다.' : '수정 되었습니다.'}`); @@ -243,7 +245,7 @@ export function StatisticsInputPeriodDialog({