From a1f02d37178a34e419e20ccda279342da8f87939 Mon Sep 17 00:00:00 2001 From: rsone001 Date: Fri, 10 Apr 2026 17:23:43 +0900 Subject: [PATCH 1/3] =?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 ( -
+
+ +
+
+
+ +
+
+ +
+
+ + + +
+
+
+ + handleSubFilterChange("status", value) + } + options={STATUS_OPTIONS} + /> + + handleSubFilterChange("approvalStatus", value) + } + options={APPROVAL_STATUS_OPTIONS} + /> +
+
+ + +
+
+
+ + + + {isLoading && ( +
+
+ +
+
+ )} +
+ ); +} + +interface FilterSelectProps { + label: string; + onChange: (value: string) => void; + options: Array<{ label: string; value: string }>; + value: string; +} + +function FilterSelect({ label, onChange, options, value }: FilterSelectProps) { + return ( +
+ + +
+ ); +} + +export { StatisticsVerificationReport }; +export default StatisticsVerificationReport; diff --git a/apps/st/src/entities/statistics/model/input/statistics-input-status-interface.ts b/apps/st/src/entities/statistics/model/input/statistics-input-status-interface.ts index ae721e462..ea6c281fe 100644 --- a/apps/st/src/entities/statistics/model/input/statistics-input-status-interface.ts +++ b/apps/st/src/entities/statistics/model/input/statistics-input-status-interface.ts @@ -6,6 +6,7 @@ export interface StatsInputStatusListRequestParams { sewerBzmnCd?: string; statsYr?: string; mode?: string; + statsCd?: string; } export interface StatsInputStatusList { @@ -25,10 +26,14 @@ export interface StatsInputStatusList { rjctOpnnCn?: string; } -export type StatsInputStatusListApiResponse = ApiResponse & { +export type StatsInputStatusListApiResponse = ApiResponse< + StatsInputStatusList[] +> & { message: string; -} +}; export interface StatisticsInputStatusService { - getStatisticsInputStatusList(param: StatsInputStatusListRequestParams): Promise; + getStatisticsInputStatusList( + param: StatsInputStatusListRequestParams, + ): Promise; } diff --git a/apps/st/src/entities/statistics/model/statistics-verification-report/statistics-verification-report-columns.ts b/apps/st/src/entities/statistics/model/statistics-verification-report/statistics-verification-report-columns.ts new file mode 100644 index 000000000..7ee24a013 --- /dev/null +++ b/apps/st/src/entities/statistics/model/statistics-verification-report/statistics-verification-report-columns.ts @@ -0,0 +1,71 @@ +import type { ColumnInfo } from "@/components/type/DataGrid.type"; + +const READ_ONLY_STYLE = "rg-cell-readonly"; + +export const statisticsVerificationSummaryColumnInfo: ColumnInfo = { + columns: [ + { name: "summaryId", fieldName: "summaryId", header: { text: "summaryId" }, visible: false }, + { name: "sido", fieldName: "sido", header: { text: "시도" }, styleName: READ_ONLY_STYLE }, + { name: "sigungu", fieldName: "sigungu", header: { text: "시군구" }, styleName: READ_ONLY_STYLE }, + { name: "reportType", fieldName: "reportType", header: { text: "구분" }, styleName: READ_ONLY_STYLE }, + { name: "progressStatus", fieldName: "progressStatus", header: { text: "진행상태" }, width: 120, styleName: READ_ONLY_STYLE }, + { name: "writerName", fieldName: "writerName", header: { text: "작성자" }, styleName: READ_ONLY_STYLE }, + { name: "writerContact", fieldName: "writerContact", header: { text: "작성자연락처" }, width: 130, styleName: READ_ONLY_STYLE }, + { name: "writerDept", fieldName: "writerDept", header: { text: "부서" }, styleName: READ_ONLY_STYLE }, + { name: "writerPosition", fieldName: "writerPosition", header: { text: "직급" }, styleName: READ_ONLY_STYLE }, + { name: "approverName", fieldName: "approverName", header: { text: "승인자" }, styleName: READ_ONLY_STYLE }, + { name: "approverDept", fieldName: "approverDept", header: { text: "부서" }, styleName: READ_ONLY_STYLE }, + { name: "approverPosition", fieldName: "approverPosition", header: { text: "직급" }, styleName: READ_ONLY_STYLE }, + { name: "returnReason", fieldName: "returnReason", header: { text: "반려사유" }, width: 220, styleName: READ_ONLY_STYLE }, + ], + columnLayout: [ + "summaryId", + "sido", + "sigungu", + "reportType", + "progressStatus", + "writerName", + "writerContact", + "writerDept", + "writerPosition", + "approverName", + "approverDept", + "approverPosition", + "returnReason", + ], +}; + +export const statisticsVerificationDetailColumnInfo: ColumnInfo = { + columns: [ + { name: "detailId", fieldName: "detailId", header: { text: "detailId" }, visible: false }, + { name: "summaryId", fieldName: "summaryId", header: { text: "summaryId" }, visible: false }, + { name: "sido", fieldName: "sido", header: { text: "시도" }, styleName: READ_ONLY_STYLE }, + { name: "sigungu", fieldName: "sigungu", header: { text: "시군구" }, styleName: READ_ONLY_STYLE }, + { name: "indicator", fieldName: "indicator", header: { text: "지표" }, width: 140, styleName: READ_ONLY_STYLE }, + { name: "detailItem", fieldName: "detailItem", header: { text: "세부항목" }, width: 180, styleName: READ_ONLY_STYLE }, + { name: "verifyDate", fieldName: "verifyDate", header: { text: "검증날짜" }, width: 110, styleName: READ_ONLY_STYLE }, + { name: "errorDetail", fieldName: "errorDetail", header: { text: "오류내역" }, width: 200, styleName: READ_ONLY_STYLE }, + { name: "inputValue", fieldName: "inputValue", header: { text: "입력값" }, width: 90, styleName: READ_ONLY_STYLE }, + { name: "compareValue", fieldName: "compareValue", header: { text: "비교값" }, width: 90, styleName: READ_ONLY_STYLE }, + { name: "status", fieldName: "status", header: { text: "상태" }, width: 90, styleName: READ_ONLY_STYLE }, + { name: "exceptionReason", fieldName: "exceptionReason", header: { text: "예외사유" }, width: 170, styleName: READ_ONLY_STYLE }, + { name: "returnOpinion", fieldName: "returnOpinion", header: { text: "반려의견" }, width: 170, styleName: READ_ONLY_STYLE }, + { name: "approvalYn", fieldName: "approvalYn", header: { text: "승인여부" }, width: 90, styleName: READ_ONLY_STYLE }, + ], + columnLayout: [ + "detailId", + "summaryId", + "sido", + "sigungu", + "indicator", + "detailItem", + "verifyDate", + "errorDetail", + "inputValue", + "compareValue", + "status", + "exceptionReason", + "returnOpinion", + "approvalYn", + ], +}; diff --git a/apps/st/src/entities/statistics/model/statistics-verification-report/statistics-verification-report-mock.ts b/apps/st/src/entities/statistics/model/statistics-verification-report/statistics-verification-report-mock.ts new file mode 100644 index 000000000..a56b16c49 --- /dev/null +++ b/apps/st/src/entities/statistics/model/statistics-verification-report/statistics-verification-report-mock.ts @@ -0,0 +1,288 @@ +import { fakerKO as faker } from "@faker-js/faker"; + +import type { + StatisticsVerificationDetailRow, + StatisticsVerificationFilterState, + StatisticsVerificationOption, + StatisticsVerificationSubFilterState, + StatisticsVerificationSummaryRow, +} from "./statistics-verification-report-types"; + +const NETWORK_DELAY_MS = 300; + +export const SIDO_OPTIONS: StatisticsVerificationOption[] = [ + { label: "전체", value: "ALL" }, + { label: "서울특별시", value: "SEOUL" }, + { label: "부산광역시", value: "BUSAN" }, + { label: "대구광역시", value: "DAEGU" }, + { label: "경기도", value: "GYEONGGI" }, +]; + +const SIGUNGU_OPTIONS_BY_SIDO: Record = + { + ALL: [{ label: "전체", value: "ALL" }], + SEOUL: [ + { label: "전체", value: "ALL" }, + { label: "중구", value: "SEOUL_JUNGGU" }, + { label: "강남구", value: "SEOUL_GANGNAM" }, + ], + BUSAN: [ + { label: "전체", value: "ALL" }, + { label: "해운대구", value: "BUSAN_HAEUNDAE" }, + { label: "수영구", value: "BUSAN_SUYEONG" }, + ], + DAEGU: [ + { label: "전체", value: "ALL" }, + { label: "중구", value: "DAEGU_JUNGGU" }, + { label: "달서구", value: "DAEGU_DALSEO" }, + ], + GYEONGGI: [ + { label: "전체", value: "ALL" }, + { label: "수원시", value: "GYEONGGI_SUWON" }, + { label: "성남시", value: "GYEONGGI_SEONGNAM" }, + ], + }; + +export const STATS_INDICATOR_OPTIONS: StatisticsVerificationOption[] = [ + { label: "통계항목", value: "ALL" }, + { label: "하수도 보급률", value: "SEWERAGE_SUPPLY" }, + { label: "관로 보급 현황", value: "PIPE_STATUS" }, + { label: "분류식 비율", value: "SEPARATED_RATIO" }, +]; + +export const STATUS_OPTIONS: StatisticsVerificationOption[] = [ + { label: "수정완료", value: "DONE" }, + { label: "검토중", value: "REVIEW" }, + { label: "오류", value: "ERROR" }, +]; + +export const APPROVAL_STATUS_OPTIONS: StatisticsVerificationOption[] = [ + { label: "전체", value: "ALL" }, + { label: "승인완료", value: "APPROVED" }, + { label: "반려", value: "REJECTED" }, + { label: "대기", value: "PENDING" }, +]; + +export const DEFAULT_FILTERS: StatisticsVerificationFilterState = { + sido: "ALL", + sigungu: "ALL", + year: "ALL", + statsIndicator: "ALL", +}; + +export const DEFAULT_SUB_FILTERS: StatisticsVerificationSubFilterState = { + status: "DONE", + approvalStatus: "ALL", +}; + +export function getYearOptions(): StatisticsVerificationOption[] { + const thisYear = new Date().getFullYear(); + return [ + { label: "전체", value: "ALL" }, + ...Array.from({ length: 5 }, (_, index) => { + const year = String(thisYear - index - 1); + return { label: year, value: year }; + }), + ]; +} + +export function getSigunguOptions( + sido: string, +): StatisticsVerificationOption[] { + return SIGUNGU_OPTIONS_BY_SIDO[sido] ?? []; +} + +export async function fetchStatisticsVerificationSummaryMock( + filters: StatisticsVerificationFilterState, +): Promise { + await wait(); + faker.seed(20260416); + + const rows = Array.from({ length: 12 }, (_, index) => { + const sido = pickSidoName(filters.sido); + const sigungu = pickSigunguName(filters.sido, filters.sigungu); + const progressStatus = faker.helpers.arrayElement([ + "승인대기", + "승인완료", + "반려", + ]); + return { + summaryId: index + 1, + sido, + sigungu, + reportType: faker.helpers.arrayElement([ + "정기보고", + "수정보고", + "보완보고", + ]), + progressStatus, + writerName: faker.person.lastName() + faker.person.firstName(), + writerContact: faker.phone.number({ style: "national" }), + writerDept: faker.helpers.arrayElement([ + "통계팀", + "하수행정과", + "정책기획과", + ]), + writerPosition: faker.helpers.arrayElement([ + "주무관", + "팀장", + "과장", + ]), + approverName: + progressStatus === "승인대기" + ? "-" + : faker.person.lastName() + faker.person.firstName(), + approverDept: + progressStatus === "승인대기" + ? "-" + : faker.helpers.arrayElement(["검증운영팀", "하수행정과"]), + approverPosition: + progressStatus === "승인대기" + ? "-" + : faker.helpers.arrayElement(["팀장", "과장"]), + returnReason: + progressStatus === "반려" + ? faker.helpers.arrayElement([ + "오류내역 확인 필요", + "예외사유 보강 필요", + "입력값 검증 재요청", + ]) + : "-", + }; + }); + + return rows.filter((row) => { + if (filters.sido !== "ALL" && row.sido !== pickSidoName(filters.sido)) + return false; + if (filters.sigungu !== "ALL") { + const currentSigungu = pickSigunguName( + filters.sido, + filters.sigungu, + ); + if (row.sigungu !== currentSigungu) return false; + } + return true; + }); +} + +export async function fetchStatisticsVerificationDetailMock( + filters: StatisticsVerificationFilterState, + subFilters: StatisticsVerificationSubFilterState, + selectedSummaryId?: number | null, +): Promise { + await wait(); + faker.seed(20260417); + + const rows = Array.from({ length: 24 }, (_, index) => { + const approvalYn = faker.helpers.arrayElement(["승인", "미승인"]); + const status = faker.helpers.arrayElement([ + "수정완료", + "검토중", + "오류", + ]); + const summaryId = faker.number.int({ min: 1, max: 12 }); + return { + detailId: index + 1, + summaryId, + sido: pickSidoName(filters.sido), + sigungu: pickSigunguName(filters.sido, filters.sigungu), + indicator: pickIndicator(filters.statsIndicator), + detailItem: faker.helpers.arrayElement([ + "처리구역면적", + "처리인구", + "분류식관거연장", + "하수관로보급률", + ]), + verifyDate: faker.date + .recent({ days: 20 }) + .toISOString() + .slice(0, 10), + errorDetail: faker.helpers.arrayElement([ + "전년 대비 편차 초과", + "입력값 누락", + "상호검증 불일치", + ]), + inputValue: faker.number.int({ min: 100, max: 5000 }), + compareValue: faker.number.int({ min: 100, max: 5000 }), + status, + exceptionReason: + status === "오류" + ? faker.lorem.sentence({ min: 1, max: 2 }) + : "-", + returnOpinion: + approvalYn === "미승인" + ? faker.helpers.arrayElement([ + "산식 재검토 필요", + "근거자료 보강 요청", + "담당자 확인 필요", + ]) + : "-", + approvalYn, + }; + }); + + return rows.filter((row) => { + if (selectedSummaryId && row.summaryId !== selectedSummaryId) + return false; + if (subFilters.status === "DONE" && row.status !== "수정완료") + return false; + if (subFilters.status === "REVIEW" && row.status !== "검토중") + return false; + if (subFilters.status === "ERROR" && row.status !== "오류") + return false; + if ( + subFilters.approvalStatus === "APPROVED" && + row.approvalYn !== "승인" + ) + return false; + if ( + subFilters.approvalStatus === "REJECTED" && + row.approvalYn !== "미승인" + ) + return false; + return true; + }); +} + +function wait() { + return new Promise((resolve) => setTimeout(resolve, NETWORK_DELAY_MS)); +} + +function pickSidoName(value: string): string { + if (value === "ALL") + return faker.helpers.arrayElement([ + "서울특별시", + "부산광역시", + "대구광역시", + "경기도", + ]); + + const matched = SIDO_OPTIONS.find((option) => option.value === value); + return matched?.label ?? "서울특별시"; +} + +function pickSigunguName(sido: string, sigungu: string): string { + if (sigungu !== "ALL") { + const options = getSigunguOptions(sido); + const matched = options.find((option) => option.value === sigungu); + if (matched) return matched.label; + } + + const options = getSigunguOptions(sido).filter( + (option) => option.value !== "ALL", + ); + if (options.length === 0) return "전체"; + return faker.helpers.arrayElement(options).label; +} + +function pickIndicator(indicator: string): string { + if (indicator === "ALL") + return faker.helpers.arrayElement( + STATS_INDICATOR_OPTIONS.filter((item) => item.value !== "ALL"), + ).label; + + const matched = STATS_INDICATOR_OPTIONS.find( + (option) => option.value === indicator, + ); + return matched?.label ?? "통계항목"; +} diff --git a/apps/st/src/entities/statistics/model/statistics-verification-report/statistics-verification-report-types.ts b/apps/st/src/entities/statistics/model/statistics-verification-report/statistics-verification-report-types.ts new file mode 100644 index 000000000..2cbcd04d9 --- /dev/null +++ b/apps/st/src/entities/statistics/model/statistics-verification-report/statistics-verification-report-types.ts @@ -0,0 +1,49 @@ +export interface StatisticsVerificationFilterState { + sido: string; + sigungu: string; + year: string; + statsIndicator: string; +} + +export interface StatisticsVerificationSubFilterState { + status: string; + approvalStatus: string; +} + +export interface StatisticsVerificationOption { + label: string; + value: string; +} + +export interface StatisticsVerificationSummaryRow { + summaryId: number; + sido: string; + sigungu: string; + reportType: string; + progressStatus: string; + writerName: string; + writerContact: string; + writerDept: string; + writerPosition: string; + approverName: string; + approverDept: string; + approverPosition: string; + returnReason: string; +} + +export interface StatisticsVerificationDetailRow { + detailId: number; + summaryId: number; + sido: string; + sigungu: string; + indicator: string; + detailItem: string; + verifyDate: string; + errorDetail: string; + inputValue: number; + compareValue: number; + status: string; + exceptionReason: string; + returnOpinion: string; + approvalYn: string; +} diff --git a/apps/st/src/shared/config/env.ts b/apps/st/src/shared/config/env.ts index 634bb7f15..6d1646631 100644 --- a/apps/st/src/shared/config/env.ts +++ b/apps/st/src/shared/config/env.ts @@ -13,4 +13,4 @@ export const REFRESH_TOKEN = export const AUTH_USER_ID = process.env.NEXT_PUBLIC_AUTH_USER_ID || "token-id"; export const ST_SERVICE_URL = process.env.NEXT_PUBLIC_ST_SERVICE_URL || "http://localhost:8080"; -export const COOKIE_DOMAIN = process.env.NEXT_PUBLIC_COOKIE_DOMAIN; \ No newline at end of file +export const COOKIE_DOMAIN = process.env.NEXT_PUBLIC_COOKIE_DOMAIN;