From 90834f3493a56c214d049ef187c7ae351b60e544 Mon Sep 17 00:00:00 2001 From: rsone001 Date: Mon, 18 May 2026 19:36:13 +0900 Subject: [PATCH] =?UTF-8?q?[st]=20-=20feat:=20=EB=AF=B8=EC=9E=85=EB=A0=A5?= =?UTF-8?q?=ED=98=84=ED=99=A9=20=EC=A1=B0=ED=9A=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/module/CompactDataGrid.tsx | 15 +- apps/st/src/components/module/DataGrid.tsx | 10 +- .../management/NotEnteredStatus.tsx | 355 ++++++++++++++++-- apps/st/src/components/type/DataGrid.type.ts | 6 +- .../statistics/api/statistics-service.ts | 10 + .../statistics/model/statistics-interface.ts | 35 ++ 6 files changed, 398 insertions(+), 33 deletions(-) diff --git a/apps/st/src/components/module/CompactDataGrid.tsx b/apps/st/src/components/module/CompactDataGrid.tsx index f7d66477d..c1fdbd226 100644 --- a/apps/st/src/components/module/CompactDataGrid.tsx +++ b/apps/st/src/components/module/CompactDataGrid.tsx @@ -117,6 +117,7 @@ const CompactDataGrid = ({ onItemAllChecked, compactFullWidth = false, compactGridHeightClass = "h-[55vh]", + headerHeight = 60, }: DataGridProps) => { const { columns, columnLayout } = columnInfo; const gridRef = useRef(null); @@ -188,7 +189,7 @@ const CompactDataGrid = ({ fitStyle: GridFitStyle.EVEN }); - gridView.header.height = 60; + gridView.header.height = headerHeight; if (columns.some((c) => Boolean(resolveColumnTooltip(c)))) { (gridView.header as { showTooltip?: boolean }).showTooltip = true; } @@ -449,7 +450,17 @@ const CompactDataGrid = ({ return () => { if (getGridDataRef) getGridDataRef.current = null; }; - }, [columns, columnLayout, fixedColumns, rowData, showHeaderCheckbox, getGridDataRef, compactFullWidth, compactGridHeightClass]); + }, [ + columns, + columnLayout, + fixedColumns, + rowData, + showHeaderCheckbox, + getGridDataRef, + compactFullWidth, + compactGridHeightClass, + headerHeight, + ]); // 행 클릭 이벤트 useEffect(() => { diff --git a/apps/st/src/components/module/DataGrid.tsx b/apps/st/src/components/module/DataGrid.tsx index d5d334269..7e4809608 100644 --- a/apps/st/src/components/module/DataGrid.tsx +++ b/apps/st/src/components/module/DataGrid.tsx @@ -409,6 +409,8 @@ const DataGrid = ({ idFieldName = "id", enableSort = false, gridHeight = 55, // vh 단위 + headerHeight = 60, + fitStyle = GridFitStyle.EVEN, }: DataGridProps) => { const { columns, columnLayout } = columnInfo; const gridRef = useRef(null); @@ -885,10 +887,9 @@ const DataGrid = ({ ) : () => false; - (gridView.displayOptions as DisplayOptions).fitStyle = - GridFitStyle.EVEN; + (gridView.displayOptions as DisplayOptions).fitStyle = fitStyle; gridView.displayOptions.rowHeight = 30; - gridView.header.height = 60; + gridView.header.height = headerHeight; if (columns.some((c) => Boolean(resolveColumnTooltip(c)))) { (gridView.header as { showTooltip?: boolean }).showTooltip = true; } @@ -1213,6 +1214,9 @@ const DataGrid = ({ rowData, showHeaderCheckbox, getGridDataRef, + headerHeight, + fitStyle, + enableSort, ]); // 행 클릭 이벤트 + 검증 오류 셀 클릭 시 showMessage (공식 문서 방식) diff --git a/apps/st/src/components/statistics/management/NotEnteredStatus.tsx b/apps/st/src/components/statistics/management/NotEnteredStatus.tsx index 20030062f..7b0740b27 100644 --- a/apps/st/src/components/statistics/management/NotEnteredStatus.tsx +++ b/apps/st/src/components/statistics/management/NotEnteredStatus.tsx @@ -1,41 +1,342 @@ -import { useState } from "react"; +import { useMemo, useState } from "react"; + +import { GridFitStyle } from "realgrid"; + +import { useAlertStore, useUserStore } from "@/shared/lib"; +import { statisticsApi } from "@/entities/statistics/api/statistics-service"; +import type { + UnSubmitDataStatusItem, + unSubmitDataRequest, +} from "@/entities/statistics/model/statistics-interface"; +import { LoadingWithAnimation } from "@common/components"; import DataGrid from "@/components/module/DataGrid"; import SearchForm from "@/components/module/SearchForm"; -import type { ColumnConfig } from "@/components/type/DataGrid.type"; +import type { ColumnConfig, ColumnLayoutItem } from "@/components/type/DataGrid.type"; +import type { SearchFormValues } from "@/components/type/SearchForm.type"; +import { toGridRows } from "@/components/util/grid/gridFunction"; -const NotEnteredStatus = () => { - const [rowData, setRowData] = useState[]>([]); +const SIDO_FIELD = "\uc2dc\ub3c4"; +const SGG_FIELD = "\uc2dc\uad70\uad6c"; - const columns: ColumnConfig[] = [ - { name: 'sido', fieldName: '시도', header: { text: '시도' }, mergeCells: true, editable: false, sortdisable: true }, - { name: 'sgg', fieldName: '시군구', header: { text: '시군구' }, mergeCells: true, editable: false, sortdisable: true }, - { name: '1', fieldName: '1', header: { text: '시군구' }, mergeCells: true, editable: false, sortdisable: true }, - { name: '2-1', fieldName: '2-1', header: { text: '2-1' }, mergeCells: true, editable: false, sortdisable: true }, - { name: '2-2', fieldName: '2-2', header: { text: '2-2' }, mergeCells: true, editable: false, sortdisable: true }, - { name: '2-3', fieldName: '2-3', header: { text: '2-3' }, mergeCells: true, editable: false, sortdisable: true }, - { name: '2-4', fieldName: '2-4', header: { text: '2-4' }, mergeCells: true, editable: false, sortdisable: true }, - { name: '3-1', fieldName: '3-1', header: { text: '3-1' }, mergeCells: true, editable: false, sortdisable: true }, - { name: '3-2', fieldName: '3-2', header: { text: '3-2' }, mergeCells: true, editable: false, sortdisable: true }, - ]; +function sbmsnYnToOx(sbmsnYn: string): string { + const v = sbmsnYn.trim().toUpperCase(); + if (v === "Y") return "O"; + if (v === "N") return "X"; + return sbmsnYn; +} - const columnLayout = [ - 'sido', - 'sgg', +function splitSidoSggNm(sidoSggNm: string): { sido: string; sgg: string } { + const trimmed = sidoSggNm.trim(); + if (!trimmed) return { sido: "", sgg: "" }; + const spaceIdx = trimmed.indexOf(" "); + if (spaceIdx < 0) return { sido: trimmed, sgg: "" }; + return { + sido: trimmed.slice(0, spaceIdx), + sgg: trimmed.slice(spaceIdx + 1).trim(), + }; +} + +function statColumnKey(statsNm: string, statsCd: string): string { + const trimmed = statsNm.trim(); + const dotIdx = trimmed.indexOf("."); + if (dotIdx > 0) return trimmed.slice(0, dotIdx).trim(); + return statsCd; +} + +type StatDefinition = { statsCd: string; statsNm: string; columnKey: string }; + +/** submission-status statusList 기준 고정 컬럼(번호) */ +const FIXED_STAT_DEFINITIONS: StatDefinition[] = [ + { columnKey: "1", statsCd: "ST00010001", statsNm: "1.하수도보급률" }, + { columnKey: "2-1", statsCd: "ST00020001", statsNm: "2-1.하수관로-지역별 시설현황" }, + { columnKey: "2-2", statsCd: "ST00020002", statsNm: "2-2.하수관로-시설별 설치현황" }, + { columnKey: "2-3", statsCd: "ST00020003", statsNm: "2-3.하수관로-관종별 현황" }, + { columnKey: "2-4", statsCd: "ST00020004", statsNm: "2-4.하수관로-관경별 현황" }, + { columnKey: "2-5", statsCd: "ST00020005", statsNm: "2-5.하수관로-맨홀 현황" }, + { columnKey: "3-1", statsCd: "ST00030001", statsNm: "3-1.하수관로유지관리-개․보수" }, + { columnKey: "3-2", statsCd: "ST00030002", statsNm: "3-2.하수관로유지관리-하수관로준설" }, + { + columnKey: "4-1", + statsCd: "ST00040001", + statsNm: "4-1.하수저류시설및빗물펌프장-하수저류시설", + }, + { + columnKey: "4-2", + statsCd: "ST00040002", + statsNm: "4-2.하수저류시설및빗물펌프장-빗물펌프장", + }, + { columnKey: "5", statsCd: "ST00050001", statsNm: "5.펌프장" }, + { columnKey: "6", statsCd: "ST00060001", statsNm: "6.공공하수처리시설" }, + { columnKey: "7", statsCd: "ST00070001", statsNm: "7.분뇨처리시설" }, + { + columnKey: "8-1", + statsCd: "ST00080001", + statsNm: "8-1.개인하수처리시설-처리용량별 오수처리시설 현황", + }, + { + columnKey: "8-2", + statsCd: "ST00080002", + statsNm: "8-2.개인하수처리시설-정화조 현황(처리방식별, 처리용량별)", + }, + { + columnKey: "8-3", + statsCd: "ST00080003", + statsNm: "8-3.개인하수처리시설-개인하수도관리지역 지정공고 현황", + }, + { columnKey: "9-1", statsCd: "ST00090001", statsNm: "9-1.하수도재정-하수도 재정 결산" }, + { columnKey: "9-2", statsCd: "ST00090002", statsNm: "9-2.하수도재정-하수도 요금" }, + { columnKey: "9-3", statsCd: "ST00090003", statsNm: "9-3.하수도재정-업종별 하수도 사용료" }, + { columnKey: "9-4", statsCd: "ST00090004", statsNm: "9-4.하수도재정-하수도 원인자부담금" }, + { columnKey: "10-1", statsCd: "ST00100001", statsNm: "10-1.물재이용-공공하수처리수 재이용" }, + { columnKey: "10-2", statsCd: "ST00100002", statsNm: "10-2.물재이용-중수도 현황" }, + { columnKey: "10-3", statsCd: "ST00100003", statsNm: "10-3.물재이용-빗물이용시설현황" }, + { + columnKey: "10-4", + statsCd: "ST00100004", + statsNm: "10-4.물재이용-하․폐수처리수 재이용시설 설계시공업 등록현황", + }, + { + columnKey: "10-5", + statsCd: "ST00100005", + statsNm: "10-5.물재이용-하․폐수처리수 재이용사업 인가현황", + }, + { + columnKey: "11-1", + statsCd: "ST00110001", + statsNm: "11-1.하수및분뇨찌꺼기(슬러지)처리-하수찌꺼기 발생 및 처리현황", + }, + { + columnKey: "11-2", + statsCd: "ST00110002", + statsNm: "11-2.하수및분뇨찌꺼기(슬러지)처리-소화조 운영 현황", + }, + { + columnKey: "11-3", + statsCd: "ST00110003", + statsNm: "11-3.하수및분뇨찌꺼기(슬러지)처리-하수찌꺼기 처리시설 운영 현황", + }, + { + columnKey: "11-4", + statsCd: "ST00110004", + statsNm: "11-4.하수및분뇨찌꺼기(슬러지)처리-분뇨찌꺼기 처리현황", + }, + { + columnKey: "12-1", + statsCd: "ST00120001", + statsNm: "12-1.하수및분뇨관련산업-공공기관 하수분야", + }, + { + columnKey: "12-2", + statsCd: "ST00120002", + statsNm: "12-2.하수및분뇨관련산업-공공기관 분뇨분야", + }, + { + columnKey: "12-3", + statsCd: "ST00120003", + statsNm: "12-3.하수및분뇨관련산업-분뇨수집 · 운반업 현황", + }, + { + columnKey: "12-4", + statsCd: "ST00120004", + statsNm: "12-4.하수및분뇨관련산업-개인하수처리 관련업 현황", + }, + { columnKey: "13-1", statsCd: "ST00130001", statsNm: "13-1.하수도서비스수준-민원" }, + { columnKey: "13-2", statsCd: "ST00130002", statsNm: "13-2.하수도서비스수준-주민친화시설" }, +]; + +const STAT_COLUMN_KEYS = FIXED_STAT_DEFINITIONS.map((d) => d.columnKey); + +const STATS_CD_TO_COLUMN_KEY = Object.fromEntries( + FIXED_STAT_DEFINITIONS.map((d) => [d.statsCd, d.columnKey]), +) as Record; + +function resolveColumnKey(item: UnSubmitDataStatusItem): string { + return STATS_CD_TO_COLUMN_KEY[item.statsCd] ?? statColumnKey(item.statsNm, item.statsCd); +} + +/** 헤더(statsNm) 텍스트 길이에 맞춘 컬럼 너비(px) */ +function columnWidthForHeaderText(text: string, min = 88, max = 340): number { + return Math.min(max, Math.max(min, text.length * 9 + 28)); +} + +function buildColumnInfo(statDefinitions: StatDefinition[]) { + const fixedColumns: ColumnConfig[] = [ { - name: 'groupA', - hideChildHeaders: false, - header: { text: '미입력 하수도 통계 세부항목(번호)' }, - items: ['1', '2-1', '2-2', '2-3', '2-4', '3-1', '3-2'], + name: "sido", + fieldName: SIDO_FIELD, + header: { text: "시도" }, + mergeCells: true, + editable: false, + sortdisable: true, + width: 100, + }, + { + name: "sgg", + fieldName: SGG_FIELD, + header: { text: "시군구" }, + mergeGroupField: SIDO_FIELD, + editable: false, + sortdisable: true, + width: 120, }, ]; + const statColumns: ColumnConfig[] = statDefinitions.map(({ columnKey, statsNm }) => ({ + name: columnKey, + fieldName: columnKey, + header: { text: statsNm }, + editable: false, + sortdisable: true, + width: columnWidthForHeaderText(statsNm), + })); + + const columns = [...fixedColumns, ...statColumns]; + const statKeys = statDefinitions.map((s) => s.columnKey); + + const columnLayout: ColumnLayoutItem[] = [ + "sido", + "sgg", + { + name: "statsGroup", + hideChildHeaders: false, + header: { text: "미입력 하수도 통계 세부항목(번호)" }, + items: statKeys, + }, + ]; + + return { columns, columnLayout, statKeys }; +} + +function buildRowsFromStatusList( + statusList: UnSubmitDataStatusItem[], + statKeys: string[], +): Record[] { + const rowMap = new Map>(); + + for (const item of statusList) { + const { sido, sgg } = splitSidoSggNm(item.sidoSggNm); + const rowKey = `${sido}\u0001${sgg}`; + const columnKey = resolveColumnKey(item); + + if (!rowMap.has(rowKey)) { + const row: Record = { + [SIDO_FIELD]: sido, + [SGG_FIELD]: sgg, + }; + for (const key of statKeys) { + row[key] = ""; + } + rowMap.set(rowKey, row); + } + + const row = rowMap.get(rowKey)!; + if (statKeys.includes(columnKey)) { + row[columnKey] = sbmsnYnToOx(item.sbmsnYn); + } + } + + return Array.from(rowMap.values()).sort((a, b) => { + const sidoCmp = String(a[SIDO_FIELD]).localeCompare(String(b[SIDO_FIELD]), "ko"); + if (sidoCmp !== 0) return sidoCmp; + return String(a[SGG_FIELD]).localeCompare(String(b[SGG_FIELD]), "ko"); + }); +} + +function buildUnSubmitDataRequest( + values: SearchFormValues, + userInfo: { dtlInstCd?: string; enoCd?: string; laeSeCd?: string } | null, +): unSubmitDataRequest { + const sewerBzmnCd = values.bsnmCd?.trim(); + return { + statsYr: values.year ?? "", + dtlInstCd: userInfo?.dtlInstCd ?? "", + enoCd: userInfo?.enoCd ?? "", + laeSeCd: userInfo?.laeSeCd ?? "", + ...(sewerBzmnCd ? { sewerBzmnCd } : {}), + sido: !values.sido || values.sido === "ALL" ? "" : values.sido, + sggNm: !values.gugun || values.gugun === "ALL" ? "" : values.gugun, + unSubmitOnly: false, + }; +} + +const COLUMN_INFO = buildColumnInfo(FIXED_STAT_DEFINITIONS); + +const NotEnteredStatus = () => { + const { setMessage: alert } = useAlertStore((state) => state); + const userInfo = useUserStore((state) => state.userInfo); + const [isLoading, setIsLoading] = useState(false); + const [rowData, setRowData] = useState[]>([]); + const gridColumnInfo = useMemo( + () => ({ + columns: COLUMN_INFO.columns, + columnLayout: COLUMN_INFO.columnLayout, + }), + [], + ); + + const handleSearch = async (values: SearchFormValues) => { + if (!values.year) { + alert("년도를 선택해주세요."); + return; + } + const params = buildUnSubmitDataRequest(values, userInfo); + console.log(params); + if (!params.dtlInstCd) { + alert("기관 정보를 확인할 수 없습니다."); + return; + } + + try { + setIsLoading(true); + const response = await statisticsApi.getUnSubmitData(params); + + if (response.status !== "success" || !response.data) { + alert(response.message ?? "조회에 실패했습니다."); + setRowData([]); + return; + } + + const statusList = response.data.statusList ?? []; + setRowData(toGridRows(buildRowsFromStatusList(statusList, STAT_COLUMN_KEYS))); + } catch { + alert("데이터 조회 중 오류가 발생했습니다."); + setRowData([]); + } finally { + setIsLoading(false); + } + }; + + const handleReset = () => { + setRowData([]); + }; + return ( - <> - - - +
+ + {isLoading && ( +
+
+ +
+
+ )} + +
); }; -export default NotEnteredStatus; \ No newline at end of file +export default NotEnteredStatus; diff --git a/apps/st/src/components/type/DataGrid.type.ts b/apps/st/src/components/type/DataGrid.type.ts index 9edcc2b03..a0d075e15 100644 --- a/apps/st/src/components/type/DataGrid.type.ts +++ b/apps/st/src/components/type/DataGrid.type.ts @@ -1,6 +1,6 @@ import React from "react"; -import { ValueType } from "realgrid"; +import { GridFitStyle, ValueType } from "realgrid"; import type { RealGridReact } from "realgrid-react"; // RealGrid 타입 정의 @@ -337,6 +337,10 @@ export type DataGridProps = { /** CompactDataGrid 전용: 그리드 영역 높이 Tailwind 클래스 */ compactGridHeightClass?: string; gridHeight?: number; + /** RealGrid `gridView.header.height` (기본 60, columnLayout 2단 헤더 등에서 조절) */ + headerHeight?: number; + /** RealGrid `displayOptions.fitStyle` (기본 EVEN). 컬럼별 width 유지 시 NONE */ + fitStyle?: GridFitStyle; }; export type TabDataGridProps = { diff --git a/apps/st/src/entities/statistics/api/statistics-service.ts b/apps/st/src/entities/statistics/api/statistics-service.ts index 7186240c4..63f90e90b 100644 --- a/apps/st/src/entities/statistics/api/statistics-service.ts +++ b/apps/st/src/entities/statistics/api/statistics-service.ts @@ -47,6 +47,8 @@ import { type SubmitExceptionResponse, type SubmitVerifyExceptionRequest, type SubmitVerifyExceptionResponse, + type unSubmitDataRequest, + type unSubmitDataResponse, type VerificationResultListRequest, type VerificationResultListResponse, } from "../model/statistics-interface"; @@ -331,6 +333,14 @@ export class StatisticsApiService implements StatisticsService { ); return response.data; } + + public async getUnSubmitData(param: unSubmitDataRequest): Promise { + const response = await stClient.get( + `${ENDPOINT.ST_SERVICE.DASHBOARD}/submission-status`, + { params: param }, + ); + return response.data; + } } export const statisticsApi = new StatisticsApiService(); diff --git a/apps/st/src/entities/statistics/model/statistics-interface.ts b/apps/st/src/entities/statistics/model/statistics-interface.ts index 5081952c7..53b9c74e6 100644 --- a/apps/st/src/entities/statistics/model/statistics-interface.ts +++ b/apps/st/src/entities/statistics/model/statistics-interface.ts @@ -486,6 +486,40 @@ export interface CheckStatusCodeResponse { data: string; } +export interface unSubmitDataRequest { + statsYr: string; + dtlInstCd: string; + enoCd: string; + laeSeCd: string; + sewerBzmnCd?: string; + sido: string; + sggNm: string; + unSubmitOnly: boolean; +} + +export interface UnSubmitDataStatusItem { + sidoSggNm: string; + statsCd: string; + statsNm: string; + sbmsnYn: string; + statusCd: string; + statusNm: string; +} + +export interface UnSubmitData { + statsYr: string; + totalCount: number; + submittedCount: number; + unSubmittedCount: number; + statusList: UnSubmitDataStatusItem[]; +} + +export interface unSubmitDataResponse { + status: string; + message?: string; + data: UnSubmitData; +} + export interface StatisticsService { getSidoList(params?: { instSeCd?: string }): Promise>; getSggList(sidoName: string, params?: { instSeCd?: string; gugunSkipYn?: "Y" | "N" }): Promise>; @@ -516,4 +550,5 @@ export interface StatisticsService { getStatiscicsManagementReviewHistory(param: StatiscicsManagementReviewHistoryRequest): Promise; StatiscicsExceptionReset(param: StatiscicsExceptionResetRequest): Promise; CheckStatusCode(param: CheckStatusCodeRequest): Promise; + getUnSubmitData(param: unSubmitDataRequest): Promise; } \ No newline at end of file