From 7e152cacd22f10cbb19fd9816479ca4744c3c622 Mon Sep 17 00:00:00 2001 From: rsone001 Date: Wed, 27 May 2026 17:04:46 +0900 Subject: [PATCH] =?UTF-8?q?[st]=20-=20feat:=20realgrid=20=EC=85=80=20?= =?UTF-8?q?=ED=8E=B8=EC=A7=91=20=EC=B2=B4=ED=81=AC=20=EC=84=A4=EC=A0=95=20?= =?UTF-8?q?=EC=88=98=EC=A0=95,=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20ur?= =?UTF-8?q?l=20=EB=B0=A9=EC=8B=9D=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=A4=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/(main)/(with-base-layout)/layout.tsx | 269 +++- .../components/button/AddArticleButton.tsx | 2 +- .../components/button/DeleteArticleButton.tsx | 2 +- apps/st/src/components/css/DataGrid.css | 50 + apps/st/src/components/module/DataGrid.tsx | 221 +++- apps/st/src/components/module/SearchForm.tsx | 90 +- .../VerificationResultHistoryDialog.tsx | 2 - .../VerificationSelectionReport.tsx | 286 +++-- .../input/PublicSewageFacilityInp.tsx | 332 ++--- .../input/SewagePipeMaintainInp.tsx | 1080 +++++++++++++++-- .../input/SewageReservoirPumpInp.tsx | 885 +++++++++++++- .../input/SeweragePenetrationRateInp.tsx | 3 +- .../management/LogHistoryDetailDialog.tsx | 121 +- apps/st/src/components/type/DataGrid.type.ts | 20 + .../PipeDredgingStatusInpColumn.ts | 95 +- .../RepairStatusInpColumn.ts | 46 +- .../RainfallPumpStationInpColumn.ts | 183 ++- .../SewageReservoirInpColumn.ts | 250 ++-- .../management/LogHistoryDetailColumn.ts | 91 ++ .../util/grid/computedFieldCallbacks.ts | 24 +- apps/st/src/components/util/grid/ymdFormat.ts | 22 + ...tics-input-sewage-pipe-maintain-service.ts | 48 + ...ics-input-sewage-reservoir-pump-service.ts | 33 + .../statistics/api/statistics-service.ts | 7 +- ...cs-input-sewage-pipe-maintain-interface.ts | 152 +++ ...s-input-sewage-reservoir-pump-interface.ts | 174 +++ ...tics-sewage-supply-rate-input-interface.ts | 2 +- apps/st/src/shared/lib/statistics-tab-url.ts | 12 + .../shared/lib/use-statistics-tab-url-sync.ts | 99 ++ .../components/type/computedFieldCallbacks.ts | 24 +- 30 files changed, 3973 insertions(+), 652 deletions(-) create mode 100644 apps/st/src/components/util/column/management/LogHistoryDetailColumn.ts create mode 100644 apps/st/src/components/util/grid/ymdFormat.ts create mode 100644 apps/st/src/entities/statistics/api/input/statistics-input-sewage-reservoir-pump-service.ts create mode 100644 apps/st/src/entities/statistics/model/input/statistics-input-sewage-reservoir-pump-interface.ts create mode 100644 apps/st/src/shared/lib/statistics-tab-url.ts create mode 100644 apps/st/src/shared/lib/use-statistics-tab-url-sync.ts diff --git a/apps/st/app/(main)/(with-base-layout)/layout.tsx b/apps/st/app/(main)/(with-base-layout)/layout.tsx index f4648a8b0..e849c3a1d 100644 --- a/apps/st/app/(main)/(with-base-layout)/layout.tsx +++ b/apps/st/app/(main)/(with-base-layout)/layout.tsx @@ -1,194 +1,423 @@ "use client"; -import { useCallback, useEffect, useMemo, useState } from "react"; + + +import { Suspense, useCallback, useEffect, useMemo, useState } from "react"; + import { usePathname } from "next/navigation"; + + import { + getTabStoreState as getGridTabStoreState, + useTabStore as gridTabStore, + } from "@rsone/components"; + import { useQuery, useQueryClient } from "@tanstack/react-query"; + + import { ENDPOINT } from "@/shared/config"; + import { getCookie, useAlertStore, useMenuStore } from "@/shared/lib"; + +import { useStatisticsTabUrlSync } from "@/shared/lib/use-statistics-tab-url-sync"; + import { isVerificationSelectionReportMenu } from "@/shared/lib/verification-selection-report"; + import { StatisticsPageLayout } from "@/shared/ui/statistics-page-layout"; + import { tokenManager } from "@/entities/auth"; + import type { MenuItem } from "@/entities/menu"; + import { MenuApi } from "@/entities/menu"; + import { BaseLayout } from "@common/components"; + import { getConfig } from "@common/config"; + + import { getTabStoreState, useTabStore } from "@/store/tab-store"; + + import VerificationSelectionReport from "@/components/statistics/VerificationSelectionReport"; + + const appConfig = getConfig(); + const userId = getCookie(appConfig.AUTH_USER_ID ?? ""); + const appSiteId = getCookie("site-id-key") ?? ""; + const isLoggedIn = !!userId; -export default function WithBaseLayout({ + + +function WithBaseLayoutInner({ + children, + }: { + children: React.ReactNode; + }) { + const pathname = usePathname(); + const queryClient = useQueryClient(); + + /** 탭 추가 없이 메뉴만 눌렀을 때 패널 유지 (다른 메뉴 클릭해도 유지) */ + const [isVerificationSelectionReportPanelOpen, setReportPanelOpen] = + useState(false); + + const setCurrentMenuInfo = useMenuStore( + (state) => state.setCurrentMenuInfo, + ); + const currentMenuInfo = useMenuStore((state) => state.currentMenuInfo); + + const addTab = useTabStore((state) => state.addTab); + const setActiveTab = useTabStore((state) => state.setActiveTab); + const setMenuList = useTabStore((state) => state.setMenuList); + const activeTabId = useTabStore((state) => state.activeTabId); + const menuList = useTabStore((state) => state.menuList); + const tabs = useTabStore((state) => state.tabs); + const addTabForGrid = gridTabStore((state) => state.addTab); + const setActiveTabForGrid = gridTabStore((state) => state.setActiveTab); + const setMenuListForGrid = gridTabStore((state) => state.setMenuList); + const alert = useAlertStore((state) => state.setMessage); - const { data: menuListData } = useQuery({ - queryKey: [ENDPOINT.CMS_SERVICE.MENU_ROLES], - queryFn: () => MenuApi.getMenuRoles(appSiteId), - enabled: isLoggedIn, + + + const { syncTabToUrl } = useStatisticsTabUrlSync({ + + pathname, + + menuList, + + activeTabId, + + addTab, + + addTabForGrid, + + setCurrentMenuInfo, + }); + + + const { data: menuListData } = useQuery({ + + queryKey: [ENDPOINT.CMS_SERVICE.MENU_ROLES], + + queryFn: () => MenuApi.getMenuRoles(appSiteId), + + enabled: isLoggedIn, + + }); + + + useEffect(() => { + if (menuListData) { + setMenuList(menuListData); + setMenuListForGrid(menuListData); + } + }, [menuListData, setMenuList, setMenuListForGrid]); + + // 새로고침 후 persist로 앱 탭만 복원된 경우 rsone 그리드 스토어에 탭을 맞춤 + useEffect(() => { + if (!menuList) return; + const appTabs = getTabStoreState().tabs; + if (appTabs.length === 0) return; + if (getGridTabStoreState().tabs.length > 0) return; + + for (const tab of appTabs) { + getGridTabStoreState().addTab(tab.menuItem); + } + + const activeId = getTabStoreState().activeTabId; + if (activeId) { + setActiveTab(activeId); + const activeTab = appTabs.find((t) => t.id === activeId); + const gridActiveId = activeTab?.menuItem.menuEngNm; + if (gridActiveId) { + getGridTabStoreState().setActiveTab(gridActiveId); + } + } + }, [menuList, tabs, setActiveTab]); - useEffect(() => { - if (!pathname) return; - // pathname이 변경될 때만 실행되도록 tabs를 직접 가져옴 - const currentTabs = getTabStoreState().tabs; - const tab = currentTabs.find((t) => t.path === pathname); - if (tab) { - setActiveTab(tab.id); - const gridActiveId = tab.menuItem.menuEngNm; - if (gridActiveId) { - setActiveTabForGrid(gridActiveId); - } - } - }, [pathname, setActiveTab, setActiveTabForGrid, tabs.length, activeTabId]); // 활성 탭의 메뉴를 찾아서 urlPath를 가져옴. 탭이 전부 닫히면 ''를 넘겨 LNB에서 메뉴 강조 제거 + const currentPath = useMemo(() => { + if (tabs.length === 0) return ""; + + if (activeTabId && menuList) { + // menuList에서 활성 탭의 메뉴 찾기 + const findMenuByEngNm = (items: MenuItem[]): MenuItem | null => { + for (const item of items) { + if (item.urlPath === activeTabId) { + return item; + } + if (item.children) { + const found = findMenuByEngNm(item.children); + if (found) return found; + } + } + return null; + }; + + const activeMenu = findMenuByEngNm(menuList); + + if (activeMenu?.urlPath) { + return activeMenu.urlPath; + } + } + + // 탭에서 직접 찾기 + if (activeTabId) { + const currentTabs = getTabStoreState().tabs; + const activeTab = currentTabs.find((t) => t.id === activeTabId); + if (activeTab?.menuItem?.urlPath) { + return activeTab.menuItem.urlPath; + } + } + + // currentMenuInfo에서 가져오기 + if (currentMenuInfo?.urlPath) { + return currentMenuInfo.urlPath; + } + + // 기본값으로 pathname 사용 + return pathname ?? ""; + }, [tabs.length, activeTabId, menuList, currentMenuInfo, pathname]); + + const handleMenuClick = useCallback( + (item: MenuItem) => { + setCurrentMenuInfo(item); + + if (isVerificationSelectionReportMenu(item)) { + setReportPanelOpen(true); + return; + } + + const addTabResult: boolean = addTab(item); + + // packages/rsone의 스토어에도 동기화 + if (addTabResult) { + addTabForGrid(item); + + if (item.urlPath) { + + syncTabToUrl(item.urlPath); + + } + } + + if (!addTabResult) { + alert(`최대 추가 할 수 있는 화면 수(10개)를 \n초과 하였습니다. + 기존 화면을 닫은 후 이용해주세요.`); + } + }, - [setCurrentMenuInfo, addTab, addTabForGrid, alert, setReportPanelOpen], + + [ + + setCurrentMenuInfo, + + addTab, + + addTabForGrid, + + alert, + + setReportPanelOpen, + + syncTabToUrl, + + ], + ); + + return ( + +
+ {isVerificationSelectionReportPanelOpen && ( + setReportPanelOpen(false)} + /> + )} + {children} +
+
+ ); + } + + + +export default function WithBaseLayout({ + + children, + +}: { + + children: React.ReactNode; + +}) { + + return ( + + + + {children} + + + + ); + +} + + diff --git a/apps/st/src/components/button/AddArticleButton.tsx b/apps/st/src/components/button/AddArticleButton.tsx index b1bb3252f..3458ca8ee 100644 --- a/apps/st/src/components/button/AddArticleButton.tsx +++ b/apps/st/src/components/button/AddArticleButton.tsx @@ -6,7 +6,7 @@ interface AddArticleButtonProps { const AddArticleButton = ({ addArticleFunc }: AddArticleButtonProps) => { return ( - diff --git a/apps/st/src/components/button/DeleteArticleButton.tsx b/apps/st/src/components/button/DeleteArticleButton.tsx index 1a6432a64..e350b404b 100644 --- a/apps/st/src/components/button/DeleteArticleButton.tsx +++ b/apps/st/src/components/button/DeleteArticleButton.tsx @@ -6,7 +6,7 @@ interface DeleteArticleButtonProps { const DeleteArticleButton = ({ deleteArticleFunc }: DeleteArticleButtonProps) => { return ( - diff --git a/apps/st/src/components/css/DataGrid.css b/apps/st/src/components/css/DataGrid.css index 76bebc387..843289179 100644 --- a/apps/st/src/components/css/DataGrid.css +++ b/apps/st/src/components/css/DataGrid.css @@ -154,6 +154,24 @@ @apply !bg-[#fff7bf]; } + /* + * 검증 오류 셀(rg-cell-verify-error / -imr) 최우선 적용. + * 클래스 이중화로 specificity를 한 단계 올려 동일 specificity의 다른 셀 클래스 + * (rg-cell-rowtype-*, rg-cell-total, rg-cell-readonly 등)보다 항상 이김. + */ + .rg-grid .rg-body .rg-data-cell.rg-cell-verify-error.rg-cell-verify-error, + .rg-grid .rg-body .rg-table .rg-data-row .rg-data-cell.rg-cell-verify-error.rg-cell-verify-error, + .rg-grid .rg-fixed-body .rg-data-cell.rg-cell-verify-error.rg-cell-verify-error, + .rg-grid .rg-fixed-body .rg-data-row .rg-data-cell.rg-cell-verify-error.rg-cell-verify-error { + @apply !bg-[#ffe6e6] !text-[#c00]; + } + .rg-grid .rg-body .rg-data-cell.rg-cell-verify-error-imr.rg-cell-verify-error-imr, + .rg-grid .rg-body .rg-table .rg-data-row .rg-data-cell.rg-cell-verify-error-imr.rg-cell-verify-error-imr, + .rg-grid .rg-fixed-body .rg-data-cell.rg-cell-verify-error-imr.rg-cell-verify-error-imr, + .rg-grid .rg-fixed-body .rg-data-row .rg-data-cell.rg-cell-verify-error-imr.rg-cell-verify-error-imr { + @apply !bg-[#fff7bf] !text-[#b45309]; + } + /* 진행상황: 소프트 인라인 요약 (툴바와 자연스럽게 blend) */ .data-grid-status-bubble { @apply rounded-lg border border-slate-200/80 bg-white px-8 py-2.5; @@ -336,3 +354,35 @@ background-color: #6bafae !important; color: #ffffff !important; } + + +.rg-grid .rg-body .rg-data-cell.rg-cell-verify-error.rg-cell-verify-error, +.rg-grid .rg-body .rg-table .rg-data-row .rg-data-cell.rg-cell-verify-error.rg-cell-verify-error, +.rg-grid .rg-fixed-body .rg-data-cell.rg-cell-verify-error.rg-cell-verify-error, +.rg-grid .rg-fixed-body .rg-data-row .rg-data-cell.rg-cell-verify-error.rg-cell-verify-error { + background-color: #ffe6e6 !important; + color: #c00 !important; +} +.rg-grid .rg-body .rg-data-cell.rg-cell-verify-error-imr.rg-cell-verify-error-imr, +.rg-grid .rg-body .rg-table .rg-data-row .rg-data-cell.rg-cell-verify-error-imr.rg-cell-verify-error-imr, +.rg-grid .rg-fixed-body .rg-data-cell.rg-cell-verify-error-imr.rg-cell-verify-error-imr, +.rg-grid .rg-fixed-body .rg-data-row .rg-data-cell.rg-cell-verify-error-imr.rg-cell-verify-error-imr { + background-color: #fff7bf !important; + color: #b45309 !important; +} + +/* dark theme에서도 검증 오류 색을 유지하고 싶다면 동일 색 유지(가독성 우선) */ +[data-theme="black"] .rg-grid .rg-body .rg-data-cell.rg-cell-verify-error.rg-cell-verify-error, +[data-theme="black"] .rg-grid .rg-body .rg-table .rg-data-row .rg-data-cell.rg-cell-verify-error.rg-cell-verify-error, +[data-theme="black"] .rg-grid .rg-fixed-body .rg-data-cell.rg-cell-verify-error.rg-cell-verify-error, +[data-theme="black"] .rg-grid .rg-fixed-body .rg-data-row .rg-data-cell.rg-cell-verify-error.rg-cell-verify-error { + background-color: #ffe6e6 !important; + color: #c00 !important; +} +[data-theme="black"] .rg-grid .rg-body .rg-data-cell.rg-cell-verify-error-imr.rg-cell-verify-error-imr, +[data-theme="black"] .rg-grid .rg-body .rg-table .rg-data-row .rg-data-cell.rg-cell-verify-error-imr.rg-cell-verify-error-imr, +[data-theme="black"] .rg-grid .rg-fixed-body .rg-data-cell.rg-cell-verify-error-imr.rg-cell-verify-error-imr, +[data-theme="black"] .rg-grid .rg-fixed-body .rg-data-row .rg-data-cell.rg-cell-verify-error-imr.rg-cell-verify-error-imr { + background-color: #fff7bf !important; + color: #b45309 !important; +} diff --git a/apps/st/src/components/module/DataGrid.tsx b/apps/st/src/components/module/DataGrid.tsx index 7e4809608..4e8a9c86d 100644 --- a/apps/st/src/components/module/DataGrid.tsx +++ b/apps/st/src/components/module/DataGrid.tsx @@ -52,6 +52,142 @@ if (typeof window !== "undefined") { RealGrid.setLicenseKey(process.env.NEXT_PUBLIC_ST_REALGRID_KEY || ""); +function isDropDownEditorColumn(column: { + editor?: { type?: string }; + values?: string[]; +}): boolean { + const type = column.editor?.type; + return ( + type === "dropdown" || + type === "list" || + type === "dropDown" || + (column.values != null && column.values.length > 0) + ); +} + +type GridViewWithSetColumnProperty = { + setColumnProperty?: (col: string, key: string, value: unknown) => void; +}; + +type GridViewWithEditOptions = { + setEditOptions?: (options: { commitWhenLeave?: boolean }) => void; +}; + +/** 포커스가 그리드 밖으로 나갈 때 편집 중인 값을 자동 commit */ +function applyDefaultGridEditOptions( + gridView: GridViewWithEditOptions | null | undefined, +) { + gridView?.setEditOptions?.({ commitWhenLeave: true }); +} + +function isDateEditorColumn(column: { + editor?: { type?: string }; +}): boolean { + return column.editor?.type === "date"; +} + +/** RealGrid 날짜(date) 편집기 컬럼 속성 적용 */ +function applyColumnDateEditorProperties( + gridView: GridViewWithSetColumnProperty, + column: import("../type/DataGrid.type").ColumnConfig, +) { + if (!gridView.setColumnProperty) return; + const fmt = + column.datetimeFormat ?? + column.editor?.datetimeFormat ?? + "yyyy-MM-dd"; + gridView.setColumnProperty(column.name, "datetimeFormat", fmt); + gridView.setColumnProperty(column.name, "editor", { + type: "date", + datetimeFormat: fmt, + dropDownWhenClick: column.editor?.dropDownWhenClick ?? true, + showToday: column.editor?.showToday ?? true, + }); + gridView.setColumnProperty(column.name, "editButtonVisibility", "always"); +} + +/** RealGrid 드롭다운·날짜 등 컬럼 편집기 속성 적용 */ +function applyColumnListEditorProperties( + gridView: GridViewWithSetColumnProperty, + column: import("../type/DataGrid.type").ColumnConfig, +) { + if (!gridView.setColumnProperty) return; + + if (isDateEditorColumn(column)) { + applyColumnDateEditorProperties(gridView, column); + return; + } + + const values = column.values ?? column.editor?.values; + const labels = column.labels ?? column.editor?.labels ?? values; + const isDropDown = isDropDownEditorColumn(column); + + if (!isDropDown) { + if (column.editor) { + gridView.setColumnProperty(column.name, "editor", column.editor); + } + return; + } + + if (values?.length) { + gridView.setColumnProperty(column.name, "values", values); + gridView.setColumnProperty(column.name, "labels", labels ?? values); + } + if (column.lookupDisplay) { + gridView.setColumnProperty(column.name, "lookupDisplay", true); + } + gridView.setColumnProperty(column.name, "editor", { + type: "dropdown", + domainOnly: column.editor?.domainOnly ?? true, + textReadOnly: column.editor?.textReadOnly ?? true, + commitOnSelect: column.editor?.commitOnSelect ?? true, + dropDownWhenClick: column.editor?.dropDownWhenClick ?? true, + values, + labels: labels ?? values, + }); + gridView.setColumnProperty(column.name, "editButtonVisibility", "always"); +} + +/** 그리드 마운트·데이터 로드 후 드롭다운 컬럼 재적용용 */ +export function applyDropDownColumnToGrid( + gridView: GridViewWithSetColumnProperty | null | undefined, + columnName: string, + options: string[], +) { + if (!gridView?.setColumnProperty || options.length === 0) return; + ( + gridView as { + setEditOptions?: (opts: { editable?: boolean }) => void; + } + ).setEditOptions?.({ editable: true }); + gridView.setColumnProperty(columnName, "editable", true); + gridView.setColumnProperty(columnName, "values", options); + gridView.setColumnProperty(columnName, "labels", options); + gridView.setColumnProperty(columnName, "lookupDisplay", true); + gridView.setColumnProperty(columnName, "editor", { + type: "dropdown", + domainOnly: true, + textReadOnly: true, + commitOnSelect: true, + dropDownWhenClick: true, + values: options, + labels: options, + }); + gridView.setColumnProperty(columnName, "editButtonVisibility", "always"); + (gridView as { refresh?: () => void }).refresh?.(); +} + +function reapplyListEditorsOnGrid( + gridView: GridViewWithSetColumnProperty | null | undefined, + cols: import("../type/DataGrid.type").ColumnConfig[], +) { + if (!gridView) return; + cols.forEach((col) => applyColumnListEditorProperties(gridView, col)); + if (cols.some((c) => isDropDownEditorColumn(c) || isDateEditorColumn(c))) { + (gridView as { refresh?: () => void }).refresh?.(); + } +} + type GridViewWithCheckApi = { checkItem?: (itemIndex: number, checked: boolean, exclusive?: boolean) => void; checkRow?: (dataRow: number, checked: boolean, exclusive?: boolean) => void; @@ -325,6 +461,8 @@ function buildFieldsFromColumns( columns: { fieldName: string; valueType?: ValueType; + datetimeFormat?: string; + editor?: { type?: string; datetimeFormat?: string }; computed?: import("../type/DataGrid.type").ComputedFieldConfig; }[], ) { @@ -332,6 +470,7 @@ function buildFieldsFromColumns( const field: { fieldName: string; dataType: string; + datetimeFormat?: string; valueCallback?: ( prod: unknown, dataRow: number, @@ -341,8 +480,19 @@ function buildFieldsFromColumns( ) => number; } = { fieldName: col.fieldName, - dataType: col.valueType === ValueType.NUMBER ? "number" : "text", + dataType: + col.editor?.type === "date" + ? "datetime" + : col.valueType === ValueType.NUMBER + ? "number" + : "text", }; + if (col.editor?.type === "date") { + field.datetimeFormat = + col.datetimeFormat ?? + col.editor.datetimeFormat ?? + "yyyy-MM-dd"; + } if (col.computed) { const hasFields = "fields" in col.computed && col.computed.fields.length > 0; @@ -844,6 +994,7 @@ const DataGrid = ({ const gridView = gridRef.current.gridView; const dataProvider = gridRef.current.dataProvider; + applyDefaultGridEditOptions(gridView as GridViewWithEditOptions); // mergeCells: true인 컬럼이 있으면 onSorting에서 클라이언트 정렬로 처리 const hasMergeCells = columns.some((col) => col.mergeCells === true); @@ -894,15 +1045,8 @@ const DataGrid = ({ (gridView.header as { showTooltip?: boolean }).showTooltip = true; } columns.forEach((column) => { - ( - gridView as { - setColumnProperty?: ( - col: string, - key: string, - value: unknown, - ) => void; - } - ).setColumnProperty?.( + const gv = gridView as GridViewWithSetColumnProperty; + gv.setColumnProperty?.( column.name, "header", realGridHeaderWithTooltip({ @@ -910,7 +1054,11 @@ const DataGrid = ({ tooltip: resolveColumnTooltip(column), }), ); + applyColumnListEditorProperties(gv, column); }); + if (columns.some((c) => isDropDownEditorColumn(c))) { + (gridView as { refresh?: () => void }).refresh?.(); + } ( gridView as { onShowHeaderTooltip?: ( @@ -1095,6 +1243,12 @@ const DataGrid = ({ (currentGridView as GridViewWithSetColumns).setColumns?.( columnsWithFooter, ); + columns.forEach((col) => + applyColumnListEditorProperties( + currentGridView as GridViewWithSetColumnProperty, + col, + ), + ); applyFooterSettings(currentGridView); if (hasFooterUserSpans) { currentGridView.setColumnLayout?.(columnLayout); @@ -1499,6 +1653,12 @@ const DataGrid = ({ autoGenerateField={true} layout={columnLayout} rows={rowData} + onRGDataColumnsChanged={(grid) => { + reapplyListEditorsOnGrid( + grid as GridViewWithSetColumnProperty, + columnsRef.current, + ); + }} onInitialized={(grid) => { if (!grid) return; ( @@ -1538,6 +1698,9 @@ const DataGrid = ({ dataProvider.setRows(rowData); if (getGridRef) getGridRef.current = gridRef.current; const gridView = gridRef.current?.gridView ?? grid; + applyDefaultGridEditOptions( + gridView as GridViewWithEditOptions, + ); if (!gridView?.columnByName) return; setTimeout(() => { const gv = gridRef.current?.gridView; @@ -1653,6 +1816,10 @@ const DataGrid = ({ tooltip: resolveColumnTooltip(column), }), ); + applyColumnListEditorProperties( + gv as GridViewWithSetColumnProperty, + column, + ); if (cellTooltipCallbackRef.current) { ( gv as { @@ -1707,9 +1874,15 @@ const DataGrid = ({ {columns.map((column) => { const baseStyleName = column.styleName; + const columnReactKey = + column.values?.length && + isDropDownEditorColumn(column) + ? `${column.name}-dropdown-${column.values.length}` + : column.name; + return ( | undefined, + defaultYear: number, +): SearchFormValues { + const d = props.defaultValues; + const initialClsfCd: SearchFormClsfCd = + d?.clsfCd === "ALL" || d?.clsfCd === "INPT" || d?.clsfCd === "VRFC" + ? d.clsfCd + : "ALL"; + + if (d?.sido) { + return { + sido: d.sido, + gugun: d.gugun || "", + year: d.year || defaultYear.toString(), + order: d.order || "", + fctName: d.fctName || "", + bsnmCd: d.bsnmCd || "", + isDetail: false, + isSummary: d.isSummary || "", + clsfCd: initialClsfCd, + }; + } + + const first = sidoList?.data?.sidoList?.[0]; + const useFirstSido = (props.sido ?? defaultSearchFormProps.sido) && first; + + return { + sido: useFirstSido ? first.sido : "", + gugun: "", + year: d?.year || defaultYear.toString(), + order: d?.order || "", + fctName: d?.fctName || "", + bsnmCd: useFirstSido ? first.bsnmCd : "", + isDetail: false, + isSummary: d?.isSummary || "", + clsfCd: initialClsfCd, + }; +} + const SearchForm = (props: SearchFormProps = {}) => { const { setMessage: alert } = useAlertStore((state) => state); const userInfo = useUserStore((state) => state.userInfo); @@ -82,23 +123,27 @@ const SearchForm = (props: SearchFormProps = {}) => { }, ); - const initialClsfCd: SearchFormClsfCd = (() => { - const d = props.defaultValues?.clsfCd; - if (d === "ALL" || d === "INPT" || d === "VRFC") return d; - return "ALL"; - })(); + // 마운트 시 SWR 캐시의 시도 목록으로 초기값 설정 (탭 닫았다 열 때 API 재호출 없이 동작) + const [values, setValues] = useState(() => + buildSearchFormValues(props, sidoList, defaultYear), + ); - const [values, setValues] = useState({ - sido: props.defaultValues?.sido || "", - gugun: props.defaultValues?.gugun || "", - year: props.defaultValues?.year || defaultYear.toString(), - order: props.defaultValues?.order || "", - fctName: props.defaultValues?.fctName || "", - bsnmCd: props.defaultValues?.bsnmCd || "", - isDetail: false, - isSummary: props.defaultValues?.isSummary || "", - clsfCd: initialClsfCd, - }); + // 최초 진입만 시도 API가 늦게 오는 경우 + useEffect(() => { + if (props.defaultValues?.sido) return; + const first = sidoList?.data?.sidoList?.[0]; + if (!first || !mergedProps.sido) return; + setValues((prev) => + prev.sido + ? prev + : { + ...prev, + sido: first.sido, + gugun: "", + bsnmCd: first.bsnmCd, + }, + ); + }, [sidoList, props.defaultValues?.sido, mergedProps.sido]); const sggKey: [string, string, string, string] | null = values.sido && values.sido !== "ALL" @@ -133,18 +178,7 @@ const SearchForm = (props: SearchFormProps = {}) => { }; const handleReset = () => { - const resetValues: SearchFormValues = { - sido: "", - gugun: "", - year: String(defaultYear), - order: "", - fctName: "", - bsnmCd: "", - isDetail: false, - isSummary: "", - clsfCd: "ALL", - }; - setValues(resetValues); + setValues(buildSearchFormValues(props, sidoList, defaultYear)); props.onReset?.(); }; diff --git a/apps/st/src/components/module/VerificationResultHistoryDialog.tsx b/apps/st/src/components/module/VerificationResultHistoryDialog.tsx index e85d481b0..6fe0606d7 100644 --- a/apps/st/src/components/module/VerificationResultHistoryDialog.tsx +++ b/apps/st/src/components/module/VerificationResultHistoryDialog.tsx @@ -143,7 +143,6 @@ const verificationResultColumnInfo: ColumnInfo = { { name: "nowVlCn", fieldName: "nowVlCn", header: { text: "입력값" }, editable: false }, { name: "compVlCn", fieldName: "compVlCn", header: { text: "기준값" }, editable: false }, { name: "prcsYnNm", fieldName: "prcsYnNm", header: { text: "처리여부" }, editable: false }, - { name: "pbcpAprvYn", fieldName: "pbcpAprvYn", header: { text: "공단승인여부" }, editable: false }, { name: "regDt", fieldName: "regDt", header: { text: "등록일시" }, editable: false }, { name: "mdfcnDt", fieldName: "mdfcnDt", header: { text: "갱신일시" }, editable: false }, { name: "prcsYn", fieldName: "prcsYn", header: { text: "검증결과상태코드" }, editable: false, visible: false }, @@ -157,7 +156,6 @@ const verificationResultColumnInfo: ColumnInfo = { "nowVlCn", "compVlCn", "prcsYnNm", - "pbcpAprvYn", "regDt", "mdfcnDt", "prcsYn", diff --git a/apps/st/src/components/statistics/VerificationSelectionReport.tsx b/apps/st/src/components/statistics/VerificationSelectionReport.tsx index 47a6c8401..18edbf4cc 100644 --- a/apps/st/src/components/statistics/VerificationSelectionReport.tsx +++ b/apps/st/src/components/statistics/VerificationSelectionReport.tsx @@ -1,11 +1,20 @@ "use client"; import { useCallback, useState } from "react"; -import { useQueries } from "@tanstack/react-query"; +import { + type InfiniteData, + keepPreviousData, + useInfiniteQuery, + useQueryClient, +} from "@tanstack/react-query"; import { useTabStore as useGridTabStore } from "@rsone/components"; import { statisticsApi } from "@/entities/statistics/api/statistics-service"; -import type { VerificationResultReportItem } from "@/entities/statistics/model/statistics-interface"; +import type { + VerificationResultListRequest, + VerificationResultListResponse, + VerificationResultReportItem, +} from "@/entities/statistics/model/statistics-interface"; import { useAlertStore, useMenuStore } from "@/shared/lib"; import { findMenuItemByUrlPath, @@ -23,7 +32,9 @@ import { TabsContent, TabsList, TabsTrigger, + Label, } from "@common/components"; +import { CodeSelectWithAll } from "@/entities/code"; type VerificationReportTabKey = "NV" | "IMR" | "HB"; @@ -45,6 +56,64 @@ const TYPE_CODE_NAME_MAP: Record = { HB: "HB", }; +const PAGE_SIZE = 100; +const SCROLL_LOAD_THRESHOLD_PX = 120; + +function getNextVerificationResultPage( + lastPage: VerificationResultListResponse, +): number | undefined { + const pageInfo = lastPage?.data?.pageInfo; + if (!pageInfo) return undefined; + const { currentPage, totalPages } = pageInfo; + if (currentPage + 1 <= totalPages) return currentPage + 1; + return undefined; +} + +function flattenVerificationResults( + data: InfiniteData | undefined, +): VerificationResultReportItem[] { + return ( + data?.pages.flatMap((page) => page?.data?.verifyResultList ?? []) ?? [] + ); +} + +function getVerificationResultTotalCount( + data: InfiniteData | undefined, + loadedCount: number, +): number { + return data?.pages[0]?.data?.pageInfo?.totalElements ?? loadedCount; +} + +function useVerificationReportList( + statsYr: string, + statsCdQueryKey: string, + vrfcMthdCd: VerificationReportTabKey, + statsCd: string, +) { + return useInfiniteQuery({ + queryKey: [ + "verificationResultList", + statsYr, + vrfcMthdCd, + statsCdQueryKey, + ], + initialPageParam: 1, + queryFn: ({ pageParam }) => + statisticsApi.getVerificationResultList({ + statsYr, + pbcpAprvYn: "N", + page: pageParam, + size: PAGE_SIZE, + prcsYnList: "0", + vrfcMthdCd, + ...(!(statsCd === "" || statsCd === "ALL") && { statsCd }), + } satisfies VerificationResultListRequest), + getNextPageParam: getNextVerificationResultPage, + placeholderData: keepPreviousData, + refetchOnWindowFocus: false, + }); +} + type VerificationSelectionReportProps = { onClose?: () => void; }; @@ -52,6 +121,7 @@ type VerificationSelectionReportProps = { const VerificationSelectionReport = ({ onClose, }: VerificationSelectionReportProps) => { + const queryClient = useQueryClient(); const menuList = useTabStore((s) => s.menuList); const addTab = useTabStore((s) => s.addTab); const setCurrentMenuInfo = useMenuStore((s) => s.setCurrentMenuInfo); @@ -61,78 +131,71 @@ const VerificationSelectionReport = ({ ); const alert = useAlertStore((s) => s.setMessage); const [activeTab, setActiveTab] = useState("NV"); + const [statsCd, setStatsCd] = useState("ALL"); const statsYr = String(dayjs().year() - 1); + const statsCdQueryKey = + statsCd === "" || statsCd === "ALL" ? "" : statsCd; - const baseRequest = { + const nvQuery = useVerificationReportList( statsYr, - pbcpAprvYn: "N" as const, - page: 1, - size: 10000, - prcsYnList: "0", - }; + statsCdQueryKey, + "NV", + statsCd, + ); + const imrQuery = useVerificationReportList( + statsYr, + statsCdQueryKey, + "IMR", + statsCd, + ); + const hbQuery = useVerificationReportList( + statsYr, + statsCdQueryKey, + "HB", + statsCd, + ); - const [nvQuery, imrQuery, hbQuery] = useQueries({ - queries: [ - { - queryKey: [ - "verificationResultList", - statsYr, - "NV", - baseRequest.pbcpAprvYn, - baseRequest.page, - baseRequest.size, - baseRequest.prcsYnList, - ], - queryFn: () => - statisticsApi.getVerificationResultList({ - ...baseRequest, - vrfcMthdCd: "NV", - }), - }, - { - queryKey: [ - "verificationResultList", - statsYr, - "IMR", - baseRequest.pbcpAprvYn, - baseRequest.page, - baseRequest.size, - baseRequest.prcsYnList, - ], - queryFn: () => - statisticsApi.getVerificationResultList({ - ...baseRequest, - vrfcMthdCd: "IMR", - }), - }, - { - queryKey: [ - "verificationResultList", - statsYr, - "HB", - baseRequest.pbcpAprvYn, - baseRequest.page, - baseRequest.size, - baseRequest.prcsYnList, - ], - queryFn: () => - statisticsApi.getVerificationResultList({ - ...baseRequest, - vrfcMthdCd: "HB", - }), - }, - ], - }); + const queryByTab = { + NV: nvQuery, + IMR: imrQuery, + HB: hbQuery, + } as const; - const isLoading = - nvQuery.isLoading || imrQuery.isLoading || hbQuery.isLoading; + const isInitialLoading = + nvQuery.isPending || imrQuery.isPending || hbQuery.isPending; + const isRefreshing = + (nvQuery.isFetching && !nvQuery.isPending) || + (imrQuery.isFetching && !imrQuery.isPending) || + (hbQuery.isFetching && !hbQuery.isPending); const error = nvQuery.error ?? imrQuery.error ?? hbQuery.error; + const handleRefresh = useCallback(() => { + const tabCodes: VerificationReportTabKey[] = ["NV", "IMR", "HB"]; + void Promise.all( + tabCodes.map((code) => + queryClient.resetQueries({ + queryKey: [ + "verificationResultList", + statsYr, + code, + statsCdQueryKey, + ], + }), + ), + ); + }, [queryClient, statsYr, statsCdQueryKey]); + const listsByTab: Record = { - NV: nvQuery.data?.data?.verifyResultList ?? [], - IMR: imrQuery.data?.data?.verifyResultList ?? [], - HB: hbQuery.data?.data?.verifyResultList ?? [], + NV: flattenVerificationResults(nvQuery.data), + IMR: flattenVerificationResults(imrQuery.data), + HB: flattenVerificationResults(hbQuery.data), + }; + + const totalCountByTab: Record = { + NV: getVerificationResultTotalCount(nvQuery.data, listsByTab.NV.length), + IMR: getVerificationResultTotalCount(imrQuery.data, listsByTab.IMR.length), + HB: getVerificationResultTotalCount(hbQuery.data, listsByTab.HB.length), }; const openInputFromStatsCd = useCallback( @@ -182,12 +245,43 @@ const VerificationSelectionReport = ({ ], ); + const handleListScroll = useCallback( + ( + e: React.UIEvent, + hasNextPage: boolean, + isFetchingNextPage: boolean, + fetchNextPage: () => void, + ) => { + const el = e.currentTarget; + const remaining = + el.scrollHeight - el.scrollTop - el.clientHeight; + if (remaining > SCROLL_LOAD_THRESHOLD_PX) return; + if (hasNextPage && !isFetchingNextPage) { + fetchNextPage(); + } + }, + [], + ); + const renderResultList = ( typeCode: VerificationReportTabKey, items: VerificationResultReportItem[], + hasNextPage: boolean, + isFetchingNextPage: boolean, + fetchNextPage: () => void, ) => ( -
- {items.length === 0 ? ( +
+ handleListScroll( + e, + hasNextPage, + isFetchingNextPage, + fetchNextPage, + ) + } + > + {items.length === 0 && !isFetchingNextPage ? (
항목이 없습니다.
@@ -234,6 +328,13 @@ const VerificationSelectionReport = ({
))} + {isFetchingNextPage && ( +
+
+ +
+
+ )} )} @@ -241,9 +342,26 @@ const VerificationSelectionReport = ({ return (
-
+
-

이상치 선별 보고서

+
+

이상치 선별 보고서

+ { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + handleRefresh(); + } + }} + > + + +
-
- {isLoading && ( +
+ + +
+
+ {isInitialLoading && (
@@ -273,24 +401,24 @@ const VerificationSelectionReport = ({ 데이터를 불러올 수 없습니다.
)} - {!isLoading && !error && ( -
+ {!isInitialLoading && !error && ( +
setActiveTab(value as VerificationReportTabKey) } - className="flex min-h-0 flex-1 flex-col h-full" + className="flex min-h-0 flex-1 flex-col" variant="default" > - + {VERIFICATION_REPORT_TABS.map((tab) => ( - {tab.label} ({listsByTab[tab.value].length}) + {tab.label} ({totalCountByTab[tab.value]}) ))} @@ -304,6 +432,12 @@ const VerificationSelectionReport = ({ ? renderResultList( tab.value, listsByTab[tab.value], + queryByTab[tab.value] + .hasNextPage ?? false, + queryByTab[tab.value] + .isFetchingNextPage, + queryByTab[tab.value] + .fetchNextPage, ) : null} diff --git a/apps/st/src/components/statistics/input/PublicSewageFacilityInp.tsx b/apps/st/src/components/statistics/input/PublicSewageFacilityInp.tsx index 84ad50646..1aaa36585 100644 --- a/apps/st/src/components/statistics/input/PublicSewageFacilityInp.tsx +++ b/apps/st/src/components/statistics/input/PublicSewageFacilityInp.tsx @@ -165,7 +165,7 @@ const PublicSewageFacilityInp = () => { const [tabStatsCdMap, setTabStatsCdMap] = useState>(new Map()); /** 탭키별 `engColNm -> columnGuide` 캐시 */ const [columnGuidesByTab, setColumnGuidesByTab] = useState>>({}); - const [toolbarSnapshotByTab, setToolbarSnapshotByTab] = useState>(() => new Map()); + const [_toolbarSnapshotByTab, setToolbarSnapshotByTab] = useState>(() => new Map()); const [inputPeriodEnterable, setInputPeriodEnterable] = useState(true); const [periodCheckList, setPeriodCheckList] = useState([]); const [showDailog, setShowDailog] = useState(false); @@ -180,7 +180,7 @@ const PublicSewageFacilityInp = () => { const searchDoneRef = useRef(false); /** ST00060001만: RealGrid 편집값이 state에 없을 수 있어 저장/제출 시 그리드에서 직접 읽음 */ const st60001GridGetRowsRef = useRef<(() => Record[]) | null>(null); - + const submitFunc = () => { void (async () => { if (activeTab !== 'ST00060001') { @@ -191,9 +191,10 @@ const PublicSewageFacilityInp = () => { alert('먼저 조회를 진행해주세요.'); return; } + const treatAsNoApplicable = getNoApplicableForRequest() || isNotApplicable; const currentData = st60001GridGetRowsRef.current?.() ?? rowDataMap.get('ST00060001') ?? []; - if (!currentData || currentData.length === 0) { + if (!treatAsNoApplicable && (!currentData || currentData.length === 0)) { alert('제출할 데이터가 없습니다.'); return; } @@ -270,9 +271,12 @@ const PublicSewageFacilityInp = () => { alert('먼저 조회를 진행해주세요.'); return; } + const noApplicable = getNoApplicableForRequest(); + const treatAsNoApplicable = noApplicable || isNotApplicable; const currentData = st60001GridGetRowsRef.current?.() ?? rowDataMap.get('ST00060001') ?? []; - if (!currentData || currentData.length === 0) { + + if (!treatAsNoApplicable && (!currentData || currentData.length === 0)) { alert('제출할 데이터가 없습니다.'); return; } @@ -280,7 +284,7 @@ const PublicSewageFacilityInp = () => { try { showGlobalProgress({ label: '서버에 요청 중...', progress: 0 }); const userInfo = await getUserInfo(); - const submitList = buildFacilitySaveList(currentData); + const submitList = treatAsNoApplicable ? [] : buildFacilitySaveList(currentData); const activeTabStatus = tabStatusInfoMap.get(activeTab) ?? null; const statsCdForTab = tabStatsCdMap.get(activeTab) ?? activeTab; const requestData = { @@ -289,7 +293,7 @@ const PublicSewageFacilityInp = () => { sggNm: lastSearchedValues.gugun === 'ALL' ? '' : lastSearchedValues.gugun ?? '', sewerBzmnCd: lastSearchedValues.bsnmCd ?? '', userId: userInfo.userUnqId, - noApplicable: getNoApplicableForRequest(), + noApplicable, laePicNm: submitInfo.laePicNm, laeKbrdrJbgdNm: submitInfo.laeKbrdrJbgdNm, laeKbrdrDeptNm: submitInfo.laeKbrdrDeptNm, @@ -408,6 +412,12 @@ const PublicSewageFacilityInp = () => { const saveFunc = () => { void (async () => { + const verificationBlocked = getVerificationPeriodActionBlockedMessage(periodCheckList); + if (verificationBlocked) { + alert(verificationBlocked); + return; + } + if (!lastSearchedValues || !searchParamRef.current) { alert('먼저 조회를 진행해주세요.'); return; @@ -419,13 +429,13 @@ const PublicSewageFacilityInp = () => { statsCd: 'ST00060001', statsYr: lastSearchedValues.year ?? '' }); - + if (statusCodeResponse.status === 'success') { if (!['0', '1', '4'].includes(statusCodeResponse.data)) { const codeName = statusCodeResponse.data === '2' ? '제출' : (statusCodeResponse.data === '3' ? '승인' : '검증중'); alert(`공공 하수처리 시설 데이터가 ${codeName} 상태일 때는 중심 처리 시설 데이터를 변경할 수 없습니다.`); return; - } + } } else { alert('오류가 발생했습니다.\n관리자에게 문의하세요') } @@ -508,14 +518,16 @@ const PublicSewageFacilityInp = () => { try { showGlobalProgress({ label: '저장 진행 중...', progress: 0 }); const userInfo = await getUserInfo(); + const noApplicable = getNoApplicableForRequest(); + const treatAsNoApplicable = noApplicable || isNotApplicable; const currentData = st60001GridGetRowsRef.current?.() ?? rowDataMap.get('ST00060001') ?? []; - if (!currentData || currentData.length === 0) { + if (!treatAsNoApplicable && (!currentData || currentData.length === 0)) { alert('저장할 데이터가 없습니다.'); return; } - const saveList = buildFacilitySaveList(currentData); + const saveList = treatAsNoApplicable ? [] : buildFacilitySaveList(currentData); const param: PublicSewageFacilitySaveRequest = { statsYr: lastSearchedValues.year ?? '', ctpvNm: lastSearchedValues.sido ?? '', @@ -523,7 +535,7 @@ const PublicSewageFacilityInp = () => { sewerBzmnCd: lastSearchedValues.bsnmCd ?? '', userId: userInfo.userUnqId, // superAdmin: isAdminInfo ? 'superAdmin' : '', - noApplicable: getNoApplicableForRequest(), + noApplicable, saveList, }; @@ -643,6 +655,8 @@ const PublicSewageFacilityInp = () => { param: PublicSewageFacilityRequestParams, searchContext: SearchFormValues | null, ): Promise => { + // 서버 데이터로 새로 불러오는 시점이므로 "해당없음" 체크는 항상 초기화 + setIsNotApplicable(false); try { if (tabKey === 'ST00060001') { const response = await statisticsInputPublicSewageFacilityApiService.getPublicSewageFacility(param); @@ -1062,7 +1076,7 @@ const PublicSewageFacilityInp = () => { onValuesChange={setSearchValues} /> {showActionToolbarCard && ( -
+
{!isMainFacilityTab && ( { {showEnterableToolbarButtons && !isMainFacilityTab && ( <> {showIsNotApplicable && } - - + + )} {!isMainFacilityTab && activeTabStatus?.laeSbmsnSttsCd === '2' && ( @@ -1088,7 +1102,7 @@ const PublicSewageFacilityInp = () => {
-
+
)} void handleActiveTabChange(v)}> @@ -1099,165 +1113,165 @@ const PublicSewageFacilityInp = () => { ))} - {PublicSewageFactInpColumnList.map((tab) => { - if (tab.isSplitGrid && tab.left && tab.right) { - const leftKey = tab.left.tabKey ?? `${tab.tabKey}_left`; - const rightKey = tab.right.tabKey ?? `${tab.tabKey}_right`; + {PublicSewageFactInpColumnList.map((tab) => { + if (tab.isSplitGrid && tab.left && tab.right) { + const leftKey = tab.left.tabKey ?? `${tab.tabKey}_left`; + const rightKey = tab.right.tabKey ?? `${tab.tabKey}_right`; + return ( + + { + void handleMainFacilityRowClick(row); + } + : undefined + } + leftCellStyleCallback={ + tab.tabKey === 'ST00060002' + ? leftMainFacilityCellStyleCallback + : undefined + } + rightOnItemChecked={ + tab.tabKey === 'ST00060002' + ? (itemIndex, checked) => { + setRowDataMap((prev) => { + const next = new Map(prev); + const rows = [...(next.get('MainFacilityTarget') ?? [])]; + if (!rows[itemIndex]) return prev; + rows[itemIndex] = { + ...rows[itemIndex], + __checked: checked ? 'Y' : '', + }; + next.set('MainFacilityTarget', rows); + return next; + }); + } + : undefined + } + rightOnItemAllChecked={ + tab.tabKey === 'ST00060002' + ? (checked) => { + setRowDataMap((prev) => { + const next = new Map(prev); + const rows = [...(next.get('MainFacilityTarget') ?? [])].map((r) => ({ + ...r, + __checked: checked ? 'Y' : '', + })); + next.set('MainFacilityTarget', rows); + return next; + }); + } + : undefined + } + setLeftRowData={(updater) => { + setRowDataMap((prev) => { + const next = new Map(prev); + const cur = next.get(leftKey) ?? []; + const n = + typeof updater === 'function' + ? (updater as (p: Record[]) => Record[])(cur) + : updater; + next.set(leftKey, n); + return next; + }); + }} + setRightRowData={(updater) => { + setRowDataMap((prev) => { + const next = new Map(prev); + const cur = next.get(rightKey) ?? []; + const n = + typeof updater === 'function' + ? (updater as (p: Record[]) => Record[])(cur) + : updater; + next.set(rightKey, n); + return next; + }); + }} + /> + + ); + } + + const gridColumnInfo = getGridColumnInfo(tab); + const tabStatus = tabStatusInfoMap.get(tab.tabKey) ?? null; + const tabEnterableForGrid = computeTabEnterable( + tabStatus, + inputPeriodEnterable, + ); + const excelToolbarEnabledForTab = + tabRowDataExists(tab.tabKey) && + tabEnterableForGrid && + gugunOkOrNotDoList; + return ( - { - void handleMainFacilityRowClick(row); - } - : undefined - } - leftCellStyleCallback={ - tab.tabKey === 'ST00060002' - ? leftMainFacilityCellStyleCallback - : undefined - } - rightOnItemChecked={ - tab.tabKey === 'ST00060002' - ? (itemIndex, checked) => { - setRowDataMap((prev) => { - const next = new Map(prev); - const rows = [...(next.get('MainFacilityTarget') ?? [])]; - if (!rows[itemIndex]) return prev; - rows[itemIndex] = { - ...rows[itemIndex], - __checked: checked ? 'Y' : '', - }; - next.set('MainFacilityTarget', rows); - return next; - }); - } - : undefined - } - rightOnItemAllChecked={ - tab.tabKey === 'ST00060002' - ? (checked) => { - setRowDataMap((prev) => { - const next = new Map(prev); - const rows = [...(next.get('MainFacilityTarget') ?? [])].map((r) => ({ - ...r, - __checked: checked ? 'Y' : '', - })); - next.set('MainFacilityTarget', rows); - return next; - }); - } - : undefined - } - setLeftRowData={(updater) => { + { setRowDataMap((prev) => { const next = new Map(prev); - const cur = next.get(leftKey) ?? []; + const cur = next.get(tab.tabKey) ?? []; const n = typeof updater === 'function' ? (updater as (p: Record[]) => Record[])(cur) : updater; - next.set(leftKey, n); + next.set(tab.tabKey, n); return next; }); }} - setRightRowData={(updater) => { - setRowDataMap((prev) => { - const next = new Map(prev); - const cur = next.get(rightKey) ?? []; - const n = - typeof updater === 'function' - ? (updater as (p: Record[]) => Record[])(cur) - : updater; - next.set(rightKey, n); - return next; - }); + getGridDataRef={ + tab.tabKey === 'ST00060001' ? st60001GridGetRowsRef : undefined + } + isExcelImport={excelToolbarEnabledForTab} + isExcelExport + 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" || e.vrfcCd === "HB"); + 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검증 - " : (e.vrfcCd === "NV" ? "일반검증 - " : "HB검증 - "); + const comp = e.compVlCn ?? ""; + const now = e.nowVlCn ?? ""; + return `${prefix}${e.rsnExpln} + (검증 기준값: ${comp} / 현재 입력값: ${now})`; + }) + .filter(Boolean) + .join("\n"); + } }} /> ); - } - - const gridColumnInfo = getGridColumnInfo(tab); - const tabStatus = tabStatusInfoMap.get(tab.tabKey) ?? null; - const tabEnterableForGrid = computeTabEnterable( - tabStatus, - inputPeriodEnterable, - ); - const excelToolbarEnabledForTab = - tabRowDataExists(tab.tabKey) && - tabEnterableForGrid && - gugunOkOrNotDoList; - - return ( - - { - setRowDataMap((prev) => { - const next = new Map(prev); - const cur = next.get(tab.tabKey) ?? []; - const n = - typeof updater === 'function' - ? (updater as (p: Record[]) => Record[])(cur) - : updater; - next.set(tab.tabKey, n); - return next; - }); - }} - getGridDataRef={ - tab.tabKey === 'ST00060001' ? st60001GridGetRowsRef : undefined - } - isExcelImport={excelToolbarEnabledForTab} - isExcelExport - 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" || e.vrfcCd === "HB"); - 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검증 - " : (e.vrfcCd === "NV" ? "일반검증 - " : "HB검증 - "); - const comp = e.compVlCn ?? ""; - const now = e.nowVlCn ?? ""; - return `${prefix}${e.rsnExpln} - (검증 기준값: ${comp} / 현재 입력값: ${now})`; - }) - .filter(Boolean) - .join("\n"); - } - }} - /> - - ); - })} + })}
({ + columns: tab.columns ?? [], + columnLayout: tab.columnLayout ?? [], +}); + +type GridRowWithVerification = Record & { + verificationResult?: SewagePipeMaintainRepairVerificationResult; +}; + +type TabToolbarSnapshot = { + showSaveInToolbar: boolean; + showEnterableToolbarButtons: boolean; + showActionToolbarCard: boolean; +}; + +const computeTabEnterable = ( + statusInfo: SewerageStatusInfo | null | undefined, + periodEnterable: boolean, +): boolean => { + if (statusInfo?.laeSbmsnSttsCd) { + if (["2", "3"].includes(statusInfo.laeSbmsnSttsCd)) { + return false; + } + return periodEnterable; + } + return periodEnterable; +}; + +function buildGugunOkForToolbar(values: SearchFormValues | null): boolean { + return doList.includes(values?.bsnmCd || "") + ? Boolean(values?.gugun && values.gugun !== "ALL") + : true; +} + +function computeTabToolbarSnapshot( + statusInfo: SewerageStatusInfo | null, + hasRowData: boolean, + treatAsSearched: boolean, + searchContext: SearchFormValues | null, + periodEnterable: boolean, + isAdminInfo: boolean, +): TabToolbarSnapshot { + const gugunOk = buildGugunOkForToolbar(searchContext); + const enterable = computeTabEnterable(statusInfo, periodEnterable); + const isSubmitted = statusInfo?.laeSbmsnSttsCd === "2"; + const showSaveInToolbar = (enterable || isAdminInfo) && gugunOk; + const showEnterableToolbarButtons = enterable && gugunOk; + const showSubmittedInInputPeriodToolbar = + isSubmitted && periodEnterable && gugunOk; + const showActionToolbarCard = + (hasRowData || treatAsSearched) && + (showSaveInToolbar || + showEnterableToolbarButtons || + showSubmittedInInputPeriodToolbar || + (isSubmitted && isAdminInfo)); + return { showSaveInToolbar, showEnterableToolbarButtons, showActionToolbarCard }; +} + +function tabRowDataExists( + m: Map[]>, + tabKey: string, +): boolean { + const rows = m.get(tabKey); + return Array.isArray(rows) && rows.length > 0; +} + +function normalizeGridString(value: unknown): string { + if (value === null || value === undefined) return ""; + return typeof value === "string" ? value : String(value); +} + +function normalizeGridRmrk(value: unknown): string | null { + if (value === null || value === undefined) return null; + const s = typeof value === "string" ? value : String(value); + return s.trim() === "" ? null : s; +} + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +async function waitForImrJobCompleted( + jobId: string, + getStatus: (id: string) => Promise, + options?: { + pollIntervalMs?: number; + maxAttempts?: number; + onProgress?: (jobProgress: number) => void; + }, +): Promise { + const pollIntervalMs = options?.pollIntervalMs ?? 2000; + const maxAttempts = options?.maxAttempts ?? 150; + for (let i = 0; i < maxAttempts; i++) { + const res = await getStatus(jobId); + options?.onProgress?.(Math.max(0, Math.min(100, Number(res.job_prog) || 0))); + if (res.status === "COMPLETED" && Number(res.job_prog) === 100) return; + if (res.status === "FAILED" || res.status === "ERROR" || res.status === "CANCELLED") { + throw new Error(res.error_msg || `IMR 검증이 ${res.status} 상태로 종료되었습니다.`); + } + await sleep(pollIntervalMs); + } + throw new Error("IMR 검증 완료 대기 시간이 초과되었습니다."); +} + +function buildColumnFormatNumber( + columns: { fieldName?: string; dataFormat?: string }[], +) { + const columnFormatMap = new Map(); + columns.forEach((col) => { + if (col.fieldName && col.dataFormat) { + columnFormatMap.set(col.fieldName, col.dataFormat); + } + }); + + return (value: string | number | null | undefined, fieldName: string): number => { + if (value === null || value === undefined || value === "") return 0; + const num = + typeof value === "string" ? parseFloat(value.replace(/,/g, "")) : value; + if (isNaN(num)) return 0; + const format = columnFormatMap.get(fieldName); + if (!format) return num; + const decimalPlaces = format.match(/\.(\d+)/)?.[1]?.length ?? 0; + return decimalPlaces > 0 ? parseFloat(num.toFixed(decimalPlaces)) : Math.round(num); + }; +} + +function buildRepairRowList( + currentData: Record[], +): SewagePipeMaintainRepairSaveItem[] { + const formatNumber = buildColumnFormatNumber(RepairStatusInpColumn.columns); + + return currentData.map((d) => ({ + admdstCd: normalizeGridString(d.admdstCd), + sggNm: normalizeGridString(d.sggNm), + admdstNm: normalizeGridString(d.admdstNm), + mendngWhol: formatNumber(d.mendngWhol, "mendngWhol"), + cbsysMendngPrlg: formatNumber(d.cbsysMendngPrlg, "cbsysMendngPrlg"), + sprsysExlnPrlg: formatNumber(d.sprsysExlnPrlg, "sprsysExlnPrlg"), + sprsysDrtwPrlg: formatNumber(d.sprsysDrtwPrlg, "sprsysDrtwPrlg"), + cbsysPartMendngPrlg: formatNumber(d.cbsysPartMendngPrlg, "cbsysPartMendngPrlg"), + sprsysExlnMendngPrlg: formatNumber(d.sprsysExlnMendngPrlg, "sprsysExlnMendngPrlg"), + sprsysDrtwMendngPrlg: formatNumber(d.sprsysDrtwMendngPrlg, "sprsysDrtwMendngPrlg"), + cbsysMnhlCnt: formatNumber(d.cbsysMnhlCnt, "cbsysMnhlCnt"), + sprsysDrtwMnhlCnt: formatNumber(d.sprsysDrtwMnhlCnt, "sprsysDrtwMnhlCnt"), + sprsysExlnMnhlCnt: formatNumber(d.sprsysExlnMnhlCnt, "sprsysExlnMnhlCnt"), + drtwRcvprpCnt: formatNumber(d.drtwRcvprpCnt, "drtwRcvprpCnt"), + exlnRcvprpCnt: formatNumber(d.exlnRcvprpCnt, "exlnRcvprpCnt"), + stofcbrCnt: formatNumber(d.stofcbrCnt, "stofcbrCnt"), + otflCnt: formatNumber(d.otflCnt, "otflCnt"), + cctvExmnPrlg: formatNumber(d.cctvExmnPrlg, "cctvExmnPrlg"), + rmrk: normalizeGridRmrk(d.rmrk), + })); +} + +function buildDredgingRowList( + currentData: Record[], +): SewagePipeMaintainDredgingSaveItem[] { + const formatNumber = buildColumnFormatNumber(PipeDredgingStatusInpColumn.columns); + + return currentData.map((d) => ({ + admdstCd: normalizeGridString(d.admdstCd), + sggNm: normalizeGridString(d.sggNm), + admdstNm: normalizeGridString(d.admdstNm), + wholFcltPrlg: formatNumber(d.wholFcltPrlg, "wholFcltPrlg"), + wholSwagePpl: formatNumber(d.wholSwagePpl, "wholSwagePpl"), + cbsysPplPrlg: formatNumber(d.cbsysPplPrlg, "cbsysPplPrlg"), + sprsysExlnPrlg: formatNumber(d.sprsysExlnPrlg, "sprsysExlnPrlg"), + sprsysDrtwPrlg: formatNumber(d.sprsysDrtwPrlg, "sprsysDrtwPrlg"), + mnhlCnt: formatNumber(d.mnhlCnt, "mnhlCnt"), + exlnDrtwRcvprpCnt: formatNumber(d.exlnDrtwRcvprpCnt, "exlnDrtwRcvprpCnt"), + stofcbrOtflCnt: formatNumber(d.stofcbrOtflCnt, "stofcbrOtflCnt"), + drdgnPlanPrlg: formatNumber(d.drdgnPlanPrlg, "drdgnPlanPrlg"), + drdgnTrgtCtrsDrdgnRt: formatNumber(d.drdgnTrgtCtrsDrdgnRt, "drdgnTrgtCtrsDrdgnRt"), + wholPrlgCtrsDrdgnRt: formatNumber(d.wholPrlgCtrsDrdgnRt, "wholPrlgCtrsDrdgnRt"), + drdgnAmt: formatNumber(d.drdgnAmt, "drdgnAmt"), + drdgnQnt: formatNumber(d.drdgnQnt, "drdgnQnt"), + drdgnUntprcAmt: formatNumber(d.drdgnUntprcAmt, "drdgnUntprcAmt"), + wholDrdgnPrcsQnt: formatNumber(d.wholDrdgnPrcsQnt, "wholDrdgnPrcsQnt"), + rclmQnt: formatNumber(d.rclmQnt, "rclmQnt"), + oceanEmsnQnt: formatNumber(d.oceanEmsnQnt, "oceanEmsnQnt"), + rcycQnt: formatNumber(d.rcycQnt, "rcycQnt"), + etcQnt: formatNumber(d.etcQnt, "etcQnt"), + rmrk: normalizeGridRmrk(d.rmrk), + })); +} + // 하수도 통계 관리 > 03. 하수관로 유지관리 const SewagePipeMaintainInp = () => { const { setMessage: alert } = useAlertStore((state) => state); + const { isAdminInfo } = useAdminStore(); + const toolbarGroupAllowed = useStatisticsInputToolbarGroupGate(); + const { confirm } = useConfirm(); + const showGlobalProgress = useGlobalProgressStore((state) => state.show); + const setGlobalProgress = useGlobalProgressStore((state) => state.setProgress); + const setGlobalProgressLabel = useGlobalProgressStore((state) => state.setLabel); + const hideGlobalProgress = useGlobalProgressStore((state) => state.hide); + const [isLoading, setIsLoading] = useState(false); const [searchValues, setSearchValues] = useState(null); - const [activeTab, setActiveTab] = useState(RepairStatusInpColumn.tabKey); + const [activeTab, setActiveTab] = useState(firstTabKey); const [lastSearchedValues, setLastSearchedValues] = useState(null); - const [lastSearchedYear, setLastSearchedYear] = useState(null); const [hasSearched, setHasSearched] = useState(false); + const [inputPeriodEnterable, setInputPeriodEnterable] = useState(true); + const [tabStatusInfoMap, setTabStatusInfoMap] = useState< + Map + >(new Map()); + const [tabStatsCdMap, setTabStatsCdMap] = useState>(new Map()); + const [toolbarSnapshotByTab, setToolbarSnapshotByTab] = useState< + Map + >(new Map()); + const [verificationHistoryOpen, setVerificationHistoryOpen] = useState(false); + const [verificationResultDialogOpen, setVerificationResultDialogOpen] = useState(false); + const [showSubmitDialog, setShowSubmitDialog] = useState(false); const searchParamRef = useRef(null); const postVerificationSearchRef = useRef<{ values: SearchFormValues; @@ -53,15 +299,19 @@ const SewagePipeMaintainInp = () => { const [verificationSearchDefaults, setVerificationSearchDefaults] = useState(null); const [searchFormRemountKey, setSearchFormRemountKey] = useState(0); + const [rowDataMap, setRowDataMap] = useState< + Map[]> + >(new Map()); - // tab List - const SewagePipeMaintainInpTabList = [ - RepairStatusInpColumn, - PipeDredgingStatusInpColumn - ]; + const searchDoneRef = useRef(false); + const periodEnterableRef = useRef(true); + const activeTabRef = useRef(activeTab); + const getGridDataRef = useRef<(() => Record[]) | null>(null); + activeTabRef.current = activeTab; const activeTabId = useTabStore((s) => s.activeTabId); const inputFocusNonce = useStatisticsInputFocusStore((s) => s.nonce); + useLayoutEffect(() => { if (activeTabId !== STATISTICS_INPUT_URL_PATH.SEWAGE_PIPE_MAINTAIN) return; const pending = useStatisticsInputFocusStore @@ -78,52 +328,126 @@ const SewagePipeMaintainInp = () => { } }, [activeTabId, inputFocusNonce]); - const SubmitExceptionFunc = () => { - console.log('submit exception'); - // 예외 제출 로직 + const commitToolbarSnapshotForLoadedTab = ( + tabKey: string, + statusInfo: SewerageStatusInfo | null, + hasRowData: boolean, + searchContext: SearchFormValues, + ) => { + setToolbarSnapshotByTab((prev) => { + const next = new Map(prev); + next.set( + tabKey, + computeTabToolbarSnapshot( + statusInfo, + hasRowData, + searchDoneRef.current, + searchContext, + periodEnterableRef.current, + isAdminInfo, + ), + ); + return next; + }); + }; + + const applyRowMap = ( + mergeIntoExistingMap: boolean, + apply: (m: Map[]>) => void, + ) => { + if (mergeIntoExistingMap) { + setRowDataMap((prev) => { + const next = new Map(prev); + apply(next); + return next; + }); + } else { + const next = new Map[]>(); + apply(next); + setRowDataMap(next); + } }; const loadActiveTabData = async ( tabKey: string, param: SewagePipeMaintainRequestParams, - append: boolean, - ): Promise => { + searchContext: SearchFormValues, + mergeIntoExistingMap: boolean, + updateLastSearchedValues: boolean, + overrides?: { mode?: string }, + ): Promise => { + const requestParam = overrides?.mode + ? { ...param, mode: overrides.mode } + : param; + if (tabKey === RepairStatusInpColumn.tabKey) { - const response = await sewagePipeMaintainApiService.getSewagePipeMaintainRepair(param); - if (response.status !== "success") { + const response = + await sewagePipeMaintainApiService.getSewagePipeMaintainRepair( + requestParam, + ); + if (response.status !== "success" || !response.data) { alert(response.message || "데이터 조회 중 오류가 발생했습니다."); - return; + return false; } - const commonInfo = response.data.commonInfo; - const convertedData = (response.data.repairStatusList ?? []).map((d) => ({ + const { commonInfo, statusInfo, repairStatusList } = response.data; + const convertedData = (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; + const gridRows = toGridRows(convertedData); + + applyRowMap(mergeIntoExistingMap, (m) => { + m.set(RepairStatusInpColumn.tabKey, gridRows); }); - return; + setTabStatusInfoMap((prev) => new Map(prev).set(tabKey, statusInfo)); + setTabStatsCdMap((prev) => new Map(prev).set(tabKey, commonInfo.statsCd)); + if (updateLastSearchedValues) { + setLastSearchedValues(searchContext); + } + commitToolbarSnapshotForLoadedTab( + tabKey, + statusInfo, + gridRows.length > 0, + searchContext, + ); + return true; } if (tabKey === PipeDredgingStatusInpColumn.tabKey) { - const response = await sewagePipeMaintainApiService.getSewagePipeMaintainDredging(param); - if (response.status !== "success") { + const response = + await sewagePipeMaintainApiService.getSewagePipeMaintainDredging( + requestParam, + ); + if (response.status !== "success" || !response.data) { alert(response.message || "데이터 조회 중 오류가 발생했습니다."); - return; + return false; } - const commonInfo = response.data.commonInfo; - const convertedData = (response.data.pipeDredgingStatusList ?? []).map((d) => ({ + const { commonInfo, statusInfo, pipeDredgingStatusList } = response.data; + const convertedData = (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 gridRows = toGridRows(convertedData); + + applyRowMap(mergeIntoExistingMap, (m) => { + m.set(PipeDredgingStatusInpColumn.tabKey, gridRows); }); + setTabStatusInfoMap((prev) => new Map(prev).set(tabKey, statusInfo)); + setTabStatsCdMap((prev) => new Map(prev).set(tabKey, commonInfo.statsCd)); + if (updateLastSearchedValues) { + setLastSearchedValues(searchContext); + } + commitToolbarSnapshotForLoadedTab( + tabKey, + statusInfo, + gridRows.length > 0, + searchContext, + ); + return true; } + + alert("해당 탭 조회 API가 없습니다."); + return false; }; const getLastYearDataFunc = async () => { @@ -132,7 +456,7 @@ const SewagePipeMaintainInp = () => { alert("시도를 선택해주세요."); return; } - if (!lastSearchedYear) { + if (!hasSearched) { alert("먼저 데이터를 조회해주세요."); return; } @@ -140,7 +464,14 @@ const SewagePipeMaintainInp = () => { try { setIsLoading(true); const param = buildSewagePipeMaintainSearchParam(values, { mode: "PRE" }); - await loadActiveTabData(activeTab, param, true); + await loadActiveTabData( + activeTabRef.current, + param, + values, + true, + false, + { mode: "PRE" }, + ); } catch { alert("데이터 조회 중 오류가 발생했습니다."); } finally { @@ -148,27 +479,365 @@ const SewagePipeMaintainInp = () => { } }; + const getTabGridRows = (tabKey: string): Record[] => + getGridDataRef.current?.() ?? rowDataMap.get(tabKey) ?? []; + + const getRepairGridRows = (): Record[] => + getTabGridRows(RepairStatusInpColumn.tabKey); + + const getDredgingGridRows = (): Record[] => + getTabGridRows(PipeDredgingStatusInpColumn.tabKey); + const submitFunc = () => { - console.log('submit'); - // 제출 로직 + void (async () => { + const tabKey = activeTabRef.current; + const isRepairTab = tabKey === RepairStatusInpColumn.tabKey; + const isDredgingTab = tabKey === PipeDredgingStatusInpColumn.tabKey; + if (!isRepairTab && !isDredgingTab) { + alert("제출할 수 없는 탭입니다."); + return; + } + if (!lastSearchedValues || !searchParamRef.current) { + alert("먼저 조회를 진행해주세요."); + return; + } + if (!buildGugunOkForToolbar(lastSearchedValues)) { + alert("시군구를 선택해주세요."); + return; + } + const currentData = isRepairTab ? getRepairGridRows() : getDredgingGridRows(); + if (!currentData.length) { + alert("제출할 데이터가 없습니다."); + return; + } + if (await confirm("제출 하시겠습니까?")) { + setShowSubmitDialog(true); + } + })(); + }; + + const executeSubmit = async (submitInfo: StatisticsInputSubmitInfoFormData) => { + const tabKey = activeTabRef.current; + const isRepairTab = tabKey === RepairStatusInpColumn.tabKey; + const isDredgingTab = tabKey === PipeDredgingStatusInpColumn.tabKey; + if (!isRepairTab && !isDredgingTab) { + alert("제출할 수 없는 탭입니다."); + return; + } + if (!lastSearchedValues || !searchParamRef.current) { + alert("먼저 조회를 진행해주세요."); + return; + } + + const currentData = isRepairTab ? getRepairGridRows() : getDredgingGridRows(); + if (!currentData.length) { + alert("제출할 데이터가 없습니다."); + return; + } + + const activeTabStatus = tabStatusInfoMap.get(tabKey) ?? null; + const statsCdForTab = tabStatsCdMap.get(tabKey) ?? tabKey; + + const submitInfoBase = { + statsYr: lastSearchedValues.year ?? "", + ctpvNm: lastSearchedValues.sido ?? "", + sggNm: + lastSearchedValues.gugun === "ALL" + ? "" + : (lastSearchedValues.gugun ?? ""), + sewerBzmnCd: lastSearchedValues.bsnmCd ?? "", + laePicNm: submitInfo.laePicNm, + laeKbrdrJbgdNm: submitInfo.laeKbrdrJbgdNm, + laeKbrdrDeptNm: submitInfo.laeKbrdrDeptNm, + laePicTelNo: submitInfo.laePicTelNo, + laeIdfrNm: submitInfo.laeIdfrNm, + laeIdfrJbgdNm: submitInfo.laeIdfrJbgdNm, + laeIdfrDeptNm: submitInfo.laeIdfrDeptNm, + laeIdfrTelNo: submitInfo.laeIdfrTelNo, + }; + + const verificationQuery = { + sewerBzmnCd: lastSearchedValues.bsnmCd ?? "", + ctpvNm: lastSearchedValues.sido ?? "", + sggNm: + lastSearchedValues.gugun === "ALL" + ? "" + : (lastSearchedValues.gugun ?? ""), + statsYr: lastSearchedValues.year ?? "", + statsCd: statsCdForTab, + }; + + const reloadSubmittedTab = async () => { + await loadActiveTabData( + tabKey, + searchParamRef.current!, + lastSearchedValues, + false, + false, + ); + }; + + try { + showGlobalProgress({ label: "서버에 요청 중...", progress: 0 }); + const userInfo = await getUserInfo(); + + setGlobalProgressLabel("일반 검증 진행 중..."); + const response = isRepairTab + ? await sewagePipeMaintainApiService.submitSewagePipeMaintainRepair({ + ...submitInfoBase, + userId: userInfo.userUnqId, + submitList: buildRepairRowList(currentData), + } satisfies SewagePipeMaintainRepairSubmitRequest) + : await sewagePipeMaintainApiService.submitSewagePipeMaintainDredging({ + ...submitInfoBase, + userId: userInfo.userUnqId, + submitList: buildDredgingRowList(currentData), + } satisfies SewagePipeMaintainDredgingSubmitRequest); + + if (response.status !== "success") { + alert(response.message || "데이터 제출 중 오류가 발생했습니다."); + return; + } + + const finalStatus = response.data?.finalStatus; + const nvpassed = response.data?.nvpassed; + + if (finalStatus === "5") { + setGlobalProgress(50); + setGlobalProgressLabel("IMR 검증 진행 중..."); + const imrParam = { + user_id: userInfo.userUnqId, + sewer_bzmn_cd: lastSearchedValues.bsnmCd ?? "", + stats_cd: statsCdForTab, + stats_yr: lastSearchedValues.year ?? "", + lae_sbmsn_stts_cd: activeTabStatus?.laeSbmsnSttsCd === "4" ? "4" : "2", + gen_verify_stts_cd: nvpassed ? "1" : "2", + }; + + try { + const imrResponse = await statisticsApi.callImrService(imrParam); + if (imrResponse.job_id) { + await waitForImrJobCompleted( + imrResponse.job_id, + (id) => statisticsApi.getImrStatus(id), + { + onProgress: (jobProgress) => { + setGlobalProgress(50 + Math.round(jobProgress / 2)); + }, + }, + ); + } + setGlobalProgress(100); + await reloadSubmittedTab(); + await openVerificationResultDialogIfNeeded( + verificationQuery, + setVerificationResultDialogOpen, + { notify: alert }, + ); + } catch (e) { + await reloadSubmittedTab(); + await openVerificationResultDialogIfNeeded(verificationQuery, setVerificationResultDialogOpen, { + notify: alert, + notifySubmitComplete: false, + }); + const msg = + e instanceof Error && e.message + ? e.message + : "IMR 검증 중 오류가 발생했습니다.\n관리자에게 문의하세요."; + alert(msg); + } + } else { + setGlobalProgress(100); + await reloadSubmittedTab(); + await openVerificationResultDialogIfNeeded( + verificationQuery, + setVerificationResultDialogOpen, + { notify: alert }, + ); + } + } catch { + alert("데이터 제출 중 오류가 발생했습니다."); + } finally { + hideGlobalProgress(); + } }; const saveFunc = () => { - console.log('save'); - // 저장 로직 + void (async () => { + const tabKey = activeTabRef.current; + const isRepairTab = tabKey === RepairStatusInpColumn.tabKey; + const isDredgingTab = tabKey === PipeDredgingStatusInpColumn.tabKey; + if (!isRepairTab && !isDredgingTab) { + alert("저장할 수 없는 탭입니다."); + return; + } + + if (!lastSearchedValues || !searchParamRef.current) { + alert("먼저 조회를 진행해주세요."); + return; + } + + if (!buildGugunOkForToolbar(lastSearchedValues)) { + alert("시군구를 선택해주세요."); + return; + } + + if (!(await confirm("저장 하시겠습니까?"))) return; + + const baseRequest = { + statsYr: lastSearchedValues.year ?? "", + ctpvNm: lastSearchedValues.sido ?? "", + sggNm: + lastSearchedValues.gugun === "ALL" + ? "" + : (lastSearchedValues.gugun ?? ""), + sewerBzmnCd: lastSearchedValues.bsnmCd ?? "", + }; + + try { + showGlobalProgress({ label: "저장 진행 중...", progress: 0 }); + const userInfo = await getUserInfo(); + + if (isRepairTab) { + const currentData = getRepairGridRows(); + if (!currentData.length) { + alert("저장할 데이터가 없습니다."); + return; + } + + const param: SewagePipeMaintainRepairSaveRequest = { + ...baseRequest, + userId: userInfo.userUnqId, + saveList: buildRepairRowList(currentData), + }; + if (isAdminInfo) { + param.superAdmin = "superAdmin"; + } + + const response = + await sewagePipeMaintainApiService.saveSewagePipeMaintainRepair( + param, + ); + + if (response.status === "success") { + setGlobalProgress(50); + await loadActiveTabData( + RepairStatusInpColumn.tabKey, + searchParamRef.current, + lastSearchedValues, + false, + false, + ); + setGlobalProgress(100); + alert("저장되었습니다."); + } else { + alert(response.message || "데이터 저장 중 오류가 발생했습니다."); + } + return; + } + + const currentData = getDredgingGridRows(); + if (!currentData.length) { + alert("저장할 데이터가 없습니다."); + return; + } + + const param: SewagePipeMaintainDredgingSaveRequest = { + ...baseRequest, + userId: userInfo.userUnqId, + saveList: buildDredgingRowList(currentData), + }; + if (isAdminInfo) { + param.superAdmin = "superAdmin"; + } + + const response = + await sewagePipeMaintainApiService.saveSewagePipeMaintainDredging( + param, + ); + + if (response.status === "success") { + setGlobalProgress(50); + await loadActiveTabData( + PipeDredgingStatusInpColumn.tabKey, + searchParamRef.current, + lastSearchedValues, + false, + false, + ); + setGlobalProgress(100); + alert("저장되었습니다."); + } else { + alert(response.message || "데이터 저장 중 오류가 발생했습니다."); + } + } catch { + alert("데이터 저장 중 오류가 발생했습니다."); + } finally { + hideGlobalProgress(); + } + })(); }; - const [rowDataMap, setRowDataMap] = useState[]>>(new Map()); + const cnacelSubmitFunc = () => { + void (async () => { + try { + if (!(await confirm("제출된 내용을 취소하시겠습니까?"))) return; + + if (!lastSearchedValues || !searchParamRef.current) { + alert("먼저 조회를 진행해주세요."); + return; + } + + const userInfo = await getUserInfo(); + const tabKey = activeTabRef.current; + const statsCdForTab = tabStatsCdMap.get(tabKey) ?? tabKey; + + const requestData: cancelSubmitRequest = { + sewerBzmnCd: lastSearchedValues.bsnmCd ?? "", + ctpvNm: lastSearchedValues.sido ?? "", + sggNm: + lastSearchedValues.gugun === "ALL" + ? "" + : (lastSearchedValues.gugun ?? ""), + statsYr: lastSearchedValues.year ?? "", + mdfrId: userInfo.userUnqId, + statsCd: statsCdForTab, + }; + + const response = await statisticsApi.cancelSubmit(requestData); + + if (response.status === "success") { + await loadActiveTabData( + tabKey, + searchParamRef.current, + lastSearchedValues, + false, + false, + ); + alert("제출 취소되었습니다."); + } else { + alert(response.message ?? "제출 취소 중 오류가 발생했습니다."); + } + } catch { + alert("제출 취소 중 오류가 발생했습니다."); + } + })(); + }; const handleReset = () => { setRowDataMap(new Map()); - setActiveTab(RepairStatusInpColumn.tabKey); + setTabStatusInfoMap(new Map()); + setTabStatsCdMap(new Map()); + setToolbarSnapshotByTab(new Map()); + setActiveTab(firstTabKey); setLastSearchedValues(null); - setLastSearchedYear(null); setHasSearched(false); + setInputPeriodEnterable(true); searchParamRef.current = null; setVerificationSearchDefaults(null); setSearchFormRemountKey((k) => k + 1); + searchDoneRef.current = false; + periodEnterableRef.current = true; }; const handleSearch = async ( @@ -181,24 +850,53 @@ const SewagePipeMaintainInp = () => { } try { - const periodResponse = await statisticsInputSewSupplyRateApi.checkInputPeriod(values.year || ""); + const periodResponse = await statisticsInputSewSupplyRateApi.checkInputPeriod( + values.year || "", + ); if (periodResponse.status !== "success") { alert(periodResponse.message); return; } + const periods = Array.isArray(periodResponse.data) + ? periodResponse.data + : []; + const periodOk = isInputPeriodCurrent(periods); + periodEnterableRef.current = periodOk; + setInputPeriodEnterable(periodOk); - const tabForLoad = opts?.forcedTabKey ?? activeTab; + const yearChangedFromLastSearch = + lastSearchedValues != null && + (lastSearchedValues.year ?? "") !== (values.year ?? ""); + + if (yearChangedFromLastSearch) { + setRowDataMap(new Map()); + setTabStatusInfoMap(new Map()); + setTabStatsCdMap(new Map()); + setToolbarSnapshotByTab(new Map()); + activeTabRef.current = firstTabKey; + setActiveTab(firstTabKey); + } + + const tabForLoad = opts?.forcedTabKey ?? activeTabRef.current; if (opts?.forcedTabKey) { + activeTabRef.current = opts.forcedTabKey; setActiveTab(opts.forcedTabKey); } setIsLoading(true); + searchDoneRef.current = true; + setHasSearched(true); const searchParam = buildSewagePipeMaintainSearchParam(values); searchParamRef.current = searchParam; - setLastSearchedValues(values); - setLastSearchedYear(values.year ?? null); - setHasSearched(true); - await loadActiveTabData(tabForLoad, searchParam, false); + + const ok = await loadActiveTabData( + tabForLoad, + searchParam, + values, + !yearChangedFromLastSearch, + true, + ); + if (!ok) return; } catch { alert("데이터 조회 중 오류가 발생했습니다."); } finally { @@ -211,16 +909,22 @@ const SewagePipeMaintainInp = () => { if (!job) return; postVerificationSearchRef.current = null; void handleSearch(job.values, { forcedTabKey: job.tabKey }); - }, [searchFormRemountKey]); const handleActiveTabChange = async (tabKey: string) => { + activeTabRef.current = tabKey; setActiveTab(tabKey); - const hasRows = (rowDataMap.get(tabKey) ?? []).length > 0; - if (hasRows || !searchParamRef.current) return; + if (!hasSearched || !lastSearchedValues || !searchParamRef.current) return; + if (tabRowDataExists(rowDataMap, tabKey)) return; try { setIsLoading(true); - await loadActiveTabData(tabKey, searchParamRef.current, true); + await loadActiveTabData( + tabKey, + searchParamRef.current, + lastSearchedValues, + true, + false, + ); } catch { alert("데이터 조회 중 오류가 발생했습니다."); } finally { @@ -228,41 +932,247 @@ const SewagePipeMaintainInp = () => { } }; - const hasRowData = Array.from(rowDataMap.values()).some((rows) => rows.length > 0); - + const columnInfoByTab = useMemo(() => { + const map = new Map(); + for (const tab of SewagePipeMaintainInpTabList) { + const tabKey = tab.tabKey; + const canEdit = + computeTabEnterable( + tabStatusInfoMap.get(tabKey) ?? null, + inputPeriodEnterable, + ) || isAdminInfo; + const columns = (tab.columns ?? []).map((col: ColumnConfig) => + canEdit + ? col + : { + ...col, + editable: false as const, + styleName: col.styleName ?? "rg-cell-readonly", + }, + ); + map.set(tabKey, { + columns, + columnLayout: tab.columnLayout ?? [], + }); + } + return map; + }, [tabStatusInfoMap, inputPeriodEnterable, isAdminInfo]); + + const searchContextForToolbar = lastSearchedValues ?? searchValues; + const activeTabStatus = tabStatusInfoMap.get(activeTab) ?? null; + const activeTabToolbar = + toolbarSnapshotByTab.get(activeTab) ?? + computeTabToolbarSnapshot( + activeTabStatus, + tabRowDataExists(rowDataMap, activeTab), + hasSearched, + searchContextForToolbar, + inputPeriodEnterable, + isAdminInfo, + ); + + const { + showSaveInToolbar: baseShowSaveInToolbar, + showEnterableToolbarButtons: baseShowEnterableToolbarButtons, + showActionToolbarCard: baseShowActionToolbarCard, + } = activeTabToolbar; + + const showSaveInToolbar = baseShowSaveInToolbar && toolbarGroupAllowed; + const showEnterableToolbarButtons = + baseShowEnterableToolbarButtons && toolbarGroupAllowed; + const showActionToolbarCard = + baseShowActionToolbarCard && toolbarGroupAllowed; + + const openVerificationHistoryDialog = () => { + const ctx = lastSearchedValues ?? searchValues; + const statsCd = tabStatsCdMap.get(activeTab); + if (!ctx?.year || !ctx?.sido || !ctx?.bsnmCd || !statsCd) { + alert("먼저 조회를 진행해주세요."); + return; + } + setVerificationHistoryOpen(true); + }; + + const verificationQueryContext = (() => { + const ctx = lastSearchedValues ?? searchValues; + const statsCd = tabStatsCdMap.get(activeTab); + if (!ctx?.year || !ctx?.sido || !ctx?.bsnmCd || !statsCd) return null; + return { + sewerBzmnCd: ctx.bsnmCd, + ctpvNm: ctx.sido, + sggNm: ctx.gugun === "ALL" ? "" : (ctx.gugun ?? ""), + statsYr: ctx.year, + statsCd, + }; + })(); + + const verificationCellStyle = ({ + fieldName, + row, + }: { + fieldName: string; + row: Record; + }): string | undefined => { + 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" || e.vrfcCd === "HB"); + return hasImr ? "rg-cell-verify-error-imr" : "rg-cell-verify-error"; + } + }; + + const verificationCellTooltip = ({ + fieldName, + row, + }: { + fieldName: string; + row: Record; + }): string | undefined => { + 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검증 - " : e.vrfcCd === "NV" ? "일반검증 - " : "HB검증 - "; + return `${prefix}${e.rsnExpln}(검증 기준값: ${e.compVlCn} / 현재 입력값: ${e.nowVlCn})`; + }) + .filter(Boolean) + .join("\n"); + } + }; + return ( <> - - {(hasRowData || hasSearched) && ( -
-
- - void getLastYearDataFunc()} /> - - +
+ + {showActionToolbarCard && ( +
+
+ + {showEnterableToolbarButtons && ( + <> + void getLastYearDataFunc()} + /> + + + )} + {activeTabStatus?.laeSbmsnSttsCd === "2" && ( + + )} + {showSaveInToolbar && } +
+ )} + {isLoading && ( +
+
+ +
+
+ )} +
+ void handleActiveTabChange(v)} + className="w-full" + variant="default" + > + + {SewagePipeMaintainInpTabList.map((info) => ( + + {info.tabLabel || info.tabKey} + + ))} + + {SewagePipeMaintainInpTabList.map((info) => { + const tabKey = info.tabKey; + const rowData = rowDataMap.get(tabKey) ?? []; + const tabEnterableForGrid = + computeTabEnterable( + tabStatusInfoMap.get(tabKey) ?? null, + inputPeriodEnterable, + ) || isAdminInfo; + const gugunOk = buildGugunOkForToolbar(lastSearchedValues); + const tabStatusInfo = tabStatusInfoMap.get(tabKey) ?? null; + + return ( + + { + setRowDataMap((prev) => { + const next = new Map(prev); + const cur = next.get(tabKey) ?? []; + const n = + typeof updater === "function" + ? updater(cur) + : updater; + next.set(tabKey, n); + return next; + }); + }} + showHeaderCheckbox={false} + enableSort={false} + statusInfo={tabStatusInfo} + isExcelImport={ + tabRowDataExists(rowDataMap, tabKey) && + tabEnterableForGrid && + gugunOk + } + cellStyleCallback={verificationCellStyle} + cellTooltipCallback={verificationCellTooltip} + getGridDataRef={ + activeTab === tabKey ? getGridDataRef : undefined + } + /> + + ); + })} +
- )} - {isLoading &&
-
- -
-
} - + + + + ); }; -export default SewagePipeMaintainInp; \ No newline at end of file +export default SewagePipeMaintainInp; diff --git a/apps/st/src/components/statistics/input/SewageReservoirPumpInp.tsx b/apps/st/src/components/statistics/input/SewageReservoirPumpInp.tsx index 88d92eeaf..d95cb428f 100644 --- a/apps/st/src/components/statistics/input/SewageReservoirPumpInp.tsx +++ b/apps/st/src/components/statistics/input/SewageReservoirPumpInp.tsx @@ -1,19 +1,58 @@ -import { useState } from "react"; +import { + useCallback, + useMemo, + useRef, + useState, + type RefObject, +} from "react"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@common/components"; +import { flushSync } from "react-dom"; + +import { ValueType } from "realgrid"; +import type { RealGridReact } from "realgrid-react"; +import { LoadingWithAnimation, Tabs, TabsContent, TabsList, TabsTrigger } from "@common/components"; import AddArticleButton from "@/components/button/AddArticleButton"; +import CancelSubmitButton from "@/components/button/CancelSubmitButton"; import DeleteArticleButton from "@/components/button/DeleteArticleButton"; import LastYearDataButton from "@/components/button/LastYearDataButton"; import NotApplicableCheckButton from "@/components/button/NotApplicableCheckButton"; import SaveButton from "@/components/button/SaveButton"; import SubmitButton from "@/components/button/SumitButton"; -import SubmitExceptionButton from "@/components/button/SubmitExceptionButton"; -import DataGrid from "@/components/module/DataGrid"; +import VerificationResultButton from "@/components/button/VerificationResultButton"; +import DataGrid, { applyDropDownColumnToGrid } from "@/components/module/DataGrid"; import SearchForm from "@/components/module/SearchForm"; -import type { ColumnInfo, ColumnInfoList } from "@/components/type/DataGrid.type"; +import { VerificationResultHistoryDialog } from "@/components/module/VerificationResultHistoryDialog"; +import type { SearchFormValues } from "@/components/type/SearchForm.type"; +import type { + CellEditedCallbackParams, + CellStyleCallbackParams, + CellTooltipCallbackParams, + ColumnConfig, + ColumnInfo, + ColumnInfoList, +} from "@/components/type/DataGrid.type"; +import { doList } from "@/components/util/column/input/DoList"; import { RainfallPumpStationInpColumn } from "@/components/util/column/input/sewageReservoirPump/RainfallPumpStationInpColumn"; import { SewageReservoirInpColumn } from "@/components/util/column/input/sewageReservoirPump/SewageReservoirInpColumn"; +import { toGridRows } from "@/components/util/grid/gridFunction"; +import { formatCompactYmdForGrid, normalizeOxYnValue } from "@/components/util/grid/ymdFormat"; +import { statisticsInputSewageReservoirPumpApi } from "@/entities/statistics/api/input/statistics-input-sewage-reservoir-pump-service"; +import { statisticsApi } from "@/entities/statistics/api/statistics-service"; +import { + SEWAGE_RESERVOIR_STATS_CD, + type SewageReservoirItem, + type SewageReservoirRequestParams, + type RainfallPumpStationItem, + type SewageReservoirVerificationResult, +} from "@/entities/statistics/model/input/statistics-input-sewage-reservoir-pump-interface"; +import type { + ApiResponse, + Sgg, + SewerageStatusInfo, +} from "@/entities/statistics/model/statistics-interface"; +import { useAlertStore, useStatisticsInputToolbarGroupGate } from "@/shared/lib"; +import { useAdminStore } from "@/store/admin-store"; const SewageReservoirPumpInpTabList = [ SewageReservoirInpColumn, @@ -25,64 +64,781 @@ const toGridColumnInfo = (tab: ColumnInfoList): ColumnInfo => ({ columnLayout: tab.columnLayout ?? [], }); -const firstTabKey = SewageReservoirPumpInpTabList[0]?.tabKey ?? "sewageReservoirInp"; +const firstTabKey = SewageReservoirPumpInpTabList[0]?.tabKey ?? SEWAGE_RESERVOIR_STATS_CD; +const rainfallPumpTabKey = + RainfallPumpStationInpColumn.tabKey ?? "rainfallPumpStationInp"; + +type GridRowWithVerification = Record & { + verificationResult?: SewageReservoirVerificationResult; +}; + +type TabToolbarSnapshot = { + showSaveInToolbar: boolean; + showEnterableToolbarButtons: boolean; + showActionToolbarCard: boolean; +}; + +const computeTabEnterable = ( + statusInfo: SewerageStatusInfo | null | undefined, + periodEnterable: boolean, +): boolean => { + if (statusInfo?.laeSbmsnSttsCd) { + if (["2", "3"].includes(statusInfo.laeSbmsnSttsCd)) { + return false; + } + return periodEnterable; + } + return periodEnterable; +}; + +function buildGugunOkForToolbar(values: SearchFormValues | null): boolean { + return doList.includes(values?.bsnmCd || "") + ? Boolean(values?.gugun && values.gugun !== "ALL") + : true; +} + +function computeTabToolbarSnapshot( + statusInfo: SewerageStatusInfo | null, + hasRowData: boolean, + treatAsSearched: boolean, + searchContext: SearchFormValues | null, + periodEnterable: boolean, + isAdminInfo: boolean, +): TabToolbarSnapshot { + const gugunOk = buildGugunOkForToolbar(searchContext); + const enterable = computeTabEnterable(statusInfo, periodEnterable); + const isSubmitted = statusInfo?.laeSbmsnSttsCd === "2"; + const showSaveInToolbar = (enterable || isAdminInfo) && gugunOk; + const showEnterableToolbarButtons = enterable && gugunOk; + const showSubmittedInInputPeriodToolbar = + isSubmitted && periodEnterable && gugunOk; + const showActionToolbarCard = + (hasRowData || treatAsSearched) && + (showSaveInToolbar || + showEnterableToolbarButtons || + showSubmittedInInputPeriodToolbar || + (isSubmitted && isAdminInfo)); + return { showSaveInToolbar, showEnterableToolbarButtons, showActionToolbarCard }; +} + +function tabRowDataExists( + m: Map[]>, + tabKey: string, +): boolean { + const rows = m.get(tabKey); + return Array.isArray(rows) && rows.length > 0; +} + +function buildSewageReservoirSearchParam( + values: SearchFormValues, + overrides?: { mode?: string }, +): SewageReservoirRequestParams { + const fcltNm = values.fctName?.trim(); + return { + statsYr: values.year, + sewerBzmnCd: values.bsnmCd, + ctpvNm: values.sido, + sggNm: values.gugun === "ALL" ? "" : values.gugun, + ...(fcltNm ? { fcltNm } : {}), + ...(overrides?.mode ? { mode: overrides.mode } : {}), + }; +} + +/** 항목 추가 시 빈 행 (시도는 조회 시 선택한 시도명) */ +function createEmptySewageReservoirRow( + ctpvNm: string, + sggNm = "", +): GridRowWithVerification { + const row: GridRowWithVerification = { ctpvNm, sggNm }; + for (const col of SewageReservoirInpColumn.columns) { + const fieldName = col.fieldName; + if (fieldName === "ctpvNm" || fieldName === "sggNm") continue; + row[fieldName] = col.valueType === ValueType.NUMBER ? 0 : ""; + } + return row; +} + +/** 빗물펌프장 탭 항목 추가 시 빈 행 */ +function createEmptyRainfallPumpStationRow( + ctpvNm: string, + sggNm = "", +): GridRowWithVerification { + const row: GridRowWithVerification = { ctpvNm, sggNm }; + for (const col of RainfallPumpStationInpColumn.columns) { + const fieldName = col.fieldName; + if (!fieldName) continue; + if (fieldName === "ctpvNm" || fieldName === "sggNm") continue; + row[fieldName] = col.valueType === ValueType.NUMBER ? 0 : ""; + } + return row; +} + +function applySggNmDropDownToReservoirGrid( + gridRef: RefObject, + labels: string[], +) { + if (labels.length === 0) return; + applyDropDownColumnToGrid( + gridRef.current?.gridView as { + setColumnProperty?: (col: string, key: string, value: unknown) => void; + }, + "sggNm", + labels, + ); +} + +function parseSggListFromResponse(res: ApiResponse): Sgg[] { + if (res.status !== "success" || res.data == null) return []; + return Array.isArray(res.data) ? res.data : []; +} + +function buildSggNmColumn( + col: ColumnConfig, + canEdit: boolean, + sggOptions: Sgg[], +): ColumnConfig { + if (!canEdit) { + return { + ...col, + editable: false as const, + styleName: col.styleName ?? "rg-cell-readonly", + }; + } + if (sggOptions.length === 0) { + return { ...col, editable: true }; + } + const labels = sggOptions.map((s) => s.gugun); + return { + ...col, + editable: true, + editor: { + type: "dropdown", + domainOnly: true, + textReadOnly: true, + commitOnSelect: true, + dropDownWhenClick: true, + values: labels, + labels, + }, + values: labels, + labels, + lookupDisplay: true, + }; +} + +function mapSewageReservoirListToGridRows( + list: SewageReservoirItem[], + ctpvNm: string, +): GridRowWithVerification[] { + return list.map((item) => { + const row = toGridRows([ + { + ...item, + ctpvNm, + oprtngStrtYmd: formatCompactYmdForGrid(item.oprtngStrtYmd), + fldngPrvtnYn: normalizeOxYnValue(item.fldngPrvtnYn), + wqPlltnMtgtYn: normalizeOxYnValue(item.wqPlltnMtgtYn), + rtlztnUsgYn: normalizeOxYnValue(item.rtlztnUsgYn), + }, + ])[0] as GridRowWithVerification; + if (item.verificationResult) { + row.verificationResult = item.verificationResult; + } + return row; + }); +} + +function mapRainfallPumpStationListToGridRows( + list: RainfallPumpStationItem[], + ctpvNm: string, +): GridRowWithVerification[] { + return list.map((item) => { + const row = toGridRows([ + { + ...item, + ctpvNm, + oprtngStrtYmd: formatCompactYmdForGrid(item.oprtngStrtYmd), + rsvrYn: normalizeOxYnValue(item.rsvrYn), + prcsgFcltYn: normalizeOxYnValue(item.prcsgFcltYn), + ddrztnEqpmYn: normalizeOxYnValue(item.ddrztnEqpmYn), + oslfEtgtYn: normalizeOxYnValue(item.oslfEtgtYn), + }, + ])[0] as GridRowWithVerification; + + if (item.verificationResult) { + row.verificationResult = item.verificationResult; + } + return row; + }); +} // 하수도 통계 관리 > 04. 하수저류 시설 및 빗물 펌프장 const SewageReservoirPumpInp = () => { - const submitExceptionFunc = () => { - console.log("submit exception"); - // 예외 제출 로직 - }; - - const getLastYearDataFunc = () => { - console.log("get last year data"); - // 지난연도 데이터 가져오기 로직 - }; + const { setMessage: alert } = useAlertStore((state) => state); + const { isAdminInfo } = useAdminStore(); + const toolbarGroupAllowed = useStatisticsInputToolbarGroupGate(); const submitFunc = () => { console.log("submit"); - // 제출 로직 }; const saveFunc = () => { console.log("save"); - // 저장 로직 }; - const addArticleFunc = () => { - console.log("save"); - // 항목 추가 로직 + const cnacelSubmitFunc = () => { + console.log("cancel submit"); }; const deleteArticleFunc = () => { - console.log("save"); - // 항목 제외 로직 + console.log("delete article"); }; const [activeTab, setActiveTab] = useState(firstTabKey); - const [rowDataMap, setRowDataMap] = useState[]>>(new Map()); + const [rowDataMap, setRowDataMap] = useState< + Map[]> + >(new Map()); const [isNotApplicable, setIsNotApplicable] = useState(false); + const [showIsNotApplicable, setShowIsNotApplicable] = useState(false); + const [searchValues, setSearchValues] = useState(null); + const [lastSearchedValues, setLastSearchedValues] = useState( + null, + ); + const [hasSearched, setHasSearched] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const [inputPeriodEnterable, setInputPeriodEnterable] = useState(true); + const [tabStatusInfoMap, setTabStatusInfoMap] = useState< + Map + >(new Map()); + const [toolbarSnapshotByTab, setToolbarSnapshotByTab] = useState< + Map + >(new Map()); + const [tabStatsCdMap, setTabStatsCdMap] = useState>(new Map()); + const [verificationHistoryOpen, setVerificationHistoryOpen] = useState(false); + const [sggOptions, setSggOptions] = useState([]); + const reservoirGridRef = useRef(null); + const sggOptionsSidoRef = useRef(null); + + const searchDoneRef = useRef(false); + const periodEnterableRef = useRef(true); + const activeTabRef = useRef(activeTab); + activeTabRef.current = activeTab; + + const commitToolbarSnapshotForLoadedTab = ( + tabKey: string, + statusInfo: SewerageStatusInfo | null, + hasRowData: boolean, + searchContext: SearchFormValues, + ) => { + setToolbarSnapshotByTab((prev) => { + const next = new Map(prev); + next.set( + tabKey, + computeTabToolbarSnapshot( + statusInfo, + hasRowData, + searchDoneRef.current, + searchContext, + periodEnterableRef.current, + isAdminInfo, + ), + ); + return next; + }); + }; + + const applyRowMap = ( + mergeIntoExistingMap: boolean, + apply: (m: Map[]>) => void, + ) => { + if (mergeIntoExistingMap) { + setRowDataMap((prev) => { + const next = new Map(prev); + apply(next); + return next; + }); + } else { + const next = new Map[]>(); + apply(next); + setRowDataMap(next); + } + }; + + const loadActiveTabData = async ( + tabKey: string, + values: SearchFormValues, + mergeIntoExistingMap: boolean, + updateLastSearchedValues: boolean, + overrides?: { mode?: string }, + ): Promise => { + // 서버 데이터로 새로 불러오는 시점이므로 "해당없음" 체크는 항상 초기화 + setIsNotApplicable(false); + if (tabKey === SEWAGE_RESERVOIR_STATS_CD) { + const response = + await statisticsInputSewageReservoirPumpApi.getSewageReservoirList( + buildSewageReservoirSearchParam(values, overrides), + ); + + if (response.status !== "success" || !response.data) { + alert(response.message ?? "데이터 조회 중 오류가 발생했습니다."); + return false; + } + + const { commonInfo, statusInfo, sewageReservoirList } = response.data; + const list = sewageReservoirList ?? []; + const gridRows = mapSewageReservoirListToGridRows( + list, + commonInfo.ctpvNm, + ); + + if (activeTabRef.current === tabKey) { + setShowIsNotApplicable(list.length === 0); + } + + applyRowMap(mergeIntoExistingMap, (m) => { + m.set(tabKey, gridRows); + }); + setTabStatusInfoMap((prev) => new Map(prev).set(tabKey, statusInfo)); + setTabStatsCdMap((prev) => new Map(prev).set(tabKey, commonInfo.statsCd)); + if (updateLastSearchedValues) { + setLastSearchedValues(values); + } + commitToolbarSnapshotForLoadedTab( + tabKey, + statusInfo, + gridRows.length > 0, + values, + ); + return true; + } + + if (tabKey === rainfallPumpTabKey) { + const response = + await statisticsInputSewageReservoirPumpApi.getRainfallPumpStationList( + buildSewageReservoirSearchParam(values, overrides), + ); + + if (response.status !== "success" || !response.data) { + alert(response.message ?? "데이터 조회 중 오류가 발생했습니다."); + return false; + } + + const { commonInfo, statusInfo, rainfallPumpStationList } = response.data; + const list = rainfallPumpStationList ?? []; + const gridRows = mapRainfallPumpStationListToGridRows( + list, + commonInfo.ctpvNm, + ); + + applyRowMap(mergeIntoExistingMap, (m) => { + m.set(tabKey, gridRows); + }); + setTabStatusInfoMap((prev) => new Map(prev).set(tabKey, statusInfo)); + setTabStatsCdMap((prev) => new Map(prev).set(tabKey, commonInfo.statsCd)); + if (updateLastSearchedValues) { + setLastSearchedValues(values); + } + commitToolbarSnapshotForLoadedTab( + tabKey, + statusInfo, + gridRows.length > 0, + values, + ); + return true; + } + + alert("해당 탭 조회 API가 없습니다."); + return false; + }; + + const getLastYearDataFunc = async () => { + const values = lastSearchedValues ?? searchValues; + if (!values?.sido || values.sido === "ALL") { + alert("시도를 선택해주세요."); + return; + } + if (!hasSearched) { + alert("먼저 데이터를 조회해주세요."); + return; + } + try { + setIsLoading(true); + await loadActiveTabData(activeTabRef.current, values, true, false, { + mode: "PRE", + }); + } catch { + alert("데이터 조회 중 오류가 발생했습니다."); + } finally { + setIsLoading(false); + } + }; + + const loadSggOptions = useCallback(async (sido: string): Promise => { + if (!sido || sido === "ALL") { + setSggOptions([]); + return []; + } + try { + const res = await statisticsApi.getSggAllList(sido); + const list = parseSggListFromResponse(res); + setSggOptions(list); + return list; + } catch { + setSggOptions([]); + return []; + } + }, []); + + const handleReset = () => { + setRowDataMap(new Map()); + setTabStatusInfoMap(new Map()); + setTabStatsCdMap(new Map()); + setToolbarSnapshotByTab(new Map()); + setLastSearchedValues(null); + setSggOptions([]); + sggOptionsSidoRef.current = null; + setHasSearched(false); + setInputPeriodEnterable(true); + setIsNotApplicable(false); + setShowIsNotApplicable(false); + searchDoneRef.current = false; + periodEnterableRef.current = true; + setActiveTab(firstTabKey); + }; + + const handleSearch = async (values: SearchFormValues) => { + if (!values.sido || values.sido === "ALL") { + alert("시도를 선택해주세요."); + return; + } + + const yearChangedFromLastSearch = + lastSearchedValues != null && + (lastSearchedValues.year ?? "") !== (values.year ?? ""); + + if (yearChangedFromLastSearch) { + setRowDataMap(new Map()); + setTabStatusInfoMap(new Map()); + setTabStatsCdMap(new Map()); + setToolbarSnapshotByTab(new Map()); + setActiveTab(firstTabKey); + } + + try { + setIsLoading(true); + searchDoneRef.current = true; + setHasSearched(true); + setLastSearchedValues(values); + + const tabKeyToLoad = yearChangedFromLastSearch ? firstTabKey : activeTabRef.current; + if (yearChangedFromLastSearch) { + activeTabRef.current = firstTabKey; + setActiveTab(firstTabKey); + } + + const ok = await loadActiveTabData( + tabKeyToLoad, + values, + !yearChangedFromLastSearch, + true, + ); + if (!ok) return; + } catch { + alert("데이터 조회 중 오류가 발생했습니다."); + } finally { + setIsLoading(false); + } + }; + + const handleActiveTabChange = async (tabKey: string) => { + setActiveTab(tabKey); + if (tabKey === SEWAGE_RESERVOIR_STATS_CD) { + setShowIsNotApplicable( + hasSearched && !tabRowDataExists(rowDataMap, tabKey), + ); + } else { + setShowIsNotApplicable(false); + } + if (!hasSearched || !lastSearchedValues) return; + if (tabRowDataExists(rowDataMap, tabKey)) return; + + try { + setIsLoading(true); + await loadActiveTabData(tabKey, lastSearchedValues, true, false); + } catch { + alert("데이터 조회 중 오류가 발생했습니다."); + } finally { + setIsLoading(false); + } + }; + + const searchContextForToolbar = lastSearchedValues ?? searchValues; + const activeTabStatus = tabStatusInfoMap.get(activeTab) ?? null; + const activeTabToolbar = + toolbarSnapshotByTab.get(activeTab) ?? + computeTabToolbarSnapshot( + activeTabStatus, + tabRowDataExists(rowDataMap, activeTab), + hasSearched, + searchContextForToolbar, + inputPeriodEnterable, + isAdminInfo, + ); + + const { + showSaveInToolbar: baseShowSaveInToolbar, + showEnterableToolbarButtons: baseShowEnterableToolbarButtons, + showActionToolbarCard: baseShowActionToolbarCard, + } = activeTabToolbar; + + const showSaveInToolbar = baseShowSaveInToolbar && toolbarGroupAllowed; + const showEnterableToolbarButtons = + baseShowEnterableToolbarButtons && toolbarGroupAllowed; + const showActionToolbarCard = + baseShowActionToolbarCard && toolbarGroupAllowed; + // 항목추가/제거 버튼은 "데이터가 있든 없든", "과거 데이터(입력 불가 상태)여도" + // 조회를 하고나면 항상 보여야 합니다. + const isReservoirOrRainfallTab = + activeTab === SEWAGE_RESERVOIR_STATS_CD || activeTab === rainfallPumpTabKey; + const showArticleToolbarCard = + hasSearched && isReservoirOrRainfallTab && toolbarGroupAllowed; + + const openVerificationHistoryDialog = () => { + const ctx = lastSearchedValues ?? searchValues; + const activeTabStatsCd = tabStatsCdMap.get(activeTab); + if (!ctx?.year || !ctx?.sido || !ctx?.bsnmCd || !activeTabStatsCd) { + alert("먼저 조회를 진행해주세요."); + return; + } + setVerificationHistoryOpen(true); + }; + + const addArticleFunc = async () => { + const ctx = lastSearchedValues ?? searchValues; + if (!hasSearched || !ctx?.sido || ctx.sido === "ALL") { + alert("먼저 조회를 진행해주세요."); + return; + } + + const activeTabKey = activeTabRef.current; + const isReservoirTab = activeTabKey === SEWAGE_RESERVOIR_STATS_CD; + const isRainfallPumpTab = activeTabKey === rainfallPumpTabKey; + if (!isReservoirTab && !isRainfallPumpTab) { + alert("항목을 추가할 수 없는 탭입니다."); + return; + } + + const sggNm = + ctx.gugun && ctx.gugun !== "ALL" ? ctx.gugun : ""; + + if (isRainfallPumpTab) { + const newRow = createEmptyRainfallPumpStationRow(ctx.sido, sggNm); + setRowDataMap((prev) => { + const next = new Map(prev); + const current = next.get(rainfallPumpTabKey) ?? []; + next.set(rainfallPumpTabKey, [...current, newRow]); + return next; + }); + + const statusInfo = tabStatusInfoMap.get(rainfallPumpTabKey) ?? null; + commitToolbarSnapshotForLoadedTab(rainfallPumpTabKey, statusInfo, true, ctx); + return; + } + + let list = sggOptions; + if (sggOptionsSidoRef.current !== ctx.sido || list.length === 0) { + list = await loadSggOptions(ctx.sido); + sggOptionsSidoRef.current = ctx.sido; + } + if (list.length === 0) { + alert("시군구 목록을 불러오지 못했습니다."); + return; + } + + const tabKey = SEWAGE_RESERVOIR_STATS_CD; + const labels = list.map((s) => s.gugun).filter(Boolean); + const newRow = createEmptySewageReservoirRow(ctx.sido, sggNm); + + flushSync(() => { + setSggOptions(list); + setRowDataMap((prev) => { + const next = new Map(prev); + const current = next.get(tabKey) ?? []; + next.set(tabKey, [...current, newRow]); + return next; + }); + }); + + // 드롭다운 컬럼은 columnInfoByTab(buildSggNmColumn) 렌더링으로 반영한다. + // 행 추가 직후 setColumnProperty를 강제 재적용하면 타이밍 이슈로 + // `fieldIndex is out of bounds`가 발생할 수 있어 제거한다. + + setShowIsNotApplicable(false); + + const statusInfo = tabStatusInfoMap.get(tabKey) ?? null; + commitToolbarSnapshotForLoadedTab(tabKey, statusInfo, true, ctx); + }; + + const columnInfoByTab = useMemo(() => { + const map = new Map(); + for (const tab of SewageReservoirPumpInpTabList) { + const tabKey = tab.tabKey ?? ""; + const canEdit = + computeTabEnterable( + tabStatusInfoMap.get(tabKey) ?? null, + inputPeriodEnterable, + ) || isAdminInfo; + const isReservoirTab = tabKey === SEWAGE_RESERVOIR_STATS_CD; + const columns = (tab.columns ?? []).map((col: ColumnConfig) => { + if (isReservoirTab && col.fieldName === "sggNm") { + return buildSggNmColumn(col, canEdit, sggOptions); + } + return canEdit + ? col + : { + ...col, + editable: false as const, + styleName: col.styleName ?? "rg-cell-readonly", + }; + }); + map.set(tabKey, { + columns, + columnLayout: tab.columnLayout ?? [], + }); + } + return map; + }, [tabStatusInfoMap, inputPeriodEnterable, isAdminInfo, sggOptions]); + + const handleSewageReservoirCellEdited = useCallback( + (params: CellEditedCallbackParams) => { + if (params.fieldName !== "sggNm") return; + const selected = sggOptions.find((s) => s.gugun === params.newValue); + if (!selected) return; + + const tabKey = SEWAGE_RESERVOIR_STATS_CD; + setRowDataMap((prev) => { + const next = new Map(prev); + const rows = [...(next.get(tabKey) ?? [])]; + const row = rows[params.itemIndex]; + if (!row) return prev; + rows[params.itemIndex] = { + ...row, + sggNm: selected.gugun, + admdstCd: selected.adstDstrCd, + admdstNm: selected.gugun, + }; + next.set(tabKey, rows); + return next; + }); + }, + [sggOptions], + ); + + const verificationCellStyle = ({ + fieldName, + row, + }: CellStyleCallbackParams): string | undefined => { + 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" || e.vrfcCd === "HB"); + return hasImr ? "rg-cell-verify-error-imr" : "rg-cell-verify-error"; + } + }; + + const verificationCellTooltip = ({ + fieldName, + row, + }: CellTooltipCallbackParams): string | undefined => { + 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검증 - " + : e.vrfcCd === "NV" + ? "일반검증 - " + : "HB검증 -"; + return `${prefix}${e.rsnExpln} +(검증 기준값: ${e.compVlCn} / 현재 입력값: ${e.nowVlCn})`; + }) + .filter(Boolean) + .join("\n"); + } + }; + + const verificationQueryContext = (() => { + const ctx = lastSearchedValues ?? searchValues; + const statsCd = tabStatsCdMap.get(activeTab); + if (!ctx?.year || !ctx?.sido || !ctx?.bsnmCd || !statsCd) return null; + return { + sewerBzmnCd: ctx.bsnmCd, + ctpvNm: ctx.sido, + sggNm: ctx.gugun === "ALL" ? "" : (ctx.gugun ?? ""), + statsYr: ctx.year, + statsCd, + }; + })(); return ( <>
- -
-
- - - - + + {showActionToolbarCard && ( +
+
+ + {showEnterableToolbarButtons && ( + <> + void getLastYearDataFunc()} + /> + + + )} + {activeTabStatus?.laeSbmsnSttsCd === "2" && ( + + )} + {showSaveInToolbar && } +
-
-
-
- - - + )} + {showArticleToolbarCard && ( +
+
+ {showIsNotApplicable && ( + + )} + + +
-
- + )} + {isLoading && ( +
+
+ +
+
+ )} + void handleActiveTabChange(v)} + > {SewageReservoirPumpInpTabList.map((tab) => ( @@ -92,10 +848,29 @@ const SewageReservoirPumpInp = () => { {SewageReservoirPumpInpTabList.map((tab) => { const tabKey = tab.tabKey ?? ""; + const tabEnterableForGrid = + computeTabEnterable( + tabStatusInfoMap.get(tabKey) ?? null, + inputPeriodEnterable, + ) || isAdminInfo; + const gugunOk = buildGugunOkForToolbar(lastSearchedValues); + const tabStatusInfo = tabStatusInfoMap.get(tabKey) ?? null; + const isReservoirTab = tabKey === SEWAGE_RESERVOIR_STATS_CD; return ( { const cur = next.get(tabKey) ?? []; const n = typeof updater === "function" - ? (updater as (p: Record[]) => Record[])(cur) + ? (updater as ( + p: Record< + string, + string | number + >[], + ) => Record< + string, + string | number + >[])(cur) : updater; next.set(tabKey, n); return next; }); }} - showHeaderCheckbox={false} + showHeaderCheckbox + statusInfo={tabStatusInfo} + isExcelImport={ + tabRowDataExists(rowDataMap, tabKey) && + tabEnterableForGrid && + gugunOk + } + cellStyleCallback={verificationCellStyle} + cellTooltipCallback={verificationCellTooltip} + onCellEdited={ + isReservoirTab && + tabEnterableForGrid && + sggOptions.length > 0 + ? handleSewageReservoirCellEdited + : undefined + } /> ); })}
+ ); }; diff --git a/apps/st/src/components/statistics/input/SeweragePenetrationRateInp.tsx b/apps/st/src/components/statistics/input/SeweragePenetrationRateInp.tsx index 4193f2535..6a31e84da 100644 --- a/apps/st/src/components/statistics/input/SeweragePenetrationRateInp.tsx +++ b/apps/st/src/components/statistics/input/SeweragePenetrationRateInp.tsx @@ -254,7 +254,6 @@ const SeweragePenetrationRateInp = () => { ...d, ctpvNm: commonInfo.ctpvNm, spcCtpnRgnCn: d.spcCtpnRgnCn === '' || d.spcCtpnRgnCn === null ? '해당없음' : d.spcCtpnRgnCn, - swagePplPsplyRt: commonInfo.swagePplPsplyRt, })); setRowData( convertedData.map((d) => { @@ -823,7 +822,7 @@ const SeweragePenetrationRateInp = () => { if (Array.isArray(errors) && errors.length > 0) { return errors .map((e) => { - const prefix = e.vrfcCd === 'IMR' ? 'IMR검증 - ' : (e.vrfcCd === 'NM' ? '일반검증 - ' : 'HB검증 -'); + const prefix = e.vrfcCd === 'IMR' ? 'IMR검증 - ' : (e.vrfcCd === 'NV' ? '일반검증 - ' : 'HB검증 -'); return `${prefix}${e.rsnExpln} (검증 기준값: ${e.compVlCn} / 현재 입력값: ${e.nowVlCn})`; }) diff --git a/apps/st/src/components/statistics/management/LogHistoryDetailDialog.tsx b/apps/st/src/components/statistics/management/LogHistoryDetailDialog.tsx index 09667bc88..359eba00d 100644 --- a/apps/st/src/components/statistics/management/LogHistoryDetailDialog.tsx +++ b/apps/st/src/components/statistics/management/LogHistoryDetailDialog.tsx @@ -1,6 +1,7 @@ "use client"; -import { useEffect } from "react"; +import { useEffect, useMemo, useState } from "react"; +import { GridFitStyle } from "realgrid"; import { Button, @@ -10,9 +11,20 @@ import { DialogFooter, DialogHeader, DialogTitle, + LoadingWithAnimation, } from "@common/components"; +import DataGrid from "@/components/module/DataGrid"; +import type { ColumnInfo } from "@/components/type/DataGrid.type"; +import { + alignLogDetailRowsToColumns, + buildLogHistoryDetailColumnInfo, + emptyLogHistoryDetailColumnInfo, +} from "@/components/util/column/management/LogHistoryDetailColumn"; +import { toGridRows } from "@/components/util/grid/gridFunction"; import { statisticsApi } from "@/entities/statistics/api/statistics-service"; +import type { StatisticsLogDetailRow } from "@/entities/statistics/model/statistics-interface"; +import { useAlertStore } from "@/shared/lib"; export interface LogHistoryDetailDialogProps { open: boolean; @@ -25,24 +37,115 @@ export function LogHistoryDetailDialog({ onOpenChange, row, }: LogHistoryDetailDialogProps) { + const { setMessage: alert } = useAlertStore((state) => state); + const [isLoading, setIsLoading] = useState(false); + const [rowData, setRowData] = useState[]>( + [], + ); + const [columnInfo, setColumnInfo] = useState( + emptyLogHistoryDetailColumnInfo, + ); + useEffect(() => { - if (!open || !row) return; + if (!open) { + setRowData([]); + setColumnInfo(emptyLogHistoryDetailColumnInfo); + setIsLoading(false); + return; + } + + if (!row) return; const sn = row.statsLogSn; + let cancelled = false; + void (async () => { - const response = await statisticsApi.getLogDetail( - sn !== undefined && sn !== null ? String(sn) : "", - ); - console.log(response.data); + setIsLoading(true); + setRowData([]); + + try { + const response = await statisticsApi.getLogDetail( + sn !== undefined && sn !== null ? String(sn) : "", + ); + + if (cancelled) return; + + if (response.status !== "success") { + alert( + response.message ?? + "로그 상세 정보를 불러오는데 실패했습니다.", + ); + return; + } + + const list = Array.isArray(response.data) + ? response.data + : []; + const nextColumnInfo = buildLogHistoryDetailColumnInfo(list); + const nextRows = alignLogDetailRowsToColumns( + toGridRows(list as StatisticsLogDetailRow[]), + nextColumnInfo, + ); + setColumnInfo(nextColumnInfo); + setRowData(nextRows); + } catch { + if (!cancelled) { + alert("로그 상세 정보를 불러오는데 실패했습니다."); + } + } finally { + if (!cancelled) setIsLoading(false); + } })(); - }, [open, row]); + + return () => { + cancelled = true; + }; + }, [open, row, alert]); + + const gridRemountKey = useMemo( + () => columnInfo.columns.map((c) => c.fieldName).join("\u0001"), + [columnInfo], + ); + + const showGrid = !isLoading && rowData.length > 0; return ( - + - + 로그 상세 정보 +
+ {isLoading && ( +
+
+ +
+
+ )} + {showGrid ? ( + + ) : ( + !isLoading && ( +

+ 표시할 상세 데이터가 없습니다. +

+ ) + )} +