diff --git a/apps/st/package.json b/apps/st/package.json index 184138657..5cf4bf67d 100644 --- a/apps/st/package.json +++ b/apps/st/package.json @@ -1,76 +1,76 @@ { - "name": "st", - "version": "0.1.0", - "private": true, - "scripts": { - "dev": "next dev", - "build": "next build", - "start": "next start", - "lint": "next lint" - }, - "dependencies": { - "@common/assets": "workspace:*", - "@common/auth": "workspace:*", - "@common/components": "workspace:*", - "@common/config": "workspace:*", - "@common/network": "workspace:*", - "@rsone/components": "workspace:*", - "@faker-js/faker": "^9.9.0", - "@fontsource-variable/noto-sans-kr": "^5.2.5", - "@frontend-opensource/use-react-hooks": "^1.1.4", - "@fullcalendar/core": "^6.1.17", - "@fullcalendar/daygrid": "^6.1.17", - "@fullcalendar/interaction": "^6.1.17", - "@fullcalendar/react": "^6.1.17", - "@fullcalendar/timegrid": "^6.1.17", - "@hookform/resolvers": "^4.1.3", - "@minoru/react-dnd-treeview": "3.5.1-alpha.1", - "@radix-ui/react-icons": "^1.3.2", - "@radix-ui/react-label": "^2.1.2", - "@radix-ui/react-slot": "^1.1.2", - "@radix-ui/react-tooltip": "^1.1.8", - "@tanstack/react-query": "^5.67.1", - "@tanstack/react-query-devtools": "^5.75.5", - "@tanstack/react-table": "^8.21.2", - "@tinymce/tinymce-react": "^5.1.1", - "@types/xlsx": "^0.0.36", - "axios": "^1.8.1", - "chart.js": "^4.4.8", - "chartjs-plugin-zoom": "^2.2.0", - "dayjs": "^1.11.13", - "js-cookie": "^3.0.5", - "jszip": "^3.10.1", - "lucide-react": "^0.474.0", - "next": "15.3.8", - "next-qrcode": "^2.5.1", - "next-themes": "^0.4.6", - "react-chartjs-2": "^5.3.0", - "react-dnd": "^16.0.1", - "react-hook-form": "^7.54.2", - "realgrid": "^2.9.5", - "realgrid-react": "^0.9.7", - "remixicon": "^4.7.0", - "sonner": "^2.0.1", - "swr": "^2.3.7", - "tailwindcss-animate": "^1.0.7", - "tus-js-client": "^4.3.1", - "xlsx": "^0.18.5", - "zod": "^3.24.2", - "zustand": "^5.0.3" - }, - "devDependencies": { - "@eslint/eslintrc": "^3", - "@types/js-cookie": "^3.0.6", - "@types/node": "^22.13.1", - "@types/webpack": "^5.28.5", - "eslint": "^9", - "eslint-config-next": "15.3.8", - "eslint-plugin-react-hooks": "^5.0.0", - "postcss": "^8", - "prettier": "^3.4.2", - "prettier-plugin-tailwindcss": "^0.6.11", - "steiger": "^0.5.5", - "tailwindcss": "^3.4.1", - "typescript": "^5" - } + "name": "st", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "next lint" + }, + "dependencies": { + "@common/assets": "workspace:*", + "@common/auth": "workspace:*", + "@common/components": "workspace:*", + "@common/config": "workspace:*", + "@common/network": "workspace:*", + "@rsone/components": "workspace:*", + "@faker-js/faker": "^9.9.0", + "@fontsource-variable/noto-sans-kr": "^5.2.5", + "@frontend-opensource/use-react-hooks": "^1.1.4", + "@fullcalendar/core": "^6.1.17", + "@fullcalendar/daygrid": "^6.1.17", + "@fullcalendar/interaction": "^6.1.17", + "@fullcalendar/react": "^6.1.17", + "@fullcalendar/timegrid": "^6.1.17", + "@hookform/resolvers": "^4.1.3", + "@minoru/react-dnd-treeview": "3.5.1-alpha.1", + "@radix-ui/react-icons": "^1.3.2", + "@radix-ui/react-label": "^2.1.2", + "@radix-ui/react-slot": "^1.1.2", + "@radix-ui/react-tooltip": "^1.1.8", + "@tanstack/react-query": "^5.67.1", + "@tanstack/react-query-devtools": "^5.75.5", + "@tanstack/react-table": "^8.21.2", + "@tinymce/tinymce-react": "^5.1.1", + "@types/xlsx": "^0.0.36", + "axios": "^1.8.1", + "chart.js": "^4.4.8", + "chartjs-plugin-zoom": "^2.2.0", + "dayjs": "^1.11.13", + "js-cookie": "^3.0.5", + "jszip": "^3.10.1", + "lucide-react": "^0.474.0", + "next": "15.3.8", + "next-qrcode": "^2.5.1", + "next-themes": "^0.4.6", + "react-chartjs-2": "^5.3.0", + "react-dnd": "^16.0.1", + "react-hook-form": "^7.54.2", + "realgrid": "^2.9.5", + "realgrid-react": "^0.9.7", + "remixicon": "^4.7.0", + "sonner": "^2.0.1", + "swr": "^2.3.7", + "tailwindcss-animate": "^1.0.7", + "tus-js-client": "^4.3.1", + "xlsx": "^0.18.5", + "zod": "^3.24.2", + "zustand": "^5.0.3" + }, + "devDependencies": { + "@eslint/eslintrc": "^3", + "@types/js-cookie": "^3.0.6", + "@types/node": "^22.13.1", + "@types/webpack": "^5.28.5", + "eslint": "^9", + "eslint-config-next": "15.3.8", + "eslint-plugin-react-hooks": "^5.0.0", + "postcss": "^8", + "prettier": "^3.4.2", + "prettier-plugin-tailwindcss": "^0.6.11", + "steiger": "^0.5.5", + "tailwindcss": "^3.4.1", + "typescript": "^5" + } } diff --git a/apps/st/src/components/button/ReviewOpinionButton.tsx b/apps/st/src/components/button/ReviewOpinionButton.tsx index 50df8fcb4..fbcdbd754 100644 --- a/apps/st/src/components/button/ReviewOpinionButton.tsx +++ b/apps/st/src/components/button/ReviewOpinionButton.tsx @@ -1,7 +1,7 @@ import { Button } from "@common/components"; interface ReviewOpinionButtonProps { - reviewOpinionFunc: () => void; + reviewOpinionFunc: () => void | Promise; } const ReviewOpinionButton = ({ reviewOpinionFunc }: ReviewOpinionButtonProps) => { diff --git a/apps/st/src/components/module/DataGrid.tsx b/apps/st/src/components/module/DataGrid.tsx index d85693a53..801a8711f 100644 --- a/apps/st/src/components/module/DataGrid.tsx +++ b/apps/st/src/components/module/DataGrid.tsx @@ -311,6 +311,7 @@ function buildFieldsFromColumns( const DataGrid = ({ columnInfo, rowStartNum, + excelExportFileSuffix, isExcelImport = true, isExcelExport = true, fixedColumns, @@ -321,10 +322,14 @@ const DataGrid = ({ cellStyleCallback, cellTooltipCallback, onRowClick, + onItemChecked, + onItemAllChecked, + onScrollToBottom, statusInfo, highlightedRowId, idFieldName = "id", enableSort = false, + gridHeight = 65, // vh 단위 }: DataGridProps) => { const { columns, columnLayout } = columnInfo; const gridRef = useRef(null); @@ -340,9 +345,18 @@ const DataGrid = ({ const cellTooltipCallbackRef = useRef( cellTooltipCallback, ); + const onScrollToBottomRef = useRef<(() => void) | undefined>( + onScrollToBottom, + ); + const onItemCheckedRef = useRef(onItemChecked); + const onItemAllCheckedRef = + useRef(onItemAllChecked); rowDataRef.current = rowData; cellStyleCallbackRef.current = cellStyleCallback; cellTooltipCallbackRef.current = cellTooltipCallback; + onScrollToBottomRef.current = onScrollToBottom; + onItemCheckedRef.current = onItemChecked; + onItemAllCheckedRef.current = onItemAllChecked; // highlightedRowId가 변경될 때 해당 행으로 스크롤 및 선택 useEffect(() => { @@ -470,10 +484,14 @@ const DataGrid = ({ gridView.setColumnLayout?.(normalizedLayout); } + const exportBaseName = excelExportFileSuffix + ? `${menuName}_${excelExportFileSuffix}` + : menuName; + gridView.exportGrid({ type: "excel", target: "local", - fileName: `${menuName}.xlsx`, + fileName: `${exportBaseName}.xlsx`, footer: "hidden", allColumns: true, // 숨김(visible: false) 컬럼도 엑셀에 포함 done: () => { @@ -558,10 +576,10 @@ const DataGrid = ({ ) { const cell = worksheet[ - XLSX.utils.encode_cell({ - r: rowIndex, - c: col, - }) + XLSX.utils.encode_cell({ + r: rowIndex, + c: col, + }) ]; if ( cell && @@ -637,7 +655,7 @@ const DataGrid = ({ const col = range.s.c + colIndex; const cell = worksheet[ - XLSX.utils.encode_cell({ r: row, c: col }) + XLSX.utils.encode_cell({ r: row, c: col }) ]; const headerName = headers[colIndex] || `Column${colIndex + 1}`; @@ -761,13 +779,13 @@ const DataGrid = ({ onSorting?: (g: unknown, f: unknown[], d: unknown[]) => boolean; } ).onSorting = enableSort - ? createOnSortingHandler( - columns, - setRowData, - sortDirRef, - originalRowsRef, - ) - : () => false; + ? createOnSortingHandler( + columns, + setRowData, + sortDirRef, + originalRowsRef, + ) + : () => false; (gridView.displayOptions as DisplayOptions).fitStyle = GridFitStyle.EVEN; @@ -937,7 +955,7 @@ const DataGrid = ({ ) { ( column.footer[ - index + index ] as FooterItemWithCallback ).valueCallback = footerItem.valueCallback; @@ -990,11 +1008,11 @@ const DataGrid = ({ getGridDataRef.current = () => { const gv = gridRef.current?.gridView as | { - getDataSource?: () => { - getRowCount(): number; - getValue(r: number, f: string): unknown; - }; - } + getDataSource?: () => { + getRowCount(): number; + getValue(r: number, f: string): unknown; + }; + } | undefined; if (!gv?.getDataSource) return []; const ds = gv.getDataSource(); @@ -1090,6 +1108,47 @@ const DataGrid = ({ }; }, [onRowClick, cellTooltipCallback, columns]); + useEffect(() => { + const gridView = gridRef.current?.gridView; + if (!gridView || !onScrollToBottom) return; + + gridView.onScrollToBottom = () => { + onScrollToBottomRef.current?.(); + }; + + return () => { + if (gridView.onScrollToBottom) + gridView.onScrollToBottom = undefined; + }; + }, [onScrollToBottom]); + + useEffect(() => { + const gridView = gridRef.current?.gridView; + if (!gridView) return; + + if (onItemChecked) { + gridView.onItemChecked = (_grid, itemIndex, checked) => { + onItemCheckedRef.current?.(itemIndex, checked); + }; + } else { + gridView.onItemChecked = undefined; + } + + if (onItemAllChecked) { + gridView.onItemAllChecked = (_grid, checked) => { + onItemAllCheckedRef.current?.(checked); + }; + } else { + gridView.onItemAllChecked = undefined; + } + + return () => { + if (gridView.onItemChecked) gridView.onItemChecked = undefined; + if (gridView.onItemAllChecked) + gridView.onItemAllChecked = undefined; + }; + }, [onItemChecked, onItemAllChecked]); + return (
진행상황:{" "} {statusInfo.laeSbmsnSttsCd === null || - statusInfo.laeSbmsnSttsCd === "0" + statusInfo.laeSbmsnSttsCd === "0" ? "미작성" : statusInfo.laeSbmsnSttsCd === "1" - ? "작성중" - : statusInfo.laeSbmsnSttsCd === "2" - ? "제출" - : statusInfo.laeSbmsnSttsCd === "3" - ? "승인" - : statusInfo.laeSbmsnSttsCd === "4" - ? "반려" - : "검증중"} + ? "작성중" + : statusInfo.laeSbmsnSttsCd === "2" + ? "제출" + : statusInfo.laeSbmsnSttsCd === "3" + ? "승인" + : statusInfo.laeSbmsnSttsCd === "4" + ? "반려" + : "검증중"} 제출일:{" "} {statusInfo.laeSbmsnYmd ? statusInfo.laeSbmsnYmd.length === 8 && - /^\d{8}$/.test(statusInfo.laeSbmsnYmd) + /^\d{8}$/.test(statusInfo.laeSbmsnYmd) ? `${statusInfo.laeSbmsnYmd.slice(0, 4)}-${statusInfo.laeSbmsnYmd.slice(4, 6)}-${statusInfo.laeSbmsnYmd.slice(6, 8)}` : statusInfo.laeSbmsnYmd : "미제출"} @@ -1159,7 +1218,7 @@ const DataGrid = ({ )}
-
+
boolean; } ).onSorting = enableSort - ? createOnSortingHandler( - columns, - setRowData, - sortDirRef, - originalRowsRef, - ) - : () => false; + ? createOnSortingHandler( + columns, + setRowData, + sortDirRef, + originalRowsRef, + ) + : () => false; const dataProvider = gridRef.current ?.dataProvider as unknown as | { - setFields?(f: unknown[]): void; - setRows?( - r: Record[], - ): void; - getJsonRow?( - r: number, - ): Record; - } + setFields?(f: unknown[]): void; + setRows?( + r: Record[], + ): void; + getJsonRow?( + r: number, + ): Record; + } | undefined; if (dataProvider?.setFields) { dataProvider.setFields( @@ -1346,14 +1405,14 @@ const DataGrid = ({ })} footer={ column.footer && - !Array.isArray(column.footer) + !Array.isArray(column.footer) ? { - text: column.footer.text, - expression: - column.footer.expression, - numberFormat: - column.footer.numberFormat, - } + text: column.footer.text, + expression: + column.footer.expression, + numberFormat: + column.footer.numberFormat, + } : undefined } mergeRule={ diff --git a/apps/st/src/components/module/SearchForm.tsx b/apps/st/src/components/module/SearchForm.tsx index c3d7e4fc2..4d16417ed 100644 --- a/apps/st/src/components/module/SearchForm.tsx +++ b/apps/st/src/components/module/SearchForm.tsx @@ -1,52 +1,66 @@ "use client"; -import { useState, useEffect } from "react"; +import { useEffect, useState } from "react"; + import useSWR from "swr"; -import { - Button, - Input, - Label, - Select, - SelectTrigger, - SelectContent, - SelectItem, - SelectValue, - Checkbox, -} from "@common/components"; - -import { useAlertStore } from "@/shared/lib"; -import { - type SearchFormProps, - type SearchFormValues, - defaultSearchFormProps, -} from "../type/SearchForm.type"; +import { useAlertStore, useUserStore } from "@/shared/lib"; import { statisticsAPi } from "@/entities/statistics/api/statistics-service"; import type { ApiResponse, SearchCondition, Sgg, } from "@/entities/statistics/model/statistics-interface"; +import { + Button, + Checkbox, + Input, + Label, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@common/components"; -const getSidoListFetcher = async (): Promise> => { - return statisticsAPi.getSidoList(); +import { + defaultSearchFormProps, + type SearchFormProps, + type SearchFormValues, +} from "../type/SearchForm.type"; +import { doList } from "../util/column/input/DoList"; + +const getSidoListFetcher = async ( + key: [string, string], +): Promise> => { + return statisticsAPi.getSidoList({ instSeCd: key[1] }); }; const getSggListFetcher = async ( - key: [string, string], + key: [string, string, string, string], ): Promise> => { - return statisticsAPi.getSggList(key[1]); + const gugunSkipYn = doList.includes(key[3]) ? "N" : "Y"; + return statisticsAPi.getSggAllList(key[1], { + instSeCd: key[2], + gugunSkipYn, + }); }; const SearchForm = (props: SearchFormProps = {}) => { const { setMessage: alert } = useAlertStore((state) => state); + const userInfo = useUserStore((state) => state.userInfo); const mergedProps = { ...defaultSearchFormProps, ...props }; + const instSeCd = userInfo?.instSeCd ?? ""; + const sidoSelectText = + props.sidoSelectText && props.sidoSelectText.trim() !== "" + ? props.sidoSelectText + : "선택"; const currentYear = new Date().getFullYear(); const defaultYear = currentYear - 1; const { data: sidoList } = useSWR>( - "sido-list", + ["sido-list", instSeCd], getSidoListFetcher, { revalidateOnFocus: false, @@ -77,8 +91,10 @@ const SearchForm = (props: SearchFormProps = {}) => { isDetail: false, }); - const sggKey: [string, string] | null = - values.sido && values.sido !== "ALL" ? ["sgg-list", values.sido] : null; + const sggKey: [string, string, string, string] | null = + values.sido && values.sido !== "ALL" + ? ["sgg-list", values.sido, instSeCd, values.bsnmCd || ""] + : null; const { data: sggList } = useSWR>( sggKey, getSggListFetcher, @@ -102,7 +118,7 @@ const SearchForm = (props: SearchFormProps = {}) => { ); const sggOptions = sggList?.data ?? []; - const handleSubmit = (e: React.SubmitEvent) => { + const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); props.onSubmit?.(values); }; @@ -163,10 +179,14 @@ const SearchForm = (props: SearchFormProps = {}) => { onValueChange={handleSidoChange} > - + - 선택 + + {sidoSelectText} + {sidoList?.data?.sidoList?.map((sido) => ( []>, + tabKey: string, +): boolean { + if (tabKey === "ST00020006") { + const left = m.get("treatmentPlantConnectionInp"); + const right = m.get("adminAreaConnectionInp"); + return ( + (Array.isArray(left) && left.length > 0) || + (Array.isArray(right) && right.length > 0) + ); + } + const rows = m.get(tabKey); + return Array.isArray(rows) && rows.length > 0; +} + // 하수도 통계 관리 > 02. 하수관로 const SewagePipeInp = () => { const [rowDataMap, setRowDataMap] = useState[]>>(new Map()); @@ -128,7 +140,7 @@ const SewagePipeInp = () => { }); }, [isEnterable, isAdminInfo]); - /** mergeIntoExistingMap: true면 지난연도(PRE) 등 — 현재 탭만 갱신, 나머지 탭 캐시 유지 */ + /** mergeIntoExistingMap: true면 기존 맵에 병합(다른 탭 rowData·검색 후 다른 탭 캐시 유지). false면 맵 전체 교체 */ const loadSewagePipeActiveTabData = async ( tabKey: string, searchParam: SeweragePipeRequestParams, @@ -290,7 +302,6 @@ const SewagePipeInp = () => { const checkEnterable = periodResponse.data; periodEnterableRef.current = checkEnterable; - setRowDataMap(new Map()); lazyTabLoggedRef.current.clear(); setIsLoading(true); @@ -301,7 +312,7 @@ const SewagePipeInp = () => { setHasSearched(true); setLastSearchedYear(values.year ?? null); - await loadSewagePipeActiveTabData(activeTab, searchParam, values, checkEnterable, false, true); + await loadSewagePipeActiveTabData(activeTab, searchParam, values, checkEnterable, true, true); } catch { alert('데이터 조회 중 오류가 발생했습니다.'); } finally { @@ -312,8 +323,7 @@ const SewagePipeInp = () => { const handleActiveTabChange = async (tabKey: string) => { setActiveTab(tabKey); - const activeTabData = rowDataMap.get(tabKey); - const hasActiveTabData = Array.isArray(activeTabData) && activeTabData.length > 0; + const hasActiveTabData = tabRowDataExists(rowDataMap, tabKey); const canLazyLoad = searchParamRef.current && @@ -324,86 +334,20 @@ const SewagePipeInp = () => { if (!canLazyLoad) return; const searchParam = searchParamRef.current; - if (!searchParam) return; + const values = lastSearchedValues ?? searchValues; + if (!searchParam || !values) return; try { setIsLoading(true); - - let response; - - if (tabKey === 'ST00020001') { - response = await statisticsInputSewPipeApiService.getRegionalFactStatus(searchParam); - } else if (tabKey === 'ST00020002') { - response = await statisticsInputSewPipeApiService.getFactInstallStatus(searchParam); - } else if (tabKey === 'ST00020003') { - response = await statisticsInputSewPipeApiService.getPipeTypeStatus(searchParam); - } else if (tabKey === 'ST00020004') { - response = await statisticsInputSewPipeApiService.getPipeDiameterStatus(searchParam); - } else if (tabKey === 'ST00020005') { - response = await statisticsInputSewPipeApiService.getManholeStatus(searchParam); - } else if (tabKey === 'ST00020006') { - response = await statisticsInputSewPipeApiService.getConnectionStatus(searchParam); - } - lazyTabLoggedRef.current.add(tabKey); - - if (response?.status === 'success') { - setStatusInfo(response.data.statusInfo); - setStatsCd(response.data.commonInfo.statsCd); - applyEnterableFromStatus(response.data.statusInfo, periodEnterableRef.current, setIsEnterable); - setRowDataMap(prev => { - const next = new Map(prev); - if (tabKey === 'ST00020001') { - const commonInfo = response.data.commonInfo; - const convertedData = (response.data.regionalFactStatusList ?? []).map((d: SeweragePipeInputRegionalFactStatusItem) => ({ - ctpvNm: commonInfo.ctpvNm, - ...d - })); - next.set(tabKey, toGridRows(convertedData)); - } else if (tabKey === 'ST00020002') { - const commonInfo = response.data.commonInfo; - const convertedData = (response.data.factInstallStatusList ?? []).map((d: SeweragePipeInputFactInstallStatusItem) => ({ - ctpvNm: commonInfo.ctpvNm, - ...d - })); - next.set(tabKey, toGridRows(convertedData)); - } else if (tabKey === 'ST00020003') { - const commonInfo = response.data.commonInfo; - const convertedData = (response.data.pipeTypeStatusList ?? []).map((d: SeweragePipeInputPipeTypeStatusItem) => ({ - ctpvNm: commonInfo.ctpvNm, - ...d - })); - next.set(tabKey, toGridRows(convertedData)); - } else if (tabKey === 'ST00020004') { - const commonInfo = response.data.commonInfo; - const convertedData = (response.data.pipeDiameterStatusList ?? []).map((d: SeweragePipeInputPipeDiameterStatusItem) => ({ - ctpvNm: commonInfo.ctpvNm, - ...d - })); - next.set(tabKey, toGridRows(convertedData)); - } else if (tabKey === 'ST00020005') { - const commonInfo = response.data.commonInfo; - const convertedData = (response.data.manholeStatusList ?? []).map((d: SeweragePipeInputManholeStatusItem) => ({ - ctpvNm: commonInfo.ctpvNm, - ...d - })); - next.set(tabKey, toGridRows(convertedData)); - } else if (tabKey === 'ST00020006') { - const commonInfo = response.data.commonInfo; - const facilityConvertedData = (response.data.facilityConnectionList ?? []).map((d: SeweragePipeInputFacilityConnectionItem) => ({ - ...d - })); - const adminConvertedData = (response.data.adminAreaConnectionList ?? []).map((d: SeweragePipeInputAdminAreaConnectionItem) => ({ - ...d, - ctpvNm: d.ctpvNm || commonInfo.ctpvNm, - })); - next.set('treatmentPlantConnectionInp', toGridRows(facilityConvertedData)); - next.set('adminAreaConnectionInp', toGridRows(adminConvertedData)); - } - return next; - }); - } else { - alert(response?.message || '데이터 조회 중 오류가 발생했습니다.'); - } + const ok = await loadSewagePipeActiveTabData( + tabKey, + searchParam, + values, + periodEnterableRef.current, + true, + false, + ); + if (ok) lazyTabLoggedRef.current.add(tabKey); } catch { alert('데이터 조회 중 오류가 발생했습니다.'); } finally { @@ -543,8 +487,9 @@ const SewagePipeInp = () => { const showSaveInToolbar = (isEnterable || isAdminInfo) && gugunOkOrNotDoList; const showEnterableToolbarButtons = isEnterable && gugunOkOrNotDoList; - /** TabDataGrid는 rowDataMap 사용 — 행이 하나라도 있을 때만 툴바 카드 의미 있음 */ + /** rowDataMap — 행이 하나라도 있을 때만 툴바·엑셀 의미 있음 */ const hasRowData = Array.from(rowDataMap.values()).some((rows) => rows.length > 0); + const excelToolbarEnabled = hasRowData && isEnterable && gugunOkOrNotDoList; const showActionToolbarCard = (hasRowData || hasSearched) && @@ -573,16 +518,132 @@ const SewagePipeInp = () => {
} - +
+ void handleActiveTabChange(v)} + className="w-full" + variant="default" + > + + {columnInfo.map((info) => ( + + {info.tabLabel || info.tabKey} + + ))} + + {columnInfo.map((info) => { + const tabKey = info.tabKey; + + if (info.isSplitGrid && info.left && info.right) { + const leftKey = info.left.tabKey || `${tabKey}_left`; + const rightKey = info.right.tabKey || `${tabKey}_right`; + const leftRowData = rowDataMap.get(leftKey) ?? []; + const rightRowData = rowDataMap.get(rightKey) ?? []; + const splitTopButton = info.right.topButton; + const showSplitTopButton = + Boolean(splitTopButton?.text) && + (splitTopButton?.visible ?? true) && + excelToolbarEnabled; + const leftColumnInfo: ColumnInfo = { + columns: info.left.columns ?? [], + columnLayout: info.left.columnLayout ?? [], + }; + const rightColumnInfo: ColumnInfo = { + columns: info.right.columns ?? [], + columnLayout: info.right.columnLayout ?? [], + }; + + return ( + +
+
+ {showSplitTopButton && ( + + )} +
+ { + setRowDataMap((prev) => { + const next = new Map(prev); + const cur = next.get(leftKey) ?? []; + const n = + typeof updater === "function" + ? updater(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(cur) + : updater; + next.set(rightKey, n); + return next; + }); + }} + /> +
+
+ ); + } + + if (!info.columns || !info.columnLayout) { + return null; + } + + const rowData = rowDataMap.get(tabKey) ?? []; + const gridColumnInfo: ColumnInfo = { + columns: info.columns, + columnLayout: info.columnLayout, + }; + + 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; + }); + }} + isExcelImport={excelToolbarEnabled} + isExcelExport + showHeaderCheckbox={false} + fixedColumns={info.fixedColumns} + enableSort={false} + getGridDataRef={tabKey === "ST00020001" ? getGridDataRef : undefined} + /> + + ); + })} +
+
); }; diff --git a/apps/st/src/components/statistics/input/SeweragePenetrationRateInp.tsx b/apps/st/src/components/statistics/input/SeweragePenetrationRateInp.tsx index e8fec3870..5b8d9c678 100644 --- a/apps/st/src/components/statistics/input/SeweragePenetrationRateInp.tsx +++ b/apps/st/src/components/statistics/input/SeweragePenetrationRateInp.tsx @@ -465,22 +465,22 @@ const SeweragePenetrationRateInp = () => { const response = await statisticsInputSewSupplyRateApi.saveSewerageSupplyRate(requestData); if (response.status === 'success') { - const imrParam = { - user_id: userInfo.userUnqId, - sewer_bzmn_cd: searchValues?.bsnmCd ?? '', - stats_cd: statsCd, - stats_yr: searchValues?.year ?? '', - lae_sbmsn_stts_cd: (statusInfo?.laeSbmsnSttsCd === '4' ? '3' : '1'), - }; + // const imrParam = { + // user_id: userInfo.userUnqId, + // sewer_bzmn_cd: searchValues?.bsnmCd ?? '', + // stats_cd: statsCd, + // stats_yr: searchValues?.year ?? '', + // lae_sbmsn_stts_cd: (statusInfo?.laeSbmsnSttsCd === '4' ? '3' : '1'), + // }; - try { - await statisticsAPi.callImrService(imrParam); - } catch { - await fetchSewerageSupplyRate(getSewerageSupplyRateParams(searchValues ?? {}), true); - setIsLoading(false); - alert('IMR 검증 중 오류가 발생했습니다.\n관리자에게 문의하세요.'); - return; - } + // try { + // await statisticsAPi.callImrService(imrParam); + // } catch { + // await fetchSewerageSupplyRate(getSewerageSupplyRateParams(searchValues ?? {}), true); + // setIsLoading(false); + // alert('IMR 검증 중 오류가 발생했습니다.\n관리자에게 문의하세요.'); + // return; + // } await fetchSewerageSupplyRate(getSewerageSupplyRateParams(searchValues ?? {}), true); alert('저장되었습니다.'); diff --git a/apps/st/src/components/statistics/input/StatsInputStatusInp.tsx b/apps/st/src/components/statistics/input/StatsInputStatusInp.tsx index 919231fde..5bbf6dd7a 100644 --- a/apps/st/src/components/statistics/input/StatsInputStatusInp.tsx +++ b/apps/st/src/components/statistics/input/StatsInputStatusInp.tsx @@ -1,22 +1,25 @@ -import { useState, useEffect } from "react"; +import { useEffect, useState } from "react"; + import useSWR, { useSWRConfig } from "swr"; -import SearchForm from "@/components/module/SearchForm"; -import { useTabStore } from "@/store/tab-store"; -import DataGrid from "@/components/module/DataGrid"; -import ApproveButton from "@/components/button/ApproveButton"; -import RejectButton from "@/components/button/RejectButton"; -import { LoadingWithAnimation } from "@common/components"; - -import { StatsInputStatusInpColumn } from "@/components/util/column/input/StatsInputStatusInpColumn"; +import { useAlertStore } from "@/shared/lib"; import { StatisticsInputStatusApi } from "@/entities/statistics/api/input/statistics-input-status-service"; import type { StatsInputStatusListRequestParams } from "@/entities/statistics/model/input/statistics-input-status-interface"; -import type { SearchFormValues } from "@/components/type/SearchForm.type"; -import { useAlertStore } from "@/shared/lib"; -import { toGridRows } from "@/components/util/grid/gridFunction"; import type { StatsInputStatusListApiResponse } from "@/entities/statistics/model/input/statistics-input-status-interface"; +import { LoadingWithAnimation } from "@common/components"; -const getStatsInputStatusFetcher = async (key: [string, StatsInputStatusListRequestParams]): Promise => { +import ApproveButton from "@/components/button/ApproveButton"; +import RejectButton from "@/components/button/RejectButton"; +import DataGrid from "@/components/module/DataGrid"; +import SearchForm from "@/components/module/SearchForm"; +import type { SearchFormValues } from "@/components/type/SearchForm.type"; +import { StatsInputStatusInpColumn } from "@/components/util/column/input/StatsInputStatusInpColumn"; +import { toGridRows } from "@/components/util/grid/gridFunction"; +import { useTabStore } from "@/store/tab-store"; + +const getStatsInputStatusFetcher = async ( + key: [string, StatsInputStatusListRequestParams], +): Promise => { return StatisticsInputStatusApi.getStatisticsInputStatusList(key[1]); }; @@ -24,10 +27,15 @@ const getStatsInputStatusFetcher = async (key: [string, StatsInputStatusListRequ const StatsInputStatusInp = () => { const { setMessage: alert } = useAlertStore((state) => state); const activeTabId = useTabStore((state) => state.activeTabId); - const [rowData, setRowData] = useState[]>([]); - const [searchParams, setSearchParams] = useState(null); + const [rowData, setRowData] = useState[]>( + [], + ); + const [searchParams, setSearchParams] = + useState(null); - const swrKey = searchParams ? (["stats-input-status", searchParams] as const) : null; + const swrKey = searchParams + ? (["stats-input-status", searchParams] as const) + : null; const { mutate } = useSWRConfig(); const { isLoading } = useSWR( swrKey, @@ -49,7 +57,7 @@ const StatsInputStatusInp = () => { ); useEffect(() => { - if (activeTabId === 'StatsInputStatusInp' && swrKey) { + if (activeTabId === "StatsInputStatusInp" && swrKey) { mutate(swrKey); } }, [activeTabId]); @@ -63,7 +71,7 @@ const StatsInputStatusInp = () => { ctpvNm: values.sido, sewerBzmnCd: values.bsnmCd, sggNm: values.gugun === "ALL" ? "" : values.gugun, - statsYr: values.year, + statsYr: values.year ?? "", }; setSearchParams(params); mutate(["stats-input-status", params]); @@ -87,22 +95,24 @@ const StatsInputStatusInp = () => {
-
+
- {isLoading &&
-
- + {isLoading && ( +
+
+ +
-
} - @@ -111,4 +121,4 @@ const StatsInputStatusInp = () => { ); }; -export default StatsInputStatusInp; \ No newline at end of file +export default StatsInputStatusInp; diff --git a/apps/st/src/components/statistics/management/LogHistory.tsx b/apps/st/src/components/statistics/management/LogHistory.tsx index 8614b0b5d..ff1747c33 100644 --- a/apps/st/src/components/statistics/management/LogHistory.tsx +++ b/apps/st/src/components/statistics/management/LogHistory.tsx @@ -2,13 +2,30 @@ import { useCallback, useEffect, useState } from "react"; +import dayjs from "dayjs"; +import useSWR from "swr"; + +import { useAlertStore } from "@/shared/lib"; import { CodeSelectWithAll } from "@/entities/code"; import { statisticsAPi } from "@/entities/statistics/api/statistics-service"; import type { + ApiResponse, + SearchCondition, + Sgg, StatisticsLog, StatisticsLogRequest, } from "@/entities/statistics/model/statistics-interface"; -import { Button, Label, LoadingWithAnimation } from "@common/components"; +import { + Button, + InputDatePicker, + Label, + LoadingWithAnimation, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@common/components"; import DataGrid from "@/components/module/DataGrid"; import { LogHistoryDetailDialog } from "@/components/statistics/management/LogHistoryDetailDialog"; @@ -18,9 +35,23 @@ import { toGridRows } from "@/components/util/grid/gridFunction"; const PAGE_SIZE = 100; +const getSidoListFetcher = async (): Promise> => { + return statisticsAPi.getSidoList(); +}; + +const getSggListFetcher = async ( + key: [string, string], +): Promise> => { + return statisticsAPi.getSggList(key[1]); +}; + const LogHistory = () => { - // const { setMessage: alert } = useAlertStore((state) => state); + const { setMessage: alert } = useAlertStore((state) => state); const [statsCd, setStatsCd] = useState(""); + const [sido, setSido] = useState("ALL"); + const [gugun, setGugun] = useState("ALL"); + const [bsnmCd, setBsnmCd] = useState(""); + const [statsDataChgComCd, setStatsDataChgComCd] = useState("ALL"); const [isLoading, setIsLoading] = useState(false); const [rowData, setRowData] = useState[]>( [], @@ -32,49 +63,148 @@ const LogHistory = () => { string, string | number > | null>(null); + const [startDate, setStartDate] = useState(() => + dayjs().subtract(1, "month").startOf("day").toDate(), + ); + const [endDate, setEndDate] = useState(() => + dayjs().endOf("day").toDate(), + ); + + const { data: sidoList } = useSWR>( + "log-history-sido-list", + getSidoListFetcher, + { + revalidateOnFocus: false, + revalidateOnReconnect: false, + revalidateIfStale: false, + dedupingInterval: 2000, + shouldRetryOnError: false, + onSuccess: (data) => { + if (data.status !== "success") { + alert(`시도 목록을 불러오는데 실패했습니다. + 새로고침 후 다시 시도해주세요.`); + } + }, + onError: () => { + alert(`시도 목록을 불러오는데 실패했습니다. +새로고침 후 다시 시도해주세요.`); + }, + }, + ); + + const sggKey: [string, string] | null = + sido && sido !== "ALL" ? ["log-history-sgg-list", sido] : null; + const { data: sggList } = useSWR>( + sggKey, + getSggListFetcher, + { + revalidateOnFocus: false, + revalidateOnReconnect: false, + revalidateIfStale: false, + dedupingInterval: 2000, + shouldRetryOnError: false, + onSuccess: (data) => { + if (data.status !== "success") { + alert(`시군구 목록을 불러오는데 실패했습니다. +새로고침 후 다시 시도해주세요.`); + } + }, + onError: () => { + alert(`시군구 목록을 불러오는데 실패했습니다. +새로고침 후 다시 시도해주세요.`); + }, + }, + ); + const sggOptions = sggList?.data ?? []; + + const handleSidoChange = (value: string) => { + const selectedBsnm = + sidoList?.data.sidoList.find((v) => v.sido === value)?.bsnmCd ?? ""; + setSido(value); + setGugun("ALL"); + setBsnmCd(selectedBsnm); + }; + + const handleSggChange = (value: string) => { + const listForBsnm = sggList?.data ?? []; + const selectedBsnm = + value === "ALL" + ? (sidoList?.data.sidoList.find((v) => v.sido === sido) + ?.bsnmCd ?? "") + : (listForBsnm.find((v) => v.gugun === value)?.bsnmCd ?? ""); + setGugun(value); + setBsnmCd(selectedBsnm); + }; const loadLogs = useCallback( async (page1Based: number) => { const param: StatisticsLogRequest = { - statsCd: statsCd === "" || statsCd === "ALL" ? "" : statsCd, page: page1Based, size: PAGE_SIZE, + startDate: startDate + ? dayjs(startDate).format("YYYY-MM-DD") + : "", + endDate: endDate ? dayjs(endDate).format("YYYY-MM-DD") : "", + ...(!(statsCd === "" || statsCd === "ALL") && { statsCd }), + ...(sido && sido !== "ALL" && { ctpvNm: sido }), + ...(gugun && gugun !== "ALL" && { sggNm: gugun }), + ...(bsnmCd && { sewerBzmnCd: bsnmCd }), + ...(statsDataChgComCd && + statsDataChgComCd !== "ALL" && { + statsDataChgComCd, + }), }; - setIsLoading(true); + try { + setIsLoading(true); - const response = await statisticsAPi.getLogList(param); - if (response.status === "success") { - const lostList = response.data?.statsLogList ?? []; - const convertedData: StatisticsLog[] = lostList.map( - (item: StatisticsLog) => ({ - ...item, - dmndFwkNm: - item.dmndFwkNm === "save" - ? "저장" - : item.dmndFwkNm === "submit" - ? "제출" - : "예외 제출", - }), - ); + const response = await statisticsAPi.getLogList(param); - setRowData(toGridRows(convertedData)); - setTotalPages(response.data?.pageInfo?.totalPages ?? 0); - setPageIndex(Math.max(0, page1Based - 1)); - } else { - alert( - response.message || "데이터 조회 중 오류가 발생했습니다.", - ); + if (response.status === "success") { + const lostList = response.data?.statsLogList ?? []; + + setRowData(toGridRows(lostList)); + setTotalPages(response.data?.pageInfo?.totalPages ?? 0); + setPageIndex(Math.max(0, page1Based - 1)); + } else { + alert( + response.message || + "데이터 조회 중 오류가 발생했습니다.", + ); + } + } catch { + alert("데이터 조회 중 오류가 발생했습니다."); + } finally { + setIsLoading(false); } }, - [statsCd], + [ + statsCd, + startDate, + endDate, + sido, + gugun, + bsnmCd, + statsDataChgComCd, + alert, + ], ); - const handleSearch = async (e: React.SubmitEvent) => { + const handleSearch = async (e: React.FormEvent) => { e.preventDefault(); await loadLogs(1); }; + const handleReset = () => { + setStatsCd(""); + setSido("ALL"); + setGugun("ALL"); + setBsnmCd(""); + setStatsDataChgComCd("ALL"); + setStartDate(dayjs().subtract(1, "month").startOf("day").toDate()); + setEndDate(dayjs().endOf("day").toDate()); + }; + const handleRowClick = (row: Record) => { setSelectedLogRow(row); setDetailDialogOpen(true); @@ -86,14 +216,58 @@ const LogHistory = () => { }; useEffect(() => { - loadLogs(1); + void loadLogs(1); }, []); return (
-
+
+
+ + +
+
+ + +
{ className="w-[16rem]" />
+
+ + +
+
+ + + + +
diff --git a/apps/st/src/components/statistics/review/ReturnReasonDialog.tsx b/apps/st/src/components/statistics/review/ReturnReasonDialog.tsx new file mode 100644 index 000000000..b2955bc9a --- /dev/null +++ b/apps/st/src/components/statistics/review/ReturnReasonDialog.tsx @@ -0,0 +1,99 @@ +"use client"; + +import { useEffect, useState } from "react"; + +import { + Button, + Dialog, + DialogClose, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, + Label, + Textarea, +} from "@common/components"; + +export interface ReturnReasonDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onSave?: (reason: string) => void | Promise; +} + +export function ReturnReasonDialog({ + open, + onOpenChange, + onSave, +}: ReturnReasonDialogProps) { + const [reason, setReason] = useState(""); + const [isSaving, setIsSaving] = useState(false); + const [reasonError, setReasonError] = useState(false); + + useEffect(() => { + if (!open) return; + setReason(""); + setReasonError(false); + }, [open]); + + const handleSave = async () => { + if (reason.trim() === "") { + setReasonError(true); + return; + } + + try { + setIsSaving(true); + await onSave?.(reason); + onOpenChange(false); + } finally { + setIsSaving(false); + } + }; + + return ( + + + + 반려 사유 + +
+
+ +