From a1f02d37178a34e419e20ccda279342da8f87939 Mon Sep 17 00:00:00 2001 From: rsone001 Date: Fri, 10 Apr 2026 17:23:43 +0900 Subject: [PATCH] =?UTF-8?q?st:=20fix:=20=EB=A1=9C=EA=B7=B8=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C,=20Grid=20cell=20style=20=EC=88=98=EC=A0=95,=20?= =?UTF-8?q?=EC=9E=85=EB=A0=A5,=EA=B2=80=ED=86=A0,=EC=A7=91=EA=B3=84?= =?UTF-8?q?=ED=91=9C=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 --- apps/st/src/components/css/DataGrid.css | 30 + apps/st/src/components/module/TabDataGrid.tsx | 136 ++- .../input/SewagePipeMaintainInp.tsx | 185 +++- .../statistics/management/LogHistory.tsx | 41 +- .../management/LogHistoryDetailDialog.tsx | 56 ++ .../management/LogHistoryTablePagination.tsx | 2 +- .../review/SewagePipeMaintainRvw.tsx | 138 +-- .../statistics/review/SewagePipeRvw.tsx | 137 +-- .../review/SeweragePenetrationRateRvw.tsx | 43 +- .../statistics/review/StatsInputStatusRvw.tsx | 11 +- .../statistics/total/SewagePipeTot.tsx | 94 ++- apps/st/src/components/type/DataGrid.type.ts | 4 + .../PipeDredgingStatusInpColumn.ts | 68 +- .../RepairStatusInpColumn.ts | 67 +- .../column/management/LogHistoryColumn.ts | 2 +- .../util/column/review/SewagePipeRvwColumn.ts | 70 +- .../review/SewageReservoirPumpRvwColumn.ts | 306 ++++--- .../SeweragePenetrationRateRvwColumn.ts | 4 +- .../review/StatsInputStatusRvwColumn.ts | 18 +- .../util/column/total/SewagePipeTotColumn.ts | 799 ++++++++++++++++++ ...tics-input-sewage-pipe-maintain-service.ts | 29 + .../statistics/api/statistics-service.ts | 8 + .../api/statistics-total-service.ts | 9 + ...cs-input-sewage-pipe-maintain-interface.ts | 112 +++ .../statistics-input-review-interface.ts | 11 +- .../statistics/model/statistics-interface.ts | 19 + ...statistics-sewage-pipe-review-interface.ts | 18 +- ...s-sewerage-penetration-review-interface.ts | 90 +- .../statistics-sewage-pipe-total-interface.ts | 59 ++ 29 files changed, 2073 insertions(+), 493 deletions(-) create mode 100644 apps/st/src/components/statistics/management/LogHistoryDetailDialog.tsx create mode 100644 apps/st/src/components/util/column/total/SewagePipeTotColumn.ts create mode 100644 apps/st/src/entities/statistics/api/input/statistics-input-sewage-pipe-maintain-service.ts create mode 100644 apps/st/src/entities/statistics/model/input/statistics-input-sewage-pipe-maintain-interface.ts create mode 100644 apps/st/src/entities/statistics/model/statistics-total/statistics-sewage-pipe-total-interface.ts diff --git a/apps/st/src/components/css/DataGrid.css b/apps/st/src/components/css/DataGrid.css index 46a1c6304..8d56c48f8 100644 --- a/apps/st/src/components/css/DataGrid.css +++ b/apps/st/src/components/css/DataGrid.css @@ -44,6 +44,21 @@ .rg-grid .rg-body .rg-table .rg-data-row .rg-data-cell.rg-cell-rowtype-other { @apply !bg-[#fff7bf]; } + /* fixedColumns 영역은 rg-table/rg-data-row 구조가 다를 수 있어 별도 커버 */ + .rg-grid .rg-body .rg-data-cell.rg-cell-rowtype-biz-sum { + @apply !bg-[#ffe9cf]; + } + .rg-grid .rg-body .rg-data-cell.rg-cell-rowtype-other { + @apply !bg-[#fff7bf]; + } + .rg-grid .rg-fixed-body .rg-table .rg-data-row td.rg-cell-rowtype-biz-sum, + .rg-grid .rg-fixed-body .rg-data-row .rg-data-cell.rg-cell-rowtype-biz-sum { + @apply !bg-[#ffe9cf]; + } + .rg-grid .rg-fixed-body .rg-table .rg-data-row td.rg-cell-rowtype-other, + .rg-grid .rg-fixed-body .rg-data-row .rg-data-cell.rg-cell-rowtype-other { + @apply !bg-[#fff7bf]; + } /* 진행상황: 소프트 인라인 요약 (툴바와 자연스럽게 blend) */ .data-grid-status-bubble { @@ -108,4 +123,19 @@ .tabs-content { margin-top: 0.6rem !important; +} + +/* + * RealGrid 기본 스타일(.rg-alternate-row { background: #fafafa })은 @layer 밖이라 + * @layer components 규칙만으로는 tr 배경이 남을 수 있음. + * 스크롤 바디(.rg-body)만 흰색으로 통일 — 고정 영역(.rg-fixed-body)은 건드리지 않음. + */ +.rg-grid .rg-body .rg-table tr.rg-alternate-row { + background: #ffffff !important; + background-color: #ffffff !important; +} + +.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; } \ No newline at end of file diff --git a/apps/st/src/components/module/TabDataGrid.tsx b/apps/st/src/components/module/TabDataGrid.tsx index 7f511665c..2aba5350b 100644 --- a/apps/st/src/components/module/TabDataGrid.tsx +++ b/apps/st/src/components/module/TabDataGrid.tsx @@ -1,19 +1,22 @@ -import { useRef, useEffect, useState } from "react" -import { ValueType } from "realgrid" +import { useCallback,useEffect, useLayoutEffect, useRef, useState } from "react" +import JSZip from "jszip" +import { ValueType } from "realgrid" import RealGrid, { GridFitStyle } from "realgrid" import { RealGridReact, RGDataColumn } from "realgrid-react" -import type { TabDataGridProps, ColumnInfo, RealGridColumnInfo, GridViewWithSetColumns, FooterItemWithCallback, ColumnLayoutItem } from "../type/DataGrid.type" -import { getComputedValueCallback } from "@/components/util/grid/computedFieldCallbacks" -import JSZip from "jszip" import * as XLSX from "xlsx" -import { useAlertStore } from "@/shared/lib"; -import { useTabStore } from "@/store/tab-store" -import { Tabs, TabsList, TabsTrigger, TabsContent } from "@common/components"; +import { useAlertStore } from "@/shared/lib"; +import { Tabs, TabsContent,TabsList, TabsTrigger } from "@common/components"; import { Button } from "@common/components"; -import SplitDataGrid from "./SplitDataGrid"; + +import type { CellStyleCallback,ColumnConfig, ColumnInfo, ColumnLayoutItem, FooterItemWithCallback, GridViewWithSetColumns, RealGridColumnInfo, TabDataGridProps } from "../type/DataGrid.type" + import GridTopActionButton from "./GridTopActionButton"; +import SplitDataGrid from "./SplitDataGrid"; + +import { getComputedValueCallback } from "@/components/util/grid/computedFieldCallbacks" +import { useTabStore } from "@/store/tab-store" // RealGrid 타입 확장 declare module "realgrid" { @@ -50,6 +53,30 @@ if (typeof window !== "undefined") { RealGrid.setLicenseKey(process.env.NEXT_PUBLIC_ST_REALGRID_KEY || ''); +/** RealGrid 내부 해제(_view 등) 타이밍에서 refresh가 터질 수 있어 try/catch */ +function safeGridViewRefresh(gv: { refresh?: () => void } | null | undefined) { + if (!gv?.refresh) return; + try { + gv.refresh(); + } catch { + /* noop */ + } +} + +/** columnByName은 그리드 _view 준비 전에 호출되면 "Cannot read ... '_view'"로 터짐 */ +function safeColumnByName( + gv: { columnByName?: (name: string) => unknown } | null | undefined, + name: string, +): { styleCallback?: (g: unknown, dataCell: Record) => string } | null { + if (!gv?.columnByName) return null; + try { + const col = gv.columnByName(name) as { styleCallback?: (g: unknown, dataCell: Record) => string } | null; + return col ?? null; + } catch { + return null; + } +} + /** 정렬 필드 값을 숫자로 파싱 (쉼표 제거) */ function toNum(v: unknown): number { if (v == null) return NaN; @@ -228,6 +255,7 @@ const TabDataGrid = ({ getGridDataRef, enableSort = false, facilitySearchStatsYr, + cellStyleCallback, }: TabDataGridProps) => { // 각 탭별로 그리드 ref와 데이터를 관리 const gridRefs = useRef>(new Map()); @@ -240,6 +268,69 @@ const TabDataGrid = ({ const activeTabId = useTabStore((state) => state.activeTabId); const menuList = useTabStore((state) => state.menuList); + const rowDataMapRef = useRef(rowDataMap); + rowDataMapRef.current = rowDataMap; + const cellStyleCallbackRef = useRef(undefined); + cellStyleCallbackRef.current = cellStyleCallback; + + const attachCellStyleCallbacksForTab = useCallback((tabKey: string, columns: ColumnConfig[], attempt = 0) => { + if (!cellStyleCallbackRef.current) return; + const el = gridRefs.current.get(tabKey); + const gv = el?.gridView; + if (!gv) return; + + let attachedCount = 0; + for (const column of columns) { + const gridCol = safeColumnByName(gv, column.name); + if (!gridCol) continue; + const baseStyleName = column.styleName; + gridCol.styleCallback = (_g, dataCell) => { + const dc = dataCell as { + itemIndex?: number; + index?: { itemIndex?: number }; + dataField?: string; + fieldName?: string; + }; + const itemIndex = dc?.index?.itemIndex ?? dc?.itemIndex ?? -1; + const fieldName = dc?.dataField ?? dc?.fieldName ?? column.fieldName; + const data = rowDataMapRef.current.get(tabKey) || []; + if (itemIndex < 0 || itemIndex >= data.length) { + return baseStyleName ?? ""; + } + const row = data[itemIndex] as Record; + const extra = cellStyleCallbackRef.current?.({ + rowIndex: itemIndex, + fieldName, + row, + }); + if (!extra) return baseStyleName ?? ""; + return baseStyleName ? `${extra} ${baseStyleName}` : extra; + }; + attachedCount += 1; + } + + if (attachedCount === 0 && attempt < 20) { + setTimeout(() => attachCellStyleCallbacksForTab(tabKey, columns, attempt + 1), 100); + return; + } + + setTimeout(() => { + const latestGv = gridRefs.current.get(tabKey)?.gridView; + safeGridViewRefresh(latestGv ?? null); + }, 0); + }, []); + + /** 스타일 콜백은 ref로 최신 row를 읽음 — 여기서는 재부착 없이 refresh만 (이른 columnByName 호출 방지) */ + useLayoutEffect(() => { + if (!cellStyleCallback) return; + const id = requestAnimationFrame(() => { + gridRefs.current.forEach((ref) => { + safeGridViewRefresh(ref?.gridView ?? null); + }); + }); + return () => cancelAnimationFrame(id); + }, [cellStyleCallback, rowDataMap]); + const [internalActiveTab, setInternalActiveTab] = useState(() => { const firstTab = columnInfo[0]; return firstTab?.tabKey || ''; @@ -559,25 +650,34 @@ const TabDataGrid = ({ // rowDataMap이 변경될 때 그리드에 데이터 반영 (Excel import 등) useEffect(() => { + const timeoutIds: ReturnType[] = []; + columnInfo.forEach((info) => { const tabKey = info.tabKey; const gridRef = gridRefs.current.get(tabKey); const currentData = rowDataMap.get(tabKey); if (gridRef?.gridView && gridRef?.dataProvider && currentData && currentData.length > 0) { - setTimeout(() => { + const id = setTimeout(() => { try { - const dp = gridRef.dataProvider as RealGridDataProviderFill | null; - if (dp?.fillJsonData) { - dp.fillJsonData(currentData, { fillMode: "set" }); - } - gridRef.gridView.refresh(); + const latestRef = gridRefs.current.get(tabKey); + const gv = latestRef?.gridView; + const dp = latestRef?.dataProvider as RealGridDataProviderFill | null; + if (!gv || !dp?.fillJsonData || !currentData.length) return; + + dp.fillJsonData(currentData, { fillMode: "set" }); + gv.refresh(); } catch (error) { console.error("그리드 데이터 설정 오류:", error); } }, 100); + timeoutIds.push(id); } }); + + return () => { + timeoutIds.forEach((tid) => clearTimeout(tid)); + }; }, [rowDataMap, columnInfo]); // getGridDataRef: 현재 활성 탭 그리드 데이터 반환 함수 할당 @@ -1096,6 +1196,12 @@ const TabDataGrid = ({ const onSortingHandler = createOnSortingHandler(columns, setRowData, sortDirRef, originalRowsRef, findGridElement); (gridView as { onSorting?: (g: unknown, f: unknown[], d: unknown[]) => boolean }).onSorting = enableSort ? onSortingHandler : () => false; + + if (cellStyleCallbackRef.current) { + setTimeout(() => { + attachCellStyleCallbacksForTab(tabKey, columns); + }, 100); + } }, 150); } } else { diff --git a/apps/st/src/components/statistics/input/SewagePipeMaintainInp.tsx b/apps/st/src/components/statistics/input/SewagePipeMaintainInp.tsx index c34d35498..cddd6cc84 100644 --- a/apps/st/src/components/statistics/input/SewagePipeMaintainInp.tsx +++ b/apps/st/src/components/statistics/input/SewagePipeMaintainInp.tsx @@ -1,17 +1,45 @@ -import { useState } from "react"; +import { useRef, useState } from "react"; + +import { useAlertStore } from "@/shared/lib"; +import { statisticsInputSewSupplyRateApi } from "@/entities/statistics/api/input/statistics-input-sew-supply-rate-service"; +import { sewagePipeMaintainApiService } from "@/entities/statistics/api/input/statistics-input-sewage-pipe-maintain-service"; +import type { SewagePipeMaintainRequestParams } from "@/entities/statistics/model/input/statistics-input-sewage-pipe-maintain-interface"; +import { LoadingWithAnimation } from "@common/components"; -import SearchForm from "@/components/module/SearchForm"; -import { TabDataGrid } from '@rsone/components'; -import SubmitButton from "@/components/button/SumitButton"; -import SaveButton from "@/components/button/SaveButton"; import LastYearDataButton from "@/components/button/LastYearDataButton"; +import SaveButton from "@/components/button/SaveButton"; import SubmitExceptionButton from "@/components/button/SubmitExceptionButton"; - -import { RepairStatusInpColumn } from "@/components/util/column/input/sewagePipeMaintain/RepairStatusInpColumn"; +import SubmitButton from "@/components/button/SumitButton"; +import SearchForm from "@/components/module/SearchForm"; +import TabDataGrid from "@/components/module/TabDataGrid"; +import type { SearchFormValues } from "@/components/type/SearchForm.type"; import { PipeDredgingStatusInpColumn } from "@/components/util/column/input/sewagePipeMaintain/PipeDredgingStatusInpColumn"; +import { RepairStatusInpColumn } from "@/components/util/column/input/sewagePipeMaintain/RepairStatusInpColumn"; +import { toGridRows } from "@/components/util/grid/gridFunction"; + +function buildSewagePipeMaintainSearchParam( + values: SearchFormValues, + overrides?: { mode?: string }, +): SewagePipeMaintainRequestParams { + return { + statsYr: values.year, + sewerBzmnCd: values.bsnmCd, + ctpvNm: values.sido, + sggNm: values.gugun === "ALL" ? "" : values.gugun, + ...(overrides?.mode ? { mode: overrides.mode } : {}), + }; +} // 하수도 통계 관리 > 03. 하수관로 유지관리 const SewagePipeMaintainInp = () => { + const { setMessage: alert } = useAlertStore((state) => state); + const [isLoading, setIsLoading] = useState(false); + const [searchValues, setSearchValues] = useState(null); + const [activeTab, setActiveTab] = useState(RepairStatusInpColumn.tabKey); + const [lastSearchedValues, setLastSearchedValues] = useState(null); + const [lastSearchedYear, setLastSearchedYear] = useState(null); + const [hasSearched, setHasSearched] = useState(false); + const searchParamRef = useRef(null); // tab List const SewagePipeMaintainInpTabList = [ @@ -24,9 +52,69 @@ const SewagePipeMaintainInp = () => { // 예외 제출 로직 }; - const getLastYearDataFunc = () => { - console.log('get last year data'); - // 지난연도 데이터 가져오기 로직 + const loadActiveTabData = async ( + tabKey: string, + param: SewagePipeMaintainRequestParams, + append: boolean, + ): Promise => { + if (tabKey === RepairStatusInpColumn.tabKey) { + const response = await sewagePipeMaintainApiService.getSewagePipeMaintainRepair(param); + if (response.status !== "success") { + alert(response.message || "데이터 조회 중 오류가 발생했습니다."); + return; + } + const commonInfo = response.data.commonInfo; + const convertedData = (response.data.repairStatusList ?? []).map((d) => ({ + ctpvNm: commonInfo.ctpvNm, + ...d, + })); + setRowDataMap((prev) => { + const next = append ? new Map(prev) : new Map[]>(); + next.set(RepairStatusInpColumn.tabKey, toGridRows(convertedData)); + return next; + }); + return; + } + + if (tabKey === PipeDredgingStatusInpColumn.tabKey) { + const response = await sewagePipeMaintainApiService.getSewagePipeMaintainDredging(param); + if (response.status !== "success") { + alert(response.message || "데이터 조회 중 오류가 발생했습니다."); + return; + } + const commonInfo = response.data.commonInfo; + const convertedData = (response.data.pipeDredgingStatusList ?? []).map((d) => ({ + ctpvNm: commonInfo.ctpvNm, + ...d, + })); + setRowDataMap((prev) => { + const next = append ? new Map(prev) : new Map[]>(); + next.set(PipeDredgingStatusInpColumn.tabKey, toGridRows(convertedData)); + return next; + }); + } + }; + + const getLastYearDataFunc = async () => { + const values = lastSearchedValues ?? searchValues; + if (!values?.sido || values.sido === "ALL") { + alert("시도를 선택해주세요."); + return; + } + if (!lastSearchedYear) { + alert("먼저 데이터를 조회해주세요."); + return; + } + + try { + setIsLoading(true); + const param = buildSewagePipeMaintainSearchParam(values, { mode: "PRE" }); + await loadActiveTabData(activeTab, param, true); + } catch { + alert("데이터 조회 중 오류가 발생했습니다."); + } finally { + setIsLoading(false); + } }; const submitFunc = () => { @@ -40,22 +128,83 @@ const SewagePipeMaintainInp = () => { }; const [rowDataMap, setRowDataMap] = useState[]>>(new Map()); + + const handleReset = () => { + setRowDataMap(new Map()); + setActiveTab(RepairStatusInpColumn.tabKey); + setLastSearchedValues(null); + setLastSearchedYear(null); + setHasSearched(false); + searchParamRef.current = null; + }; + + const handleSearch = async (values: SearchFormValues) => { + if (!values.sido || values.sido === "ALL") { + alert("시도를 선택해주세요."); + return; + } + + try { + const periodResponse = await statisticsInputSewSupplyRateApi.checkInputPeriod(values.year || ""); + if (periodResponse.status !== "success") { + alert(periodResponse.message); + return; + } + + setIsLoading(true); + const searchParam = buildSewagePipeMaintainSearchParam(values); + searchParamRef.current = searchParam; + setLastSearchedValues(values); + setLastSearchedYear(values.year ?? null); + setHasSearched(true); + await loadActiveTabData(activeTab, searchParam, false); + } catch { + alert("데이터 조회 중 오류가 발생했습니다."); + } finally { + setIsLoading(false); + } + }; + + const handleActiveTabChange = async (tabKey: string) => { + setActiveTab(tabKey); + const hasRows = (rowDataMap.get(tabKey) ?? []).length > 0; + if (hasRows || !searchParamRef.current) return; + try { + setIsLoading(true); + await loadActiveTabData(tabKey, searchParamRef.current, true); + } catch { + alert("데이터 조회 중 오류가 발생했습니다."); + } finally { + setIsLoading(false); + } + }; + + const hasRowData = Array.from(rowDataMap.values()).some((rows) => rows.length > 0); return ( <> - -
-
- - - - + + {(hasRowData || hasSearched) && ( +
+
+ + void getLastYearDataFunc()} /> + + +
-
+ )} + {isLoading &&
+
+ +
+
} ); diff --git a/apps/st/src/components/statistics/management/LogHistory.tsx b/apps/st/src/components/statistics/management/LogHistory.tsx index 225de9399..c94528cc5 100644 --- a/apps/st/src/components/statistics/management/LogHistory.tsx +++ b/apps/st/src/components/statistics/management/LogHistory.tsx @@ -1,18 +1,18 @@ "use client"; -import { useCallback, useState } from 'react'; +import { useCallback, useState, useEffect } from 'react'; import { Button, Label, LoadingWithAnimation, -} - from "@common/components"; +} from "@common/components"; import DataGrid from '@/components/module/DataGrid'; import { CodeSelectWithAll } from '@/entities/code'; import { statisticsAPi } from '@/entities/statistics/api/statistics-service'; -import type { StatisticsLogRequest } from '@/entities/statistics/model/statistics-interface'; +import type { StatisticsLogRequest, StatisticsLog } from '@/entities/statistics/model/statistics-interface'; +import { LogHistoryDetailDialog } from '@/components/statistics/management/LogHistoryDetailDialog'; import { toGridRows } from '@/components/util/grid/gridFunction'; import { LogHistoryColumn } from '@/components/util/column/management/LogHistoryColumn'; @@ -27,6 +27,11 @@ const LogHistory = () => { const [rowData, setRowData] = useState[]>([]); const [pageIndex, setPageIndex] = useState(0); const [totalPages, setTotalPages] = useState(0); + const [detailDialogOpen, setDetailDialogOpen] = useState(false); + const [selectedLogRow, setSelectedLogRow] = useState | null>(null); const loadLogs = useCallback(async (page1Based: number) => { const param: StatisticsLogRequest = { @@ -41,7 +46,13 @@ const LogHistory = () => { const response = await statisticsAPi.getLogList(param); if (response.status === 'success') { - setRowData(toGridRows(response.data?.statsLogList ?? [])); + const lostList = response.data?.statsLogList ?? []; + const convertedData: StatisticsLog[] = lostList.map((item: StatisticsLog) => ({ + ...item, + dmndFwkNm: item.dmndFwkNm === 'save' ? '저장' : (item.dmndFwkNm === 'submit' ? '제출' : '예외 제출'), + })); + + setRowData(toGridRows(convertedData)); setTotalPages(response.data?.pageInfo?.totalPages ?? 0); setPageIndex(Math.max(0, page1Based - 1)); } else { @@ -59,6 +70,20 @@ const LogHistory = () => { await loadLogs(1); }; + const handleRowClick = (row: Record) => { + setSelectedLogRow(row); + setDetailDialogOpen(true); + }; + + const handleDetailDialogOpenChange = (next: boolean) => { + setDetailDialogOpen(next); + if (!next) setSelectedLogRow(null); + }; + + useEffect(() => { + loadLogs(1); + }, []); + return (
@@ -97,6 +122,12 @@ const LogHistory = () => { isExcelImport={false} rowData={rowData} setRowData={() => { }} + onRowClick={handleRowClick} + /> + void; + row: Record | null; +} + +export function LogHistoryDetailDialog({ + open, + onOpenChange, + row, +}: LogHistoryDetailDialogProps) { + useEffect(() => { + if (!open || !row) return; + + const sn = row.statsLogSn; + void (async () => { + const response = await statisticsAPi.getLogDetail( + sn !== undefined && sn !== null ? String(sn) : "", + ); + console.log(response.data); + })(); + }, [open, row]); + + return ( + + + + + + + + + + + + + ); +} diff --git a/apps/st/src/components/statistics/management/LogHistoryTablePagination.tsx b/apps/st/src/components/statistics/management/LogHistoryTablePagination.tsx index 88c589e01..ddcc34531 100644 --- a/apps/st/src/components/statistics/management/LogHistoryTablePagination.tsx +++ b/apps/st/src/components/statistics/management/LogHistoryTablePagination.tsx @@ -43,7 +43,7 @@ export function LogHistoryTablePagination({ const canNext = pageCount > 0 && currentPage < pageCount - 1; return ( -
+